diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 39b44bcf43..1387a6b08f 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -1295,6 +1295,24 @@ export interface ChainAdapter { */ getKnowledgeAssetOwner?(kaId: bigint): Promise; + /** + * Adopt-existing-mint support: for a kaId the contract reports as already + * minted, verify chain truth (single merkle root == expectedMerkleRoot, + * KA bound to expectedContextGraphId) and recover the mint transaction's + * provenance from the `KnowledgeAssetCreated` event log. Returns a + * synthesized OnChainPublishResult equivalent to what the original mint + * receipt would have produced, or `null` when the log cannot be recovered + * (pruned / non-archive RPCs) — callers must then rethrow their original + * error, never synthesize a txHash (finalization-handler invariant). + * Throws typed errors (code KA_ID_COLLISION / KA_SUPERSEDED / + * KA_CG_MISMATCH) when chain truth contradicts the caller's content. + */ + getMintedKnowledgeAssetProvenance?( + kaId: bigint, + expectedMerkleRoot: Uint8Array, + expectedContextGraphId: bigint, + ): Promise; + /** Read minimumRequiredSignatures from ParametersStorage. Used by ACKCollector. */ getMinimumRequiredSignatures?(): Promise; diff --git a/packages/chain/src/evm-adapter-base.ts b/packages/chain/src/evm-adapter-base.ts index d937421338..ae7ab99e89 100644 --- a/packages/chain/src/evm-adapter-base.ts +++ b/packages/chain/src/evm-adapter-base.ts @@ -2718,6 +2718,123 @@ export class EVMChainAdapterBase { } } + /** + * Adopt-existing-mint (ChainAdapter.getMintedKnowledgeAssetProvenance): + * verify chain truth for an already-minted kaId and recover the mint tx's + * provenance from the KnowledgeAssetCreated log. See chain-adapter.ts for + * the contract. Verification failures throw typed errors; an unrecoverable + * log (pruned RPC) returns null so the caller rethrows its original error. + */ + async getMintedKnowledgeAssetProvenance( + kaId: bigint, + expectedMerkleRoot: Uint8Array, + expectedContextGraphId: bigint, + ): Promise { + const storage = this.contracts.knowledgeAssetStorage; + if (!storage) return null; + const expectedHex = ethers.hexlify(expectedMerkleRoot).toLowerCase(); + + // 1. Chain root must be EXACTLY the locally sealed root, and exactly one + // version (a superseded mint must go through named recovery — adopting + // index 0 would later stamp vmCurrentAssertion to a stale version). + const roots: Array<{ publisher: string; merkleRoot: string; timestamp: bigint }> = + await this.readContract(storage, 'kas.getMerkleRoots', 'getMerkleRoots', kaId); + if (!roots || roots.length === 0) { + throw Object.assign( + new Error(`adopt-existing-mint: kaId ${kaId} reported minted but has no on-chain merkle roots`), + { code: 'KA_ID_COLLISION' }, + ); + } + if (ethers.hexlify(roots[0].merkleRoot).toLowerCase() !== expectedHex) { + throw Object.assign( + new Error( + `adopt-existing-mint: kaId ${kaId} on-chain root ${ethers.hexlify(roots[0].merkleRoot)} ` + + `does not match locally sealed root ${expectedHex} — refusing to adopt someone else's content`, + ), + { code: 'KA_ID_COLLISION' }, + ); + } + if (roots.length > 1) { + throw Object.assign( + new Error(`adopt-existing-mint: kaId ${kaId} has ${roots.length} merkle roots (updated since mint); use named recovery`), + { code: 'KA_SUPERSEDED' }, + ); + } + + // 2. CG binding: the minted KA must belong to the CG this publish targets. + if (this.contracts.contextGraphStorage) { + const boundCg = BigInt( + await this.readContract( + this.contracts.contextGraphStorage, 'cgStorage.kaToContextGraph', + 'kaToContextGraph', kaId, + ), + ); + if (boundCg !== expectedContextGraphId) { + throw Object.assign( + new Error(`adopt-existing-mint: kaId ${kaId} bound to CG ${boundCg}, expected ${expectedContextGraphId}`), + { code: 'KA_CG_MISMATCH' }, + ); + } + } + + // 3. Recover the mint tx via the KnowledgeAssetCreated(kaId indexed) log. + // The contract stored block.timestamp verbatim into roots[0].timestamp, + // so binary-search the block by timestamp and scan a padded window. + // Everything below is best-effort: any failure -> null (caller rethrows). + try { + const mintTs = Number(roots[0].timestamp); + const storageAddress = String(storage.target); + const { fromBlock, head, scanProviders } = await this.resolveKaStorageDeployBlock(storageAddress); + let lo = fromBlock; + let hi = head; + while (lo < hi) { + const mid = lo + Math.floor((hi - lo) / 2); + const ts = await this.getBlockTimestamp(mid); + if (ts >= mintTs) hi = mid; else lo = mid + 1; + } + const PAD = 128; // absorbs same-timestamp neighbours; single getLogs page + const scanLo = Math.max(fromBlock, lo - PAD); + const scanHi = Math.min(head, lo + PAD); + const filter = storage.filters.KnowledgeAssetCreated(kaId); + const connected = new Map(); + const { logs } = await this.queryEventLogsPage( + storage, filter, scanLo, scanHi, scanProviders, connected, 'adoptExistingMint', + ); + if (logs.length === 0) return null; + const found = logs[0]; + const parsed = 'args' in found && (found as ethers.EventLog).args + ? (found as ethers.EventLog) + : null; + const parsedArgs = parsed?.args ?? storage.interface.parseLog(found)?.args; + if (!parsedArgs) return null; + // Independent binding of the tx to the content: the event's merkleRoot + // must equal the sealed root too, not just storage state. + if (ethers.hexlify(parsedArgs.merkleRoot).toLowerCase() !== expectedHex) { + throw Object.assign( + new Error(`adopt-existing-mint: kaId ${kaId} mint-event root does not match sealed root`), + { code: 'KA_ID_COLLISION' }, + ); + } + return { + batchId: kaId, + kaId, + startKAId: kaId, + endKAId: kaId, + merkleRoot: expectedMerkleRoot, + knowledgeAssetsContract: storageAddress.toLowerCase(), + txHash: found.transactionHash, + blockNumber: found.blockNumber, + txIndex: found.transactionIndex, + blockTimestamp: mintTs, + publisherAddress: roots[0].publisher, + authorAddress: String(parsedArgs.author), + }; + } catch (err) { + if ((err as { code?: string })?.code === 'KA_ID_COLLISION') throw err; + return null; + } + } + protected async getBlockTimestamp(blockNumber: number): Promise { // A CONCRETE (already-mined receipt) block — NOT the tip, so it uses normal // endpoint stickiness (the endpoint that produced the receipt is the one most diff --git a/packages/chain/src/evm-adapter-errors.ts b/packages/chain/src/evm-adapter-errors.ts index 402a572c03..712c21599b 100644 --- a/packages/chain/src/evm-adapter-errors.ts +++ b/packages/chain/src/evm-adapter-errors.ts @@ -272,6 +272,32 @@ export function enrichEvmError(err: unknown): string | null { * falls back to string matching on the message / shortMessage / reason / * nested cause so a stringified or re-wrapped revert is still caught. */ +/** + * Adopt-existing-mint (see dkg-publisher adoptExistingMintOrRethrow): decode a + * `KaIdAlreadyMinted(uint256 kaId)` custom-error revert and return the minted + * kaId. Unlike `isTooLowAllowanceError` there is deliberately NO string-matching + * fallback: adoption is state-changing and must cross-check the decoded kaId + * against the locally reserved id, so we require the structured decode that + * `enrichEvmError` stamps at `err.revert`. + */ +export function getKaIdAlreadyMintedKaId(err: unknown): bigint | undefined { + if (!err || typeof err !== 'object') return undefined; + // enrichEvmError is idempotent — call defensively in case no upstream layer + // (isRetryableRpcError / allowance recovery) enriched this error object yet. + enrichEvmError(err); + const e = err as { revert?: { name?: unknown; args?: unknown[] }; cause?: unknown }; + if (e.revert?.name === 'KaIdAlreadyMinted') { + const raw = e.revert.args?.[0]; + try { + return raw == null ? undefined : BigInt(raw as string | number | bigint); + } catch { + return undefined; + } + } + if (e.cause && typeof e.cause === 'object') return getKaIdAlreadyMintedKaId(e.cause); + return undefined; +} + export function isTooLowAllowanceError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 4bd761afa6..9ee7484a75 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -30,6 +30,7 @@ import { EventsMethods } from './evm-adapter-events.js'; export { decodeEvmError, enrichEvmError, + getKaIdAlreadyMintedKaId, isTooLowAllowanceError, isInsufficientFundsError, InsufficientPublisherFundsError, diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index a80dd8025b..db4071731f 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -29,6 +29,7 @@ export { type EVMAdapterConfig, decodeEvmError, enrichEvmError, + getKaIdAlreadyMintedKaId, isRetryableRpcError, isKnownTransactionError, resolveRpcUrls, diff --git a/packages/chain/test/enrich-evm-error-extra.test.ts b/packages/chain/test/enrich-evm-error-extra.test.ts index 2a8b01eead..22a79da847 100644 --- a/packages/chain/test/enrich-evm-error-extra.test.ts +++ b/packages/chain/test/enrich-evm-error-extra.test.ts @@ -37,7 +37,12 @@ */ import { describe, it, expect } from 'vitest'; import { Interface } from 'ethers'; -import { enrichEvmError, decodeEvmError, isTooLowAllowanceError } from '../src/evm-adapter.js'; +import { + enrichEvmError, + decodeEvmError, + isTooLowAllowanceError, + getKaIdAlreadyMintedKaId, +} from '../src/evm-adapter.js'; const iface = new Interface([ 'error BatchNotFound(uint256 batchId)', @@ -198,3 +203,107 @@ describe('enrichEvmError — regression guards [CH-10]', () => { expect(err.message).toMatch(/NotBatchPublisher\(7, 0x[0-9a-fA-F]{40}\)/); }); }); + +// --------------------------------------------------------------------------- +// getKaIdAlreadyMintedKaId — adopt-existing-mint classifier +// (see dkg-publisher adoptExistingMintOrRethrow). The decode goes through the +// AGGREGATE error interface built from packages/chain/abi/*.json — the +// KaIdAlreadyMinted(uint256) fragment ships in DKGKnowledgeAssets.json, which +// is what makes the local encode below decodable in production. The local +// Interface here is used only to ENCODE the revert payload, mirroring the +// carrier-construction idiom of the CH-10 suites above. +// --------------------------------------------------------------------------- + +const kaMintedIface = new Interface(['error KaIdAlreadyMinted(uint256 kaId)']); +// Realistic packed kaId ((author << 96) | number) — deliberately far beyond +// Number.MAX_SAFE_INTEGER so any float round-trip in the classifier would +// corrupt it and fail the strict bigint equality below. +const PACKED_KA_ID = + (BigInt('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') << 96n) | 41n; +const KA_ALREADY_MINTED_HEX = kaMintedIface.encodeErrorResult('KaIdAlreadyMinted', [ + PACKED_KA_ID, +]); + +describe('getKaIdAlreadyMintedKaId — adopt-existing-mint classifier', () => { + it('decodes a direct KaIdAlreadyMinted CALL_EXCEPTION revert into the minted kaId (bigint)', () => { + // Hardhat-shape ethers CALL_EXCEPTION: revert data embedded in the message. + const err = Object.assign( + new Error( + `execution reverted (unknown custom error) (action="call", data="${KA_ALREADY_MINTED_HEX}", reason=null)`, + ), + { code: 'CALL_EXCEPTION' }, + ); + expect(getKaIdAlreadyMintedKaId(err)).toBe(PACKED_KA_ID); + // The classifier enriches defensively — the structured revert must now be + // stamped (decoded via the aggregate DKGKnowledgeAssets ABI, not a local one). + expect((err as unknown as { revert?: { name?: string } }).revert?.name).toBe( + 'KaIdAlreadyMinted', + ); + + // Geth-shape structured field carrier decodes identically. + const raw = { + message: 'execution reverted (unknown custom error)', + data: KA_ALREADY_MINTED_HEX, + }; + expect(getKaIdAlreadyMintedKaId(raw)).toBe(PACKED_KA_ID); + }); + + it('recurses into err.cause when only the nested error carries the revert', () => { + // Pre-stamped structured revert on the CAUSE only — no raw revert data + // anywhere, so the outer enrich pass finds nothing and the classifier + // must take its explicit `err.cause` recursion branch. + const preStamped = { + message: 'wrapped by an upstream retry layer (no data fields here)', + cause: { + revert: { name: 'KaIdAlreadyMinted', args: [PACKED_KA_ID] }, + }, + }; + expect(getKaIdAlreadyMintedKaId(preStamped)).toBe(PACKED_KA_ID); + + // Raw revert data nested under cause (typical ethers v6 wrap) decodes too. + const rawNested = { + message: 'could not coalesce error', + cause: Object.assign( + new Error('execution reverted (unknown custom error)'), + { code: 'CALL_EXCEPTION', data: KA_ALREADY_MINTED_HEX }, + ), + }; + expect(getKaIdAlreadyMintedKaId(rawNested)).toBe(PACKED_KA_ID); + }); + + it('returns undefined for a different custom-error revert (TooLowAllowance)', () => { + const err = { + message: 'execution reverted (unknown custom error)', + data: TOO_LOW_ALLOWANCE_HEX, + }; + expect(getKaIdAlreadyMintedKaId(err)).toBeUndefined(); + // Not silently undecoded — it IS decoded, just not the error we adopt on. + expect((err as unknown as { revert?: { name?: string } }).revert?.name).toBe( + 'TooLowAllowance', + ); + expect(isTooLowAllowanceError(err)).toBe(true); + }); + + it('returns undefined for undecodable / garbage revert data', () => { + expect( + getKaIdAlreadyMintedKaId( + new Error('execution reverted (unknown custom error) (data="0xdeadbeef")'), + ), + ).toBeUndefined(); + expect( + getKaIdAlreadyMintedKaId({ message: 'execution reverted', data: '0xdeadbeef' }), + ).toBeUndefined(); + expect(getKaIdAlreadyMintedKaId(new Error('connect ECONNREFUSED 127.0.0.1:8545'))).toBeUndefined(); + // Non-object carriers must not throw (deliberately NO string-matching + // fallback — adoption is state-changing and requires the structured decode). + expect(getKaIdAlreadyMintedKaId(null)).toBeUndefined(); + expect(getKaIdAlreadyMintedKaId(undefined)).toBeUndefined(); + expect(getKaIdAlreadyMintedKaId('KaIdAlreadyMinted(42)')).toBeUndefined(); + // A stamped revert whose args are garbage must not throw either. + expect( + getKaIdAlreadyMintedKaId({ + revert: { name: 'KaIdAlreadyMinted', args: ['not-a-number'] }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/chain/vitest.unit.config.ts b/packages/chain/vitest.unit.config.ts index c4b2a606c7..71b6015f01 100644 --- a/packages/chain/vitest.unit.config.ts +++ b/packages/chain/vitest.unit.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ include: [ 'test/**/*.unit.test.ts', 'test/conviction-cost-covered-decode.test.ts', + 'test/enrich-evm-error-extra.test.ts', 'test/evm-adapter-pca-rpc.unit.test.ts', 'test/evm-adapter-pca-enrich.test.ts', 'test/filter-error-console-suppressor.test.ts', diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 05e5021182..00a53e0200 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1,6 +1,6 @@ import type { Quad, SharedMemoryGraphScope, TripleStore } from '@origintrail-official/dkg-storage'; import type { ChainAdapter, OnChainPublishResult, AddBatchToContextGraphParams } from '@origintrail-official/dkg-chain'; -import { enrichEvmError } from '@origintrail-official/dkg-chain'; +import { enrichEvmError, getKaIdAlreadyMintedKaId } from '@origintrail-official/dkg-chain'; import type { EventBus, GraphKnowledgeAssetScope, OperationContext } from '@origintrail-official/dkg-core'; import type { AssertionSeal } from '@origintrail-official/dkg-core'; import { DKGEvent, Logger, createOperationContext, sha256, encodeWorkspacePublishRequest, encodeEncryptedWorkspacePayload, encryptWorkspacePayload, contextGraphDataUri, contextGraphDataGraphUri, contextGraphMetaUri, contextGraphPrivateUri, contextGraphAssertionUri, contextGraphLayerUri, MemoryLayer, assertionLifecycleUri, contextGraphSubGraphUri, contextGraphSubGraphMetaUri, contextGraphSubGraphPrivateUri, SYSTEM_CONTEXT_GRAPHS, validateSubGraphName, isSafeIri, assertSafeIri, assertSafeRdfTerm, assertQuadLiteralsMutf8Safe, DKG_GOSSIP_MAX_MESSAGE_BYTES, SwmGossipPayloadTooLargeError, STORAGE_ACK_MAX_STAGING_BYTES, type Ed25519Keypair, buildAuthorAttestationTypedData, buildUpdateAuthorAttestationTypedData, AUTHOR_SCHEME_VERSION_V1, TrustLevel, TRUST_LEVEL_PREDICATE, assertNoUserAuthoredTrustLevelQuads, buildTrustLevelQuads, isTrustLevelQuad, isSwmMerkleExcludedQuad, WORKSPACE_OWNER_PREDICATE, DKG_ENTITY, DKG_ROOT_ENTITY_LEGACY, ENTITY_PRED_ALT, parseAssertionSealQuads, ASSERTION_SEAL_PREDICATES, DKG_ONTOLOGY, GRAPH_KA_CONTENT_SCOPE_VERSION, LegacyKnowledgeAssetReadOnlyError, createGraphKnowledgeAssetScope, knowledgeAssetLayerGraphUri } from '@origintrail-official/dkg-core'; @@ -3904,6 +3904,7 @@ export class DKGPublisher implements Publisher { merkleLeafCount: kcMerkleLeafCount, }, }); + try { onChainResult = await this.chain.createKnowledgeAssets!({ publishOperationId, contextGraphId: v10CgId, @@ -3943,6 +3944,25 @@ export class DKGPublisher implements Publisher { })), onBroadcast: emitWriteAheadStart, }); + } catch (mintErr) { + // Adopt-existing-mint: a KaIdAlreadyMinted revert for OUR reserved + // kaId is cryptographic proof this exact publish already landed + // (an earlier attempt confirmed while a transient error — RPC + // confirm-wait failure / replacement-fee nonce race — made us + // record failure). Verify chain truth + recover the mint tx's + // provenance, synthesize onChainResult, and fall through into the + // UNCHANGED success path so the local state is by-construction + // identical to a normal confirmed publish. Anything not provably + // ours rethrows unchanged. + onChainResult = await this.adoptExistingMintOrRethrow({ + mintErr, + reservedKaId, + kcMerkleRoot, + v10CgId, + hasSeal: graphPublish !== undefined && options.precomputedAttestation !== undefined, + ctx, + }); + } } finally { if (wroteAhead) onPhase?.('chain:writeahead', 'end'); } @@ -9061,6 +9081,55 @@ export class DKGPublisher implements Publisher { * source of truth for the id and eliminates the double-allocation that would * otherwise burn a second `(author, number)` on every finalize→publish. */ + /** + * Adopt-existing-mint: called from the createKnowledgeAssets catch. When the + * revert is KaIdAlreadyMinted for exactly our reserved kaId on a sealed + * graph publish, verify chain truth and recover the mint provenance via + * ChainAdapter.getMintedKnowledgeAssetProvenance; otherwise (or when the + * log is unrecoverable) rethrow the ORIGINAL error — never synthesize a + * txHash (finalization-handler.ts:1345 invariant). + */ + private async adoptExistingMintOrRethrow(args: { + mintErr: unknown; + reservedKaId: bigint | undefined; + kcMerkleRoot: Uint8Array; + v10CgId: bigint; + hasSeal: boolean; + ctx: OperationContext; + }): Promise { + const mintedKaId = getKaIdAlreadyMintedKaId(args.mintErr); + const provenanceFn = this.chain.getMintedKnowledgeAssetProvenance?.bind(this.chain); + if ( + mintedKaId === undefined + || args.reservedKaId === undefined + || mintedKaId !== args.reservedKaId + || !args.hasSeal + || provenanceFn === undefined + ) { + throw args.mintErr; + } + this.log.warn( + args.ctx, + `[adopt-existing-mint] kaId ${args.reservedKaId} already minted on-chain; ` + + 'verifying sealed root against chain and recovering mint provenance', + ); + const synthesized = await provenanceFn(args.reservedKaId, args.kcMerkleRoot, args.v10CgId); + if (!synthesized) { + this.log.warn( + args.ctx, + `[adopt-existing-mint] mint provenance unrecoverable for kaId ${args.reservedKaId} ` + + '(pruned/non-archive RPC?); rethrowing original mint error', + ); + throw args.mintErr; + } + this.log.info( + args.ctx, + `[adopt-existing-mint] adopted kaId ${args.reservedKaId} ` + + `tx=${synthesized.txHash} block=${synthesized.blockNumber}; continuing confirmed publish path`, + ); + return synthesized; + } + private async ensureReservedKaId(author: string, precomputed?: bigint): Promise { if (precomputed !== undefined) return precomputed; if (!this.kaAllocator) return undefined; diff --git a/packages/publisher/test/_helpers/seal.ts b/packages/publisher/test/_helpers/seal.ts index 803e0373af..dbbdd04ee0 100644 --- a/packages/publisher/test/_helpers/seal.ts +++ b/packages/publisher/test/_helpers/seal.ts @@ -48,6 +48,13 @@ export interface PrecomputedAttestation { authorAddress: string; signature: { r: Uint8Array; vs: Uint8Array }; schemeVersion: number; + /** + * §F2 — packed `(author << 96) | number` kaId the seal's typed data binds. + * `buildSeal` has always returned it (the publisher mints exactly this id); + * it was just missing from this declared type, which made sealed publish + * option bags fail tsc against `PublishOptions.precomputedAttestation`. + */ + reservedKaId: bigint; } export interface PrecomputedUpdateAttestation { diff --git a/packages/publisher/test/publish-adopt-existing-mint.test.ts b/packages/publisher/test/publish-adopt-existing-mint.test.ts new file mode 100644 index 0000000000..491f448cb2 --- /dev/null +++ b/packages/publisher/test/publish-adopt-existing-mint.test.ts @@ -0,0 +1,232 @@ +/** + * Adopt-existing-mint interception (dkg-publisher `adoptExistingMintOrRethrow`). + * + * A transient error during confirm-wait can land the mint on-chain while the + * publisher records failure; every retry then reverts `KaIdAlreadyMinted` + * forever. When that revert decodes to OUR reserved kaId on a sealed + * graph-scoped publish, the publisher must verify chain truth + recover the + * original mint's provenance via `ChainAdapter.getMintedKnowledgeAssetProvenance` + * and fall through the UNCHANGED confirmed-publish path. Anything not provably + * ours — a different kaId, or unrecoverable provenance (`null`) — must rethrow + * the ORIGINAL error object verbatim: the publisher must never synthesize a + * txHash (finalization-handler invariant). + * + * Integration-shaped: these tests drive the real `publish()` graph-scoped + * path end to end (seal preflight, ACK collection, chain submit, VM storage) + * against a `MockChainAdapter` subclass whose `createKnowledgeAssets` throws + * an ethers-style CALL_EXCEPTION already stamped with the structured + * `revert = { name: 'KaIdAlreadyMinted', args: [kaId] }` shape that + * `enrichEvmError` produces (with `revert` pre-stamped and no raw revert-data + * fields, the classifier's defensive re-enrich is a no-op). The decode of raw + * revert data into that shape is covered separately in + * `packages/chain/test/enrich-evm-error-extra.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { + GRAPH_KA_CONTENT_SCOPE_VERSION, + TypedEventBus, + generateEd25519Keypair, +} from '@origintrail-official/dkg-core'; +import { MockChainAdapter, type OnChainPublishResult } from '@origintrail-official/dkg-chain'; +import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; +import { DKGPublisher } from '../src/dkg-publisher.js'; +import { buildSeal, mockSealCtx } from './_helpers/seal.js'; +import { mockChainStubACKProvider } from './_helpers/acks.js'; + +const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const CONTEXT_GRAPH_ID = '1'; +const ADOPTED_TX_HASH = `0x${'abc1'.repeat(16)}`; + +/** + * Mirrors `AdapterSigningChain` from publisher-no-random-wallet.test.ts + * (adapter-backed signer bound to a real wallet) plus the two hooks this + * suite exercises: a configurable `createKnowledgeAssets` mint failure and a + * recording `getMintedKnowledgeAssetProvenance` stub. + */ +class AdoptableMintChain extends MockChainAdapter { + mintError?: unknown; + createAttempts = 0; + provenanceCalls: Array<{ + kaId: bigint; + expectedMerkleRoot: Uint8Array; + expectedContextGraphId: bigint; + }> = []; + provenanceResult: OnChainPublishResult | null = null; + + constructor(private readonly wallet: ethers.Wallet) { + super('mock:31337', wallet.address); + this.seedIdentity(wallet.address, 1n); + this.minimumRequiredSignatures = 1; + } + + override async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.wallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } + + async signTypedData( + domain: ethers.TypedDataDomain, + types: Record>, + value: Record, + ): Promise { + return this.wallet.signTypedData(domain, types, value); + } + + override async createKnowledgeAssets( + params: Parameters[0], + ): Promise { + this.createAttempts += 1; + if (this.mintError !== undefined) throw this.mintError; + return super.createKnowledgeAssets(params); + } + + async getMintedKnowledgeAssetProvenance( + kaId: bigint, + expectedMerkleRoot: Uint8Array, + expectedContextGraphId: bigint, + ): Promise { + this.provenanceCalls.push({ kaId, expectedMerkleRoot, expectedContextGraphId }); + return this.provenanceResult; + } +} + +/** + * Ethers-style CALL_EXCEPTION carrying the structured revert that + * `enrichEvmError` stamps after decoding a `KaIdAlreadyMinted(uint256)` + * custom-error revert (see the classifier tests in the chain package for + * the raw-data → structured-shape decode itself). + */ +function kaIdAlreadyMintedRevert(kaId: bigint): Error { + return Object.assign( + new Error(`execution reverted (custom error): KaIdAlreadyMinted(${kaId})`), + { + code: 'CALL_EXCEPTION', + revert: { name: 'KaIdAlreadyMinted', args: [kaId] }, + }, + ); +} + +/** + * Build a sealed graph-scoped publish argument bag against a fresh + * publisher + AdoptableMintChain. Mirrors the graph-scoped publish setup in + * publisher-no-random-wallet.test.ts: the seal allocates the packed + * reservedKaId, and the kaUal must derive exactly that id. + */ +async function setupSealedGraphPublish() { + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdoptableMintChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair: await generateEd25519Keypair(), + publisherNodeIdentityId: 1n, + }); + const publishQuad: Quad = { + subject: 'urn:test:adopt-existing-mint', + predicate: 'http://schema.org/name', + object: '"adopted"', + graph: '', + }; + const seal = await buildSeal({ + quads: [publishQuad], + author: wallet, + contextGraphId: CONTEXT_GRAPH_ID, + ctx: mockSealCtx(), + }); + const reservedKaId = seal.reservedKaId; + const kaNumber = reservedKaId & ((1n << 96n) - 1n); + // Bare EIP-155 label targets the adapter's `mock:31337` via numeric alias; + // lowercase author address = the canonical scope UAL the publisher returns. + const ual = `did:dkg:31337/${wallet.address.toLowerCase()}/${kaNumber}`; + const publishOptions = { + contextGraphId: CONTEXT_GRAPH_ID, + quads: [publishQuad], + contentScopeVersion: GRAPH_KA_CONTENT_SCOPE_VERSION, + kaUal: ual, + assertionVersion: 1, + publicTripleCount: 1, + privateTripleCount: 0, + precomputedAttestation: seal, + v10ACKProvider: mockChainStubACKProvider(), + }; + return { wallet, chain, publisher, seal, reservedKaId, ual, publishOptions }; +} + +describe('publish adopt-existing-mint interception (KaIdAlreadyMinted)', () => { + it('adopts OUR already-minted kaId: synthesized provenance flows through the confirmed path', async () => { + const s = await setupSealedGraphPublish(); + s.chain.mintError = kaIdAlreadyMintedRevert(s.reservedKaId); + s.chain.provenanceResult = { + batchId: s.reservedKaId, + kaId: s.reservedKaId, + startKAId: s.reservedKaId, + endKAId: s.reservedKaId, + merkleRoot: s.seal.expectedMerkleRoot, + knowledgeAssetsContract: (await s.chain.getDKGKnowledgeAssetsAddress()).toLowerCase(), + txHash: ADOPTED_TX_HASH, + blockNumber: 4242, + txIndex: 0, + blockTimestamp: 1_753_000_000, + publisherAddress: s.wallet.address, + authorAddress: s.wallet.address, + }; + + const result = await s.publisher.publish(s.publishOptions); + + expect(result.status).toBe('confirmed'); + expect(result.ual).toBe(s.ual); + // The synthesized provenance IS the on-chain result of this publish. + expect(result.onChainResult?.txHash).toBe(ADOPTED_TX_HASH); + expect(result.onChainResult?.blockNumber).toBe(4242); + expect(s.chain.createAttempts).toBe(1); + // Chain truth was verified with exactly (reservedKaId, sealedRoot, cgId). + expect(s.chain.provenanceCalls).toHaveLength(1); + const call = s.chain.provenanceCalls[0]; + expect(call.kaId).toBe(s.reservedKaId); + expect(ethers.hexlify(call.expectedMerkleRoot)).toBe( + ethers.hexlify(s.seal.expectedMerkleRoot), + ); + expect(call.expectedContextGraphId).toBe(BigInt(CONTEXT_GRAPH_ID)); + }); + + it('rethrows the ORIGINAL error for someone else\'s kaId — no provenance lookup', async () => { + const s = await setupSealedGraphPublish(); + const original = kaIdAlreadyMintedRevert(s.reservedKaId + 1n); + s.chain.mintError = original; + + const caught = await s.publisher.publish(s.publishOptions).then( + () => null, + (err: unknown) => err, + ); + + // Verbatim rethrow of the original error object, still carrying the + // structured revert — downstream classifiers must keep working. + expect(caught).toBe(original); + expect((caught as { revert?: { name?: string } }).revert?.name).toBe('KaIdAlreadyMinted'); + // A foreign kaId must never trigger a chain-truth probe. + expect(s.chain.provenanceCalls).toHaveLength(0); + }); + + it('rethrows the ORIGINAL error when provenance is unrecoverable (null) — never synthesizes a txHash', async () => { + const s = await setupSealedGraphPublish(); + const original = kaIdAlreadyMintedRevert(s.reservedKaId); + s.chain.mintError = original; + s.chain.provenanceResult = null; + + const caught = await s.publisher.publish(s.publishOptions).then( + () => null, + (err: unknown) => err, + ); + + expect(caught).toBe(original); + expect((caught as { revert?: { name?: string } }).revert?.name).toBe('KaIdAlreadyMinted'); + // The adoption path DID consult chain truth for our kaId before giving up. + expect(s.chain.provenanceCalls).toHaveLength(1); + expect(s.chain.provenanceCalls[0].kaId).toBe(s.reservedKaId); + }); +}); diff --git a/packages/publisher/vitest.unit.config.ts b/packages/publisher/vitest.unit.config.ts index eda638cc8d..25650e6463 100644 --- a/packages/publisher/vitest.unit.config.ts +++ b/packages/publisher/vitest.unit.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ 'test/rootless-access.test.ts', 'test/agents-meta-bound.test.ts', 'test/ack-collector.test.ts', + 'test/publish-adopt-existing-mint.test.ts', 'test/publish-lifecycle-logger.test.ts', 'test/storage-ack-handler.test.ts', 'test/swm-slice-ack-unbounded.test.ts',