Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions packages/chain/src/chain-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,24 @@ export interface ChainAdapter {
*/
getKnowledgeAssetOwner?(kaId: bigint): Promise<string>;

/**
* 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<OnChainPublishResult | null>;

/** Read minimumRequiredSignatures from ParametersStorage. Used by ACKCollector. */
getMinimumRequiredSignatures?(): Promise<number>;

Expand Down
117 changes: 117 additions & 0 deletions packages/chain/src/evm-adapter-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: The real provenance verifier is untested

What's wrong
This PR's safety claim depends on the new adapter method refusing to adopt a kaId unless storage roots, context graph binding, and the mint event all match the sealed publish. The added publisher test only verifies that publish calls a stubbed hook with the expected arguments, so the most important adoption checks can regress while the new suite stays green.

Example
A regression that removed the root comparison at packages/chain/src/evm-adapter-base.ts:2748, skipped the CG mismatch check at 2772, or returned the first log without checking parsedArgs.merkleRoot at 2812 would still pass the new publisher tests because the provenance hook is stubbed to return a successful OnChainPublishResult.

Suggested direction
Keep the publisher tests for the handoff, but add unit coverage for the EVM adapter method itself so the security-critical chain-truth checks and log-recovery fallback are validated instead of stubbed away.

For Agents
Add focused chain-package tests around EVMChainAdapterBase.getMintedKnowledgeAssetProvenance. Stub readContract, getBlockTimestamp, resolveKaStorageDeployBlock, and queryEventLogsPage to prove: matching root+CG+event returns the synthesized result; empty/no logs returns null; no roots/root mismatch/multiple roots/CG mismatch/event-root mismatch throw the documented codes; non-collision scan failures return null.

kaId: bigint,
expectedMerkleRoot: Uint8Array,
expectedContextGraphId: bigint,
): Promise<OnChainPublishResult | null> {
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<JsonRpcProvider, Contract>();
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<number> {
// 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
Expand Down
26 changes: 26 additions & 0 deletions packages/chain/src/evm-adapter-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nit: Do not split the allowance helper from its JSDoc

Why it matters
This file is mostly classifier helpers whose safety depends on precise comments about fallback behavior. Detaching the JSDoc makes the error-classifier section harder to scan and easier to misread.

Suggestion
Move getKaIdAlreadyMintedKaId above the isTooLowAllowanceError comment, or move the allowance JSDoc back down so it remains directly attached to isTooLowAllowanceError.

* 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 {
Expand Down
1 change: 1 addition & 0 deletions packages/chain/src/evm-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { EventsMethods } from './evm-adapter-events.js';
export {
decodeEvmError,
enrichEvmError,
getKaIdAlreadyMintedKaId,
isTooLowAllowanceError,
isInsufficientFundsError,
InsufficientPublisherFundsError,
Expand Down
1 change: 1 addition & 0 deletions packages/chain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
type EVMAdapterConfig,
decodeEvmError,
enrichEvmError,
getKaIdAlreadyMintedKaId,
isRetryableRpcError,
isKnownTransactionError,
resolveRpcUrls,
Expand Down
111 changes: 110 additions & 1 deletion packages/chain/test/enrich-evm-error-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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();
});
});
1 change: 1 addition & 0 deletions packages/chain/vitest.unit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading