Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
41 changes: 39 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,45 @@ 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') {
// A direct subscription key is the original local identifier, including
// its spelling/case when a legitimate cleartext id happens to look like
// a bytes32 wire hash. Only use mappedLocalId when admission was found
// through the reverse wire-id index.
const localIdForCommitment = directSubscription !== undefined
? contextGraphId
: mappedLocalId;
const wireId = localSubscription.onChainHash
? this.contextGraphWireId(localSubscription.onChainHash)
: this.contextGraphNameCommitment(localIdForCommitment);
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
169 changes: 169 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,169 @@
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('allows a host-only Core record to recover its historical binding', async () => {
const fixture = selectedFixture();
const subscription = fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!;
subscription.subscribed = false;
subscription.syncAdmission = 'none';
subscription.coreHosted = true;

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

it('hashes the original spelling of a hash-shaped cleartext subscription id', async () => {
const fixture = selectedFixture();
const localId = `0x${'AB'.repeat(32)}`;
const committedHash = `0x${'cd'.repeat(32)}`;
const subscription = fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!;
fixture.agent.subscribedContextGraphs = new Map([[
localId,
{ ...subscription, onChainHash: undefined },
]]);
fixture.agent.contextGraphWireId = (id: string) => id.toLowerCase();
fixture.agent.localCgIdForWireId = (id: string) => id.toLowerCase();
fixture.agent.contextGraphNameCommitment = vi.fn((id: string) =>
id === localId ? committedHash : NAME_HASH);
fixture.bindOnChainContextGraphIdFromNameHash.mockReturnValue(localId);

await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
localId,
),
).resolves.toBe('42');
expect(fixture.agent.contextGraphNameCommitment).toHaveBeenCalledWith(localId);
expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(committedHash);
});

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
79 changes: 79 additions & 0 deletions packages/chain/src/context-graph-name-hash-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0

import { ethers } from 'ethers';
import { ReadThroughTtlCache } from './keyed-ttl-single-flight-cache.js';

const CONTEXT_GRAPH_NAME_HASH_NEGATIVE_TTL_MS = 30_000;

export interface ContextGraphNameHashResolverDependencies {
/** One concrete adapter-owned lookup for a normalized bytes32 commitment. */
readonly load: (nameHash: string) => Promise<bigint | null>;
}

/**
* Deployment-scoped, single-flight reverse lookup for cold Context Graphs.
*
* Only misses are cached. A positive binding is returned to the caller and
* persisted by the Agent subscription layer, but it is deliberately not kept
* here: ContextGraphStorage does not enforce name-hash uniqueness, so a later
* duplicate event must be visible to the next independent lookup.
*/
export class ContextGraphNameHashResolver {
private readonly cache = new ReadThroughTtlCache<string, bigint | null>({
ttlMs: (value) => value === null
? CONTEXT_GRAPH_NAME_HASH_NEGATIVE_TTL_MS
: 0,
});

constructor(
private readonly dependencies: ContextGraphNameHashResolverDependencies,
) {}

async resolve(
rawNameHash: string,
signal?: AbortSignal,
): Promise<bigint | null> {
signal?.throwIfAborted();
const nameHash = normalizeContextGraphNameHash(rawNameHash);
if (nameHash === ethers.ZeroHash) return null;

const shared = this.cache.getOrLoad(
nameHash,
nameHash,
() => this.dependencies.load(nameHash),
);
return waitForResolution(shared, signal);
}

invalidateAll(): void {
this.cache.invalidateAll();
}
}

function normalizeContextGraphNameHash(value: string): string {
if (!ethers.isHexString(value, 32)) {
throw new TypeError('resolveContextGraphIdByNameHash requires a bytes32 nameHash');
}
return value.toLowerCase();
}

function waitForResolution<T>(
work: Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
if (!signal) return work;
signal.throwIfAborted();
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(
signal.reason instanceof Error
? signal.reason
: Object.assign(new Error('Context Graph name-hash resolution aborted'), {
name: 'AbortError',
}),
);
signal.addEventListener('abort', onAbort, { once: true });
work.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
5 changes: 5 additions & 0 deletions packages/chain/src/evm-adapter-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ReadThroughTtlCache } from './keyed-ttl-single-flight-cache.js';
import { PcaReadCache } from './pca-read-cache.js';
import { HubRotationPoller } from './hub-rotation-poller.js';
import { ContextGraphRegistryScanCursor } from './context-graph-registry-scan-cursor.js';
import type { ContextGraphNameHashResolver } from './context-graph-name-hash-resolver.js';
import type { ContractCache, EVMAdapterConfig } from './evm-adapter-types.js';
import { RPC_READ_STALL_TIMEOUT_MS, DEFAULT_RANDOM_SAMPLING_HUB_REFRESH_MS, resolveReceiptTimeoutMs, RPC_RECEIPT_POLL_INTERVAL_MS, RPC_ENDPOINT_SET_RETRIES, RPC_ENDPOINT_SET_RETRY_BACKOFF_MS, ADMIN_KEY_PURPOSE, OPERATIONAL_KEY_PURPOSE, PUBLISHER_FUNDING_CACHE_TTL_MS } from './evm-adapter-constants.js';

Expand Down Expand Up @@ -914,6 +915,9 @@ export class EVMChainAdapterBase {
*/
protected readonly cachedContractDeployBlocks: Map<string, number> = new Map();

/** Lazily constructed by the context-graph mixin; state must live on base. */
protected contextGraphNameHashResolver: ContextGraphNameHashResolver | undefined;

protected readonly contextGraphRegistryScanCursor: ContextGraphRegistryScanCursor;

/**
Expand All @@ -939,6 +943,7 @@ export class EVMChainAdapterBase {
this.cachedKav10Address = undefined;
this.cachedMinRequiredSignatures = undefined;
this.cachedContractDeployBlocks.clear();
this.contextGraphNameHashResolver?.invalidateAll();
Comment thread
branarakic marked this conversation as resolved.
this.contextGraphRegistryScanCursor.clearMemoryCache();
}

Expand Down
Loading
Loading