Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions packages/agent/src/dkg-agent-cg-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
contextGraphId: string,
options: { signal?: AbortSignal; source?: string } = {},
): Promise<string | null> {
const subscribed = this.subscribedContextGraphs.get(contextGraphId)?.onChainId;
const directSubscription = this.subscribedContextGraphs.get(contextGraphId);
const subscribed = directSubscription?.onChainId;
if (subscribed) return subscribed;

// Registered CG events carry only the curator-committed name hash. Resolve
Expand All @@ -428,9 +429,38 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
const mappedLocalId = this.localCgIdForWireId(
this.contextGraphWireId(contextGraphId),
);
const mapped = this.subscribedContextGraphs.get(mappedLocalId)?.onChainId;
const mappedSubscription = this.subscribedContextGraphs.get(mappedLocalId);
const mapped = mappedSubscription?.onChainId;
if (mapped) return mapped;

// A cold node may select a CG long after ContextGraphCreated fell outside
// the live event poller's bounded lookback. Resolve the exact indexed
// nameHash from chain before consulting the legacy ontology projection.
// Scope this expensive historical operation to a locally admitted
// subscription (explicit Edge selection or Core-hosted record): an
// arbitrary remote id must never trigger a chain crawl.
const localSubscription = directSubscription ?? mappedSubscription;
const resolveHistorical = this.chain?.resolveContextGraphIdByNameHash;
const locallyAdmitted = localSubscription !== undefined && (
localSubscription.syncAdmission !== 'none'
|| localSubscription.coreHosted === true
Comment thread
branarakic marked this conversation as resolved.
);
if (locallyAdmitted && typeof resolveHistorical === 'function') {
const wireId = localSubscription.onChainHash
? this.contextGraphWireId(localSubscription.onChainHash)
: this.contextGraphNameCommitment(mappedLocalId);
Comment thread
branarakic marked this conversation as resolved.
Outdated
const resolved = options.signal === undefined
? await resolveHistorical.call(this.chain, wireId)
: await resolveHistorical.call(this.chain, wireId, { signal: options.signal });
if (resolved !== null) {
const boundLocalId = this.bindOnChainContextGraphIdFromNameHash(
wireId,
resolved.toString(),
);
if (boundLocalId !== null) return resolved.toString();
}
}

const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY);
const contextGraphUri = `did:dkg:context-graph:${contextGraphId}`;
const result = await this.store.query(
Expand Down
128 changes: 128 additions & 0 deletions packages/agent/test/context-graph-historical-name-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import type { TripleStore } from '@origintrail-official/dkg-storage';
import { ContextGraphRegistryMethods } from '../src/dkg-agent-cg-registry.js';

const LOCAL_ID = 'selected-public-cg';
const NAME_HASH = `0x${'ab'.repeat(32)}`;

function selectedFixture(resolved: bigint | null = 42n) {
const query = vi.fn<TripleStore['query']>(async () => ({
type: 'bindings',
bindings: [],
}));
const resolveContextGraphIdByNameHash = vi.fn(async () => resolved);
const bindOnChainContextGraphIdFromNameHash = vi.fn(() => LOCAL_ID);
const subscription = {
subscribed: true,
synced: false,
syncMode: 'always-on',
syncAdmission: 'explicit',
onChainHash: NAME_HASH,
};
return {
query,
resolveContextGraphIdByNameHash,
bindOnChainContextGraphIdFromNameHash,
agent: {
store: { query } as unknown as TripleStore,
chain: { resolveContextGraphIdByNameHash },
subscribedContextGraphs: new Map([[LOCAL_ID, subscription]]),
contextGraphWireId: (id: string) => id === LOCAL_ID ? NAME_HASH : id.toLowerCase(),
contextGraphNameCommitment: (id: string) => id === LOCAL_ID ? NAME_HASH : id.toLowerCase(),
localCgIdForWireId: (id: string) => id.toLowerCase() === NAME_HASH ? LOCAL_ID : id,
bindOnChainContextGraphIdFromNameHash,
},
};
}

describe('cold historical Context Graph name binding', () => {
it('resolves and binds an explicit cleartext selection without ontology data', async () => {
const fixture = selectedFixture();
const signal = new AbortController().signal;

await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
{ signal },
),
).resolves.toBe('42');

expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(
NAME_HASH,
{ signal },
);
expect(fixture.bindOnChainContextGraphIdFromNameHash).toHaveBeenCalledWith(
NAME_HASH,
'42',
);
expect(fixture.query).not.toHaveBeenCalled();
});

it('uses the same binding for a selected wire-hash request', async () => {
const fixture = selectedFixture();
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
NAME_HASH,
),
).resolves.toBe('42');
expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(
NAME_HASH,
);
});

it('never lets an arbitrary unselected id trigger a historical chain scan', async () => {
const fixture = selectedFixture();
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
'unselected-remote-cg',
),
).resolves.toBeNull();
expect(fixture.resolveContextGraphIdByNameHash).not.toHaveBeenCalled();
expect(fixture.query).toHaveBeenCalledTimes(1);
});

it('does not let a passive non-admitted local record trigger a historical chain scan', async () => {
const fixture = selectedFixture();
fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!.syncAdmission = 'none';
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).resolves.toBeNull();
expect(fixture.resolveContextGraphIdByNameHash).not.toHaveBeenCalled();
expect(fixture.query).toHaveBeenCalledTimes(1);
});

it('retains the legacy ontology fallback for a selected pre-name-hash miss', async () => {
const fixture = selectedFixture(null);
fixture.query.mockResolvedValueOnce({
type: 'bindings',
bindings: [{ id: '"7"' }],
});
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).resolves.toBe('7');
expect(fixture.bindOnChainContextGraphIdFromNameHash).not.toHaveBeenCalled();
});

it('propagates ambiguous or failed chain resolution instead of trusting local metadata', async () => {
const fixture = selectedFixture();
fixture.resolveContextGraphIdByNameHash.mockRejectedValueOnce(
new Error('ambiguous name hash'),
);
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).rejects.toThrow('ambiguous name hash');
expect(fixture.query).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions packages/chain/src/chain-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,22 @@ export interface ChainAdapter {
contextGraphId: bigint,
options?: ChainReadOptions,
): Promise<string | null>;

/**
* Resolve the numeric ContextGraphStorage slot committed to an exact
* `ContextGraphCreated.nameHash` topic.
*
* This is the cold-start inverse of {@link getContextGraphNameHash}: a node
* that selected a CG after its creation event fell outside the live poller's
* lookback still needs an authoritative hash -> numeric-id binding before it
* can evaluate policy or authorize SWM. Implementations MUST fail closed on
* ambiguous matches and independently verify the current slot still commits
* to `nameHash` before returning it.
*/
resolveContextGraphIdByNameHash?(
nameHash: string,
options?: ChainReadOptions,
): Promise<bigint | null>;
}

// ----- Backward-compat deprecated aliases -----
Expand Down
19 changes: 19 additions & 0 deletions packages/chain/src/evm-adapter-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,23 @@ export class EVMChainAdapterBase {
*/
protected readonly cachedContractDeployBlocks: Map<string, number> = new Map();

/**
* Deployment-scoped reverse bindings recovered from indexed
* ContextGraphCreated.nameHash logs. Positive results are immutable for a
* contract address. Negative results use a short TTL so a configured CG that
* is registered later becomes visible without restarting the adapter.
*/
protected readonly contextGraphIdsByNameHash = new Map<
Comment thread
branarakic marked this conversation as resolved.
Outdated
string,
{ value: bigint | null; cachedAt: number }
>();

/** One bounded historical scan per deployment + name hash at a time. */
protected readonly contextGraphIdByNameHashInflight = new Map<
string,
Promise<bigint | null>
>();

protected readonly contextGraphRegistryScanCursor: ContextGraphRegistryScanCursor;

/**
Expand All @@ -939,6 +956,8 @@ export class EVMChainAdapterBase {
this.cachedKav10Address = undefined;
this.cachedMinRequiredSignatures = undefined;
this.cachedContractDeployBlocks.clear();
this.contextGraphIdsByNameHash.clear();
this.contextGraphIdByNameHashInflight.clear();
this.contextGraphRegistryScanCursor.clearMemoryCache();
}

Expand Down
Loading
Loading