diff --git a/packages/agent/src/dkg-agent-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index c62bb800fa..434cc85138 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -977,21 +977,25 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { * caller naming the slot directly, not a cleartext→numeric remapping, so it is * treated like the bare-numeric raw-slot path (not identity-bound) and gated * by liveness + fresh policy alone. + * + * `requireCommittedNameHash` is the strict durable-sync mode: unlike the + * legacy policy probe, it rejects malformed ids and adapters that cannot + * prove a non-numeric local id from the chain commitment. The canonical + * direct numeric self-address remains valid in either mode. */ async localCgMatchesOnChainSlot(this: DKGAgent, contextGraphId: string, onChainId: string, opCtx?: OperationContext, + options?: { requireCommittedNameHash?: boolean }, ): Promise { - const getNameHash = this.chain.getContextGraphNameHash; - if (typeof getNameHash !== 'function') return true; let numericId: bigint; try { numericId = BigInt(onChainId); } catch { - return true; + return options?.requireCommittedNameHash !== true; } - if (numericId <= 0n) return true; + if (numericId <= 0n) return options?.requireCommittedNameHash !== true; const trimmed = contextGraphId.trim(); // DIRECT NUMERIC SELF-ADDRESS: a local CG whose own id IS its numeric @@ -1005,7 +1009,11 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { // so name-hash binding is inapplicable — defer to the liveness + fresh-policy // gate. The stale-mapping risk the name-hash defends against (#884 review // 🔴 GaZk2) only exists for a cleartext id that REMAPS to a different slot. - if (/^\d+$/.test(trimmed) && trimmed === onChainId.trim()) return true; + if (/^\d+$/.test(trimmed) && trimmed === numericId.toString()) return true; + const getNameHash = this.chain.getContextGraphNameHash; + if (typeof getNameHash !== 'function') { + return options?.requireCommittedNameHash !== true; + } // A locally-resolved (cleartext) id can be committed two ways, and both are // legitimate (#884 review 🔴 GZumc + 🔴 GaJf_), so accept a match against EITHER: // - CLEARTEXT (always): a curator-created CG stores its cleartext id (even @@ -1027,7 +1035,7 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { try { acceptable.add(ethers.keccak256(ethers.toUtf8Bytes(trimmed)).toLowerCase()); } catch { - return true; + return options?.requireCommittedNameHash !== true; } if (/^0x[0-9a-fA-F]{64}$/.test(trimmed) && this.isWireIdKeyedSubscription(trimmed)) { acceptable.add(trimmed.toLowerCase()); diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 373dc3e0b8..454ece91e5 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -4156,14 +4156,29 @@ export class LifecycleSyncMethods extends DKGAgentBase { source: 'agent.durableSync.storeInsert', }), storeGraphScopedAsset: async (asset) => { - const authenticatedAsset = await authenticateVerifiedGraphScopedAsset( + const authentication = await authenticateVerifiedGraphScopedAsset( this.chain, asset, - (cgId) => this.getContextGraphOnChainId(cgId), + (localContextGraphId, onChainContextGraphId) => this.localCgMatchesOnChainSlot( + localContextGraphId, + onChainContextGraphId.toString(), + ctx, + { requireCommittedNameHash: true }, + ), ); + const verifiedOnChainId = authentication.onChainContextGraphId; + const subscription = this.subscribedContextGraphs.get(asset.contextGraphId); + if (verifiedOnChainId && subscription && subscription.onChainId !== verifiedOnChainId) { + this.bindSubscriptionOnChainId( + asset.contextGraphId, + subscription, + verifiedOnChainId, + ); + this.persistContextGraphSubscriptionState(asset.contextGraphId); + } const outcome = await materializeVerifiedGraphScopedAsset({ store: this.store, - asset: authenticatedAsset, + asset: authentication.asset, options: { priority: 'background', source: 'agent.durableSync.graphScopedMaterialization', @@ -4174,7 +4189,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { }); if (outcome === 'applied') { this.invalidateListContextGraphsCache(); - this.contextGraphMetaProjection.markDirtyFromQuads(authenticatedAsset.metadataQuads); + this.contextGraphMetaProjection.markDirtyFromQuads(authentication.asset.metadataQuads); } return outcome; }, diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index cf7dafe0a9..2121980912 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -36,20 +36,32 @@ export interface VerifiedGraphScopedAsset { metadataQuads: Quad[]; } +export interface AuthenticatedGraphScopedAsset { + asset: VerifiedGraphScopedAsset; + /** Null only for explicit no-chain development mode. */ + onChainContextGraphId: string | null; +} + +export type VerifyContextGraphBinding = ( + localContextGraphId: string, + onChainContextGraphId: bigint, +) => Promise; + export type GraphScopedMaterializationOutcome = 'applied' | 'stale' | 'quarantined'; /** * Bind the peer-verified payload to current chain truth before its structural * metadata can influence local assertion ordering. No-chain development keeps - * the integrity-only behavior; production chains fail closed without both - * constant-size views. + * the integrity-only behavior; production chains fail closed without the + * required constant-size chain views. Local-id matching stays in the agent's + * context-graph identity layer and is injected here as a focused verifier. */ export async function authenticateVerifiedGraphScopedAsset( chain: ChainAdapter, asset: VerifiedGraphScopedAsset, - resolveOnChainContextGraphId?: (contextGraphId: string) => Promise, + verifyContextGraphBinding?: VerifyContextGraphBinding, receivedAt = new Date(), -): Promise { +): Promise { const receivedAtMs = receivedAt.getTime(); if (!Number.isFinite(receivedAtMs)) { throw new Error(`Graph-scoped durable sync ${asset.ual} has an invalid local receive time`); @@ -74,15 +86,17 @@ export async function authenticateVerifiedGraphScopedAsset( // tentative because it has integrity verification but no chain provenance. if (chain.chainId === 'none') { return { - ...asset, - metadataQuads: [...asset.metadataQuads, ...locallyVisibleMetadata('tentative')], + asset: { + ...asset, + metadataQuads: [...asset.metadataQuads, ...locallyVisibleMetadata('tentative')], + }, + onChainContextGraphId: null, }; } if ( !chain.getLatestMerkleRoot || !chain.getMerkleRootCount || !chain.getKAContextGraphId - || !resolveOnChainContextGraphId ) { throw Object.assign( new Error( @@ -104,11 +118,10 @@ export async function authenticateVerifiedGraphScopedAsset( if (roots.length !== 1) { throw new Error(`Graph-scoped durable sync ${asset.ual} has ${roots.length} Merkle roots`); } - const [latestRoot, rootCount, boundContextGraphId, expectedContextGraphId] = await Promise.all([ + const [latestRoot, rootCount, boundContextGraphId] = await Promise.all([ chain.getLatestMerkleRoot(kaId), chain.getMerkleRootCount(kaId), chain.getKAContextGraphId(kaId), - resolveOnChainContextGraphId(asset.contextGraphId), ]); if (latestRoot.length !== 32 || !bytesEqual(latestRoot, roots[0]!)) { throw Object.assign( @@ -125,15 +138,25 @@ export async function authenticateVerifiedGraphScopedAsset( { code: 'VM_CHAIN_ASSERTION_VERSION_MISMATCH' }, ); } - if ( - expectedContextGraphId === null - || BigInt(expectedContextGraphId) <= 0n - || boundContextGraphId !== BigInt(expectedContextGraphId) - ) { + if (boundContextGraphId <= 0n) { throw Object.assign( new Error( - `Graph-scoped durable sync ${asset.ual} is bound to context graph ${boundContextGraphId}, ` + - `not local context graph ${asset.contextGraphId} (${expectedContextGraphId ?? 'unresolved'})`, + `Graph-scoped durable sync ${asset.ual} is bound to invalid context graph ${boundContextGraphId}`, + ), + { code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH' }, + ); + } + if (!verifyContextGraphBinding) { + throw Object.assign( + new Error('Graph-scoped durable sync requires local-to-chain context-graph verification'), + { code: 'VM_CHAIN_VERIFICATION_UNSUPPORTED' }, + ); + } + if (!(await verifyContextGraphBinding(asset.contextGraphId, boundContextGraphId))) { + throw Object.assign( + new Error( + `Graph-scoped durable sync ${asset.ual} is bound to context graph ${boundContextGraphId}, ` + + `which does not match local context graph ${asset.contextGraphId}`, ), { code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH' }, ); @@ -211,17 +234,20 @@ export async function authenticateVerifiedGraphScopedAsset( throw new Error(`Graph-scoped durable sync ${asset.ual} has invalid receipt ordering data`); } return { - ...asset, - metadataQuads: [ - ...asset.metadataQuads, - ...locallyVisibleMetadata('confirmed'), - { - subject: asset.ual, - predicate: MATERIALIZED_VERSION, - object: `"${materializedBlock}:${materializedTxIndex}"`, - graph: asset.metaGraph, - }, - ], + asset: { + ...asset, + metadataQuads: [ + ...asset.metadataQuads, + ...locallyVisibleMetadata('confirmed'), + { + subject: asset.ual, + predicate: MATERIALIZED_VERSION, + object: `"${materializedBlock}:${materializedTxIndex}"`, + graph: asset.metaGraph, + }, + ], + }, + onChainContextGraphId: boundContextGraphId.toString(), }; } diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 62bdf0d462..dcc80aa85f 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; import type { OperationContext } from '@origintrail-official/dkg-core'; import type { ChainAdapter } from '@origintrail-official/dkg-chain'; import { @@ -15,9 +16,11 @@ import { import { processDurableBatchForWire } from '../src/sync-verify-worker-impl.js'; import { runDurableSync } from '../src/sync/requester/durable-sync.js'; import type { SyncPageResult } from '../src/sync/requester/page-fetch.js'; +import { DKGAgent } from '../src/dkg-agent.js'; import { authenticateVerifiedGraphScopedAsset, materializeVerifiedGraphScopedAsset, + type VerifyContextGraphBinding, } from '../src/sync/requester/graph-scoped-materialization.js'; const DKG = 'http://dkg.io/ontology/'; @@ -108,6 +111,34 @@ async function graphQuads(store: OxigraphStore, graph: string): Promise })); } +function strictContextGraphBindingVerifier( + chain: ChainAdapter, + wireKeyedLocalIds: string[] = [], +): VerifyContextGraphBinding { + const subscribedContextGraphs = new Map(); + const wireIdToLocalCgId = new Map(); + for (const localId of wireKeyedLocalIds) { + const lower = localId.toLowerCase(); + subscribedContextGraphs.set(localId, { onChainHash: lower }); + wireIdToLocalCgId.set(lower, localId); + } + const agentLike: any = { + chain, + subscribedContextGraphs, + wireIdToLocalCgId, + log: { info: () => {}, warn: () => {}, debug: () => {} }, + }; + agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; + agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; + return (localId, onChainId) => (DKGAgent.prototype as any).localCgMatchesOnChainSlot.call( + agentLike, + localId, + onChainId.toString(), + ctx, + { requireCommittedNameHash: true }, + ); +} + describe('durable graph-scoped KA materialization', () => { it('adds reader-visible local metadata in no-chain mode and keeps receive time stable on replay', async () => { const store = new OxigraphStore(); @@ -129,7 +160,7 @@ describe('durable graph-scoped KA materialization', () => { firstReceivedAt, ); - await expect(materializeVerifiedGraphScopedAsset({ store, asset: authenticated })) + await expect(materializeVerifiedGraphScopedAsset({ store, asset: authenticated.asset })) .resolves.toBe('applied'); expect(await values(store, 'status')).toEqual(['"tentative"']); expect(await values(store, 'publishedAt')).toEqual([ @@ -147,13 +178,198 @@ describe('durable graph-scoped KA materialization', () => { undefined, new Date('2026-07-16T09:00:00.000Z'), ); - await expect(materializeVerifiedGraphScopedAsset({ store, asset: replayed })) + await expect(materializeVerifiedGraphScopedAsset({ store, asset: replayed.asset })) .resolves.toBe('applied'); expect(await values(store, 'publishedAt')).toEqual([ `"2026-07-16T08:00:00Z"^^<${XSD_DATE_TIME}>`, ]); }); + it('authenticates a cold-join CG directly from its chain-committed name hash', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const nameHashReads: bigint[] = []; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async (onChainId: bigint) => { + nameHashReads.push(onChainId); + return ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)); + }, + getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', + verifyKAUpdate: async () => ({ + verified: true, + onChainMerkleRoot: root, + blockNumber: 123, + txIndex: 4, + merkleRootCount: 2n, + }), + } as ChainAdapter; + const authenticated = await authenticateVerifiedGraphScopedAsset( + chain, + { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, + strictContextGraphBindingVerifier(chain), + new Date('2026-07-16T08:30:00.000Z'), + ); + + expect(nameHashReads).toEqual([14n]); + expect(authenticated.onChainContextGraphId).toBe('14'); + expect(authenticated.asset.metadataQuads).toContainEqual(expect.objectContaining({ + predicate: `${DKG}status`, + object: '"confirmed"', + })); + }); + + it('fails closed when the bound CG commits a different name hash', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256( + ethers.toUtf8Bytes('different-context-graph'), + ), + } as ChainAdapter; + + await expect(authenticateVerifiedGraphScopedAsset(chain, { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, strictContextGraphBindingVerifier(chain))).rejects.toMatchObject({ + code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH', + }); + }); + + it('fails closed when the bound CG has no committed name hash', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => null, + } as ChainAdapter; + + await expect(authenticateVerifiedGraphScopedAsset(chain, { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, strictContextGraphBindingVerifier(chain))).rejects.toMatchObject({ + code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH', + }); + }); + + it('does not treat a zero-padded numeric local id as a direct slot address', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256( + ethers.toUtf8Bytes('different-context-graph'), + ), + } as ChainAdapter; + + await expect(authenticateVerifiedGraphScopedAsset(chain, { + contextGraphId: '0014', + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, strictContextGraphBindingVerifier(chain))).rejects.toMatchObject({ + code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH', + }); + }); + + it('accepts a locally proven wire-id keyed subscription without double hashing it', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const wireId = `0x${'ab'.repeat(32)}`; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => wireId, + getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', + verifyKAUpdate: async () => ({ + verified: true, + onChainMerkleRoot: root, + blockNumber: 123, + txIndex: 4, + merkleRootCount: 2n, + }), + } as ChainAdapter; + + const authenticated = await authenticateVerifiedGraphScopedAsset(chain, { + contextGraphId: wireId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, strictContextGraphBindingVerifier(chain, [wireId])); + + expect(authenticated.onChainContextGraphId).toBe('14'); + }); + + it('accepts only the canonical decimal spelling as a direct slot address', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', + verifyKAUpdate: async () => ({ + verified: true, + onChainMerkleRoot: root, + blockNumber: 123, + txIndex: 4, + merkleRootCount: 2n, + }), + } as ChainAdapter; + + const authenticated = await authenticateVerifiedGraphScopedAsset(chain, { + contextGraphId: '14', + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, strictContextGraphBindingVerifier(chain)); + + expect(authenticated.onChainContextGraphId).toBe('14'); + }); + it('does not mistake a lifecycle assertionGraph pointer for a second UAL owner', async () => { const v2Data = dataQuad(1); const root = toHex(computeFlatKCRootV10([v2Data], [])); @@ -314,6 +530,7 @@ describe('durable graph-scoped KA materialization', () => { getLatestMerkleRoot: async () => v2Root, getMerkleRootCount: async () => 2n, getKAContextGraphId: async () => 1n, + getContextGraphNameHash: async () => ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)), getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', verifyKAUpdate: async () => ({ verified: true, @@ -329,12 +546,12 @@ describe('durable graph-scoped KA materialization', () => { asset: Parameters[0]['asset'], ) => materializeVerifiedGraphScopedAsset({ store, - asset: await authenticateVerifiedGraphScopedAsset( + asset: (await authenticateVerifiedGraphScopedAsset( chain, asset, - async () => '1', + strictContextGraphBindingVerifier(chain), new Date('2026-07-16T08:30:00.000Z'), - ), + )).asset, }), }; @@ -553,7 +770,7 @@ describe('durable graph-scoped KA materialization', () => { await expect(authenticateVerifiedGraphScopedAsset( chain, asset, - async () => '1', + strictContextGraphBindingVerifier(chain), )).rejects.toMatchObject({ code: 'VM_CHAIN_ASSERTION_VERSION_MISMATCH', }); @@ -578,12 +795,15 @@ describe('durable graph-scoped KA materialization', () => { }, getMerkleRootCount: async () => 2n, getKAContextGraphId: async () => 2n, + getContextGraphNameHash: async () => ethers.keccak256( + ethers.toUtf8Bytes('different-context-graph'), + ), } as ChainAdapter; await expect(authenticateVerifiedGraphScopedAsset( chain, asset, - async () => '1', + strictContextGraphBindingVerifier(chain), )).rejects.toMatchObject({ code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH' }); }); diff --git a/packages/agent/test/durable-sync-lifecycle-binding.test.ts b/packages/agent/test/durable-sync-lifecycle-binding.test.ts new file mode 100644 index 0000000000..26b04901a4 --- /dev/null +++ b/packages/agent/test/durable-sync-lifecycle-binding.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ethers } from 'ethers'; +import type { ChainAdapter } from '@origintrail-official/dkg-chain'; +import type { OperationContext } from '@origintrail-official/dkg-core'; + +vi.mock('../src/sync/requester/durable-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runDurableSync: vi.fn(async () => ({})) }; +}); + +vi.mock('../src/sync/requester/graph-scoped-materialization.js', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../src/sync/requester/graph-scoped-materialization.js') + >(); + return { + ...actual, + materializeVerifiedGraphScopedAsset: vi.fn(async () => 'applied' as const), + }; +}); + +import { DKGAgent } from '../src/dkg-agent.js'; +import { LifecycleSyncMethods } from '../src/dkg-agent-lifecycle.js'; +import { runDurableSync } from '../src/sync/requester/durable-sync.js'; +import { + materializeVerifiedGraphScopedAsset, + type VerifiedGraphScopedAsset, +} from '../src/sync/requester/graph-scoped-materialization.js'; + +const DKG = 'http://dkg.io/ontology/'; +const contextGraphId = 'agent-blackbox-vm'; +const ual = 'did:dkg:otp:2043/0x1111111111111111111111111111111111111111/1'; +const assertionGraph = `did:dkg:context-graph:${contextGraphId}/_verifiable_memory/asset/1`; +const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`; +const ctx = { kind: 'sync', id: 'lifecycle-binding-test', startedAt: 0 } as OperationContext; + +const mockedRunDurableSync = vi.mocked(runDurableSync); +const mockedMaterialize = vi.mocked(materializeVerifiedGraphScopedAsset); + +describe('durable sync lifecycle chain binding', () => { + beforeEach(() => { + mockedRunDurableSync.mockClear(); + mockedMaterialize.mockClear(); + }); + + it('persists the authenticated on-chain CG id before materializing the asset', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const rootHex = Array.from(root, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256( + ethers.toUtf8Bytes(contextGraphId), + ), + getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', + verifyKAUpdate: async () => ({ + verified: true, + onChainMerkleRoot: root, + blockNumber: 123, + txIndex: 4, + merkleRootCount: 2n, + }), + } as ChainAdapter; + const subscription: { onChainId?: string; subscribed: boolean } = { subscribed: true }; + const bindSubscriptionOnChainId = vi.fn( + (_localId: string, sub: typeof subscription, onChainId: string) => { + sub.onChainId = onChainId; + }, + ); + const persistContextGraphSubscriptionState = vi.fn(); + const agentLike: any = { + config: {}, + chain, + store: {}, + subscribedContextGraphs: new Map([[contextGraphId, subscription]]), + wireIdToLocalCgId: new Map(), + bindSubscriptionOnChainId, + persistContextGraphSubscriptionState, + processDurableBatchInWorker: async () => ({}), + insertSyncedQuadsAndInvalidateListCache: async () => {}, + syncCheckpoints: new Map(), + oversizeTombstoneLog: { record: () => {} }, + invalidateListContextGraphsCache: vi.fn(), + contextGraphMetaProjection: { markDirtyFromQuads: vi.fn() }, + log: { info: () => {}, warn: () => {}, debug: () => {} }, + }; + agentLike.localCgMatchesOnChainSlot = (DKGAgent.prototype as any).localCgMatchesOnChainSlot; + agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; + agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; + + await LifecycleSyncMethods.prototype.runLegacyDurableSyncForContextGraph.call( + agentLike, + ctx, + 'peer-remote', + contextGraphId, + 1, + ); + expect(mockedRunDurableSync).toHaveBeenCalledTimes(1); + const storeGraphScopedAsset = mockedRunDurableSync.mock.calls[0]![0].storeGraphScopedAsset; + expect(storeGraphScopedAsset).toBeTypeOf('function'); + + const asset: VerifiedGraphScopedAsset = { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [], + metadataQuads: [ + { + subject: ual, + predicate: `${DKG}merkleRoot`, + object: `"${rootHex}"`, + graph: metaGraph, + }, + { + subject: ual, + predicate: `${DKG}transactionHash`, + object: `"0x${'02'.padStart(64, '0')}"`, + graph: metaGraph, + }, + ], + }; + await expect(storeGraphScopedAsset!(asset)).resolves.toBe('applied'); + + expect(bindSubscriptionOnChainId).toHaveBeenCalledWith( + contextGraphId, + subscription, + '14', + ); + expect(subscription.onChainId).toBe('14'); + expect(persistContextGraphSubscriptionState).toHaveBeenCalledWith(contextGraphId); + expect(bindSubscriptionOnChainId.mock.invocationCallOrder[0]).toBeLessThan( + mockedMaterialize.mock.invocationCallOrder[0]!, + ); + expect(persistContextGraphSubscriptionState.mock.invocationCallOrder[0]).toBeLessThan( + mockedMaterialize.mock.invocationCallOrder[0]!, + ); + const materializedAsset = mockedMaterialize.mock.calls[0]![0].asset as unknown as Record< + string, + unknown + >; + expect('verifiedOnChainContextGraphId' in materializedAsset).toBe(false); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index f89c5a1511..7ff30c9e4f 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "test/changelog-requester.test.ts", "test/durable-sync-since-threading.test.ts", "test/durable-sync-graph-scoped-materialization.test.ts", + "test/durable-sync-lifecycle-binding.test.ts", "test/durable-progress.test.ts", "test/gossip-publish-handler.test.ts", "test/discovery-subscription-boundary.test.ts",