From fef964034c908063a59bb377396bba14473ffa72 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 3 May 2026 20:03:34 +0200 Subject: [PATCH 01/43] fix(publisher): drop random fallback wallet + audit Wallet.createRandom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DKGPublisher constructed without `publisherPrivateKey` previously generated `ethers.Wallet.createRandom()` whenever chain was enabled and used it to sign on-chain publish digests, ACK self-signatures, and authorship proofs. Resulting signatures were unverifiable: signed by a throw-away key the caller never saw, attributed via `publisherAddress` to a different address. Constructor now leaves `publisherWallet` undefined; every signing call site is already guarded by `if (this.publisherWallet)` and skips gracefully. This is the same anti-pattern that destroyed nine testnet admin keys at profile-creation time pre-PR-#366 (`ensureProfile` minted a random admin wallet, used it once in `createProfile`, then dropped it without persistence — keys were unrecoverable). PR #366 fixed `ensureProfile` itself; this PR closes the remaining instance and adds a CI gate so the pattern cannot creep back. Changes: - packages/publisher/src/dkg-publisher.ts: remove random fallback; document why publisherWallet stays undefined absent a key. - packages/publisher/test/publisher-no-random-wallet.test.ts: regression test for all three branches (chain enabled + no key, chain disabled + no key, explicit key). - scripts/audit-create-random.mjs: dependency-free Node script that walks `packages/*/src/**` and fails on `Wallet.createRandom(` outside three explicitly justified call sites (op-wallets first-run bootstrap, agent-keystore custodial agent registration, evm-module hardhat helper). Skips comment lines and test files. - .github/workflows/ci.yml: run the audit before pnpm install in the build job (~300ms, fails fast). - CHANGELOG.md: entry under Unreleased. Verified: audit script catches a synthetic regression and passes on the fixed tree; new test passes; pre-existing publisher test suite passes (share-size-boundary-extra + dkg-publisher.test.ts, 39/39). Co-authored-by: Cursor --- .github/workflows/ci.yml | 8 + CHANGELOG.md | 6 + packages/publisher/src/dkg-publisher.ts | 14 +- .../test/publisher-no-random-wallet.test.ts | 79 ++++++++ scripts/audit-create-random.mjs | 183 ++++++++++++++++++ 5 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 packages/publisher/test/publisher-no-random-wallet.test.ts create mode 100755 scripts/audit-create-random.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b9e1a57a..6a13cf40b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,14 @@ jobs: node-version-file: .nvmrc cache: pnpm + # Cheap (~300 ms) audit that bans `Wallet.createRandom()` outside a + # small allowlist of intentional call sites. See + # `scripts/audit-create-random.mjs` for the incident this prevents + # from regressing — pre-PR-#366 `ensureProfile` used the anti-pattern + # to destroy nine testnet admin keys at registration time. + - name: Audit Wallet.createRandom usage + run: node scripts/audit-create-random.mjs + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/CHANGELOG.md b/CHANGELOG.md index da9d72c24..02b60ce61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the DKG V9 node are documented here. The format is based ## [Unreleased] +### Fixed +- **Publisher no longer auto-mints an ephemeral signing wallet** (`packages/publisher/src/dkg-publisher.ts`): `DKGPublisher` constructed without `publisherPrivateKey` previously generated `ethers.Wallet.createRandom()` whenever chain was enabled and used it to sign on-chain publish digests, ACK self-signatures, and authorship proofs. Signatures were unverifiable (signed by a throw-away key the caller never saw, attributed via `publisherAddress` to a different address). The constructor now leaves `publisherWallet` undefined; every signing call site is already guarded by `if (this.publisherWallet)` and skips gracefully. + +### Added +- **`scripts/audit-create-random.mjs`** + CI gate: bans new `Wallet.createRandom()` use in `packages/*/src/**` outside three explicitly justified call sites (`op-wallets.ts` first-run wallet bootstrap, `agent-keystore.ts` custodial chat-agent registration, `evm-module/utils/helpers.ts` deploy script). Same anti-pattern destroyed nine testnet admin keys in May 2026 via the pre-PR-#366 `ensureProfile` random-and-discard path; audit runs in <300 ms on every CI build. + --- ## [10.0.0-rc.4] - 2026-05-04 diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index e1d352906..7bebc296f 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -244,11 +244,17 @@ export class DKGPublisher implements Publisher { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; } else { + // No private key supplied → no signing capability. Every on-chain code + // path in this class is gated on `if (this.publisherWallet)` and + // gracefully no-ops when absent (see `executeOnChainPublish` and the + // self-sign / authorship-proof blocks). The previous behaviour + // generated an ephemeral `Wallet.createRandom()` here whenever chain + // was enabled, which produced unverifiable signatures attributed to a + // throw-away address — actively misleading callers that supplied + // `publisherAddress` separately. See PR fixing this for the + // testnet-blocking incident chain (`ensureProfile` had the same + // anti-pattern, fixed in PR #366). this.publisherAddress = config.publisherAddress ?? '0x' + '0'.repeat(40); - if (config.chain.chainId !== 'none') { - const random = ethers.Wallet.createRandom(); - this.publisherWallet = new ethers.Wallet(random.privateKey); - } } for (const key of config.additionalSignerKeys ?? []) { diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts new file mode 100644 index 000000000..2951aeeac --- /dev/null +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -0,0 +1,79 @@ +/** + * Regression test: DKGPublisher must NOT auto-mint a random publisher wallet + * when no `publisherPrivateKey` is supplied. + * + * Background + * ---------- + * Pre-fix behaviour generated `ethers.Wallet.createRandom()` whenever + * `chain.chainId !== 'none'` and no key was supplied. The resulting wallet + * was used to sign on-chain publish digests, ACK self-signatures, and + * authorship proofs — all attributed (by `publisherAddress`) to whatever + * address the caller had passed in separately. So signatures looked + * authoritative but were verifiably-junk: signed by a throw-away key the + * caller had never seen. + * + * This is the same anti-pattern that destroyed nine testnet admin keys via + * `ensureProfile` (see `scripts/audit-create-random.mjs` header). Fix and + * test both land in the same PR. The constructor now leaves + * `publisherWallet` undefined; every signing call site is already guarded + * by `if (this.publisherWallet)` and skips gracefully. + */ +import { describe, it, expect } from 'vitest'; +import { DKGPublisher } from '../src/dkg-publisher.js'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; +import type { ChainAdapter } from '@origintrail-official/dkg-chain'; + +// Minimal stub — DKGPublisher's constructor only reads `chain.chainId`. +// All other ChainAdapter methods are unused in this test. +function makeStubChain(chainId: string): ChainAdapter { + return { chainId } as unknown as ChainAdapter; +} + +describe('DKGPublisher: no random publisher wallet without explicit key', () => { + it('leaves publisherWallet undefined when chain is enabled but no key supplied', async () => { + const keypair = await generateEd25519Keypair(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + publisherAddress: '0x000000000000000000000000000000000000dEaD', + }); + + // Cast to any so we can assert on the private field — the regression we + // are guarding against is exactly that this field used to be a freshly + // generated random wallet, which is observable here. + expect((publisher as any).publisherWallet).toBeUndefined(); + expect((publisher as any).publisherAddress).toBe('0x000000000000000000000000000000000000dEaD'); + }); + + it('leaves publisherWallet undefined when chain is disabled and no key supplied', async () => { + const keypair = await generateEd25519Keypair(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('none'), + eventBus: new TypedEventBus(), + keypair, + }); + + expect((publisher as any).publisherWallet).toBeUndefined(); + }); + + it('still constructs publisherWallet when an explicit key is supplied', async () => { + const keypair = await generateEd25519Keypair(); + // Deterministic test-only key (not used elsewhere in the suite). + const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + publisherPrivateKey: TEST_KEY, + }); + const wallet = (publisher as any).publisherWallet; + expect(wallet).toBeDefined(); + expect(wallet.address.toLowerCase()).toBe('0x70997970c51812dc3a010c7d01b50e0d17dc79c8'); + expect((publisher as any).publisherAddress.toLowerCase()).toBe('0x70997970c51812dc3a010c7d01b50e0d17dc79c8'); + }); +}); diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs new file mode 100755 index 000000000..d73d442b6 --- /dev/null +++ b/scripts/audit-create-random.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Audit: ban undisciplined `Wallet.createRandom()` in production code. + * + * Why this exists + * --------------- + * On 2026-05-01 every testnet node bootstrapped on the V10 isolated Hub got + * its on-chain admin key from `ethers.Wallet.createRandom()` inside + * `EVMChainAdapter.ensureProfile()`. The wallet existed only as a local + * variable for the duration of one `createProfile` tx, then was garbage + * collected. The private key was never logged, never persisted, never + * surfaced to the operator. Result: nine identities (1–9) had their admin + * keys destroyed at registration time, breaking PR #366's auto-add of + * operational ACK signers (which is `onlyAdmin`) and forcing a partial + * network rebuild. + * + * PR #366 itself fixed `ensureProfile` (added a hard `if (!this.adminSigner) + * throw` guard so the random+discard path can't run silently). This audit + * script keeps that fix from regressing AND catches the same anti-pattern + * elsewhere — the publisher constructor had an analogous bug producing + * unverifiable signatures attributed to throw-away addresses, fixed in the + * same PR as this script. + * + * What it does + * ------------ + * Walks every `.ts` / `.js` source file under `packages/*\/src/**` and fails + * if it finds `Wallet.createRandom(` outside the explicitly allowlisted + * call sites below. Each allowlist entry has a one-line justification — + * if you need to add a new one, justify it in code review. + * + * What's allowed + * -------------- + * Random key generation IS legitimate when the resulting key is either + * (a) returned to a caller that persists it (e.g. `loadOpWallets` + * writes to `wallets.json`, custodial agent registration writes to + * the keystore), OR + * (b) used in a hardhat deploy script that prints the key for the + * operator to capture. + * It is NEVER legitimate to use a random key for actual signing inside the + * daemon and let it go out of scope without persistence. + * + * Tests are excluded — they routinely use random keys for fixtures and + * never run against real chains. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { resolve, relative, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +// Plain recursive walk — keeps this script dependency-free so it can run +// in any CI step before `pnpm install`. Only descends into directories we +// actually care about (skips node_modules, dist, .git, test dirs). +const SKIP_DIRS = new Set([ + 'node_modules', 'dist', 'dist-ui', '.git', '.turbo', 'coverage', + 'test', 'tests', '__tests__', 'cache', 'artifacts', 'typechain', +]); +const SOURCE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs']); + +async function* walkSourceFiles(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') return; + throw err; + } + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const full = join(dir, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + yield* walkSourceFiles(full); + } else if (e.isFile()) { + if (e.name.endsWith('.test.ts') || e.name.endsWith('.test.js')) continue; + if (e.name.endsWith('.spec.ts') || e.name.endsWith('.spec.js')) continue; + const dot = e.name.lastIndexOf('.'); + if (dot === -1 || !SOURCE_EXTS.has(e.name.slice(dot))) continue; + yield full; + } + } +} + +// Each allowlist entry must have a justification. Reviewers should not +// extend this list without understanding why — see file header. +const ALLOWLIST = new Map([ + [ + 'packages/agent/src/op-wallets.ts', + 'first-run admin+op wallet generation, persisted to wallets.json (chmod 0600)', + ], + [ + 'packages/agent/src/agent-keystore.ts', + 'custodial chat-agent keypair, returned to caller and persisted in keystore', + ], + [ + 'packages/evm-module/utils/helpers.ts', + 'hardhat deploy-script utility, key returned to operator (`generateEvmWallet`)', + ], +]); + +// Match `Wallet.createRandom(` (with the dot, so `createRandom` standalone +// or as a method on something else still passes). Trailing `(` is required +// to avoid matching identifiers in narrative prose. +const PATTERN = /\bWallet\.createRandom\s*\(/; + +// Skip single-line `//` comments and JSDoc-style block-comment lines +// starting with `*`. This isn't a full comment parser — multi-line +// `/* … */` regions on a single line still get scanned, which is fine +// for this audit (they're rare and obvious in review). +function isCommentLine(line) { + const trimmed = line.trimStart(); + return trimmed.startsWith('//') || trimmed.startsWith('*'); +} + +function fileHasOffense(text) { + for (const line of text.split('\n')) { + if (isCommentLine(line)) continue; + if (PATTERN.test(line)) return true; + } + return false; +} + +async function main() { + const packagesDir = join(REPO_ROOT, 'packages'); + let pkgs; + try { + pkgs = await readdir(packagesDir, { withFileTypes: true }); + } catch (err) { + console.error(`audit-create-random: cannot read ${packagesDir}: ${err.message}`); + return 2; + } + const offenders = []; + for (const pkg of pkgs) { + if (!pkg.isDirectory() || pkg.name.startsWith('.')) continue; + // Scan src/ and utils/ — utils/ catches packages/evm-module/utils/ + for (const subdir of ['src', 'utils']) { + const root = join(packagesDir, pkg.name, subdir); + for await (const absPath of walkSourceFiles(root)) { + const text = await readFile(absPath, 'utf8'); + if (!fileHasOffense(text)) continue; + const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); + if (ALLOWLIST.has(relPath)) continue; + const lines = text.split('\n'); + const hits = []; + for (let i = 0; i < lines.length; i++) { + if (isCommentLine(lines[i])) continue; + if (PATTERN.test(lines[i])) hits.push({ line: i + 1, text: lines[i].trim() }); + } + if (hits.length > 0) offenders.push({ path: relPath, hits }); + } + } + } + + if (offenders.length === 0) { + console.log('audit-create-random: OK — no undisciplined Wallet.createRandom() in production code.'); + if (ALLOWLIST.size > 0) { + console.log(`Allowlisted (${ALLOWLIST.size}):`); + for (const [p, why] of ALLOWLIST) console.log(` - ${p}: ${why}`); + } + return 0; + } + + console.error('audit-create-random: FAIL — Wallet.createRandom() found outside the audited allowlist:'); + for (const o of offenders) { + console.error(`\n ${o.path}`); + for (const h of o.hits) console.error(` L${h.line}: ${h.text}`); + } + console.error(` +Why this matters: random keys generated in-process and then discarded +produce unverifiable signatures and unrecoverable on-chain identities. +This is exactly how the May 2026 testnet incident destroyed nine admin +keys — see scripts/audit-create-random.mjs header for the full story. + +If your new use site genuinely persists the key (and the operator can +recover it), add it to the ALLOWLIST in scripts/audit-create-random.mjs +with a one-line justification. Otherwise: take a key from the caller. +`); + return 1; +} + +const exitCode = await main(); +process.exit(exitCode); From 199f3854b39d5baf1fea1b674cbb00882298a2c4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 02:01:26 +0200 Subject: [PATCH 02/43] fix(audit): scan tsx/jsx + reword publisher wallet comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Codex review on PR #371: 1. scripts/audit-create-random.mjs: SOURCE_EXTS missed .tsx/.jsx, but packages/node-ui and packages/graph-viz ship production code from .tsx files (40+ files in packages/*/src/). The CI gate could be bypassed by introducing Wallet.createRandom() in a tsx entrypoint. Fix: add .tsx/.jsx to SOURCE_EXTS and to the test-suffix exclusion list. Manually verified the script now flags a tsx offense and correctly excludes .spec.tsx fixtures. 2. packages/publisher/src/dkg-publisher.ts: the constructor comment claimed every on-chain path is gated on `if (this.publisherWallet)`, but `update()` calls `chain.updateKnowledgeCollectionV10` / `chain.updateKnowledgeAssets` directly — those use the chain adapter's own signer pool and never touch this wallet. Reworded to the narrower (accurate) guarantee: only the publish-time self-sign ACK, on-chain publishDirect signature, and per-KA authorship proofs need it. Co-authored-by: Cursor --- packages/publisher/src/dkg-publisher.ts | 24 +++++++++++++++--------- scripts/audit-create-random.mjs | 6 +++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 7bebc296f..c51d924d4 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -244,15 +244,21 @@ export class DKGPublisher implements Publisher { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; } else { - // No private key supplied → no signing capability. Every on-chain code - // path in this class is gated on `if (this.publisherWallet)` and - // gracefully no-ops when absent (see `executeOnChainPublish` and the - // self-sign / authorship-proof blocks). The previous behaviour - // generated an ephemeral `Wallet.createRandom()` here whenever chain - // was enabled, which produced unverifiable signatures attributed to a - // throw-away address — actively misleading callers that supplied - // `publisherAddress` separately. See PR fixing this for the - // testnet-blocking incident chain (`ensureProfile` had the same + // No private key supplied → no in-process signing capability. The + // publish-time paths that need a `publisherWallet` to produce a + // signature (V10 self-sign ACK at L1237, the on-chain `publishDirect` + // signature at L1276, and the per-KA authorship proofs at L1444) + // are explicitly gated on `if (this.publisherWallet)` and degrade + // safely when absent. Other on-chain entry points (`update()`, + // `chain.updateKnowledgeCollectionV10` / `updateKnowledgeAssets`) + // delegate signing to the chain adapter's own signer pool and + // therefore do not require this wallet at all. + // + // The previous behaviour generated an ephemeral `Wallet.createRandom()` + // here whenever chain was enabled, which produced unverifiable + // signatures attributed to a throw-away address — actively misleading + // callers that supplied `publisherAddress` separately. See PR #371 for + // the testnet-blocking incident chain (`ensureProfile` had the same // anti-pattern, fixed in PR #366). this.publisherAddress = config.publisherAddress ?? '0x' + '0'.repeat(40); } diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index d73d442b6..6e75934d5 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -56,7 +56,8 @@ const SKIP_DIRS = new Set([ 'node_modules', 'dist', 'dist-ui', '.git', '.turbo', 'coverage', 'test', 'tests', '__tests__', 'cache', 'artifacts', 'typechain', ]); -const SOURCE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs']); +const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); +const TEST_SUFFIXES = ['.test.ts', '.test.tsx', '.test.js', '.test.jsx', '.spec.ts', '.spec.tsx', '.spec.js', '.spec.jsx']; async function* walkSourceFiles(dir) { let entries; @@ -73,8 +74,7 @@ async function* walkSourceFiles(dir) { if (SKIP_DIRS.has(e.name)) continue; yield* walkSourceFiles(full); } else if (e.isFile()) { - if (e.name.endsWith('.test.ts') || e.name.endsWith('.test.js')) continue; - if (e.name.endsWith('.spec.ts') || e.name.endsWith('.spec.js')) continue; + if (TEST_SUFFIXES.some((s) => e.name.endsWith(s))) continue; const dot = e.name.lastIndexOf('.'); if (dot === -1 || !SOURCE_EXTS.has(e.name.slice(dot))) continue; yield full; From 8ea17f110861278faee39ab0ae29d1943b73be16 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 02:15:41 +0200 Subject: [PATCH 03/43] fix(audit): also scan .mts/.cts source files Codex round 2 on PR #371: the previous round added .tsx/.jsx but I forgot the explicit-ESM/CJS TypeScript variants. A future Wallet.createRandom() in packages/*/src/**/*.mts would slip past the gate. Adds .mts and .cts to SOURCE_EXTS and to the test-suffix exclusion list. Verified with a synthetic .mts fixture that the script now flags the offense. Co-authored-by: Cursor --- scripts/audit-create-random.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index 6e75934d5..fb6380c43 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -56,8 +56,11 @@ const SKIP_DIRS = new Set([ 'node_modules', 'dist', 'dist-ui', '.git', '.turbo', 'coverage', 'test', 'tests', '__tests__', 'cache', 'artifacts', 'typechain', ]); -const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); -const TEST_SUFFIXES = ['.test.ts', '.test.tsx', '.test.js', '.test.jsx', '.spec.ts', '.spec.tsx', '.spec.js', '.spec.jsx']; +const SOURCE_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); +const TEST_SUFFIXES = [ + '.test.ts', '.test.tsx', '.test.mts', '.test.cts', '.test.js', '.test.jsx', '.test.mjs', '.test.cjs', + '.spec.ts', '.spec.tsx', '.spec.mts', '.spec.cts', '.spec.js', '.spec.jsx', '.spec.mjs', '.spec.cjs', +]; async function* walkSourceFiles(dir) { let entries; From 8e416a4dbbc755ef936c47bd681eaa1e8a4ead41 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 04:04:20 +0200 Subject: [PATCH 04/43] fix(audit): per-hit allowlist + multi-line scan + stable comment refs Addresses Codex review feedback on PR #371: - Whole-file scan after stripping `//` and block comments. The previous per-line regex let `Wallet\n .createRandom()` and `Wallet/* */.createRandom()` bypass the audit purely by formatting. The new scan uses the same regex with `\s*` between every token over the comment-stripped buffer, so split invocations are caught while the original line numbers still resolve (comments are blanked to whitespace of the same byte length). - Per-hit allowlist (vs whole-file). Each ALLOWLIST entry now pins the expected hit count, and any extra `Wallet.createRandom()` added to an already-exempt file fails CI even though one site is justified. Stale allowlist entries (file no longer contains a hit) also fail, so exemptions can't outlive their call site. - Replaced the L1237/L1276/L1444 references in the publisher constructor comment with named-path references (V10 self-signed ACK fallback, on-chain `publishDirect` signature, per-KA authorship-proof loop). These line numbers were already stale on the previous commit and would have gone stale again on the next change. Verified locally with synthetic fixtures covering all three new bypasses (split-line, comment-injected, second-hit-in-exempt-file) plus the preserved `.tsx` / `.mts` coverage and stale-allowlist detection. Co-authored-by: Cursor --- packages/publisher/src/dkg-publisher.ts | 25 ++-- scripts/audit-create-random.mjs | 183 ++++++++++++++++++------ 2 files changed, 154 insertions(+), 54 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index c51d924d4..88b79d8cd 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -244,22 +244,27 @@ export class DKGPublisher implements Publisher { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; } else { - // No private key supplied → no in-process signing capability. The - // publish-time paths that need a `publisherWallet` to produce a - // signature (V10 self-sign ACK at L1237, the on-chain `publishDirect` - // signature at L1276, and the per-KA authorship proofs at L1444) - // are explicitly gated on `if (this.publisherWallet)` and degrade - // safely when absent. Other on-chain entry points (`update()`, - // `chain.updateKnowledgeCollectionV10` / `updateKnowledgeAssets`) - // delegate signing to the chain adapter's own signer pool and - // therefore do not require this wallet at all. + // No private key supplied → no in-process signing capability. Three + // call sites inside `publishFromSharedMemory` need `publisherWallet` + // to produce a signature: + // 1. the V10 self-signed ACK fallback (when no peer ACKs were + // collected for the publish), + // 2. the on-chain `publishDirect` publisher signature, and + // 3. the per-KA authorship-proof loop (spec §9.0.6). + // All three are guarded by `if (this.publisherWallet)` and degrade + // safely when absent. Other on-chain entry points — `update()` and + // its `chain.updateKnowledgeCollectionV10` / `updateKnowledgeAssets` + // descendants — delegate signing to the chain adapter's own signer + // pool and never touch this field, so they're unaffected. // // The previous behaviour generated an ephemeral `Wallet.createRandom()` // here whenever chain was enabled, which produced unverifiable // signatures attributed to a throw-away address — actively misleading // callers that supplied `publisherAddress` separately. See PR #371 for // the testnet-blocking incident chain (`ensureProfile` had the same - // anti-pattern, fixed in PR #366). + // anti-pattern, fixed in PR #366). A future enhancement could fall + // back to `chain.signMessage` for the publish-time paths so adapter- + // owned signers can still confirm publishes; tracked in #373. this.publisherAddress = config.publisherAddress ?? '0x' + '0'.repeat(40); } diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index fb6380c43..9483ab175 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -23,10 +23,12 @@ * * What it does * ------------ - * Walks every `.ts` / `.js` source file under `packages/*\/src/**` and fails - * if it finds `Wallet.createRandom(` outside the explicitly allowlisted - * call sites below. Each allowlist entry has a one-line justification — - * if you need to add a new one, justify it in code review. + * Walks every `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / `.mjs` / + * `.cjs` source file under `packages/*\/{src,utils}/**` and fails if it + * finds `Wallet.createRandom(` outside the explicitly allowlisted call + * sites below. Each allowlist entry pins ONE expected hit with a one-line + * justification — adding a second `createRandom()` to the same file does + * NOT inherit the existing exemption and must be reviewed on its own. * * What's allowed * -------------- @@ -41,6 +43,19 @@ * * Tests are excluded — they routinely use random keys for fixtures and * never run against real chains. + * + * Bypass-resistance notes + * ----------------------- + * - Whole-file scan after stripping `//` line comments and `/* … *​/` + * block comments, so split invocations like `Wallet\n.createRandom()` + * or `Wallet /* … *​/.createRandom()` cannot bypass the regex by + * formatting. + * - Per-hit allowlist (not per-file): an extra `createRandom()` call + * added to an already-exempt file fails CI and must be justified. + * - String literals containing `//` or `/​*` are treated as comments. + * Acceptable false-negative scope: a string containing + * `"Wallet.createRandom("` would slip past, but that pattern is + * trivially obvious in code review. */ import { readFile, readdir } from 'node:fs/promises'; @@ -49,9 +64,6 @@ import { fileURLToPath } from 'node:url'; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); -// Plain recursive walk — keeps this script dependency-free so it can run -// in any CI step before `pnpm install`. Only descends into directories we -// actually care about (skips node_modules, dist, .git, test dirs). const SKIP_DIRS = new Set([ 'node_modules', 'dist', 'dist-ui', '.git', '.turbo', 'coverage', 'test', 'tests', '__tests__', 'cache', 'artifacts', 'typechain', @@ -85,43 +97,94 @@ async function* walkSourceFiles(dir) { } } -// Each allowlist entry must have a justification. Reviewers should not -// extend this list without understanding why — see file header. +// Each entry pins ONE expected hit. `expectedHits` is the number of +// `Wallet.createRandom(` invocations that must appear in this file; a +// future PR adding a second call will fail CI even though one is justified. const ALLOWLIST = new Map([ [ 'packages/agent/src/op-wallets.ts', - 'first-run admin+op wallet generation, persisted to wallets.json (chmod 0600)', + { + expectedHits: 1, + justification: 'first-run admin+op wallet generation, persisted to wallets.json (chmod 0600)', + }, ], [ 'packages/agent/src/agent-keystore.ts', - 'custodial chat-agent keypair, returned to caller and persisted in keystore', + { + expectedHits: 1, + justification: 'custodial chat-agent keypair, returned to caller and persisted in keystore', + }, ], [ 'packages/evm-module/utils/helpers.ts', - 'hardhat deploy-script utility, key returned to operator (`generateEvmWallet`)', + { + expectedHits: 1, + justification: 'hardhat deploy-script utility, key returned to operator (`generateEvmWallet`)', + }, ], ]); -// Match `Wallet.createRandom(` (with the dot, so `createRandom` standalone -// or as a method on something else still passes). Trailing `(` is required -// to avoid matching identifiers in narrative prose. -const PATTERN = /\bWallet\.createRandom\s*\(/; +// Match `Wallet . createRandom (` allowing arbitrary whitespace (including +// newlines) between the tokens. This is intentionally a single regex over +// the comment-stripped file, NOT a per-line scan — that previously let +// `Wallet\n .createRandom()` bypass the audit by formatting alone. +const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; -// Skip single-line `//` comments and JSDoc-style block-comment lines -// starting with `*`. This isn't a full comment parser — multi-line -// `/* … */` regions on a single line still get scanned, which is fine -// for this audit (they're rare and obvious in review). -function isCommentLine(line) { - const trimmed = line.trimStart(); - return trimmed.startsWith('//') || trimmed.startsWith('*'); +/** + * Strip `//` line comments and `/​* … *​/` block comments from `text`, + * replacing them with whitespace of the same byte length so that match + * indexes computed against the returned string still map back to the + * original line numbers. + * + * Non-goals: this is not a full lexer. String literals containing comment + * tokens (`const s = "// ...";`) are treated as comments, which is fine + * for an audit that only cares about real `Wallet.createRandom(` calls — + * such calls cannot live inside string literals. + */ +function stripCommentsPreservingPositions(text) { + let out = ''; + let i = 0; + while (i < text.length) { + const c = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + if (c === '/' && next === '/') { + while (i < text.length && text[i] !== '\n') { + out += ' '; + i += 1; + } + } else if (c === '/' && next === '*') { + out += ' '; + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { + out += text[i] === '\n' ? '\n' : ' '; + i += 1; + } + if (i < text.length) { + out += ' '; + i += 2; + } + } else { + out += c; + i += 1; + } + } + return out; } -function fileHasOffense(text) { - for (const line of text.split('\n')) { - if (isCommentLine(line)) continue; - if (PATTERN.test(line)) return true; +function findHits(originalText) { + const stripped = stripCommentsPreservingPositions(originalText); + const hits = []; + for (const m of stripped.matchAll(PATTERN)) { + const upToMatch = stripped.slice(0, m.index); + const line = upToMatch.split('\n').length; + const lineStart = upToMatch.lastIndexOf('\n') + 1; + const lineEnd = stripped.indexOf('\n', m.index); + const snippet = originalText + .slice(lineStart, lineEnd === -1 ? originalText.length : lineEnd) + .trim(); + hits.push({ line, snippet }); } - return false; + return hits; } async function main() { @@ -133,41 +196,73 @@ async function main() { console.error(`audit-create-random: cannot read ${packagesDir}: ${err.message}`); return 2; } - const offenders = []; + const violations = []; + const seenAllowlistPaths = new Set(); for (const pkg of pkgs) { if (!pkg.isDirectory() || pkg.name.startsWith('.')) continue; - // Scan src/ and utils/ — utils/ catches packages/evm-module/utils/ for (const subdir of ['src', 'utils']) { const root = join(packagesDir, pkg.name, subdir); for await (const absPath of walkSourceFiles(root)) { const text = await readFile(absPath, 'utf8'); - if (!fileHasOffense(text)) continue; + const hits = findHits(text); + if (hits.length === 0) continue; const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); - if (ALLOWLIST.has(relPath)) continue; - const lines = text.split('\n'); - const hits = []; - for (let i = 0; i < lines.length; i++) { - if (isCommentLine(lines[i])) continue; - if (PATTERN.test(lines[i])) hits.push({ line: i + 1, text: lines[i].trim() }); + const exemption = ALLOWLIST.get(relPath); + if (!exemption) { + violations.push({ + path: relPath, + kind: 'no-exemption', + hits, + expectedHits: 0, + }); + continue; + } + seenAllowlistPaths.add(relPath); + if (hits.length !== exemption.expectedHits) { + violations.push({ + path: relPath, + kind: 'hit-count-mismatch', + hits, + expectedHits: exemption.expectedHits, + justification: exemption.justification, + }); } - if (hits.length > 0) offenders.push({ path: relPath, hits }); } } } - if (offenders.length === 0) { + // A stale allowlist entry is itself a violation — we don't want exemptions + // to silently outlive the file/call site they were granted for. + const staleAllowlist = []; + for (const [path] of ALLOWLIST) { + if (!seenAllowlistPaths.has(path)) staleAllowlist.push(path); + } + + if (violations.length === 0 && staleAllowlist.length === 0) { console.log('audit-create-random: OK — no undisciplined Wallet.createRandom() in production code.'); if (ALLOWLIST.size > 0) { console.log(`Allowlisted (${ALLOWLIST.size}):`); - for (const [p, why] of ALLOWLIST) console.log(` - ${p}: ${why}`); + for (const [p, { expectedHits, justification }] of ALLOWLIST) { + console.log(` - ${p} (${expectedHits} hit${expectedHits === 1 ? '' : 's'}): ${justification}`); + } } return 0; } - console.error('audit-create-random: FAIL — Wallet.createRandom() found outside the audited allowlist:'); - for (const o of offenders) { - console.error(`\n ${o.path}`); - for (const h of o.hits) console.error(` L${h.line}: ${h.text}`); + console.error('audit-create-random: FAIL'); + for (const v of violations) { + console.error(`\n ${v.path}`); + if (v.kind === 'no-exemption') { + console.error(` No allowlist entry. Found ${v.hits.length} hit${v.hits.length === 1 ? '' : 's'}:`); + } else { + console.error(` Allowlisted for ${v.expectedHits} hit${v.expectedHits === 1 ? '' : 's'} (${v.justification}), but found ${v.hits.length}:`); + } + for (const h of v.hits) console.error(` L${h.line}: ${h.snippet}`); + } + for (const p of staleAllowlist) { + console.error(`\n ${p}`); + console.error(' Allowlist entry is stale (no Wallet.createRandom() found in the file).'); + console.error(' Remove the entry from ALLOWLIST in scripts/audit-create-random.mjs.'); } console.error(` Why this matters: random keys generated in-process and then discarded From f0e691798de3229bd7f110397cca580316584fae Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 14:20:10 +0200 Subject: [PATCH 05/43] fix(audit): track string/template-literal state to close // bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on PR #371 flagged that the prior comment-stripper treated `//` and `/*` inside string literals as real comments, so a line like `const url = "http://"; Wallet.createRandom();` would blank from the `//` onwards — silently swallowing the real `createRandom()` call after the string. False-negative on a real offending call is the worst failure mode for a security audit (it provides false assurance), so the audit script's bypass-resistance was effectively undermined by formatting. Replace the comment-only stripper with a small state machine that also tracks single-quoted, double-quoted, and template-literal contents. Inside any string state, `//` and `/*` no longer trigger comment mode. String contents themselves are also blanked (not passed through), which prevents the inverse false-positive of a string literal containing `Wallet.createRandom(` matching the regex. Template substitutions (`${ ... }`) are scanned as code so a `${Wallet.createRandom()}` inside a template still gets flagged. Add a node:test unit suite covering: - comment / string / template stripping basics - the PR #371 regression cases (string with `//`, `/*`, escaped quotes, template literal — both same-line and following-line variants) - that calls inside string literals are NOT false-positives - that calls inside template substitutions ARE detected Verified the regression: with the old lexer inlined, the string-bypass test reports 0 hits (BYPASS); with the new lexer, 1 hit (CORRECT). Wire `node --test scripts/audit-create-random.test.mjs` into the lint job in `ci.yml` so a future regression of the lexer would fail CI. Co-authored-by: Cursor --- .github/workflows/ci.yml | 7 + scripts/audit-create-random.mjs | 198 ++++++++++++++++++++++----- scripts/audit-create-random.test.mjs | 145 ++++++++++++++++++++ 3 files changed, 312 insertions(+), 38 deletions(-) create mode 100644 scripts/audit-create-random.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a13cf40b..e25aa61a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,13 @@ jobs: - name: Audit Wallet.createRandom usage run: node scripts/audit-create-random.mjs + # Unit tests for the audit lexer itself (string/template-literal + # awareness, comment stripping, etc.). Catches regressions in the + # bypass-resistance the audit relies on — e.g. the PR #371 fix where + # `//` inside a string literal silently blanked real code after it. + - name: Test audit-create-random lexer + run: node --test scripts/audit-create-random.test.mjs + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index 9483ab175..c21537052 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -46,21 +46,31 @@ * * Bypass-resistance notes * ----------------------- - * - Whole-file scan after stripping `//` line comments and `/* … *​/` - * block comments, so split invocations like `Wallet\n.createRandom()` - * or `Wallet /* … *​/.createRandom()` cannot bypass the regex by - * formatting. + * - Whole-file scan after a small lexer blanks comments AND string / + * template-literal contents (preserving byte length and newlines so + * line numbers in error output stay accurate). This means: + * * Split invocations like `Wallet\n.createRandom()` or + * `Wallet /* … *​/.createRandom()` cannot bypass via formatting. + * * `//` or `/​*` inside a string literal does NOT trigger comment + * mode (so `const url = "http://"; Wallet.createRandom();` is + * correctly flagged — previously the `//` inside the string + * silently blanked the real call after it). + * * A string literal containing the literal text `Wallet.createRandom(` + * is blanked along with the rest of the string, so the regex + * can't false-positive on it either. * - Per-hit allowlist (not per-file): an extra `createRandom()` call * added to an already-exempt file fails CI and must be justified. - * - String literals containing `//` or `/​*` are treated as comments. - * Acceptable false-negative scope: a string containing - * `"Wallet.createRandom("` would slip past, but that pattern is - * trivially obvious in code review. + * - Template-literal substitutions (`${ … }`) ARE scanned as code, so a + * `\`${Wallet.createRandom()}\`` would be flagged. Strings nested + * inside such substitutions are not recursively re-lexed; an exotic + * `\`${"Wallet.createRandom("}\`` would false-positive (acceptable — + * false-positive is much safer than false-negative for a security + * audit, and that pattern is trivially obvious in review). */ import { readFile, readdir } from 'node:fs/promises'; import { resolve, relative, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); @@ -131,47 +141,153 @@ const ALLOWLIST = new Map([ const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; /** - * Strip `//` line comments and `/​* … *​/` block comments from `text`, - * replacing them with whitespace of the same byte length so that match - * indexes computed against the returned string still map back to the - * original line numbers. + * Blank out comments AND string / template-literal contents from `text`, + * replacing them with spaces (newlines preserved) so the returned string + * has the same byte length as the input and match indexes still resolve + * to the original line numbers. * - * Non-goals: this is not a full lexer. String literals containing comment - * tokens (`const s = "// ...";`) are treated as comments, which is fine - * for an audit that only cares about real `Wallet.createRandom(` calls — - * such calls cannot live inside string literals. + * State machine (small, intentionally not a full TS parser): + * + * normal → code we want to scan + * line-comment // … \n (entered only from normal) + * block-comment /​* … *​/ (entered only from normal) + * sq-string ' … ' (entered only from normal; \-escapes consumed) + * dq-string " … " (entered only from normal; \-escapes consumed) + * tpl-string ` … ` (entered only from normal; \-escapes consumed) + * tpl-substitution ${ … } (entered from tpl-string; brace-balanced; treated as normal code) + * + * Why the explicit string state? The previous comment-only stripper + * treated `//` and `/​*` as comment-start unconditionally — so the line + * `const url = "http://"; Wallet.createRandom();` blanked everything + * after `//`, swallowing the real `createRandom()` call after the string. + * That is a false-NEGATIVE bypass for a security audit, the worst case. + * The fixed stripper enters `dq-string` at the opening `"`, blanks the + * string contents, exits at the closing `"`, then resumes normal scanning + * which sees `Wallet.createRandom(` intact. + * + * Strings are blanked rather than passed through verbatim so that a + * literal `"Wallet.createRandom("` inside a string can't false-positive + * the regex either. + * + * Regex literals (`/foo/g`) are NOT explicitly handled — we'd need full + * JS expression context to disambiguate `/` from division. In practice + * the only pattern that could bypass is a regex literal whose body + * contains an unescaped `/​/` or `/​*`, which is exotic enough to ignore. */ -function stripCommentsPreservingPositions(text) { +export function stripCommentsPreservingPositions(text) { + const len = text.length; let out = ''; let i = 0; - while (i < text.length) { + let state = 'normal'; + let stringQuote = ''; // " | ' | ` for sq/dq/tpl + let braceDepth = 0; // tracked inside tpl-substitution + + const blank = (c) => (c === '\n' ? '\n' : ' '); + + while (i < len) { const c = text[i]; - const next = i + 1 < text.length ? text[i + 1] : ''; - if (c === '/' && next === '/') { - while (i < text.length && text[i] !== '\n') { - out += ' '; - i += 1; + const next = i + 1 < len ? text[i + 1] : ''; + + if (state === 'normal') { + if (c === '/' && next === '/') { + out += ' '; i += 2; + state = 'line-comment'; + } else if (c === '/' && next === '*') { + out += ' '; i += 2; + state = 'block-comment'; + } else if (c === '"' || c === "'") { + out += ' '; i += 1; + state = c === '"' ? 'dq-string' : 'sq-string'; + stringQuote = c; + } else if (c === '`') { + out += ' '; i += 1; + state = 'tpl-string'; + stringQuote = '`'; + } else { + out += c; i += 1; + } + continue; + } + + if (state === 'line-comment') { + if (c === '\n') { + out += '\n'; i += 1; + state = 'normal'; + } else { + out += ' '; i += 1; + } + continue; + } + + if (state === 'block-comment') { + if (c === '*' && next === '/') { + out += ' '; i += 2; + state = 'normal'; + } else { + out += blank(c); i += 1; } - } else if (c === '/' && next === '*') { - out += ' '; - i += 2; - while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { - out += text[i] === '\n' ? '\n' : ' '; - i += 1; + continue; + } + + if (state === 'sq-string' || state === 'dq-string') { + if (c === '\\' && i + 1 < len) { + // Consume escape sequence (e.g. \", \\, \n) — blank both bytes. + out += blank(c) + blank(text[i + 1]); + i += 2; + } else if (c === stringQuote) { + out += ' '; i += 1; + state = 'normal'; + stringQuote = ''; + } else { + out += blank(c); i += 1; } - if (i < text.length) { - out += ' '; + continue; + } + + if (state === 'tpl-string') { + if (c === '\\' && i + 1 < len) { + out += blank(c) + blank(text[i + 1]); i += 2; + } else if (c === '`') { + out += ' '; i += 1; + state = 'normal'; + stringQuote = ''; + } else if (c === '$' && next === '{') { + // Enter a substitution: subsequent code is scanned as normal. + // We DO NOT recursively re-lex strings inside the substitution — + // see the docstring's bypass-resistance notes for the trade-off. + out += ' '; i += 2; + state = 'tpl-substitution'; + braceDepth = 1; + } else { + out += blank(c); i += 1; } - } else { - out += c; - i += 1; + continue; + } + + if (state === 'tpl-substitution') { + if (c === '{') { + braceDepth += 1; + out += c; i += 1; + } else if (c === '}') { + braceDepth -= 1; + if (braceDepth === 0) { + out += ' '; i += 1; + state = 'tpl-string'; + } else { + out += c; i += 1; + } + } else { + // Pass through so regex catches Wallet.createRandom() inside ${...} + out += c; i += 1; + } + continue; } } return out; } -function findHits(originalText) { +export function findHits(originalText) { const stripped = stripCommentsPreservingPositions(originalText); const hits = []; for (const m of stripped.matchAll(PATTERN)) { @@ -277,5 +393,11 @@ with a one-line justification. Otherwise: take a key from the caller. return 1; } -const exitCode = await main(); -process.exit(exitCode); +// Run only when invoked directly (so the test file can import the lexer +// + findHits without triggering a full repository scan + process.exit). +const invokedDirectly = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) { + const exitCode = await main(); + process.exit(exitCode); +} diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs new file mode 100644 index 000000000..f5e7db9fa --- /dev/null +++ b/scripts/audit-create-random.test.mjs @@ -0,0 +1,145 @@ +/** + * Unit tests for the lexer + hit-finder powering + * `scripts/audit-create-random.mjs`. + * + * Run with: node --test scripts/audit-create-random.test.mjs + * + * The most important case here is the regression test for the + * string-literal bypass that codex flagged on PR #371: the previous + * comment-only stripper treated `//` and `/​*` inside string / template + * literals as real comments, which silently blanked any real + * `Wallet.createRandom()` call that happened to live on the same line as + * a string containing those tokens. A security audit that misses real + * call sites is worse than useless — it provides false assurance. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { stripCommentsPreservingPositions, findHits } from './audit-create-random.mjs'; + +describe('stripCommentsPreservingPositions', () => { + it('blanks // line comments', () => { + const input = 'let x = 1; // hello\nlet y = 2;'; + const out = stripCommentsPreservingPositions(input); + assert.equal(out, 'let x = 1; \nlet y = 2;'); + assert.equal(out.length, input.length); + }); + + it('blanks /* … */ block comments and preserves embedded newlines', () => { + const input = 'a /* one\ntwo */ b'; + const out = stripCommentsPreservingPositions(input); + assert.equal(out, 'a \n b'); + assert.equal(out.length, input.length); + }); + + it('does NOT enter line-comment mode when // appears inside a "…" string (the PR #371 bypass)', () => { + const text = 'const url = "http://"; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + // The string contents (incl. the `//`) should be blanked but the + // `Wallet.createRandom();` after the string MUST remain visible. + assert.match(out, /Wallet\.createRandom\(\);/); + assert.equal(out.length, text.length); + }); + + it('does NOT enter line-comment mode when // appears inside a \'…\' string', () => { + const text = "const url = 'http://'; Wallet.createRandom();"; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + }); + + it('does NOT enter block-comment mode when /* appears inside a string', () => { + const text = 'const s = "/* not a comment"; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + }); + + it('blanks Wallet.createRandom( inside a string literal (no false positive)', () => { + const text = 'const s = "Wallet.createRandom(arg)"; const z = 1;'; + const out = stripCommentsPreservingPositions(text); + assert.doesNotMatch(out, /Wallet\.createRandom\(/); + }); + + it('handles escape sequences inside strings (\\" does not close the string early)', () => { + const text = 'const s = "she said \\"// hi\\""; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + }); + + it('does NOT enter line-comment mode when // appears inside a `…` template literal', () => { + const text = 'const t = `http://`; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + }); + + it('scans Wallet.createRandom() inside a ${…} template substitution as code', () => { + const text = 'const t = `value: ${Wallet.createRandom()}`;'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\)/); + }); + + it('handles brace nesting inside template substitutions', () => { + const text = 'const t = `${({ a: 1 })} ${Wallet.createRandom()}`;'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\)/); + }); +}); + +describe('findHits', () => { + it('finds a basic Wallet.createRandom() call', () => { + const hits = findHits('const w = Wallet.createRandom();'); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 1); + assert.match(hits[0].snippet, /Wallet\.createRandom\(\)/); + }); + + it('finds split-line invocations like Wallet\\n.createRandom()', () => { + const hits = findHits('const w = Wallet\n .createRandom();'); + assert.equal(hits.length, 1); + }); + + it('finds invocations with block comments between tokens', () => { + const hits = findHits('Wallet/* nope */.createRandom()'); + assert.equal(hits.length, 1); + }); + + it('does NOT report calls inside string literals', () => { + const hits = findHits('const s = "Wallet.createRandom()";'); + assert.equal(hits.length, 0); + }); + + it('does NOT report calls in line comments', () => { + const hits = findHits('// Wallet.createRandom()'); + assert.equal(hits.length, 0); + }); + + it('REGRESSION (PR #371): finds calls after a string containing //', () => { + const text = [ + "const url = 'http://example.com';", + 'const w = Wallet.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1, `expected 1 hit, got ${hits.length}; out: ${JSON.stringify(hits)}`); + assert.equal(hits[0].line, 2); + }); + + it('REGRESSION (PR #371): finds calls on the SAME line as a string with // inside it', () => { + const text = 'const url = "http://"; Wallet.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1, `expected 1 hit, got ${hits.length}; out: ${JSON.stringify(hits)}`); + assert.equal(hits[0].line, 1); + }); + + it('REGRESSION (PR #371): finds calls after a string containing /*', () => { + const text = 'const note = "TODO /*";\nWallet.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + }); + + it('returns the original-source line snippet (not the blanked version)', () => { + const text = 'const url = "http://"; Wallet.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.match(hits[0].snippet, /const url = "http:\/\/"; Wallet\.createRandom\(\);/); + }); +}); From 3f225131ad2321b4a70ad98b746f0a403886fc48 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 14:54:57 +0200 Subject: [PATCH 06/43] fix(audit): stack-based lexer so braces in nested strings can't close ${} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review on the round-1 fix flagged a deeper bypass: the flat `state + braceDepth` shape didn't recursively re-enter string contexts when scanning a template substitution. So: `${"}" + Wallet.createRandom()}` the `}` inside `"}"` decremented the substitution's braceDepth to 0, popped us back to template-string mode, and blanked the real `Wallet.createRandom()` after it — same false-NEGATIVE class as the round-1 string-comment bypass, just one level deeper. Refactor the lexer to use an explicit context stack instead of a flat state variable. Now strings entered from inside a tpl-substitution are pushed on top of it; the substitution's brace counter advances only on top-level braces (i.e. braces seen while the substitution itself is the active state, not while a nested string/template is). When the inner string closes, we pop back to the substitution and resume brace counting where we left off. Three new regression tests: - the codex-flagged case (`}` inside a string inside `${...}`) - symmetric `{` inside a string (defensive — wasn't a bypass on round 1 but the stack now handles it correctly without depending on the asymmetry) - nested template literals inside substitutions (defensive) Sabotage-verified: the round-1 lexer reports 0 hits on the codex case (BYPASS confirmed); the round-2 stack-based lexer reports 1 hit (FIXED). All 22 unit tests pass. Co-authored-by: Cursor --- scripts/audit-create-random.mjs | 138 ++++++++++++++------------- scripts/audit-create-random.test.mjs | 34 +++++++ 2 files changed, 106 insertions(+), 66 deletions(-) diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index c21537052..6989f3916 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -61,11 +61,11 @@ * - Per-hit allowlist (not per-file): an extra `createRandom()` call * added to an already-exempt file fails CI and must be justified. * - Template-literal substitutions (`${ … }`) ARE scanned as code, so a - * `\`${Wallet.createRandom()}\`` would be flagged. Strings nested - * inside such substitutions are not recursively re-lexed; an exotic - * `\`${"Wallet.createRandom("}\`` would false-positive (acceptable — - * false-positive is much safer than false-negative for a security - * audit, and that pattern is trivially obvious in review). + * `\`${Wallet.createRandom()}\`` would be flagged. Nested strings + * and templates inside substitutions are properly re-lexed via the + * state stack — so braces inside a string inside a substitution + * (e.g. `\`${"}" + Wallet.createRandom()}\``) cannot prematurely + * close the substitution and hide a real call after it. */ import { readFile, readdir } from 'node:fs/promises'; @@ -146,24 +146,36 @@ const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; * has the same byte length as the input and match indexes still resolve * to the original line numbers. * - * State machine (small, intentionally not a full TS parser): + * Stack-based state machine (small, intentionally not a full TS parser). + * Each context on the stack is one of: * * normal → code we want to scan - * line-comment // … \n (entered only from normal) - * block-comment /​* … *​/ (entered only from normal) - * sq-string ' … ' (entered only from normal; \-escapes consumed) - * dq-string " … " (entered only from normal; \-escapes consumed) - * tpl-string ` … ` (entered only from normal; \-escapes consumed) - * tpl-substitution ${ … } (entered from tpl-string; brace-balanced; treated as normal code) + * line-comment // … \n (only above normal/substitution) + * block-comment /​* … *​/ (only above normal/substitution) + * sq-string ' … ' (\-escapes consumed) + * dq-string " … " (\-escapes consumed) + * tpl-string ` … ` (\-escapes; ${ pushes a substitution) + * tpl-substitution { braceDepth } ends when braceDepth → 0 on a `}` * - * Why the explicit string state? The previous comment-only stripper - * treated `//` and `/​*` as comment-start unconditionally — so the line - * `const url = "http://"; Wallet.createRandom();` blanked everything - * after `//`, swallowing the real `createRandom()` call after the string. - * That is a false-NEGATIVE bypass for a security audit, the worst case. - * The fixed stripper enters `dq-string` at the opening `"`, blanks the - * string contents, exits at the closing `"`, then resumes normal scanning - * which sees `Wallet.createRandom(` intact. + * Why a stack? We need string contexts to be re-entered RECURSIVELY when + * we're inside a template substitution, so braces inside such strings + * can't prematurely close the substitution. A flat `state` + `braceDepth` + * (the previous attempt) misses this case. Concretely: + * + * `${"}" + Wallet.createRandom()}` + * + * Without the stack, the `}` inside `"}"` decremented the substitution's + * braceDepth to 0 and popped us back to template-string mode, blanking + * the rest including the real `Wallet.createRandom()`. With the stack, + * the `"` pushes dq-string on top of tpl-substitution, the `}` is just + * string content (blanked, brace counter untouched), the closing `"` + * pops back to tpl-substitution which still has braceDepth=1, and the + * real `Wallet.createRandom()` is detected. + * + * Bypass-history regression cases that this stripper now correctly + * blocks: + * - `// or /* inside a string literal` (PR #371 codex round 1) + * - `} inside a string inside a ${ ... } substitution` (round 2) * * Strings are blanked rather than passed through verbatim so that a * literal `"Wallet.createRandom("` inside a string can't false-positive @@ -178,111 +190,105 @@ export function stripCommentsPreservingPositions(text) { const len = text.length; let out = ''; let i = 0; - let state = 'normal'; - let stringQuote = ''; // " | ' | ` for sq/dq/tpl - let braceDepth = 0; // tracked inside tpl-substitution + // Stack of contexts. Top of the stack is the active state. + const stack = [{ kind: 'normal' }]; + const top = () => stack[stack.length - 1]; const blank = (c) => (c === '\n' ? '\n' : ' '); while (i < len) { + const cur = top(); const c = text[i]; const next = i + 1 < len ? text[i + 1] : ''; - if (state === 'normal') { + if (cur.kind === 'normal' || cur.kind === 'tpl-substitution') { + // tpl-substitution behaves exactly like normal code, EXCEPT that + // top-level `{` / `}` adjust the substitution's brace counter and + // the closing `}` pops back to the enclosing tpl-string. + if (cur.kind === 'tpl-substitution') { + if (c === '{') { + cur.braceDepth += 1; + out += c; i += 1; + continue; + } + if (c === '}') { + cur.braceDepth -= 1; + if (cur.braceDepth === 0) { + stack.pop(); + out += ' '; i += 1; + continue; + } + out += c; i += 1; + continue; + } + } if (c === '/' && next === '/') { out += ' '; i += 2; - state = 'line-comment'; + stack.push({ kind: 'line-comment' }); } else if (c === '/' && next === '*') { out += ' '; i += 2; - state = 'block-comment'; + stack.push({ kind: 'block-comment' }); } else if (c === '"' || c === "'") { out += ' '; i += 1; - state = c === '"' ? 'dq-string' : 'sq-string'; - stringQuote = c; + stack.push({ kind: c === '"' ? 'dq-string' : 'sq-string' }); } else if (c === '`') { out += ' '; i += 1; - state = 'tpl-string'; - stringQuote = '`'; + stack.push({ kind: 'tpl-string' }); } else { out += c; i += 1; } continue; } - if (state === 'line-comment') { + if (cur.kind === 'line-comment') { if (c === '\n') { out += '\n'; i += 1; - state = 'normal'; + stack.pop(); } else { out += ' '; i += 1; } continue; } - if (state === 'block-comment') { + if (cur.kind === 'block-comment') { if (c === '*' && next === '/') { out += ' '; i += 2; - state = 'normal'; + stack.pop(); } else { out += blank(c); i += 1; } continue; } - if (state === 'sq-string' || state === 'dq-string') { + if (cur.kind === 'sq-string' || cur.kind === 'dq-string') { + const quote = cur.kind === 'sq-string' ? "'" : '"'; if (c === '\\' && i + 1 < len) { - // Consume escape sequence (e.g. \", \\, \n) — blank both bytes. out += blank(c) + blank(text[i + 1]); i += 2; - } else if (c === stringQuote) { + } else if (c === quote) { out += ' '; i += 1; - state = 'normal'; - stringQuote = ''; + stack.pop(); } else { out += blank(c); i += 1; } continue; } - if (state === 'tpl-string') { + if (cur.kind === 'tpl-string') { if (c === '\\' && i + 1 < len) { out += blank(c) + blank(text[i + 1]); i += 2; } else if (c === '`') { out += ' '; i += 1; - state = 'normal'; - stringQuote = ''; + stack.pop(); } else if (c === '$' && next === '{') { - // Enter a substitution: subsequent code is scanned as normal. - // We DO NOT recursively re-lex strings inside the substitution — - // see the docstring's bypass-resistance notes for the trade-off. out += ' '; i += 2; - state = 'tpl-substitution'; - braceDepth = 1; + stack.push({ kind: 'tpl-substitution', braceDepth: 1 }); } else { out += blank(c); i += 1; } continue; } - - if (state === 'tpl-substitution') { - if (c === '{') { - braceDepth += 1; - out += c; i += 1; - } else if (c === '}') { - braceDepth -= 1; - if (braceDepth === 0) { - out += ' '; i += 1; - state = 'tpl-string'; - } else { - out += c; i += 1; - } - } else { - // Pass through so regex catches Wallet.createRandom() inside ${...} - out += c; i += 1; - } - continue; - } } return out; } diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index f5e7db9fa..db5d182be 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -82,6 +82,40 @@ describe('stripCommentsPreservingPositions', () => { const out = stripCommentsPreservingPositions(text); assert.match(out, /Wallet\.createRandom\(\)/); }); + + it('REGRESSION (PR #371 round 2): a `}` inside a string inside a ${...} substitution does NOT close the substitution', () => { + // Codex caught this exact bypass on the round-1 fix: the flat + // `state + braceDepth` machine treated the `}` inside `"}"` as the + // substitution's closing brace, popped back to template-string mode, + // and blanked the real `Wallet.createRandom()` after it. The + // stack-based machine pushes dq-string on top of tpl-substitution, + // so the brace inside the string is just blanked-content. + const text = 'const t = `${"}" + Wallet.createRandom()}`;'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\)/); + }); + + it('REGRESSION: a `{` inside a string inside a ${...} substitution does NOT inflate the brace counter', () => { + // Symmetric inverse: `{` inside the inner string must not be counted + // either, otherwise the substitution would never close and we'd + // blank everything to EOF (also a bypass — anything after the + // template would be treated as still-inside-a-template). + const text = 'const t = `${"{" + Wallet.createRandom()}`; const z = 1;'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\)/); + assert.match(out, /const z = 1;/); + }); + + it('REGRESSION: nested template literal inside a substitution is properly re-lexed', () => { + // `\`outer ${\`inner ${Wallet.createRandom()}\`}\`` + // Inner template is pushed onto the stack on top of the outer + // substitution; its own ${} pushes another substitution. Real + // Wallet.createRandom() lives in the inner-inner substitution and + // must be detected. + const text = 'const t = `outer ${`inner ${Wallet.createRandom()}`}`;'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\)/); + }); }); describe('findHits', () => { From 45cb799ee2c97a788ab771d4b5f3915e18d9c7f8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 5 May 2026 18:31:02 +0200 Subject: [PATCH 07/43] fix(publisher): require explicit publisher signing key --- CHANGELOG.md | 2 +- packages/publisher/src/dkg-publisher.ts | 139 +++++++++++------- .../publisher/test/phase-sequences.test.ts | 28 +--- .../test/publisher-no-random-wallet.test.ts | 53 ++++++- 4 files changed, 136 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b60ce61..68c922336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the DKG V9 node are documented here. The format is based ## [Unreleased] ### Fixed -- **Publisher no longer auto-mints an ephemeral signing wallet** (`packages/publisher/src/dkg-publisher.ts`): `DKGPublisher` constructed without `publisherPrivateKey` previously generated `ethers.Wallet.createRandom()` whenever chain was enabled and used it to sign on-chain publish digests, ACK self-signatures, and authorship proofs. Signatures were unverifiable (signed by a throw-away key the caller never saw, attributed via `publisherAddress` to a different address). The constructor now leaves `publisherWallet` undefined; every signing call site is already guarded by `if (this.publisherWallet)` and skips gracefully. +- **Publisher no longer auto-mints an ephemeral signing wallet** (`packages/publisher/src/dkg-publisher.ts`): `DKGPublisher` constructed without `publisherPrivateKey` previously generated `ethers.Wallet.createRandom()` whenever chain was enabled and used it to sign on-chain publish digests, ACK self-signatures, and authorship proofs. Signatures were unverifiable (signed by a throw-away key the caller never saw, attributed via `publisherAddress` to a different address). The constructor now leaves `publisherWallet` undefined, rejects zero/mismatched `publisherAddress` values, and publish/update fail before emitting publisher-attributed output unless a real signing key exists. ### Added - **`scripts/audit-create-random.mjs`** + CI gate: bans new `Wallet.createRandom()` use in `packages/*/src/**` outside three explicitly justified call sites (`op-wallets.ts` first-run wallet bootstrap, `agent-keystore.ts` custodial chat-agent registration, `evm-module/utils/helpers.ts` deploy script). Same anti-pattern destroyed nine testnet admin keys in May 2026 via the pre-PR-#366 `ensureProfile` random-and-discard path; audit runs in <300 ms on every CI build. diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 88b79d8cd..d3fa9956c 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -57,6 +57,28 @@ export interface DKGPublisherConfig { writeLocks?: Map>; } +export class PublisherWalletRequiredError extends Error { + constructor(operation: string) { + super( + `${operation} requires "publisherPrivateKey". ` + + 'Publishing without a signing key would produce unattributable or unverifiable publisher output.', + ); + this.name = 'PublisherWalletRequiredError'; + } +} + +function normalizePublisherAddress(address: string | undefined): string | undefined { + if (address === undefined) return undefined; + if (!ethers.isAddress(address)) { + throw new Error(`Invalid publisherAddress: "${address}" is not a valid EVM address`); + } + const normalized = ethers.getAddress(address); + if (normalized === ethers.ZeroAddress) { + throw new Error('Invalid publisherAddress: zero address is not a valid publisher'); + } + return normalized; +} + export interface ShareOptions { publisherPeerId: string; operationCtx?: OperationContext; @@ -224,7 +246,7 @@ export class DKGPublisher implements Publisher { private readonly sharedMemoryOwnedEntities: Map>; readonly knownBatchContextGraphs: Map; private publisherNodeIdentityId: bigint; - private readonly publisherAddress: string; + private readonly publisherAddress?: string; private readonly publisherWallet?: ethers.Wallet; /** Additional wallets that can provide receiver signatures. */ private readonly additionalSignerWallets: ethers.Wallet[] = []; @@ -240,32 +262,35 @@ export class DKGPublisher implements Publisher { this.keypair = config.keypair; this.publisherNodeIdentityId = config.publisherNodeIdentityId ?? 0n; + const configuredPublisherAddress = normalizePublisherAddress(config.publisherAddress); if (config.publisherPrivateKey) { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; + if ( + configuredPublisherAddress && + configuredPublisherAddress.toLowerCase() !== this.publisherAddress.toLowerCase() + ) { + throw new Error( + `publisherAddress (${configuredPublisherAddress}) does not match publisherPrivateKey signer ` + + `(${this.publisherAddress})`, + ); + } } else { - // No private key supplied → no in-process signing capability. Three - // call sites inside `publishFromSharedMemory` need `publisherWallet` - // to produce a signature: - // 1. the V10 self-signed ACK fallback (when no peer ACKs were - // collected for the publish), - // 2. the on-chain `publishDirect` publisher signature, and - // 3. the per-KA authorship-proof loop (spec §9.0.6). - // All three are guarded by `if (this.publisherWallet)` and degrade - // safely when absent. Other on-chain entry points — `update()` and - // its `chain.updateKnowledgeCollectionV10` / `updateKnowledgeAssets` - // descendants — delegate signing to the chain adapter's own signer - // pool and never touch this field, so they're unaffected. + // No private key supplied means no in-process publisher signing + // capability. Keep an optional, validated address only for callers + // that need to inspect configuration; publish/update fail before + // emitting publisher-attributed data unless a real signing key exists. // // The previous behaviour generated an ephemeral `Wallet.createRandom()` // here whenever chain was enabled, which produced unverifiable - // signatures attributed to a throw-away address — actively misleading - // callers that supplied `publisherAddress` separately. See PR #371 for + // signatures attributed to a throw-away address. We also must not use + // `0x000...000` as a sentinel: it looks like an on-chain publisher and + // can leak into UALs/metadata. See PR #371 for // the testnet-blocking incident chain (`ensureProfile` had the same // anti-pattern, fixed in PR #366). A future enhancement could fall // back to `chain.signMessage` for the publish-time paths so adapter- // owned signers can still confirm publishes; tracked in #373. - this.publisherAddress = config.publisherAddress ?? '0x' + '0'.repeat(40); + this.publisherAddress = configuredPublisherAddress; } for (const key of config.additionalSignerKeys ?? []) { @@ -279,6 +304,13 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } + private requirePublisherWallet(operation: string): { wallet: ethers.Wallet; address: string } { + if (!this.publisherWallet || !this.publisherAddress) { + throw new PublisherWalletRequiredError(operation); + } + return { wallet: this.publisherWallet, address: this.publisherAddress }; + } + private async withWriteLocks(keys: string[], fn: () => Promise): Promise { const uniqueKeys = [...new Set(keys)].sort(); const predecessor = Promise.all(uniqueKeys.map(k => this.writeLocks.get(k) ?? Promise.resolve())); @@ -1001,6 +1033,7 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); + const { wallet: publisherWallet, address: publisherAddress } = this.requirePublisherWallet('publish'); if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1134,7 +1167,7 @@ export class DKGPublisher implements Publisher { // `KnowledgeAssetsV10._executePublishCore`. const publishEpochs = 1; let precomputedTokenAmount = 0n; - if (this.publisherWallet && typeof this.chain.getRequiredPublishTokenAmount === 'function') { + if (typeof this.chain.getRequiredPublishTokenAmount === 'function') { precomputedTokenAmount = await this.chain.getRequiredPublishTokenAmount(publicByteSize, publishEpochs); if (precomputedTokenAmount <= 0n) { this.log.warn(ctx, `getRequiredPublishTokenAmount returned ${precomputedTokenAmount} for byteSize=${publicByteSize} — using 1n as minimum`); @@ -1256,7 +1289,6 @@ export class DKGPublisher implements Publisher { // the contract is the ultimate gatekeeper. if ( (!v10ACKs || v10ACKs.length === 0) && - this.publisherWallet && this.publisherNodeIdentityId > 0n && v10ChainId !== undefined && v10KavAddress !== undefined @@ -1275,7 +1307,7 @@ export class DKGPublisher implements Publisher { BigInt(kcMerkleLeafCount), ); const ackSig = ethers.Signature.from( - await this.publisherWallet.signMessage(ackDigest), + await publisherWallet.signMessage(ackDigest), ); v10ACKs = [{ peerId: 'self', @@ -1290,18 +1322,16 @@ export class DKGPublisher implements Publisher { let onChainResult: OnChainPublishResult | undefined; let status: 'tentative' | 'confirmed' = 'tentative'; const tentativeSeq = ++this.tentativeCounter; - let ual = `did:dkg:${this.chain.chainId}/${this.publisherAddress}/t${this.sessionId}-${tentativeSeq}`; + let ual = `did:dkg:${this.chain.chainId}/${publisherAddress}/t${this.sessionId}-${tentativeSeq}`; const identityId = this.publisherNodeIdentityId; let usedV10Path = false; - if (!this.publisherWallet) { - this.log.warn(ctx, `No EVM wallet configured — skipping on-chain publish`); - } else if (identityId === 0n) { + if (identityId === 0n) { this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); } else { onPhase?.('chain:sign', 'start'); - this.log.info(ctx, `Signing on-chain publish (identityId=${identityId}, signer=${this.publisherWallet.address})`); + this.log.info(ctx, `Signing on-chain publish (identityId=${identityId}, signer=${publisherWallet.address})`); const tokenAmount = precomputedTokenAmount; usedV10Path = true; @@ -1339,7 +1369,7 @@ export class DKGPublisher implements Publisher { kcMerkleRoot, ); const pubSig = ethers.Signature.from( - await this.publisherWallet.signMessage(pubMsgHash), + await publisherWallet.signMessage(pubMsgHash), ); // P-1 review (iter-2): `chain:writeahead:start` now fires // *from inside* the adapter via the `onBroadcast` callback, @@ -1463,30 +1493,28 @@ export class DKGPublisher implements Publisher { await this.store.insert(confirmedQuads); // Agent authorship proof (spec §9.0.6): sign keccak256(merkleRoot) and store in _meta - if (this.publisherWallet) { - try { - const merkleHashBytes = ethers.keccak256(kcMerkleRoot); - const sig = await this.publisherWallet.signMessage(ethers.getBytes(merkleHashBytes)); - const proofQuads = generateAuthorshipProof({ - kcUal: ual, - contextGraphId, - agentAddress: this.publisherWallet.address, - signature: sig, - signedHash: merkleHashBytes, - }); - if (options.targetMetaGraphUri) { - const defaultMeta = `did:dkg:context-graph:${contextGraphId}/_meta`; - const remapped = proofQuads.map((q) => - q.graph === defaultMeta ? { ...q, graph: options.targetMetaGraphUri! } : q, - ); - await this.store.insert(remapped); - } else { - await this.store.insert(proofQuads); - } - this.log.info(ctx, `Authorship proof stored for agent ${this.publisherWallet.address}`); - } catch (proofErr) { - this.log.warn(ctx, `Failed to generate authorship proof: ${proofErr instanceof Error ? proofErr.message : String(proofErr)}`); + try { + const merkleHashBytes = ethers.keccak256(kcMerkleRoot); + const sig = await publisherWallet.signMessage(ethers.getBytes(merkleHashBytes)); + const proofQuads = generateAuthorshipProof({ + kcUal: ual, + contextGraphId, + agentAddress: publisherWallet.address, + signature: sig, + signedHash: merkleHashBytes, + }); + if (options.targetMetaGraphUri) { + const defaultMeta = `did:dkg:context-graph:${contextGraphId}/_meta`; + const remapped = proofQuads.map((q) => + q.graph === defaultMeta ? { ...q, graph: options.targetMetaGraphUri! } : q, + ); + await this.store.insert(remapped); + } else { + await this.store.insert(proofQuads); } + this.log.info(ctx, `Authorship proof stored for agent ${publisherWallet.address}`); + } catch (proofErr) { + this.log.warn(ctx, `Failed to generate authorship proof: ${proofErr instanceof Error ? proofErr.message : String(proofErr)}`); } status = 'confirmed'; @@ -1581,6 +1609,7 @@ export class DKGPublisher implements Publisher { if (privateQuads.length > 0) rejectReservedSubjectPrefixes(privateQuads); } const ctx: OperationContext = operationCtx ?? createOperationContext('publish'); + const { address: publisherAddress } = this.requirePublisherWallet('update'); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); @@ -1674,7 +1703,7 @@ export class DKGPublisher implements Publisher { newByteSize: updateByteSize, newMerkleLeafCount: kcMerkleLeafCount, mintAmount: 0, - publisherAddress: this.publisherAddress, + publisherAddress, v10Origin: true, onBroadcast: emitWriteAheadStart, }); @@ -1688,7 +1717,7 @@ export class DKGPublisher implements Publisher { this.log.warn(ctx, `V10 update rejected (${errorName}): ${v10Err instanceof Error ? v10Err.message : String(v10Err)}`); earlyReturn = { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', @@ -1714,7 +1743,7 @@ export class DKGPublisher implements Publisher { batchId: kcId, newMerkleRoot: kcMerkleRoot, newPublicByteSize: updateByteSize, - publisherAddress: this.publisherAddress, + publisherAddress, }); } catch (v9Err) { enrichEvmError(v9Err); @@ -1732,7 +1761,7 @@ export class DKGPublisher implements Publisher { batchId: kcId, newMerkleRoot: kcMerkleRoot, newPublicByteSize: updateByteSize, - publisherAddress: this.publisherAddress, + publisherAddress, }); } else { throw new Error('Chain adapter does not support updates (no V10 or V9 update method available)'); @@ -1752,7 +1781,7 @@ export class DKGPublisher implements Publisher { onPhase?.('chain', 'end'); return { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', @@ -1789,7 +1818,7 @@ export class DKGPublisher implements Publisher { const result: PublishResult = { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'confirmed', @@ -1799,7 +1828,7 @@ export class DKGPublisher implements Publisher { txHash: txResult.hash, blockNumber: txResult.blockNumber ?? 0, blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress: this.publisherAddress, + publisherAddress, }, }; diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index b9f55f09e..b332a9a38 100644 --- a/packages/publisher/test/phase-sequences.test.ts +++ b/packages/publisher/test/phase-sequences.test.ts @@ -129,9 +129,9 @@ describe('Phase-sequence contracts', () => { ]); }); - // -- Publish (no wallet — tentative path) ----------------------------- + // -- Publish (no wallet — fail closed) -------------------------------- - it('publish: tentative path omits sign/submit sub-phases', async () => { + it('publish: missing publisher wallet rejects before phase emission', async () => { const store = new OxigraphStore(); const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP); const keypair = await generateEd25519Keypair(); @@ -146,28 +146,12 @@ describe('Phase-sequence contracts', () => { const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; const { calls, fn } = recorder(); - await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); + await expect( + publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }), + ).rejects.toThrow(/publisherPrivateKey/i); const phases = calls.map(([p, s]) => `${p}:${s}`); - - expect(phases).toEqual([ - 'prepare:start', - 'prepare:ensureContextGraph:start', - 'prepare:ensureContextGraph:end', - 'prepare:partition:start', - 'prepare:partition:end', - 'prepare:manifest:start', - 'prepare:manifest:end', - 'prepare:validate:start', - 'prepare:validate:end', - 'prepare:merkle:start', - 'prepare:merkle:end', - 'prepare:end', - 'store:start', - 'store:end', - 'chain:start', - 'chain:end', - ]); + expect(phases).toEqual([]); }); // -- Update (happy path) ----------------------------------------------- diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 2951aeeac..f40e9220c 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -1,6 +1,7 @@ /** * Regression test: DKGPublisher must NOT auto-mint a random publisher wallet - * when no `publisherPrivateKey` is supplied. + * when no `publisherPrivateKey` is supplied, and must not use the zero + * address as a placeholder publisher. * * Background * ---------- @@ -15,8 +16,8 @@ * This is the same anti-pattern that destroyed nine testnet admin keys via * `ensureProfile` (see `scripts/audit-create-random.mjs` header). Fix and * test both land in the same PR. The constructor now leaves - * `publisherWallet` undefined; every signing call site is already guarded - * by `if (this.publisherWallet)` and skips gracefully. + * `publisherWallet` undefined; publish/update now fail before emitting + * publisher-attributed output unless an explicit signing key exists. */ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; @@ -31,33 +32,55 @@ function makeStubChain(chainId: string): ChainAdapter { } describe('DKGPublisher: no random publisher wallet without explicit key', () => { - it('leaves publisherWallet undefined when chain is enabled but no key supplied', async () => { + it('leaves publisherWallet and publisherAddress undefined when no key or address is supplied', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ store: new OxigraphStore(), chain: makeStubChain('test-evm-chain'), eventBus: new TypedEventBus(), keypair, - publisherAddress: '0x000000000000000000000000000000000000dEaD', }); // Cast to any so we can assert on the private field — the regression we // are guarding against is exactly that this field used to be a freshly // generated random wallet, which is observable here. expect((publisher as any).publisherWallet).toBeUndefined(); - expect((publisher as any).publisherAddress).toBe('0x000000000000000000000000000000000000dEaD'); + expect((publisher as any).publisherAddress).toBeUndefined(); }); - it('leaves publisherWallet undefined when chain is disabled and no key supplied', async () => { + it('rejects publish without a publisherPrivateKey before producing a UAL', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ store: new OxigraphStore(), - chain: makeStubChain('none'), + chain: makeStubChain('test-evm-chain'), eventBus: new TypedEventBus(), keypair, + publisherAddress: '0x000000000000000000000000000000000000dEaD', }); expect((publisher as any).publisherWallet).toBeUndefined(); + await expect( + publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:no-key', + predicate: 'http://schema.org/name', + object: '"NoKey"', + graph: 'did:dkg:context-graph:1', + }], + }), + ).rejects.toThrow(/publisherPrivateKey/i); + }); + + it('rejects a zero publisherAddress instead of treating it as a sentinel', async () => { + const keypair = await generateEd25519Keypair(); + expect(() => new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + publisherAddress: '0x0000000000000000000000000000000000000000', + })).toThrow(/zero address/i); }); it('still constructs publisherWallet when an explicit key is supplied', async () => { @@ -76,4 +99,18 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(wallet.address.toLowerCase()).toBe('0x70997970c51812dc3a010c7d01b50e0d17dc79c8'); expect((publisher as any).publisherAddress.toLowerCase()).toBe('0x70997970c51812dc3a010c7d01b50e0d17dc79c8'); }); + + it('rejects publisherAddress values that do not match the supplied private key', async () => { + const keypair = await generateEd25519Keypair(); + const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + + expect(() => new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + publisherPrivateKey: TEST_KEY, + publisherAddress: '0x000000000000000000000000000000000000dEaD', + })).toThrow(/does not match publisherPrivateKey signer/i); + }); }); From ff796b397448e1147cc0374a69330285876e25c8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 5 May 2026 18:52:01 +0200 Subject: [PATCH 08/43] test(agent): require publisher keys for publish paths --- packages/agent/test/agent.test.ts | 40 ++++++++++++++++--- packages/agent/test/v10-ack-provider.test.ts | 23 +++++++++-- .../publisher/test/phase-sequences.test.ts | 2 +- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index eef34fcdc..32c57e5bc 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -313,7 +313,13 @@ describe('ProfileManager', () => { const { TypedEventBus, generateEd25519Keypair } = await import('@origintrail-official/dkg-core'); const eventBus = new TypedEventBus(); const keypair = await generateEd25519Keypair(); - const publisher = new DKGPublisher({ store, chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), eventBus, keypair }); + const publisher = new DKGPublisher({ + store, + chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + eventBus, + keypair, + publisherPrivateKey: HARDHAT_KEYS.CORE_OP, + }); const manager = new ProfileManager(publisher, store); const result = await manager.publishProfile({ @@ -347,7 +353,13 @@ describe('ProfileManager', () => { const { TypedEventBus, generateEd25519Keypair } = await import('@origintrail-official/dkg-core'); const eventBus = new TypedEventBus(); const keypair = await generateEd25519Keypair(); - const publisher = new DKGPublisher({ store, chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), eventBus, keypair }); + const publisher = new DKGPublisher({ + store, + chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + eventBus, + keypair, + publisherPrivateKey: HARDHAT_KEYS.CORE_OP, + }); const manager = new ProfileManager(publisher, store); const peerId = 'QmLegacyUpgrade'; @@ -432,7 +444,13 @@ describe('ProfileManager', () => { const walletB = '0x' + 'bb'.repeat(20); // Publish under wallet A. - const publisher1 = new DKGPublisher({ store, chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), eventBus, keypair }); + const publisher1 = new DKGPublisher({ + store, + chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + eventBus, + keypair, + publisherPrivateKey: HARDHAT_KEYS.CORE_OP, + }); const managerA = new ProfileManager(publisher1, store); await managerA.publishProfile({ peerId, @@ -457,7 +475,13 @@ describe('ProfileManager', () => { // Simulate a daemon restart + wallet rotation — brand new // ProfileManager with NO lastRootEntity memory, but the same // store + peerId + a NEW wallet. - const publisher2 = new DKGPublisher({ store, chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), eventBus, keypair }); + const publisher2 = new DKGPublisher({ + store, + chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + eventBus, + keypair, + publisherPrivateKey: HARDHAT_KEYS.CORE_OP, + }); const managerB = new ProfileManager(publisher2, store); await managerB.publishProfile({ peerId, @@ -523,7 +547,13 @@ describe('ProfileManager', () => { const { TypedEventBus, generateEd25519Keypair } = await import('@origintrail-official/dkg-core'); const eventBus = new TypedEventBus(); const keypair = await generateEd25519Keypair(); - const publisher = new DKGPublisher({ store, chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), eventBus, keypair }); + const publisher = new DKGPublisher({ + store, + chain: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + eventBus, + keypair, + publisherPrivateKey: HARDHAT_KEYS.CORE_OP, + }); const manager = new ProfileManager(publisher, store); diff --git a/packages/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index ff7107f43..748e57aa4 100644 --- a/packages/agent/test/v10-ack-provider.test.ts +++ b/packages/agent/test/v10-ack-provider.test.ts @@ -19,7 +19,7 @@ afterAll(async () => { await revertSnapshot(_fileSnapshot); }); -async function createAgent(chainAdapter: ChainAdapter) { +async function createAgent(chainAdapter: ChainAdapter, operationalKeys?: string[]) { const store = new OxigraphStore(); const agent = await DKGAgent.create({ name: 'AckProviderTestAgent', @@ -27,6 +27,13 @@ async function createAgent(chainAdapter: ChainAdapter) { listenHost: '127.0.0.1', store, chainAdapter, + chainConfig: operationalKeys + ? { + rpcUrl: 'http://127.0.0.1:8545', + hubAddress: ethers.ZeroAddress, + operationalKeys, + } + : undefined, nodeRole: 'core', }); await agent.start(); @@ -60,8 +67,8 @@ describe('v10 ACK provider wiring', () => { expect(typeof result.onChainResult!.batchId).toBe('bigint'); }); - it('publishes tentatively when chain does not support V10 (NoChainAdapter)', async () => { - ({ agent } = await createAgent(new NoChainAdapter())); + it('publishes tentatively when chain does not support V10 but a publisher key is configured', async () => { + ({ agent } = await createAgent(new NoChainAdapter(), [HARDHAT_KEYS.CORE_OP])); const result = await agent.publish(SYSTEM_PARANETS.ONTOLOGY, [ { subject: 'urn:test:no-ack-provider', predicate: 'http://schema.org/name', object: '"No ACK"', graph: '' }, @@ -70,4 +77,14 @@ describe('v10 ACK provider wiring', () => { expect(result.status).toBe('tentative'); expect(result.onChainResult).toBeUndefined(); }); + + it('fails clearly when publishing without a publisher key', async () => { + ({ agent } = await createAgent(new NoChainAdapter())); + + await expect( + agent.publish(SYSTEM_PARANETS.ONTOLOGY, [ + { subject: 'urn:test:no-publisher-key', predicate: 'http://schema.org/name', object: '"No key"', graph: '' }, + ]), + ).rejects.toThrow(/publisherPrivateKey/i); + }); }); diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index b332a9a38..8e0dded68 100644 --- a/packages/publisher/test/phase-sequences.test.ts +++ b/packages/publisher/test/phase-sequences.test.ts @@ -141,7 +141,7 @@ describe('Phase-sequence contracts', () => { chain, eventBus: new TypedEventBus(), keypair, - // No publisherPrivateKey → tentative only + // No publisherPrivateKey → fail closed before any publish phases }); const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; From f210e125a98986eaa6855eb6a388765c879223e1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 01:44:22 +0200 Subject: [PATCH 09/43] fix(publisher): support adapter-backed signing --- packages/publisher/src/dkg-publisher.ts | 83 ++++++++++++---- .../test/publisher-no-random-wallet.test.ts | 96 +++++++++++++++++-- scripts/audit-create-random.mjs | 48 +++++++++- scripts/audit-create-random.test.mjs | 27 ++++++ 4 files changed, 224 insertions(+), 30 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index d3fa9956c..4b0893f39 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -60,8 +60,9 @@ export interface DKGPublisherConfig { export class PublisherWalletRequiredError extends Error { constructor(operation: string) { super( - `${operation} requires "publisherPrivateKey". ` + - 'Publishing without a signing key would produce unattributable or unverifiable publisher output.', + `${operation} requires "publisherPrivateKey" or a non-zero "publisherAddress" ` + + 'backed by ChainAdapter.signMessage(). Publishing without a publisher signing key ' + + 'would produce unattributable or unverifiable publisher output.', ); this.name = 'PublisherWalletRequiredError'; } @@ -200,6 +201,12 @@ type InternalPublishOptions = PublishOptions & { [INTERNAL_ORIGIN_TOKEN]?: true; }; +interface PublisherSigner { + address: string; + source: 'publisherPrivateKey' | 'chainAdapter'; + signMessage(message: Uint8Array): Promise; +} + function isInternalOrigin(options: PublishOptions): boolean { return (options as InternalPublishOptions)[INTERNAL_ORIGIN_TOKEN] === true; } @@ -278,8 +285,10 @@ export class DKGPublisher implements Publisher { } else { // No private key supplied means no in-process publisher signing // capability. Keep an optional, validated address only for callers - // that need to inspect configuration; publish/update fail before - // emitting publisher-attributed data unless a real signing key exists. + // that route signing through their ChainAdapter (e.g. adapter-backed + // or hardware-signer deployments). Publish still fails unless that + // address is backed by ChainAdapter.signMessage(); update can let the + // adapter select its signer from the configured signer pool. // // The previous behaviour generated an ephemeral `Wallet.createRandom()` // here whenever chain was enabled, which produced unverifiable @@ -287,9 +296,7 @@ export class DKGPublisher implements Publisher { // `0x000...000` as a sentinel: it looks like an on-chain publisher and // can leak into UALs/metadata. See PR #371 for // the testnet-blocking incident chain (`ensureProfile` had the same - // anti-pattern, fixed in PR #366). A future enhancement could fall - // back to `chain.signMessage` for the publish-time paths so adapter- - // owned signers can still confirm publishes; tracked in #373. + // anti-pattern, fixed in PR #366). this.publisherAddress = configuredPublisherAddress; } @@ -304,11 +311,45 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } - private requirePublisherWallet(operation: string): { wallet: ethers.Wallet; address: string } { - if (!this.publisherWallet || !this.publisherAddress) { - throw new PublisherWalletRequiredError(operation); + private requirePublisherAddress(operation: string): string { + if (!this.publisherAddress) throw new PublisherWalletRequiredError(operation); + return this.publisherAddress; + } + + private requirePublisherSigner(operation: string): PublisherSigner { + if (this.publisherWallet && this.publisherAddress) { + const wallet = this.publisherWallet; + return { + address: this.publisherAddress, + source: 'publisherPrivateKey', + signMessage: (message: Uint8Array) => wallet.signMessage(message), + }; + } + + if (this.publisherAddress && typeof this.chain.signMessage === 'function') { + const expectedAddress = this.publisherAddress; + return { + address: expectedAddress, + source: 'chainAdapter', + signMessage: async (message: Uint8Array) => { + const compact = await this.chain.signMessage!(message); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + const recovered = ethers.verifyMessage(message, signature); + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + + `(${recovered})`, + ); + } + return signature; + }, + }; } - return { wallet: this.publisherWallet, address: this.publisherAddress }; + + throw new PublisherWalletRequiredError(operation); } private async withWriteLocks(keys: string[], fn: () => Promise): Promise { @@ -1033,7 +1074,8 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); - const { wallet: publisherWallet, address: publisherAddress } = this.requirePublisherWallet('publish'); + const publisherSigner = this.requirePublisherSigner('publish'); + const publisherAddress = publisherSigner.address; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1307,7 +1349,7 @@ export class DKGPublisher implements Publisher { BigInt(kcMerkleLeafCount), ); const ackSig = ethers.Signature.from( - await publisherWallet.signMessage(ackDigest), + await publisherSigner.signMessage(ackDigest), ); v10ACKs = [{ peerId: 'self', @@ -1331,7 +1373,10 @@ export class DKGPublisher implements Publisher { this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); } else { onPhase?.('chain:sign', 'start'); - this.log.info(ctx, `Signing on-chain publish (identityId=${identityId}, signer=${publisherWallet.address})`); + this.log.info( + ctx, + `Signing on-chain publish (identityId=${identityId}, signer=${publisherSigner.address}, source=${publisherSigner.source})`, + ); const tokenAmount = precomputedTokenAmount; usedV10Path = true; @@ -1369,7 +1414,7 @@ export class DKGPublisher implements Publisher { kcMerkleRoot, ); const pubSig = ethers.Signature.from( - await publisherWallet.signMessage(pubMsgHash), + await publisherSigner.signMessage(pubMsgHash), ); // P-1 review (iter-2): `chain:writeahead:start` now fires // *from inside* the adapter via the `onBroadcast` callback, @@ -1495,11 +1540,11 @@ export class DKGPublisher implements Publisher { // Agent authorship proof (spec §9.0.6): sign keccak256(merkleRoot) and store in _meta try { const merkleHashBytes = ethers.keccak256(kcMerkleRoot); - const sig = await publisherWallet.signMessage(ethers.getBytes(merkleHashBytes)); + const sig = await publisherSigner.signMessage(ethers.getBytes(merkleHashBytes)); const proofQuads = generateAuthorshipProof({ kcUal: ual, contextGraphId, - agentAddress: publisherWallet.address, + agentAddress: publisherSigner.address, signature: sig, signedHash: merkleHashBytes, }); @@ -1512,7 +1557,7 @@ export class DKGPublisher implements Publisher { } else { await this.store.insert(proofQuads); } - this.log.info(ctx, `Authorship proof stored for agent ${publisherWallet.address}`); + this.log.info(ctx, `Authorship proof stored for agent ${publisherSigner.address}`); } catch (proofErr) { this.log.warn(ctx, `Failed to generate authorship proof: ${proofErr instanceof Error ? proofErr.message : String(proofErr)}`); } @@ -1609,7 +1654,7 @@ export class DKGPublisher implements Publisher { if (privateQuads.length > 0) rejectReservedSubjectPrefixes(privateQuads); } const ctx: OperationContext = operationCtx ?? createOperationContext('publish'); - const { address: publisherAddress } = this.requirePublisherWallet('update'); + const publisherAddress = this.requirePublisherAddress('update'); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index f40e9220c..9d4092aa0 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -16,14 +16,18 @@ * This is the same anti-pattern that destroyed nine testnet admin keys via * `ensureProfile` (see `scripts/audit-create-random.mjs` header). Fix and * test both land in the same PR. The constructor now leaves - * `publisherWallet` undefined; publish/update now fail before emitting - * publisher-attributed output unless an explicit signing key exists. + * `publisherWallet` undefined; publish now requires either an explicit local + * signing key or a configured adapter signer bound to a non-zero publisher + * address. */ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; -import type { ChainAdapter } from '@origintrail-official/dkg-chain'; +import { MockChainAdapter, type ChainAdapter } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; // Minimal stub — DKGPublisher's constructor only reads `chain.chainId`. // All other ChainAdapter methods are unused in this test. @@ -31,6 +35,22 @@ function makeStubChain(chainId: string): ChainAdapter { return { chainId } as unknown as ChainAdapter; } +class AdapterSigningChain extends MockChainAdapter { + 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), + }; + } +} + describe('DKGPublisher: no random publisher wallet without explicit key', () => { it('leaves publisherWallet and publisherAddress undefined when no key or address is supplied', async () => { const keypair = await generateEd25519Keypair(); @@ -48,7 +68,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect((publisher as any).publisherAddress).toBeUndefined(); }); - it('rejects publish without a publisherPrivateKey before producing a UAL', async () => { + it('rejects publish without a publisherPrivateKey or adapter signer before producing a UAL', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ store: new OxigraphStore(), @@ -85,8 +105,6 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => it('still constructs publisherWallet when an explicit key is supplied', async () => { const keypair = await generateEd25519Keypair(); - // Deterministic test-only key (not used elsewhere in the suite). - const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; const publisher = new DKGPublisher({ store: new OxigraphStore(), chain: makeStubChain('test-evm-chain'), @@ -102,7 +120,6 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => it('rejects publisherAddress values that do not match the supplied private key', async () => { const keypair = await generateEd25519Keypair(); - const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; expect(() => new DKGPublisher({ store: new OxigraphStore(), @@ -113,4 +130,69 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => publisherAddress: '0x000000000000000000000000000000000000dEaD', })).toThrow(/does not match publisherPrivateKey signer/i); }); + + it('publishes with an adapter-backed signer when a non-zero publisherAddress is configured', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterSigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherAddress: wallet.address, + publisherNodeIdentityId: 1n, + }); + + expect((publisher as any).publisherWallet).toBeUndefined(); + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-signer', + predicate: 'http://schema.org/name', + object: '"AdapterSigner"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + + it('updates with an adapter-backed signer and configured publisherAddress', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterSigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherAddress: wallet.address, + publisherNodeIdentityId: 1n, + }); + + const created = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-update', + predicate: 'http://schema.org/name', + object: '"Before"', + graph: 'did:dkg:context-graph:1', + }], + }); + + const updated = await publisher.update(created.kcId, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-update', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(updated.status).toBe('confirmed'); + expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); }); diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index 6989f3916..21d525327 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -156,6 +156,7 @@ const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; * dq-string " … " (\-escapes consumed) * tpl-string ` … ` (\-escapes; ${ pushes a substitution) * tpl-substitution { braceDepth } ends when braceDepth → 0 on a `}` + * regex-literal / … /flags (\-escapes + character classes) * * Why a stack? We need string contexts to be re-entered RECURSIVELY when * we're inside a template substitution, so braces inside such strings @@ -181,10 +182,11 @@ const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; * literal `"Wallet.createRandom("` inside a string can't false-positive * the regex either. * - * Regex literals (`/foo/g`) are NOT explicitly handled — we'd need full - * JS expression context to disambiguate `/` from division. In practice - * the only pattern that could bypass is a regex literal whose body - * contains an unescaped `/​/` or `/​*`, which is exotic enough to ignore. + * Regex literals are handled conservatively when `/` appears in an + * expression-start position (`=`, `(`, `[`, `{`, `,`, `;`, etc.). This is + * still not a full JS parser, but it closes the dangerous false-negative + * class where `//` or `/*` inside a regex body was previously treated as + * a real comment and blanked a following `Wallet.createRandom()` call. */ export function stripCommentsPreservingPositions(text) { const len = text.length; @@ -195,6 +197,16 @@ export function stripCommentsPreservingPositions(text) { const stack = [{ kind: 'normal' }]; const top = () => stack[stack.length - 1]; const blank = (c) => (c === '\n' ? '\n' : ' '); + const previousSignificantChar = () => { + for (let j = out.length - 1; j >= 0; j -= 1) { + if (!/\s/.test(out[j])) return out[j]; + } + return ''; + }; + const canStartRegexLiteral = () => { + const prev = previousSignificantChar(); + return prev === '' || '([{=,:;!&|?+-*~^<>'.includes(prev); + }; while (i < len) { const cur = top(); @@ -228,6 +240,9 @@ export function stripCommentsPreservingPositions(text) { } else if (c === '/' && next === '*') { out += ' '; i += 2; stack.push({ kind: 'block-comment' }); + } else if (c === '/' && canStartRegexLiteral()) { + out += ' '; i += 1; + stack.push({ kind: 'regex-literal', inClass: false }); } else if (c === '"' || c === "'") { out += ' '; i += 1; stack.push({ kind: c === '"' ? 'dq-string' : 'sq-string' }); @@ -260,6 +275,31 @@ export function stripCommentsPreservingPositions(text) { continue; } + if (cur.kind === 'regex-literal') { + if (c === '\n') { + out += '\n'; i += 1; + stack.pop(); + } else if (c === '\\' && i + 1 < len) { + out += blank(c) + blank(text[i + 1]); + i += 2; + } else if (c === '[') { + cur.inClass = true; + out += ' '; i += 1; + } else if (c === ']' && cur.inClass) { + cur.inClass = false; + out += ' '; i += 1; + } else if (c === '/' && !cur.inClass) { + out += ' '; i += 1; + while (i < len && /[A-Za-z]/.test(text[i])) { + out += ' '; i += 1; + } + stack.pop(); + } else { + out += blank(c); i += 1; + } + continue; + } + if (cur.kind === 'sq-string' || cur.kind === 'dq-string') { const quote = cur.kind === 'sq-string' ? "'" : '"'; if (c === '\\' && i + 1 < len) { diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index db5d182be..899814c44 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -116,6 +116,27 @@ describe('stripCommentsPreservingPositions', () => { const out = stripCommentsPreservingPositions(text); assert.match(out, /Wallet\.createRandom\(\)/); }); + + it('REGRESSION: // inside a regex literal does NOT start a line comment', () => { + const text = 'const r = /\\/\\//; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + assert.equal(out.length, text.length); + }); + + it('REGRESSION: /* inside a regex literal does NOT start a block comment', () => { + const text = 'const r = /\\/\\*/; Wallet.createRandom();'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + assert.equal(out.length, text.length); + }); + + it('blanks Wallet.createRandom( inside a regex literal (no false positive)', () => { + const text = 'const r = /Wallet\\.createRandom\\(/; const z = 1;'; + const out = stripCommentsPreservingPositions(text); + assert.doesNotMatch(out, /Wallet\.createRandom\(/); + assert.match(out, /const z = 1;/); + }); }); describe('findHits', () => { @@ -176,4 +197,10 @@ describe('findHits', () => { assert.equal(hits.length, 1); assert.match(hits[0].snippet, /const url = "http:\/\/"; Wallet\.createRandom\(\);/); }); + + it('REGRESSION (PR #371): finds calls after a regex containing //', () => { + const hits = findHits('const r = /\\/\\//; Wallet.createRandom();'); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 1); + }); }); From d25cc586954a8a58705cc8b32bb3ecd349d81b9a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 01:56:22 +0200 Subject: [PATCH 10/43] fix(publisher): keep tentative publish compatibility --- packages/publisher/src/dkg-publisher.ts | 92 +++++++++------- .../test/publisher-no-random-wallet.test.ts | 39 ++++++- scripts/audit-create-random.mjs | 104 +++++++++++++++--- scripts/audit-create-random.test.mjs | 40 +++++++ 4 files changed, 215 insertions(+), 60 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 4b0893f39..3bb8c7f71 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -316,7 +316,7 @@ export class DKGPublisher implements Publisher { return this.publisherAddress; } - private requirePublisherSigner(operation: string): PublisherSigner { + private getPublisherSigner(): PublisherSigner | undefined { if (this.publisherWallet && this.publisherAddress) { const wallet = this.publisherWallet; return { @@ -349,7 +349,13 @@ export class DKGPublisher implements Publisher { }; } - throw new PublisherWalletRequiredError(operation); + return undefined; + } + + private requirePublisherSigner(operation: string): PublisherSigner { + const signer = this.getPublisherSigner(); + if (!signer) throw new PublisherWalletRequiredError(operation); + return signer; } private async withWriteLocks(keys: string[], fn: () => Promise): Promise { @@ -1074,8 +1080,7 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); - const publisherSigner = this.requirePublisherSigner('publish'); - const publisherAddress = publisherSigner.address; + const publisherAddress = this.requirePublisherAddress('publish'); if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1335,28 +1340,33 @@ export class DKGPublisher implements Publisher { v10ChainId !== undefined && v10KavAddress !== undefined ) { - const reason = !options.v10ACKProvider ? 'no v10ACKProvider (single-node mode)' : 'ACK collection failed/skipped'; - this.log.info(ctx, `Self-signing ACK — ${reason}`); - const ackDigest = computePublishACKDigest( - v10ChainId, - v10KavAddress, - v10CgId, - kcMerkleRoot, - BigInt(kaCount), - publicByteSize, - BigInt(publishEpochs), - precomputedTokenAmount, - BigInt(kcMerkleLeafCount), - ); - const ackSig = ethers.Signature.from( - await publisherSigner.signMessage(ackDigest), - ); - v10ACKs = [{ - peerId: 'self', - signatureR: ethers.getBytes(ackSig.r), - signatureVS: ethers.getBytes(ackSig.yParityAndS), - nodeIdentityId: this.publisherNodeIdentityId, - }]; + const publisherSigner = this.getPublisherSigner(); + if (publisherSigner) { + const reason = !options.v10ACKProvider ? 'no v10ACKProvider (single-node mode)' : 'ACK collection failed/skipped'; + this.log.info(ctx, `Self-signing ACK — ${reason}`); + const ackDigest = computePublishACKDigest( + v10ChainId, + v10KavAddress, + v10CgId, + kcMerkleRoot, + BigInt(kaCount), + publicByteSize, + BigInt(publishEpochs), + precomputedTokenAmount, + BigInt(kcMerkleLeafCount), + ); + const ackSig = ethers.Signature.from( + await publisherSigner.signMessage(ackDigest), + ); + v10ACKs = [{ + peerId: 'self', + signatureR: ethers.getBytes(ackSig.r), + signatureVS: ethers.getBytes(ackSig.yParityAndS), + nodeIdentityId: this.publisherNodeIdentityId, + }]; + } else { + this.log.warn(ctx, 'Self-sign ACK skipped: publisher signing key is unavailable'); + } } onPhase?.('chain', 'start'); @@ -1372,19 +1382,25 @@ export class DKGPublisher implements Publisher { if (identityId === 0n) { this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); } else { - onPhase?.('chain:sign', 'start'); - this.log.info( - ctx, - `Signing on-chain publish (identityId=${identityId}, signer=${publisherSigner.address}, source=${publisherSigner.source})`, - ); - const tokenAmount = precomputedTokenAmount; usedV10Path = true; - - onPhase?.('chain:sign', 'end'); - onPhase?.('chain:submit', 'start'); - this.log.info(ctx, `Submitting V10 on-chain publish tx (${kaCount} KAs, publicByteSize=${publicByteSize}, tokenAmount=${tokenAmount})`); + let signStarted = false; + let submitStarted = false; try { + onPhase?.('chain:sign', 'start'); + signStarted = true; + const publisherSigner = this.requirePublisherSigner('publish'); + this.log.info( + ctx, + `Signing on-chain publish (identityId=${identityId}, signer=${publisherSigner.address}, source=${publisherSigner.source})`, + ); + + onPhase?.('chain:sign', 'end'); + signStarted = false; + onPhase?.('chain:submit', 'start'); + submitStarted = true; + this.log.info(ctx, `Submitting V10 on-chain publish tx (${kaCount} KAs, publicByteSize=${publicByteSize}, tokenAmount=${tokenAmount})`); + if (!v10ACKs || v10ACKs.length === 0) { throw new Error('V10 ACKs required for on-chain publish — no ACKs collected'); } @@ -1564,10 +1580,12 @@ export class DKGPublisher implements Publisher { status = 'confirmed'; onPhase?.('chain:submit', 'end'); + submitStarted = false; onPhase?.('chain:metadata', 'start'); this.log.info(ctx, `On-chain confirmed: UAL=${ual} batchId=${onChainResult.batchId} tx=${onChainResult.txHash}`); } catch (err) { - onPhase?.('chain:submit', 'end'); + if (signStarted) onPhase?.('chain:sign', 'end'); + if (submitStarted) onPhase?.('chain:submit', 'end'); this.log.warn(ctx, `On-chain tx failed: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 9d4092aa0..f88ac59a7 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -18,7 +18,9 @@ * test both land in the same PR. The constructor now leaves * `publisherWallet` undefined; publish now requires either an explicit local * signing key or a configured adapter signer bound to a non-zero publisher - * address. + * address before it can create ACKs, publisher signatures, or authorship + * proofs. Local tentative/no-chain publishes may proceed only when the caller + * supplied an explicit non-zero publisher address. */ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; @@ -68,30 +70,55 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect((publisher as any).publisherAddress).toBeUndefined(); }); - it('rejects publish without a publisherPrivateKey or adapter signer before producing a UAL', async () => { + it('rejects publish without any publisher address before producing a UAL', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ store: new OxigraphStore(), chain: makeStubChain('test-evm-chain'), eventBus: new TypedEventBus(), keypair, - publisherAddress: '0x000000000000000000000000000000000000dEaD', }); - expect((publisher as any).publisherWallet).toBeUndefined(); await expect( publisher.publish({ contextGraphId: '1', quads: [{ - subject: 'urn:test:no-key', + subject: 'urn:test:no-address', predicate: 'http://schema.org/name', - object: '"NoKey"', + object: '"NoAddress"', graph: 'did:dkg:context-graph:1', }], }), ).rejects.toThrow(/publisherPrivateKey/i); }); + it('publishes tentatively with an explicit non-zero publisherAddress when no on-chain signer is needed', async () => { + const keypair = await generateEd25519Keypair(); + const publisherAddress = '0x000000000000000000000000000000000000dEaD'; + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + publisherAddress, + }); + + expect((publisher as any).publisherWallet).toBeUndefined(); + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:no-key', + predicate: 'http://schema.org/name', + object: '"NoKey"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(result.ual.toLowerCase()).toContain(publisherAddress.toLowerCase()); + }); + it('rejects a zero publisherAddress instead of treating it as a sentinel', async () => { const keypair = await generateEd25519Keypair(); expect(() => new DKGPublisher({ diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index 21d525327..d6e0ff087 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -25,8 +25,9 @@ * ------------ * Walks every `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / `.mjs` / * `.cjs` source file under `packages/*\/{src,utils}/**` and fails if it - * finds `Wallet.createRandom(` outside the explicitly allowlisted call - * sites below. Each allowlist entry pins ONE expected hit with a one-line + * finds `Wallet.createRandom(` (including obvious `Wallet` aliases) outside + * the explicitly allowlisted call sites below. Each allowlist entry pins ONE + * expected hit with a one-line * justification — adding a second `createRandom()` to the same file does * NOT inherit the existing exemption and must be reviewed on its own. * @@ -51,6 +52,8 @@ * line numbers in error output stay accurate). This means: * * Split invocations like `Wallet\n.createRandom()` or * `Wallet /* … *​/.createRandom()` cannot bypass via formatting. + * * Import/local aliases such as `import { Wallet as EthersWallet }` + * and `const W = Wallet; W.createRandom()` are scanned too. * * `//` or `/​*` inside a string literal does NOT trigger comment * mode (so `const url = "http://"; Wallet.createRandom();` is * correctly flagged — previously the `//` inside the string @@ -134,11 +137,8 @@ const ALLOWLIST = new Map([ ], ]); -// Match `Wallet . createRandom (` allowing arbitrary whitespace (including -// newlines) between the tokens. This is intentionally a single regex over -// the comment-stripped file, NOT a per-line scan — that previously let -// `Wallet\n .createRandom()` bypass the audit by formatting alone. -const PATTERN = /\bWallet\s*\.\s*createRandom\s*\(/g; +const IDENT = String.raw`[A-Za-z_$][\w$]*`; +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); /** * Blank out comments AND string / template-literal contents from `text`, @@ -335,18 +335,88 @@ export function stripCommentsPreservingPositions(text) { export function findHits(originalText) { const stripped = stripCommentsPreservingPositions(originalText); + const walletAliases = collectWalletAliases(stripped); const hits = []; - for (const m of stripped.matchAll(PATTERN)) { - const upToMatch = stripped.slice(0, m.index); - const line = upToMatch.split('\n').length; - const lineStart = upToMatch.lastIndexOf('\n') + 1; - const lineEnd = stripped.indexOf('\n', m.index); - const snippet = originalText - .slice(lineStart, lineEnd === -1 ? originalText.length : lineEnd) - .trim(); - hits.push({ line, snippet }); + const seen = new Set(); + + for (const alias of walletAliases) { + const pattern = new RegExp(String.raw`\b${escapeRegExp(alias)}\s*\.\s*createRandom\s*\(`, 'g'); + for (const m of stripped.matchAll(pattern)) { + if (seen.has(m.index)) continue; + seen.add(m.index); + hits.push(hitFromIndex(originalText, stripped, m.index, alias)); + } + } + + hits.sort((a, b) => a.index - b.index); + return hits.map(({ index: _index, ...hit }) => hit); +} + +function hitFromIndex(originalText, stripped, index, identifier) { + const upToMatch = stripped.slice(0, index); + const line = upToMatch.split('\n').length; + const lineStart = upToMatch.lastIndexOf('\n') + 1; + const lineEnd = stripped.indexOf('\n', index); + const snippet = originalText + .slice(lineStart, lineEnd === -1 ? originalText.length : lineEnd) + .trim(); + return { index, line, snippet, identifier }; +} + +function collectWalletAliases(stripped) { + const aliases = new Set(['Wallet']); + + // Named imports, including aliases: import { Wallet as EthersWallet } from ... + // String contents are blanked by the lexer, so this intentionally keys on the + // imported binding shape rather than the module specifier. The audit is a + // fail-safe for high-impact key loss, so a conservative false positive is + // preferable to an alias bypass. + const importPattern = new RegExp(String.raw`\bimport\s*\{([^}]*)\}\s*from\b`, 'g'); + for (const m of stripped.matchAll(importPattern)) { + for (const rawSpecifier of m[1].split(',')) { + const specifier = rawSpecifier.trim(); + const aliasMatch = specifier.match(new RegExp(String.raw`^Wallet\s+as\s+(${IDENT})$`)); + if (aliasMatch) aliases.add(aliasMatch[1]); + if (specifier === 'Wallet') aliases.add('Wallet'); + } } - return hits; + + // Destructuring aliases from ethers: const { Wallet: W } = ethers; + const destructurePattern = new RegExp( + String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*ethers\b`, + 'g', + ); + for (const m of stripped.matchAll(destructurePattern)) { + for (const rawSpecifier of m[1].split(',')) { + const specifier = rawSpecifier.trim(); + const aliasMatch = specifier.match(new RegExp(String.raw`^Wallet\s*:\s*(${IDENT})$`)); + if (aliasMatch) aliases.add(aliasMatch[1]); + if (specifier === 'Wallet') aliases.add('Wallet'); + } + } + + // Follow simple assignment aliases transitively: + // const W = Wallet; + // const W = ethers.Wallet; + // const W2 = W; + let changed = true; + while (changed) { + changed = false; + for (const alias of [...aliases]) { + const aliasPattern = new RegExp( + String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*(?:ethers\s*\.\s*)?${escapeRegExp(alias)}\b`, + 'g', + ); + for (const m of stripped.matchAll(aliasPattern)) { + if (!aliases.has(m[1])) { + aliases.add(m[1]); + changed = true; + } + } + } + } + + return aliases; } async function main() { diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index 899814c44..4b04fed52 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -157,6 +157,46 @@ describe('findHits', () => { assert.equal(hits.length, 1); }); + it('REGRESSION (PR #371 round 4): finds Wallet import aliases', () => { + const text = 'import { Wallet as EthersWallet } from "ethers";\nconst w = EthersWallet.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + assert.equal(hits[0].identifier, 'EthersWallet'); + }); + + it('REGRESSION (PR #371 round 4): follows local Wallet aliases', () => { + const text = 'const W = Wallet;\nconst w = W.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + assert.equal(hits[0].identifier, 'W'); + }); + + it('REGRESSION (PR #371 round 4): follows transitive Wallet aliases', () => { + const text = 'const W = Wallet;\nconst W2 = W;\nconst w = W2.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 3); + assert.equal(hits[0].identifier, 'W2'); + }); + + it('REGRESSION (PR #371 round 4): finds aliases assigned from ethers.Wallet', () => { + const text = 'const W = ethers.Wallet;\nconst w = W.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + assert.equal(hits[0].identifier, 'W'); + }); + + it('REGRESSION (PR #371 round 4): finds destructured ethers Wallet aliases', () => { + const text = 'const { Wallet: EthersWallet } = ethers;\nconst w = EthersWallet.createRandom();'; + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + assert.equal(hits[0].identifier, 'EthersWallet'); + }); + it('does NOT report calls inside string literals', () => { const hits = findHits('const s = "Wallet.createRandom()";'); assert.equal(hits.length, 0); From d64248ead639e62565a9b13ffd4c6764f54075db Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:03:06 +0200 Subject: [PATCH 11/43] fix(publisher): skip token estimate for local tentative publish --- packages/publisher/src/dkg-publisher.ts | 19 ++++++++++++++----- .../test/publisher-no-random-wallet.test.ts | 8 +++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 3bb8c7f71..d4b652fe5 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1081,6 +1081,7 @@ export class DKGPublisher implements Publisher { const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); const publisherAddress = this.requirePublisherAddress('publish'); + const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && this.getPublisherSigner() !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1214,11 +1215,19 @@ export class DKGPublisher implements Publisher { // `KnowledgeAssetsV10._executePublishCore`. const publishEpochs = 1; let precomputedTokenAmount = 0n; - if (typeof this.chain.getRequiredPublishTokenAmount === 'function') { - precomputedTokenAmount = await this.chain.getRequiredPublishTokenAmount(publicByteSize, publishEpochs); - if (precomputedTokenAmount <= 0n) { - this.log.warn(ctx, `getRequiredPublishTokenAmount returned ${precomputedTokenAmount} for byteSize=${publicByteSize} — using 1n as minimum`); - precomputedTokenAmount = 1n; + if (canAttemptOnChainPublish && typeof this.chain.getRequiredPublishTokenAmount === 'function') { + try { + precomputedTokenAmount = await this.chain.getRequiredPublishTokenAmount(publicByteSize, publishEpochs); + if (precomputedTokenAmount <= 0n) { + this.log.warn(ctx, `getRequiredPublishTokenAmount returned ${precomputedTokenAmount} for byteSize=${publicByteSize} — using 1n as minimum`); + precomputedTokenAmount = 1n; + } + } catch (err) { + this.log.warn( + ctx, + `getRequiredPublishTokenAmount failed — publish will fall back to tentative if on-chain submit cannot proceed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index f88ac59a7..dffb21f90 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -95,9 +95,15 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => it('publishes tentatively with an explicit non-zero publisherAddress when no on-chain signer is needed', async () => { const keypair = await generateEd25519Keypair(); const publisherAddress = '0x000000000000000000000000000000000000dEaD'; + const chain = { + ...makeStubChain('test-evm-chain'), + getRequiredPublishTokenAmount: async () => { + throw new Error('RPC unavailable'); + }, + } as ChainAdapter; const publisher = new DKGPublisher({ store: new OxigraphStore(), - chain: makeStubChain('test-evm-chain'), + chain, eventBus: new TypedEventBus(), keypair, publisherAddress, From 5cbaec42e33ce5a7fa3857e556e8112502639ff2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:10:50 +0200 Subject: [PATCH 12/43] fix(publisher): restore no-chain tentative publishing --- packages/agent/test/v10-ack-provider.test.ts | 16 +++++--- packages/publisher/src/dkg-publisher.ts | 10 ++++- .../test/publisher-no-random-wallet.test.ts | 41 ++++++++++++------- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/packages/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index 748e57aa4..c41e6404c 100644 --- a/packages/agent/test/v10-ack-provider.test.ts +++ b/packages/agent/test/v10-ack-provider.test.ts @@ -78,13 +78,17 @@ describe('v10 ACK provider wiring', () => { expect(result.onChainResult).toBeUndefined(); }); - it('fails clearly when publishing without a publisher key', async () => { + it('publishes tentatively without chain config using a non-zero local publisher address', async () => { ({ agent } = await createAgent(new NoChainAdapter())); - await expect( - agent.publish(SYSTEM_PARANETS.ONTOLOGY, [ - { subject: 'urn:test:no-publisher-key', predicate: 'http://schema.org/name', object: '"No key"', graph: '' }, - ]), - ).rejects.toThrow(/publisherPrivateKey/i); + const result = await agent.publish(SYSTEM_PARANETS.ONTOLOGY, [ + { subject: 'urn:test:no-publisher-key', predicate: 'http://schema.org/name', object: '"No key"', graph: '' }, + ]); + + const match = result.ual.match(/^did:dkg:none\/(0x[0-9a-fA-F]{40})\/t/); + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(match?.[1]).toBeDefined(); + expect(match![1]).not.toBe(ethers.ZeroAddress); }); }); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index d4b652fe5..9b16ddbdb 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -316,6 +316,14 @@ export class DKGPublisher implements Publisher { return this.publisherAddress; } + // Local-only tentative publishes need a stable, non-zero UAL component even + // when no EVM publisher key exists. This is not used for signatures. + private localTentativePublisherAddress(): string { + const digest = ethers.keccak256(this.keypair.publicKey); + const address = ethers.getAddress(ethers.dataSlice(digest, 12)); + return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address; + } + private getPublisherSigner(): PublisherSigner | undefined { if (this.publisherWallet && this.publisherAddress) { const wallet = this.publisherWallet; @@ -1080,7 +1088,7 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); - const publisherAddress = this.requirePublisherAddress('publish'); + const publisherAddress = this.publisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && this.getPublisherSigner() !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index dffb21f90..e29e034d1 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -19,8 +19,9 @@ * `publisherWallet` undefined; publish now requires either an explicit local * signing key or a configured adapter signer bound to a non-zero publisher * address before it can create ACKs, publisher signatures, or authorship - * proofs. Local tentative/no-chain publishes may proceed only when the caller - * supplied an explicit non-zero publisher address. + * proofs. Local tentative/no-chain publishes never use the zero address; when + * no EVM publisher address exists, they use a deterministic non-zero address + * derived from the agent keypair solely for tentative metadata/UAL scoping. */ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; @@ -70,26 +71,36 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect((publisher as any).publisherAddress).toBeUndefined(); }); - it('rejects publish without any publisher address before producing a UAL', async () => { + it('publishes tentatively with a deterministic non-zero local address when no publisher address is configured', async () => { const keypair = await generateEd25519Keypair(); + const chain = { + ...makeStubChain('test-evm-chain'), + getRequiredPublishTokenAmount: async () => { + throw new Error('RPC unavailable'); + }, + } as ChainAdapter; const publisher = new DKGPublisher({ store: new OxigraphStore(), - chain: makeStubChain('test-evm-chain'), + chain, eventBus: new TypedEventBus(), keypair, }); - await expect( - publisher.publish({ - contextGraphId: '1', - quads: [{ - subject: 'urn:test:no-address', - predicate: 'http://schema.org/name', - object: '"NoAddress"', - graph: 'did:dkg:context-graph:1', - }], - }), - ).rejects.toThrow(/publisherPrivateKey/i); + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:no-address', + predicate: 'http://schema.org/name', + object: '"NoAddress"', + graph: 'did:dkg:context-graph:1', + }], + }); + + const match = result.ual.match(/^did:dkg:test-evm-chain\/(0x[0-9a-fA-F]{40})\/t/); + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(match?.[1]).toBeDefined(); + expect(match![1]).not.toBe(ethers.ZeroAddress); }); it('publishes tentatively with an explicit non-zero publisherAddress when no on-chain signer is needed', async () => { From 2c5c28960a7c1c4425d6d797867f148274335ad3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:31:08 +0200 Subject: [PATCH 13/43] test(publisher): update no-wallet phase expectation --- .../publisher/test/phase-sequences.test.ts | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index 8e0dded68..e1694f6ad 100644 --- a/packages/publisher/test/phase-sequences.test.ts +++ b/packages/publisher/test/phase-sequences.test.ts @@ -129,9 +129,9 @@ describe('Phase-sequence contracts', () => { ]); }); - // -- Publish (no wallet — fail closed) -------------------------------- + // -- Publish (no wallet — local tentative) ----------------------------- - it('publish: missing publisher wallet rejects before phase emission', async () => { + it('publish: missing publisher wallet stores tentative data without sign/submit phases', async () => { const store = new OxigraphStore(); const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP); const keypair = await generateEd25519Keypair(); @@ -141,17 +141,38 @@ describe('Phase-sequence contracts', () => { chain, eventBus: new TypedEventBus(), keypair, - // No publisherPrivateKey → fail closed before any publish phases + // No publisherPrivateKey and no identity -> local tentative publish. }); const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; const { calls, fn } = recorder(); - await expect( - publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }), - ).rejects.toThrow(/publisherPrivateKey/i); + const result = await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); + const match = result.ual.match(/^did:dkg:evm:31337\/(0x[0-9a-fA-F]{40})\/t/); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(match?.[1]).toBeDefined(); + expect(match![1]).not.toBe(ethers.ZeroAddress); const phases = calls.map(([p, s]) => `${p}:${s}`); - expect(phases).toEqual([]); + expect(phases).toEqual([ + 'prepare:start', + 'prepare:ensureContextGraph:start', + 'prepare:ensureContextGraph:end', + 'prepare:partition:start', + 'prepare:partition:end', + 'prepare:manifest:start', + 'prepare:manifest:end', + 'prepare:validate:start', + 'prepare:validate:end', + 'prepare:merkle:start', + 'prepare:merkle:end', + 'prepare:end', + 'store:start', + 'store:end', + 'chain:start', + 'chain:end', + ]); }); // -- Update (happy path) ----------------------------------------------- From c34c82b605c97ea544a817d09701d496260cb51c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:37:23 +0200 Subject: [PATCH 14/43] fix(publisher): fail closed for evm publish without signer --- packages/publisher/src/dkg-publisher.ts | 13 ++++--- .../publisher/test/phase-sequences.test.ts | 36 ++++--------------- .../test/publisher-no-random-wallet.test.ts | 30 +++++++++++++--- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 9b16ddbdb..822d81d61 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -286,9 +286,9 @@ export class DKGPublisher implements Publisher { // No private key supplied means no in-process publisher signing // capability. Keep an optional, validated address only for callers // that route signing through their ChainAdapter (e.g. adapter-backed - // or hardware-signer deployments). Publish still fails unless that - // address is backed by ChainAdapter.signMessage(); update can let the - // adapter select its signer from the configured signer pool. + // or hardware-signer deployments). Chain-backed publish still fails + // unless that address is backed by ChainAdapter.signMessage(); update + // can let the adapter select its signer from the configured signer pool. // // The previous behaviour generated an ephemeral `Wallet.createRandom()` // here whenever chain was enabled, which produced unverifiable @@ -1088,8 +1088,9 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); + const publisherSigner = this.getPublisherSigner(); const publisherAddress = this.publisherAddress ?? this.localTentativePublisherAddress(); - const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && this.getPublisherSigner() !== undefined; + const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1104,6 +1105,10 @@ export class DKGPublisher implements Publisher { throw new Error('Publish rejected: "allowedPeers" is only valid when accessPolicy is "allowList"'); } + if (this.chain.chainId !== 'none' && !publisherSigner) { + throw new PublisherWalletRequiredError('publish'); + } + onPhase?.('prepare', 'start'); onPhase?.('prepare:ensureContextGraph', 'start'); this.log.info(ctx, `Preparing publish: ${quads.length} public triples, ${privateQuads.length} private`); diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index e1694f6ad..aeb40b246 100644 --- a/packages/publisher/test/phase-sequences.test.ts +++ b/packages/publisher/test/phase-sequences.test.ts @@ -129,9 +129,9 @@ describe('Phase-sequence contracts', () => { ]); }); - // -- Publish (no wallet — local tentative) ----------------------------- + // -- Publish (no wallet — fail closed before local storage) ------------- - it('publish: missing publisher wallet stores tentative data without sign/submit phases', async () => { + it('publish: missing publisher wallet rejects before phase emission', async () => { const store = new OxigraphStore(); const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP); const keypair = await generateEd25519Keypair(); @@ -141,38 +141,14 @@ describe('Phase-sequence contracts', () => { chain, eventBus: new TypedEventBus(), keypair, - // No publisherPrivateKey and no identity -> local tentative publish. + // EVM adapter but no publisherPrivateKey / adapter-backed signer. }); const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; const { calls, fn } = recorder(); - const result = await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); - const match = result.ual.match(/^did:dkg:evm:31337\/(0x[0-9a-fA-F]{40})\/t/); - - expect(result.status).toBe('tentative'); - expect(result.onChainResult).toBeUndefined(); - expect(match?.[1]).toBeDefined(); - expect(match![1]).not.toBe(ethers.ZeroAddress); - - const phases = calls.map(([p, s]) => `${p}:${s}`); - expect(phases).toEqual([ - 'prepare:start', - 'prepare:ensureContextGraph:start', - 'prepare:ensureContextGraph:end', - 'prepare:partition:start', - 'prepare:partition:end', - 'prepare:manifest:start', - 'prepare:manifest:end', - 'prepare:validate:start', - 'prepare:validate:end', - 'prepare:merkle:start', - 'prepare:merkle:end', - 'prepare:end', - 'store:start', - 'store:end', - 'chain:start', - 'chain:end', - ]); + await expect(publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn })) + .rejects.toThrow(/publisherPrivateKey/); + expect(calls).toEqual([]); }); // -- Update (happy path) ----------------------------------------------- diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index e29e034d1..fadf673d0 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -71,10 +71,10 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect((publisher as any).publisherAddress).toBeUndefined(); }); - it('publishes tentatively with a deterministic non-zero local address when no publisher address is configured', async () => { + it('publishes tentatively with a deterministic non-zero local address on no-chain publishes', async () => { const keypair = await generateEd25519Keypair(); const chain = { - ...makeStubChain('test-evm-chain'), + ...makeStubChain('none'), getRequiredPublishTokenAmount: async () => { throw new Error('RPC unavailable'); }, @@ -96,18 +96,18 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => }], }); - const match = result.ual.match(/^did:dkg:test-evm-chain\/(0x[0-9a-fA-F]{40})\/t/); + const match = result.ual.match(/^did:dkg:none\/(0x[0-9a-fA-F]{40})\/t/); expect(result.status).toBe('tentative'); expect(result.onChainResult).toBeUndefined(); expect(match?.[1]).toBeDefined(); expect(match![1]).not.toBe(ethers.ZeroAddress); }); - it('publishes tentatively with an explicit non-zero publisherAddress when no on-chain signer is needed', async () => { + it('publishes tentatively with an explicit non-zero publisherAddress on no-chain publishes', async () => { const keypair = await generateEd25519Keypair(); const publisherAddress = '0x000000000000000000000000000000000000dEaD'; const chain = { - ...makeStubChain('test-evm-chain'), + ...makeStubChain('none'), getRequiredPublishTokenAmount: async () => { throw new Error('RPC unavailable'); }, @@ -136,6 +136,26 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.ual.toLowerCase()).toContain(publisherAddress.toLowerCase()); }); + it('rejects chain-backed publish without a publisher signer before local storage', async () => { + const keypair = await generateEd25519Keypair(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('evm:31337'), + eventBus: new TypedEventBus(), + keypair, + }); + + await expect(publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:evm-no-signer', + predicate: 'http://schema.org/name', + object: '"EvmNoSigner"', + graph: 'did:dkg:context-graph:1', + }], + })).rejects.toThrow(/publisherPrivateKey/); + }); + it('rejects a zero publisherAddress instead of treating it as a sentinel', async () => { const keypair = await generateEd25519Keypair(); expect(() => new DKGPublisher({ From 5a8355f949cc2f6dcb9353538bf05c4899ccd387 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:46:13 +0200 Subject: [PATCH 15/43] fix(agent): preserve adapter-backed publisher signing --- packages/agent/src/dkg-agent.ts | 47 ++++++++++++++++++++ packages/agent/test/v10-ack-provider.test.ts | 27 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 652bf149c..506e58b84 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -452,6 +452,11 @@ export interface DKGAgentConfig { chainAdapter?: ChainAdapter; /** Private key for the V10 ACK signer. When omitted, falls back to chainConfig.operationalKeys[0]. */ ackSignerKey?: string; + /** + * Publisher EVM address used when publish signing is delegated to the + * ChainAdapter instead of an in-process publisherPrivateKey. + */ + publisherAddress?: string; /** * EVM chain configuration. If omitted, publishing won't have on-chain finality. * `adminPrivateKey` is the private key for the profile admin wallet used @@ -482,6 +487,44 @@ export interface DKGAgentConfig { contextGraphMembershipStore?: ContextGraphMembershipStore; } +function normalizeAdapterPublisherAddress(value: unknown): string | undefined { + if (typeof value !== 'string' || !ethers.isAddress(value)) return undefined; + const address = ethers.getAddress(value); + return address === ethers.ZeroAddress ? undefined : address; +} + +async function inferAdapterPublisherAddress(chain: ChainAdapter): Promise { + const signerAddresses = (chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; + if (typeof signerAddresses === 'function') { + const advertised = signerAddresses.call(chain); + if (Array.isArray(advertised)) { + for (const value of advertised) { + const address = normalizeAdapterPublisherAddress(value); + if (address) return address; + } + } + } + + const signerAddress = normalizeAdapterPublisherAddress( + (chain as unknown as { signerAddress?: unknown }).signerAddress, + ); + if (signerAddress) return signerAddress; + + if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return undefined; + + try { + const challenge = ethers.getBytes(ethers.id('dkg-agent:publisher-address-probe')); + const compact = await chain.signMessage(challenge); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + return normalizeAdapterPublisherAddress(ethers.verifyMessage(challenge, signature)); + } catch { + return undefined; + } +} + /** * High-level facade that ties together all DKG agent capabilities: * identity, networking, publishing, querying, discovery, and messaging. @@ -680,12 +723,16 @@ export class DKGAgent { const node = new DKGNode(nodeConfig); const workspaceOwnedEntities = new Map>(); const writeLocks = new Map>(); + const publisherAddress = config.publisherAddress ?? ( + opKeys?.[0] ? undefined : await inferAdapterPublisherAddress(chain) + ); const publisher = new DKGPublisher({ store, chain, eventBus, keypair, publisherPrivateKey: opKeys?.[0], + publisherAddress, sharedMemoryOwnedEntities: workspaceOwnedEntities, writeLocks, }); diff --git a/packages/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index c41e6404c..ccb0ef1d9 100644 --- a/packages/agent/test/v10-ack-provider.test.ts +++ b/packages/agent/test/v10-ack-provider.test.ts @@ -40,6 +40,16 @@ async function createAgent(chainAdapter: ChainAdapter, operationalKeys?: string[ return { agent, store, chain: chainAdapter }; } +function hideOperationalPrivateKey(chain: ChainAdapter): ChainAdapter { + return new Proxy(chain, { + get(target, prop, receiver) { + if (prop === 'getOperationalPrivateKey') return undefined; + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as ChainAdapter; +} + describe('v10 ACK provider wiring', () => { let agent: DKGAgent | undefined; @@ -67,6 +77,23 @@ describe('v10 ACK provider wiring', () => { expect(typeof result.onChainResult!.batchId).toBe('bigint'); }); + it('uses adapter-backed publisher signing when chainAdapter does not expose a private key', async () => { + const chain = hideOperationalPrivateKey(createEVMAdapter(HARDHAT_KEYS.CORE_OP)); + ({ agent } = await createAgent(chain)); + + const cgId = 'adapter-backed-publisher-cg'; + const expectedAddress = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address; + await agent.createContextGraph({ id: cgId, name: 'Adapter-backed Publisher CG' }); + await agent.registerContextGraph(cgId, { callerAgentAddress: expectedAddress }); + + const result = await agent.publish(cgId, [ + { subject: 'urn:test:adapter-backed-agent', predicate: 'http://schema.org/name', object: '"Adapter backed"', graph: '' }, + ]); + + expect(result.status).toBe('confirmed'); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(expectedAddress.toLowerCase()); + }); + it('publishes tentatively when chain does not support V10 but a publisher key is configured', async () => { ({ agent } = await createAgent(new NoChainAdapter(), [HARDHAT_KEYS.CORE_OP])); From fae36ce090598ad4a370b41d651d9ed05fef8aaf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 02:56:42 +0200 Subject: [PATCH 16/43] fix(publisher): retry adapter signer resolution --- packages/agent/src/dkg-agent.ts | 22 +++-- packages/agent/test/v10-ack-provider.test.ts | 29 +++++-- packages/publisher/src/dkg-publisher.ts | 86 +++++++++++++------ .../test/publisher-no-random-wallet.test.ts | 37 ++++++++ 4 files changed, 134 insertions(+), 40 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 506e58b84..94f5022f6 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -496,12 +496,16 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { async function inferAdapterPublisherAddress(chain: ChainAdapter): Promise { const signerAddresses = (chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; if (typeof signerAddresses === 'function') { - const advertised = signerAddresses.call(chain); - if (Array.isArray(advertised)) { - for (const value of advertised) { - const address = normalizeAdapterPublisherAddress(value); - if (address) return address; + try { + const advertised = signerAddresses.call(chain); + if (Array.isArray(advertised)) { + for (const value of advertised) { + const address = normalizeAdapterPublisherAddress(value); + if (address) return address; + } } + } catch { + // Best-effort probe; the publisher resolver retries on later publish/update attempts. } } @@ -723,16 +727,16 @@ export class DKGAgent { const node = new DKGNode(nodeConfig); const workspaceOwnedEntities = new Map>(); const writeLocks = new Map>(); - const publisherAddress = config.publisherAddress ?? ( - opKeys?.[0] ? undefined : await inferAdapterPublisherAddress(chain) - ); const publisher = new DKGPublisher({ store, chain, eventBus, keypair, publisherPrivateKey: opKeys?.[0], - publisherAddress, + publisherAddress: config.publisherAddress, + publisherAddressResolver: opKeys?.[0] || config.publisherAddress + ? undefined + : () => inferAdapterPublisherAddress(chain), sharedMemoryOwnedEntities: workspaceOwnedEntities, writeLocks, }); diff --git a/packages/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index ccb0ef1d9..1b74b75bb 100644 --- a/packages/agent/test/v10-ack-provider.test.ts +++ b/packages/agent/test/v10-ack-provider.test.ts @@ -40,14 +40,31 @@ async function createAgent(chainAdapter: ChainAdapter, operationalKeys?: string[ return { agent, store, chain: chainAdapter }; } -function hideOperationalPrivateKey(chain: ChainAdapter): ChainAdapter { - return new Proxy(chain, { +function delayedAdapterPublisherAddress(chain: ChainAdapter, address: string): { chain: ChainAdapter; unlock: () => void } { + let unlocked = false; + return { + chain: new Proxy(chain, { get(target, prop, receiver) { if (prop === 'getOperationalPrivateKey') return undefined; + if (prop === 'getSignerAddresses') { + return () => { + if (!unlocked) throw new Error('signer address unavailable during startup'); + return [address]; + }; + } + if (prop === 'signMessage') { + const sign = Reflect.get(target, prop, receiver) as (...args: unknown[]) => Promise; + return async (...args: unknown[]) => { + if (!unlocked) throw new Error('signer locked during startup'); + return sign.apply(target, args); + }; + } const value = Reflect.get(target, prop, receiver); return typeof value === 'function' ? value.bind(target) : value; }, - }) as ChainAdapter; + }) as ChainAdapter, + unlock: () => { unlocked = true; }, + }; } describe('v10 ACK provider wiring', () => { @@ -78,13 +95,15 @@ describe('v10 ACK provider wiring', () => { }); it('uses adapter-backed publisher signing when chainAdapter does not expose a private key', async () => { - const chain = hideOperationalPrivateKey(createEVMAdapter(HARDHAT_KEYS.CORE_OP)); + const expectedAddress = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address; + const delayed = delayedAdapterPublisherAddress(createEVMAdapter(HARDHAT_KEYS.CORE_OP), expectedAddress); + const chain = delayed.chain; ({ agent } = await createAgent(chain)); const cgId = 'adapter-backed-publisher-cg'; - const expectedAddress = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address; await agent.createContextGraph({ id: cgId, name: 'Adapter-backed Publisher CG' }); await agent.registerContextGraph(cgId, { callerAgentAddress: expectedAddress }); + delayed.unlock(); const result = await agent.publish(cgId, [ { subject: 'urn:test:adapter-backed-agent', predicate: 'http://schema.org/name', object: '"Adapter backed"', graph: '' }, diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 822d81d61..b80c96609 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -42,6 +42,8 @@ export interface DKGPublisherConfig { keypair: Ed25519Keypair; publisherNodeIdentityId?: bigint; publisherAddress?: string; + /** Retryable publisher address resolver for adapter-backed signing. */ + publisherAddressResolver?: () => Promise; /** EVM private key for signing publish requests (hex string with 0x prefix) */ publisherPrivateKey?: string; /** @@ -253,7 +255,8 @@ export class DKGPublisher implements Publisher { private readonly sharedMemoryOwnedEntities: Map>; readonly knownBatchContextGraphs: Map; private publisherNodeIdentityId: bigint; - private readonly publisherAddress?: string; + private publisherAddress?: string; + private readonly publisherAddressResolver?: () => Promise; private readonly publisherWallet?: ethers.Wallet; /** Additional wallets that can provide receiver signatures. */ private readonly additionalSignerWallets: ethers.Wallet[] = []; @@ -268,6 +271,7 @@ export class DKGPublisher implements Publisher { this.eventBus = config.eventBus; this.keypair = config.keypair; this.publisherNodeIdentityId = config.publisherNodeIdentityId ?? 0n; + this.publisherAddressResolver = config.publisherAddressResolver; const configuredPublisherAddress = normalizePublisherAddress(config.publisherAddress); if (config.publisherPrivateKey) { @@ -316,6 +320,12 @@ export class DKGPublisher implements Publisher { return this.publisherAddress; } + private async resolvePublisherAddress(): Promise { + if (this.publisherAddress || !this.publisherAddressResolver) return; + const resolved = normalizePublisherAddress(await this.publisherAddressResolver()); + if (resolved) this.publisherAddress = resolved; + } + // Local-only tentative publishes need a stable, non-zero UAL component even // when no EVM publisher key exists. This is not used for signatures. private localTentativePublisherAddress(): string { @@ -1088,6 +1098,7 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); + await this.resolvePublisherAddress(); const publisherSigner = this.getPublisherSigner(); const publisherAddress = this.publisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; @@ -1694,7 +1705,11 @@ export class DKGPublisher implements Publisher { if (privateQuads.length > 0) rejectReservedSubjectPrefixes(privateQuads); } const ctx: OperationContext = operationCtx ?? createOperationContext('publish'); - const publisherAddress = this.requirePublisherAddress('update'); + await this.resolvePublisherAddress(); + const localOnlyUpdate = this.chain.chainId === 'none'; + const publisherAddress = this.publisherAddress ?? ( + localOnlyUpdate ? this.localTentativePublisherAddress() : this.requirePublisherAddress('update') + ); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); @@ -1737,6 +1752,48 @@ export class DKGPublisher implements Publisher { onPhase?.('prepare:merkle', 'end'); onPhase?.('prepare', 'end'); + const storeUpdatedQuads = async (): Promise => { + onPhase?.('store', 'start'); + for (const [rootEntity, publicQuads] of kaMap) { + await this.store.deleteByPattern({ graph: dataGraph, subject: rootEntity }); + await this.store.deleteBySubjectPrefix(dataGraph, rootEntity + '/.well-known/genid/'); + await this.privateStore.deletePrivateTriples(contextGraphId, rootEntity, options.subGraphName); + + const normalized = publicQuads.map((q) => ({ ...q, graph: dataGraph })); + await this.store.insert(normalized); + + const entityPrivateQuads = entityPrivateMap.get(rootEntity) ?? []; + if (entityPrivateQuads.length > 0) { + await this.privateStore.storePrivateTriples(contextGraphId, rootEntity, entityPrivateQuads, options.subGraphName); + } + } + + try { + await updateMetaMerkleRoot(this.store, this.graphManager, contextGraphId, kcId, kcMerkleRoot); + } catch (err) { + this.log.warn( + ctx, + `Failed to sync _meta merkleRoot for kcId=${kcId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + onPhase?.('store', 'end'); + }; + + if (localOnlyUpdate) { + this.log.warn(ctx, 'No chain configured — applying update locally and returning tentative result'); + await storeUpdatedQuads(); + const result: PublishResult = { + kcId, + ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, + merkleRoot: kcMerkleRoot, + kaManifest: manifestEntries, + status: 'tentative', + publicQuads: allSkolemizedQuads, + }; + this.eventBus.emit(DKGEvent.KA_UPDATED, result); + return result; + } + onPhase?.('chain', 'start'); onPhase?.('chain:submit', 'start'); @@ -1876,30 +1933,7 @@ export class DKGPublisher implements Publisher { onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); - onPhase?.('store', 'start'); - for (const [rootEntity, publicQuads] of kaMap) { - await this.store.deleteByPattern({ graph: dataGraph, subject: rootEntity }); - await this.store.deleteBySubjectPrefix(dataGraph, rootEntity + '/.well-known/genid/'); - await this.privateStore.deletePrivateTriples(contextGraphId, rootEntity, options.subGraphName); - - const normalized = publicQuads.map((q) => ({ ...q, graph: dataGraph })); - await this.store.insert(normalized); - - const entityPrivateQuads = entityPrivateMap.get(rootEntity) ?? []; - if (entityPrivateQuads.length > 0) { - await this.privateStore.storePrivateTriples(contextGraphId, rootEntity, entityPrivateQuads, options.subGraphName); - } - } - - try { - await updateMetaMerkleRoot(this.store, this.graphManager, contextGraphId, kcId, kcMerkleRoot); - } catch (err) { - this.log.warn( - ctx, - `Failed to sync _meta merkleRoot for kcId=${kcId}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - onPhase?.('store', 'end'); + await storeUpdatedQuads(); const result: PublishResult = { kcId, diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index fadf673d0..8dbbc55ca 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -103,6 +103,43 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(match![1]).not.toBe(ethers.ZeroAddress); }); + it('updates no-chain tentative publishes with the same deterministic local address', async () => { + const keypair = await generateEd25519Keypair(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('none'), + eventBus: new TypedEventBus(), + keypair, + }); + + const created = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:no-chain-update', + predicate: 'http://schema.org/name', + object: '"Before"', + graph: 'did:dkg:context-graph:1', + }], + }); + const createdAddress = created.ual.match(/^did:dkg:none\/(0x[0-9a-fA-F]{40})\/t/)?.[1]; + + const updated = await publisher.update(created.kcId, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:no-chain-update', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(updated.status).toBe('tentative'); + expect(updated.onChainResult).toBeUndefined(); + expect(createdAddress).toBeDefined(); + expect(updated.ual.toLowerCase()).toContain(createdAddress!.toLowerCase()); + expect(updated.publicQuads[0]?.object).toBe('"After"'); + }); + it('publishes tentatively with an explicit non-zero publisherAddress on no-chain publishes', async () => { const keypair = await generateEd25519Keypair(); const publisherAddress = '0x000000000000000000000000000000000000dEaD'; From 95f6238e805c8d6dcc158d0c3ff16dd63c94fc71 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 03:13:11 +0200 Subject: [PATCH 17/43] fix(publisher): bind adapter signer to publish tx --- packages/agent/src/dkg-agent.ts | 28 ++++++- packages/chain/src/chain-adapter.ts | 22 ++++++ packages/chain/src/evm-adapter.ts | 73 +++++++++++++++++- packages/chain/src/mock-adapter.ts | 7 +- packages/publisher/src/dkg-publisher.ts | 54 +++++++++---- .../test/publisher-no-random-wallet.test.ts | 77 +++++++++++++++++++ scripts/audit-create-random.mjs | 69 +++++++++-------- scripts/distribute-publisher-trac.ts | 8 +- 8 files changed, 283 insertions(+), 55 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 94f5022f6..b5119992a 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -493,7 +493,31 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { return address === ethers.ZeroAddress ? undefined : address; } -async function inferAdapterPublisherAddress(chain: ChainAdapter): Promise { +async function inferAdapterPublisherAddress( + chain: ChainAdapter, + contextGraphId?: bigint, +): Promise { + if (contextGraphId !== undefined && typeof chain.getAuthorizedPublisherAddress === 'function') { + try { + const address = normalizeAdapterPublisherAddress( + await chain.getAuthorizedPublisherAddress(contextGraphId), + ); + if (address) return address; + } catch { + // Best-effort probe; the publisher resolver retries on later publish/update attempts. + } + } + + const signerAddressGetter = (chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress; + if (typeof signerAddressGetter === 'function') { + try { + const address = normalizeAdapterPublisherAddress(signerAddressGetter.call(chain)); + if (address) return address; + } catch { + // Best-effort probe; fall through to broader adapter surfaces. + } + } + const signerAddresses = (chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; if (typeof signerAddresses === 'function') { try { @@ -736,7 +760,7 @@ export class DKGAgent { publisherAddress: config.publisherAddress, publisherAddressResolver: opKeys?.[0] || config.publisherAddress ? undefined - : () => inferAdapterPublisherAddress(chain), + : (contextGraphId?: bigint) => inferAdapterPublisherAddress(chain, contextGraphId), sharedMemoryOwnedEntities: workspaceOwnedEntities, writeLocks, }); diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 15316d53e..0de436bb9 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -192,6 +192,14 @@ export interface ConvictionAccountInfo { export interface V10PublishDirectParams { publishOperationId: string; contextGraphId: bigint; + /** + * Optional signer hint selected by the caller for the publish. Adapters + * with signer pools MUST use this address for the concrete tx when present + * (or throw clearly if unavailable/unauthorized), so the off-chain + * publisher/ACK/authorship signatures and on-chain attribution stay bound + * to the same key. + */ + publisherAddress?: string; merkleRoot: Uint8Array; knowledgeAssetsAmount: number; byteSize: bigint; @@ -529,6 +537,20 @@ export interface ChainAdapter { */ signMessage?(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }>; + /** + * Return the adapter signer that would be used for a publish to the given + * context graph without advancing any round-robin cursor. Used by publishers + * that need the off-chain signature address to match the eventual tx signer. + */ + getAuthorizedPublisherAddress?(contextGraphId: bigint): Promise; + + /** + * Sign with a specific adapter-held address. Adapters with signer pools + * should implement this so callers can bind a context-graph-selected + * publisher address to the digest signatures generated before tx submit. + */ + signMessageAs?(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }>; + // On-Chain Context Graphs (ContextGraphs contract) createOnChainContextGraph?(params: CreateOnChainContextGraphParams): Promise; getContextGraphParticipants?(contextGraphId: bigint): Promise; diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 078c775dc..c20174713 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -368,6 +368,13 @@ export class EVMChainAdapter implements ChainAdapter { return s; } + private findSignerByAddress(address: string): { signer: Wallet; index: number } | undefined { + const normalized = ethers.getAddress(address).toLowerCase(); + const index = this.signerPool.findIndex((signer) => signer.address.toLowerCase() === normalized); + if (index === -1) return undefined; + return { signer: this.signerPool[index], index }; + } + /** * Pick the next signer in the pool that the on-chain ContextGraphs contract * authorizes for the target context graph. Falls back to round-robin only @@ -395,6 +402,32 @@ export class EVMChainAdapter implements ChainAdapter { ); } + /** + * Inspect the same authorized-signer order as `nextAuthorizedSigner`, but do + * not advance the round-robin cursor. The publisher uses this to bind + * off-chain signatures to the tx signer before `publishDirect` is submitted. + */ + async getAuthorizedPublisherAddress(contextGraphId: bigint): Promise { + await this.init(); + + if (!this.contracts.contextGraphs) { + return this.signerPool[this.signerIndex % this.signerPool.length].address; + } + + const start = this.signerIndex % this.signerPool.length; + for (let i = 0; i < this.signerPool.length; i += 1) { + const idx = (start + i) % this.signerPool.length; + const signer = this.signerPool[idx]; + const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher(contextGraphId, signer.address); + if (authorized) return signer.address; + } + + throw new Error( + `No authorized publisher wallet found in signer pool for context graph ${contextGraphId.toString()}. ` + + 'Ensure at least one configured wallet is permitted by on-chain publish authority.', + ); + } + /** All operational wallet addresses (for display / funding). */ getSignerAddresses(): string[] { return this.signerPool.map((s) => s.address); @@ -1629,7 +1662,31 @@ export class EVMChainAdapter implements ChainAdapter { ); } - const txSigner = await this.nextAuthorizedSigner(params.contextGraphId); + let txSigner: Wallet; + if (params.publisherAddress) { + const selected = this.findSignerByAddress(params.publisherAddress); + if (!selected) { + throw new Error( + `Configured publisherAddress ${params.publisherAddress} is not present in the EVM signer pool.`, + ); + } + if (this.contracts.contextGraphs) { + const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher( + params.contextGraphId, + selected.signer.address, + ); + if (!authorized) { + throw new Error( + `Configured publisherAddress ${selected.signer.address} is not authorized to publish ` + + `to context graph ${params.contextGraphId.toString()}.`, + ); + } + } + txSigner = selected.signer; + this.signerIndex = selected.index + 1; + } else { + txSigner = await this.nextAuthorizedSigner(params.contextGraphId); + } const ka = this.contracts.knowledgeAssetsV10.connect(txSigner) as Contract; const kaAddress = await ka.getAddress(); @@ -2399,6 +2456,20 @@ export class EVMChainAdapter implements ChainAdapter { }; } + async signMessageAs(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const selected = this.findSignerByAddress(address); + if (!selected) { + throw new Error(`Cannot sign with ${address}: address is not present in the EVM signer pool.`); + } + const sig = ethers.Signature.from( + await selected.signer.signMessage(messageHash), + ); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } + async getBlockNumber(): Promise { return this.provider.getBlockNumber(); } diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 891a39cde..3addf6898 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -918,11 +918,12 @@ export class MockChainAdapter implements ChainAdapter { } const kcId = this.nextBatchId++; + const publisherAddress = params.publisherAddress ?? this.signerAddress; this.collections.set(kcId, { merkleRoot: params.merkleRoot, kaCount: params.knowledgeAssetsAmount, merkleLeafCount: params.merkleLeafCount, - publisherAddress: this.signerAddress, + publisherAddress, cgId: params.contextGraphId, }); // Also store in batches so verify() can find this publish @@ -941,7 +942,7 @@ export class MockChainAdapter implements ChainAdapter { merkleRoot: toHex(params.merkleRoot), byteSize: params.byteSize.toString(), txHash, - publisherAddress: this.signerAddress, + publisherAddress, startKAId: startKAId.toString(), endKAId: endKAId.toString(), isImmutable: params.isImmutable, @@ -957,7 +958,7 @@ export class MockChainAdapter implements ChainAdapter { txHash: result.hash, blockNumber: result.blockNumber, blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress: this.signerAddress, + publisherAddress, tokenAmount: params.tokenAmount, }; } diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index b80c96609..6ea2ebc32 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -43,7 +43,7 @@ export interface DKGPublisherConfig { publisherNodeIdentityId?: bigint; publisherAddress?: string; /** Retryable publisher address resolver for adapter-backed signing. */ - publisherAddressResolver?: () => Promise; + publisherAddressResolver?: (contextGraphId?: bigint) => Promise; /** EVM private key for signing publish requests (hex string with 0x prefix) */ publisherPrivateKey?: string; /** @@ -63,7 +63,7 @@ export class PublisherWalletRequiredError extends Error { constructor(operation: string) { super( `${operation} requires "publisherPrivateKey" or a non-zero "publisherAddress" ` + - 'backed by ChainAdapter.signMessage(). Publishing without a publisher signing key ' + + 'backed by ChainAdapter.signMessageAs()/signMessage(). Publishing without a publisher signing key ' + 'would produce unattributable or unverifiable publisher output.', ); this.name = 'PublisherWalletRequiredError'; @@ -256,7 +256,8 @@ export class DKGPublisher implements Publisher { readonly knownBatchContextGraphs: Map; private publisherNodeIdentityId: bigint; private publisherAddress?: string; - private readonly publisherAddressResolver?: () => Promise; + private readonly publisherAddressResolver?: (contextGraphId?: bigint) => Promise; + private readonly dynamicallyResolvePublisherAddress: boolean; private readonly publisherWallet?: ethers.Wallet; /** Additional wallets that can provide receiver signatures. */ private readonly additionalSignerWallets: ethers.Wallet[] = []; @@ -274,6 +275,9 @@ export class DKGPublisher implements Publisher { this.publisherAddressResolver = config.publisherAddressResolver; const configuredPublisherAddress = normalizePublisherAddress(config.publisherAddress); + this.dynamicallyResolvePublisherAddress = !config.publisherPrivateKey && + !configuredPublisherAddress && + typeof config.publisherAddressResolver === 'function'; if (config.publisherPrivateKey) { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; @@ -291,8 +295,9 @@ export class DKGPublisher implements Publisher { // capability. Keep an optional, validated address only for callers // that route signing through their ChainAdapter (e.g. adapter-backed // or hardware-signer deployments). Chain-backed publish still fails - // unless that address is backed by ChainAdapter.signMessage(); update - // can let the adapter select its signer from the configured signer pool. + // unless that address is backed by ChainAdapter.signMessageAs() or + // signMessage(); update can let the adapter select its signer from the + // configured signer pool. // // The previous behaviour generated an ephemeral `Wallet.createRandom()` // here whenever chain was enabled, which produced unverifiable @@ -320,10 +325,13 @@ export class DKGPublisher implements Publisher { return this.publisherAddress; } - private async resolvePublisherAddress(): Promise { - if (this.publisherAddress || !this.publisherAddressResolver) return; - const resolved = normalizePublisherAddress(await this.publisherAddressResolver()); - if (resolved) this.publisherAddress = resolved; + private async resolvePublisherAddress(contextGraphId?: bigint): Promise { + if (!this.publisherAddressResolver) return; + if (this.publisherAddress && !this.dynamicallyResolvePublisherAddress) return; + const resolved = normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); + if (resolved || this.dynamicallyResolvePublisherAddress) { + this.publisherAddress = resolved; + } } // Local-only tentative publishes need a stable, non-zero UAL component even @@ -344,13 +352,18 @@ export class DKGPublisher implements Publisher { }; } - if (this.publisherAddress && typeof this.chain.signMessage === 'function') { + if ( + this.publisherAddress && + (typeof this.chain.signMessageAs === 'function' || typeof this.chain.signMessage === 'function') + ) { const expectedAddress = this.publisherAddress; return { address: expectedAddress, source: 'chainAdapter', signMessage: async (message: Uint8Array) => { - const compact = await this.chain.signMessage!(message); + const compact = typeof this.chain.signMessageAs === 'function' + ? await this.chain.signMessageAs(expectedAddress, message) + : await this.chain.signMessage!(message); const signature = ethers.Signature.from({ r: ethers.hexlify(compact.r), yParityAndS: ethers.hexlify(compact.vs), @@ -1098,7 +1111,14 @@ export class DKGPublisher implements Publisher { const effectiveAccessPolicy = accessPolicy ?? (privateQuads.length > 0 ? 'ownerOnly' : 'public'); const normalizedAllowedPeers = [...new Set((allowedPeers ?? []).map((p) => p.trim()).filter(Boolean))]; const normalizedPublisherPeerId = publisherPeerId.trim(); - await this.resolvePublisherAddress(); + let publisherContextGraphId: bigint | undefined; + try { + const parsed = BigInt(options.publishContextGraphId ?? contextGraphId); + if (parsed > 0n) publisherContextGraphId = parsed; + } catch { + // Descriptive SWM graph names stay on the existing tentative/mock path. + } + await this.resolvePublisherAddress(publisherContextGraphId); const publisherSigner = this.getPublisherSigner(); const publisherAddress = this.publisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; @@ -1524,6 +1544,7 @@ export class DKGPublisher implements Publisher { onChainResult = await this.chain.createKnowledgeAssetsV10!({ publishOperationId: `${this.sessionId}-${tentativeSeq}`, contextGraphId: v10CgId, + publisherAddress: publisherSigner.address, merkleRoot: kcMerkleRoot, knowledgeAssetsAmount: kaCount, byteSize: publicByteSize, @@ -1705,7 +1726,14 @@ export class DKGPublisher implements Publisher { if (privateQuads.length > 0) rejectReservedSubjectPrefixes(privateQuads); } const ctx: OperationContext = operationCtx ?? createOperationContext('publish'); - await this.resolvePublisherAddress(); + let publisherContextGraphId: bigint | undefined; + try { + const parsed = BigInt(options.publishContextGraphId ?? contextGraphId); + if (parsed > 0n) publisherContextGraphId = parsed; + } catch { + // Descriptive SWM graph names are valid local/mock update scopes. + } + await this.resolvePublisherAddress(publisherContextGraphId); const localOnlyUpdate = this.chain.chainId === 'none'; const publisherAddress = this.publisherAddress ?? ( localOnlyUpdate ? this.localTentativePublisherAddress() : this.requirePublisherAddress('update') diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 8dbbc55ca..238307add 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -31,6 +31,7 @@ import { MockChainAdapter, type ChainAdapter } from '@origintrail-official/dkg-c import { ethers } from 'ethers'; const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const TEST_KEY_ALT = '0x5de4111a56f4c24611d9ed4d5318a7e03f9b9a9d73f3a5f3f6324a2a0e6fbb36'; // Minimal stub — DKGPublisher's constructor only reads `chain.chainId`. // All other ChainAdapter methods are unused in this test. @@ -54,6 +55,52 @@ class AdapterSigningChain extends MockChainAdapter { } } +class ContextAwareAdapterSigningChain extends MockChainAdapter { + capturedPublisherAddress?: string; + + constructor( + private readonly primaryWallet: ethers.Wallet, + private readonly authorizedWallet: ethers.Wallet, + ) { + super('mock:31337', primaryWallet.address); + this.seedIdentity(authorizedWallet.address, 7n); + this.minimumRequiredSignatures = 1; + } + + async getAuthorizedPublisherAddress(contextGraphId: bigint): Promise { + expect(contextGraphId).toBe(42n); + return this.authorizedWallet.address; + } + + override async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.primaryWallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } + + async signMessageAs(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const normalized = ethers.getAddress(address); + if (normalized.toLowerCase() !== this.authorizedWallet.address.toLowerCase()) { + throw new Error(`unexpected signer ${address}`); + } + const sig = ethers.Signature.from(await this.authorizedWallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } + + override async createKnowledgeAssetsV10(params: Parameters[0]) { + this.capturedPublisherAddress = params.publisherAddress; + if (params.publisherAddress?.toLowerCase() !== this.authorizedWallet.address.toLowerCase()) { + throw new Error('publish tx signer did not match resolved publisher address'); + } + return super.createKnowledgeAssetsV10(params); + } +} + describe('DKGPublisher: no random publisher wallet without explicit key', () => { it('leaves publisherWallet and publisherAddress undefined when no key or address is supplied', async () => { const keypair = await generateEd25519Keypair(); @@ -260,6 +307,36 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('binds context-graph-aware adapter signer resolution to the V10 tx publisher address', async () => { + const keypair = await generateEd25519Keypair(); + const primaryWallet = new ethers.Wallet(TEST_KEY); + const authorizedWallet = new ethers.Wallet(TEST_KEY_ALT); + const chain = new ContextAwareAdapterSigningChain(primaryWallet, authorizedWallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherAddressResolver: (contextGraphId?: bigint) => + contextGraphId === undefined ? Promise.resolve(undefined) : chain.getAuthorizedPublisherAddress(contextGraphId), + publisherNodeIdentityId: 7n, + }); + + const result = await publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:adapter-context-aware-signer', + predicate: 'http://schema.org/name', + object: '"AdapterContextAwareSigner"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(authorizedWallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(authorizedWallet.address.toLowerCase()); + }); + it('updates with an adapter-backed signer and configured publisherAddress', async () => { const keypair = await generateEd25519Keypair(); const wallet = new ethers.Wallet(TEST_KEY); diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index d6e0ff087..69f28df87 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -24,9 +24,10 @@ * What it does * ------------ * Walks every `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / `.mjs` / - * `.cjs` source file under `packages/*\/{src,utils}/**` and fails if it - * finds `Wallet.createRandom(` (including obvious `Wallet` aliases) outside - * the explicitly allowlisted call sites below. Each allowlist entry pins ONE + * `.cjs` source file under root `scripts/**` and + * `packages/*\/{src,utils,scripts}/**`, then fails if it finds + * `Wallet.createRandom(` (including obvious `Wallet` aliases) outside the + * explicitly allowlisted call sites below. Each allowlist entry pins ONE * expected hit with a one-line * justification — adding a second `createRandom()` to the same file does * NOT inherit the existing exemption and must be reviewed on its own. @@ -430,36 +431,42 @@ async function main() { } const violations = []; const seenAllowlistPaths = new Set(); + + const scanRoot = async (root) => { + for await (const absPath of walkSourceFiles(root)) { + const text = await readFile(absPath, 'utf8'); + const hits = findHits(text); + if (hits.length === 0) continue; + const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); + const exemption = ALLOWLIST.get(relPath); + if (!exemption) { + violations.push({ + path: relPath, + kind: 'no-exemption', + hits, + expectedHits: 0, + }); + continue; + } + seenAllowlistPaths.add(relPath); + if (hits.length !== exemption.expectedHits) { + violations.push({ + path: relPath, + kind: 'hit-count-mismatch', + hits, + expectedHits: exemption.expectedHits, + justification: exemption.justification, + }); + } + } + }; + + await scanRoot(join(REPO_ROOT, 'scripts')); + for (const pkg of pkgs) { if (!pkg.isDirectory() || pkg.name.startsWith('.')) continue; - for (const subdir of ['src', 'utils']) { - const root = join(packagesDir, pkg.name, subdir); - for await (const absPath of walkSourceFiles(root)) { - const text = await readFile(absPath, 'utf8'); - const hits = findHits(text); - if (hits.length === 0) continue; - const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); - const exemption = ALLOWLIST.get(relPath); - if (!exemption) { - violations.push({ - path: relPath, - kind: 'no-exemption', - hits, - expectedHits: 0, - }); - continue; - } - seenAllowlistPaths.add(relPath); - if (hits.length !== exemption.expectedHits) { - violations.push({ - path: relPath, - kind: 'hit-count-mismatch', - hits, - expectedHits: exemption.expectedHits, - justification: exemption.justification, - }); - } - } + for (const subdir of ['src', 'utils', 'scripts']) { + await scanRoot(join(packagesDir, pkg.name, subdir)); } } diff --git a/scripts/distribute-publisher-trac.ts b/scripts/distribute-publisher-trac.ts index 280a56cac..a0e605a3d 100644 --- a/scripts/distribute-publisher-trac.ts +++ b/scripts/distribute-publisher-trac.ts @@ -154,14 +154,12 @@ async function main() { // Load distribution wallet const privateKey = process.env.DISTRIBUTION_WALLET_KEY; - if (!privateKey && !dryRun) { - console.error('Set DISTRIBUTION_WALLET_KEY in .env or environment'); + if (!privateKey) { + console.error('Set DISTRIBUTION_WALLET_KEY in .env or environment. Refusing to use an ephemeral wallet, even for dry runs.'); process.exit(1); } - const wallet = privateKey - ? new Wallet(privateKey, provider) - : Wallet.createRandom().connect(provider); + const wallet = new Wallet(privateKey, provider); const walletAddress = await wallet.getAddress(); const token = new Contract(TOKEN_ADDRESSES[chain], ERC20_ABI, wallet); From 18f079d1b5a53894bb4078cc830fa070984dfa08 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 03:20:50 +0200 Subject: [PATCH 18/43] test(chain): document signer-pool parity exemptions --- packages/chain/test/mock-adapter-parity.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index 1302c8ae6..ed73d64a8 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -77,6 +77,8 @@ const MOCK_EXEMPT_FROM_EVM = new Set([ 'getProvider', // returns a JsonRpcProvider; mock has none 'getSignerAddress', // mock exposes `signerAddress` as a field 'getSignerAddresses', // pool not applicable to mock + 'getAuthorizedPublisherAddress', // pool-specific signer selection; mock has one signerAddress + 'signMessageAs', // pool-specific wallet-key signing; mock has no adapter-held private keys 'getOperationalPrivateKey', // mock has no wallet keys 'getRequiredPublishTokenAmount', // TODO: missing on mock, cross-check below // TypeScript `private` is erased at runtime; these are adapter-internal @@ -84,6 +86,7 @@ const MOCK_EXEMPT_FROM_EVM = new Set([ // ChainAdapter contract. They must remain EVM-only. 'nextSigner', 'nextAuthorizedSigner', + 'findSignerByAddress', 'walletKeyHash', 'hasAdminPurpose', 'hasOperationalPurpose', From 8e6dcee8e7fd134ec2e00a30c62fc9bc1ea35c9a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 03:28:53 +0200 Subject: [PATCH 19/43] fix(publisher): keep adapter signer per operation --- packages/publisher/src/dkg-publisher.ts | 109 +++++++++++++----- .../test/publisher-no-random-wallet.test.ts | 26 +++++ scripts/audit-create-random.mjs | 95 +++++++++++---- scripts/audit-create-random.test.mjs | 15 +++ 4 files changed, 195 insertions(+), 50 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 6ea2ebc32..a773478a3 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -82,6 +82,12 @@ function normalizePublisherAddress(address: string | undefined): string | undefi return normalized; } +function coercePublisherAddress(value: unknown): string | undefined { + if (typeof value !== 'string' || !ethers.isAddress(value)) return undefined; + const normalized = ethers.getAddress(value); + return normalized === ethers.ZeroAddress ? undefined : normalized; +} + export interface ShareOptions { publisherPeerId: string; operationCtx?: OperationContext; @@ -257,7 +263,6 @@ export class DKGPublisher implements Publisher { private publisherNodeIdentityId: bigint; private publisherAddress?: string; private readonly publisherAddressResolver?: (contextGraphId?: bigint) => Promise; - private readonly dynamicallyResolvePublisherAddress: boolean; private readonly publisherWallet?: ethers.Wallet; /** Additional wallets that can provide receiver signatures. */ private readonly additionalSignerWallets: ethers.Wallet[] = []; @@ -275,9 +280,6 @@ export class DKGPublisher implements Publisher { this.publisherAddressResolver = config.publisherAddressResolver; const configuredPublisherAddress = normalizePublisherAddress(config.publisherAddress); - this.dynamicallyResolvePublisherAddress = !config.publisherPrivateKey && - !configuredPublisherAddress && - typeof config.publisherAddressResolver === 'function'; if (config.publisherPrivateKey) { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; @@ -320,17 +322,71 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } - private requirePublisherAddress(operation: string): string { - if (!this.publisherAddress) throw new PublisherWalletRequiredError(operation); - return this.publisherAddress; + private requirePublisherAddress(operation: string, address = this.publisherAddress): string { + if (!address) throw new PublisherWalletRequiredError(operation); + return address; } - private async resolvePublisherAddress(contextGraphId?: bigint): Promise { - if (!this.publisherAddressResolver) return; - if (this.publisherAddress && !this.dynamicallyResolvePublisherAddress) return; - const resolved = normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); - if (resolved || this.dynamicallyResolvePublisherAddress) { - this.publisherAddress = resolved; + private async resolvePublisherAddress(contextGraphId?: bigint): Promise { + if (this.publisherAddress) return this.publisherAddress; + if (this.publisherAddressResolver) { + return normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); + } + return this.inferAdapterPublisherAddress(contextGraphId); + } + + private async inferAdapterPublisherAddress(contextGraphId?: bigint): Promise { + if (contextGraphId !== undefined && typeof this.chain.getAuthorizedPublisherAddress === 'function') { + try { + const address = coercePublisherAddress(await this.chain.getAuthorizedPublisherAddress(contextGraphId)); + if (address) return address; + } catch { + // Best-effort inference; the publish path will fail clearly if no signer resolves. + } + } + + const signerAddressGetter = (this.chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress; + if (typeof signerAddressGetter === 'function') { + try { + const address = coercePublisherAddress(signerAddressGetter.call(this.chain)); + if (address) return address; + } catch { + // Fall through to other common adapter surfaces. + } + } + + const signerAddressesGetter = (this.chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; + if (typeof signerAddressesGetter === 'function') { + try { + const advertised = signerAddressesGetter.call(this.chain); + if (Array.isArray(advertised)) { + for (const value of advertised) { + const address = coercePublisherAddress(value); + if (address) return address; + } + } + } catch { + // Fall through to legacy adapter surfaces. + } + } + + const signerAddress = coercePublisherAddress( + (this.chain as unknown as { signerAddress?: unknown }).signerAddress, + ); + if (signerAddress) return signerAddress; + + if (this.chain.chainId === 'none' || typeof this.chain.signMessage !== 'function') return undefined; + + try { + const challenge = ethers.getBytes(ethers.id('dkg-publisher:publisher-address-probe')); + const compact = await this.chain.signMessage(challenge); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + return coercePublisherAddress(ethers.verifyMessage(challenge, signature)); + } catch { + return undefined; } } @@ -342,7 +398,7 @@ export class DKGPublisher implements Publisher { return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address; } - private getPublisherSigner(): PublisherSigner | undefined { + private getPublisherSigner(address = this.publisherAddress): PublisherSigner | undefined { if (this.publisherWallet && this.publisherAddress) { const wallet = this.publisherWallet; return { @@ -353,10 +409,10 @@ export class DKGPublisher implements Publisher { } if ( - this.publisherAddress && + address && (typeof this.chain.signMessageAs === 'function' || typeof this.chain.signMessage === 'function') ) { - const expectedAddress = this.publisherAddress; + const expectedAddress = address; return { address: expectedAddress, source: 'chainAdapter', @@ -383,12 +439,6 @@ export class DKGPublisher implements Publisher { return undefined; } - private requirePublisherSigner(operation: string): PublisherSigner { - const signer = this.getPublisherSigner(); - if (!signer) throw new PublisherWalletRequiredError(operation); - return signer; - } - private async withWriteLocks(keys: string[], fn: () => Promise): Promise { const uniqueKeys = [...new Set(keys)].sort(); const predecessor = Promise.all(uniqueKeys.map(k => this.writeLocks.get(k) ?? Promise.resolve())); @@ -1118,9 +1168,9 @@ export class DKGPublisher implements Publisher { } catch { // Descriptive SWM graph names stay on the existing tentative/mock path. } - await this.resolvePublisherAddress(publisherContextGraphId); - const publisherSigner = this.getPublisherSigner(); - const publisherAddress = this.publisherAddress ?? this.localTentativePublisherAddress(); + const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); + const publisherSigner = this.getPublisherSigner(resolvedPublisherAddress); + const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { @@ -1393,7 +1443,6 @@ export class DKGPublisher implements Publisher { v10ChainId !== undefined && v10KavAddress !== undefined ) { - const publisherSigner = this.getPublisherSigner(); if (publisherSigner) { const reason = !options.v10ACKProvider ? 'no v10ACKProvider (single-node mode)' : 'ACK collection failed/skipped'; this.log.info(ctx, `Self-signing ACK — ${reason}`); @@ -1442,7 +1491,7 @@ export class DKGPublisher implements Publisher { try { onPhase?.('chain:sign', 'start'); signStarted = true; - const publisherSigner = this.requirePublisherSigner('publish'); + if (!publisherSigner) throw new PublisherWalletRequiredError('publish'); this.log.info( ctx, `Signing on-chain publish (identityId=${identityId}, signer=${publisherSigner.address}, source=${publisherSigner.source})`, @@ -1733,10 +1782,10 @@ export class DKGPublisher implements Publisher { } catch { // Descriptive SWM graph names are valid local/mock update scopes. } - await this.resolvePublisherAddress(publisherContextGraphId); + const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); const localOnlyUpdate = this.chain.chainId === 'none'; - const publisherAddress = this.publisherAddress ?? ( - localOnlyUpdate ? this.localTentativePublisherAddress() : this.requirePublisherAddress('update') + const publisherAddress = resolvedPublisherAddress ?? ( + localOnlyUpdate ? this.localTentativePublisherAddress() : this.requirePublisherAddress('update', resolvedPublisherAddress) ); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 238307add..e900e0368 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -307,6 +307,32 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('infers adapter-backed signer address for direct DKGPublisher consumers', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterSigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-inferred-signer', + predicate: 'http://schema.org/name', + object: '"AdapterInferredSigner"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + it('binds context-graph-aware adapter signer resolution to the V10 tx publisher address', async () => { const keypair = await generateEd25519Keypair(); const primaryWallet = new ethers.Wallet(TEST_KEY); diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index 69f28df87..f919946e4 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -24,11 +24,11 @@ * What it does * ------------ * Walks every `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / `.mjs` / - * `.cjs` source file under root `scripts/**` and - * `packages/*\/{src,utils,scripts}/**`, then fails if it finds - * `Wallet.createRandom(` (including obvious `Wallet` aliases) outside the - * explicitly allowlisted call sites below. Each allowlist entry pins ONE - * expected hit with a one-line + * `.cjs` source file under root `scripts/**` and every non-test package + * source under `packages/**`, then fails if it finds `Wallet.createRandom(` + * (including obvious `Wallet` aliases and equivalent optional/bracket access + * forms) outside the explicitly allowlisted call sites below. Each allowlist + * entry pins ONE expected hit with a one-line * justification — adding a second `createRandom()` to the same file does * NOT inherit the existing exemption and must be reviewed on its own. * @@ -341,8 +341,9 @@ export function findHits(originalText) { const seen = new Set(); for (const alias of walletAliases) { - const pattern = new RegExp(String.raw`\b${escapeRegExp(alias)}\s*\.\s*createRandom\s*\(`, 'g'); + const pattern = new RegExp(String.raw`\b${escapeRegExp(alias)}\b`, 'g'); for (const m of stripped.matchAll(pattern)) { + if (!matchesCreateRandomCall(originalText, stripped, m.index + alias.length)) continue; if (seen.has(m.index)) continue; seen.add(m.index); hits.push(hitFromIndex(originalText, stripped, m.index, alias)); @@ -353,6 +354,73 @@ export function findHits(originalText) { return hits.map(({ index: _index, ...hit }) => hit); } +function skipWhitespace(text, index) { + let i = index; + while (i < text.length && /\s/.test(text[i])) i += 1; + return i; +} + +function readQuotedProperty(originalText, index) { + const quote = originalText[index]; + if (quote !== '"' && quote !== "'") return null; + let value = ''; + let i = index + 1; + while (i < originalText.length) { + const c = originalText[i]; + if (c === '\\' && i + 1 < originalText.length) { + value += originalText[i + 1]; + i += 2; + continue; + } + if (c === quote) return { value, end: i + 1 }; + value += c; + i += 1; + } + return null; +} + +function matchesCreateRandomCall(originalText, stripped, indexAfterAlias) { + let i = skipWhitespace(stripped, indexAfterAlias); + + if (stripped.startsWith('?.[', i)) { + i += 3; + const property = readQuotedProperty(originalText, skipWhitespace(originalText, i)); + if (!property || property.value !== 'createRandom') return false; + i = skipWhitespace(originalText, property.end); + if (stripped[i] !== ']') return false; + i = skipWhitespace(stripped, i + 1); + if (stripped.startsWith('?.', i)) i = skipWhitespace(stripped, i + 2); + return stripped[i] === '('; + } + + if (stripped[i] === '[') { + i += 1; + const property = readQuotedProperty(originalText, skipWhitespace(originalText, i)); + if (!property || property.value !== 'createRandom') return false; + i = skipWhitespace(originalText, property.end); + if (stripped[i] !== ']') return false; + i = skipWhitespace(stripped, i + 1); + if (stripped.startsWith('?.', i)) i = skipWhitespace(stripped, i + 2); + return stripped[i] === '('; + } + + if (stripped.startsWith('?.', i)) { + i += 2; + } else if (stripped[i] === '.') { + i += 1; + } else { + return false; + } + + i = skipWhitespace(stripped, i); + if (!stripped.startsWith('createRandom', i)) return false; + const afterName = i + 'createRandom'.length; + if (/[\w$]/.test(stripped[afterName] ?? '')) return false; + i = skipWhitespace(stripped, afterName); + if (stripped.startsWith('?.', i)) i = skipWhitespace(stripped, i + 2); + return stripped[i] === '('; +} + function hitFromIndex(originalText, stripped, index, identifier) { const upToMatch = stripped.slice(0, index); const line = upToMatch.split('\n').length; @@ -422,13 +490,6 @@ function collectWalletAliases(stripped) { async function main() { const packagesDir = join(REPO_ROOT, 'packages'); - let pkgs; - try { - pkgs = await readdir(packagesDir, { withFileTypes: true }); - } catch (err) { - console.error(`audit-create-random: cannot read ${packagesDir}: ${err.message}`); - return 2; - } const violations = []; const seenAllowlistPaths = new Set(); @@ -462,13 +523,7 @@ async function main() { }; await scanRoot(join(REPO_ROOT, 'scripts')); - - for (const pkg of pkgs) { - if (!pkg.isDirectory() || pkg.name.startsWith('.')) continue; - for (const subdir of ['src', 'utils', 'scripts']) { - await scanRoot(join(packagesDir, pkg.name, subdir)); - } - } + await scanRoot(packagesDir); // A stale allowlist entry is itself a violation — we don't want exemptions // to silently outlive the file/call site they were granted for. diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index 4b04fed52..09655ce70 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -197,6 +197,21 @@ describe('findHits', () => { assert.equal(hits[0].identifier, 'EthersWallet'); }); + it('REGRESSION (PR #371 round 5): finds optional chaining calls', () => { + const hits = findHits('const w = Wallet?.createRandom();'); + assert.equal(hits.length, 1); + }); + + it('REGRESSION (PR #371 round 5): finds bracket-property calls', () => { + const hits = findHits('const w = Wallet["createRandom"]();'); + assert.equal(hits.length, 1); + }); + + it('REGRESSION (PR #371 round 5): finds optional bracket-property calls', () => { + const hits = findHits("const w = Wallet?.['createRandom']?.();"); + assert.equal(hits.length, 1); + }); + it('does NOT report calls inside string literals', () => { const hits = findHits('const s = "Wallet.createRandom()";'); assert.equal(hits.length, 0); From e6005f2ced4ed70c36f9084baf814e0aa216a823 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 03:36:04 +0200 Subject: [PATCH 20/43] test(publisher): align adapter-backed tentative publish phases --- .../publisher/test/phase-sequences.test.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index aeb40b246..7770d1636 100644 --- a/packages/publisher/test/phase-sequences.test.ts +++ b/packages/publisher/test/phase-sequences.test.ts @@ -129,9 +129,9 @@ describe('Phase-sequence contracts', () => { ]); }); - // -- Publish (no wallet — fail closed before local storage) ------------- + // -- Publish (adapter-backed signer, no identity — tentative) ----------- - it('publish: missing publisher wallet rejects before phase emission', async () => { + it('publish: adapter-backed signer without node identity returns tentative after local phases', async () => { const store = new OxigraphStore(); const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP); const keypair = await generateEd25519Keypair(); @@ -146,9 +146,32 @@ describe('Phase-sequence contracts', () => { const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; const { calls, fn } = recorder(); - await expect(publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn })) - .rejects.toThrow(/publisherPrivateKey/); - expect(calls).toEqual([]); + const result = await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); + const adapterAddress = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address; + + expect(result.status).toBe('tentative'); + expect(result.ual).toContain(`/${adapterAddress}/`); + expect(result.ual).not.toContain(`/${ethers.ZeroAddress}/`); + + const phases = stripTxSigned(calls).map(([p, s]) => `${p}:${s}`); + expect(phases).toEqual([ + 'prepare:start', + 'prepare:ensureContextGraph:start', + 'prepare:ensureContextGraph:end', + 'prepare:partition:start', + 'prepare:partition:end', + 'prepare:manifest:start', + 'prepare:manifest:end', + 'prepare:validate:start', + 'prepare:validate:end', + 'prepare:merkle:start', + 'prepare:merkle:end', + 'prepare:end', + 'store:start', + 'store:end', + 'chain:start', + 'chain:end', + ]); }); // -- Update (happy path) ----------------------------------------------- From cccf06c4ac3c45c083e37b0c3b9c3da92fa24c01 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 03:56:18 +0200 Subject: [PATCH 21/43] fix(publisher): defer adapter signer ownership --- packages/agent/src/dkg-agent.ts | 3 +- packages/agent/test/agent.test.ts | 74 ++++++++++++ packages/chain/src/chain-adapter.ts | 2 + packages/chain/src/evm-adapter.ts | 2 + packages/chain/src/mock-adapter.ts | 10 +- packages/publisher/src/dkg-publisher.ts | 86 ++++++++++---- .../test/publisher-no-random-wallet.test.ts | 112 +++++++++++++++++- 7 files changed, 260 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index b5119992a..59f2e4efd 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -756,9 +756,8 @@ export class DKGAgent { chain, eventBus, keypair, - publisherPrivateKey: opKeys?.[0], publisherAddress: config.publisherAddress, - publisherAddressResolver: opKeys?.[0] || config.publisherAddress + publisherAddressResolver: config.publisherAddress ? undefined : (contextGraphId?: bigint) => inferAdapterPublisherAddress(chain, contextGraphId), sharedMemoryOwnedEntities: workspaceOwnedEntities, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 32c57e5bc..31afeaf5e 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -78,6 +78,48 @@ class FlakyRegistrationACKChainAdapter extends MockChainAdapter { } } +class ContextAuthorizedPublisherChainAdapter extends MockChainAdapter { + capturedPublisherAddress?: string; + + constructor( + private readonly primaryWallet: ethers.Wallet, + private readonly authorizedWallet: ethers.Wallet, + ) { + super('mock:31337', primaryWallet.address); + this.seedIdentity(authorizedWallet.address, 77n); + this.minimumRequiredSignatures = 1; + } + + getOperationalPrivateKey(): string { + return this.primaryWallet.privateKey; + } + + async getAuthorizedPublisherAddress(contextGraphId: bigint): Promise { + expect(contextGraphId).toBe(42n); + return this.authorizedWallet.address; + } + + async signMessageAs(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const normalized = ethers.getAddress(address); + if (normalized.toLowerCase() !== this.authorizedWallet.address.toLowerCase()) { + throw new Error(`unexpected publisher signer ${address}`); + } + const sig = ethers.Signature.from(await this.authorizedWallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } + + override async createKnowledgeAssetsV10(params: Parameters[0]) { + this.capturedPublisherAddress = params.publisherAddress; + if (params.publisherAddress?.toLowerCase() !== this.authorizedWallet.address.toLowerCase()) { + throw new Error('agent pinned publish to the primary operational key'); + } + return super.createKnowledgeAssetsV10(params); + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -972,6 +1014,38 @@ describe('DKGAgent ACK signer gating', () => { await agent.stop().catch(() => {}); } }); + + it('resolves publish signer from the adapter instead of pinning operationalKeys[0]', async () => { + const primary = ethers.Wallet.createRandom(); + const authorized = ethers.Wallet.createRandom(); + const chain = new ContextAuthorizedPublisherChainAdapter(primary, authorized); + + const agent = await DKGAgent.create({ + name: 'AdapterAuthorizedPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + }); + + try { + agent.publisher.setIdentityId(77n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-adapter-authorized-publisher', + predicate: 'http://schema.org/name', + object: '"AdapterAuthorizedPublisher"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(authorized.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(authorized.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); }); describe('DKGAgent (integration)', () => { diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 0de436bb9..5d85f529a 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -68,6 +68,8 @@ export interface TxResult { hash: string; blockNumber: number; success: boolean; + /** Effective publisher/signing address used by update-style txs when known. */ + publisherAddress?: string; /** Set by createContextGraph when V9 registry is used (on-chain contextGraphId as hex). */ contextGraphId?: string; } diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index c20174713..63bca3245 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -1035,6 +1035,7 @@ export class EVMChainAdapter implements ChainAdapter { hash: receipt.hash, blockNumber: receipt.blockNumber, success: receipt.status === 1, + publisherAddress: signer.address, }; } @@ -2123,6 +2124,7 @@ export class EVMChainAdapter implements ChainAdapter { hash: receipt.hash, blockNumber: receipt.blockNumber, success: receipt.status === 1, + publisherAddress: signer.address, }; } diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 3addf6898..e618c700d 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -319,7 +319,10 @@ export class MockChainAdapter implements ChainAdapter { txIndex, }); - return this.txResult(true); + return { + ...this.txResult(true), + publisherAddress: this.signerAddress, + }; } async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { @@ -362,7 +365,10 @@ export class MockChainAdapter implements ChainAdapter { txIndex, }); - return this.txResult(true); + return { + ...this.txResult(true), + publisherAddress: this.signerAddress, + }; } async verifyKAUpdate(txHash: string, batchId: bigint, publisherAddress: string): Promise { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index a773478a3..fc895c720 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -88,6 +88,17 @@ function coercePublisherAddress(value: unknown): string | undefined { return normalized === ethers.ZeroAddress ? undefined : normalized; } +function recoverCompactMessageSigner( + message: Uint8Array, + signature: { r: Uint8Array; vs: Uint8Array }, +): string { + const serialized = ethers.Signature.from({ + r: ethers.hexlify(signature.r), + yParityAndS: ethers.hexlify(signature.vs), + }).serialized; + return ethers.verifyMessage(message, serialized); +} + export interface ShareOptions { publisherPeerId: string; operationCtx?: OperationContext; @@ -322,11 +333,6 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } - private requirePublisherAddress(operation: string, address = this.publisherAddress): string { - if (!address) throw new PublisherWalletRequiredError(operation); - return address; - } - private async resolvePublisherAddress(contextGraphId?: bigint): Promise { if (this.publisherAddress) return this.publisherAddress; if (this.publisherAddressResolver) { @@ -380,11 +386,7 @@ export class DKGPublisher implements Publisher { try { const challenge = ethers.getBytes(ethers.id('dkg-publisher:publisher-address-probe')); const compact = await this.chain.signMessage(challenge); - const signature = ethers.Signature.from({ - r: ethers.hexlify(compact.r), - yParityAndS: ethers.hexlify(compact.vs), - }).serialized; - return coercePublisherAddress(ethers.verifyMessage(challenge, signature)); + return coercePublisherAddress(recoverCompactMessageSigner(challenge, compact)); } catch { return undefined; } @@ -398,7 +400,7 @@ export class DKGPublisher implements Publisher { return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address; } - private getPublisherSigner(address = this.publisherAddress): PublisherSigner | undefined { + private async getPublisherSigner(address = this.publisherAddress): Promise { if (this.publisherWallet && this.publisherAddress) { const wallet = this.publisherWallet; return { @@ -408,18 +410,47 @@ export class DKGPublisher implements Publisher { }; } - if ( - address && - (typeof this.chain.signMessageAs === 'function' || typeof this.chain.signMessage === 'function') - ) { + if (address && typeof this.chain.signMessageAs === 'function') { const expectedAddress = address; return { address: expectedAddress, source: 'chainAdapter', signMessage: async (message: Uint8Array) => { - const compact = typeof this.chain.signMessageAs === 'function' - ? await this.chain.signMessageAs(expectedAddress, message) - : await this.chain.signMessage!(message); + const compact = await this.chain.signMessageAs!(expectedAddress, message); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + const recovered = ethers.verifyMessage(message, signature); + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + + `(${recovered})`, + ); + } + return signature; + }, + }; + } + + if (address && typeof this.chain.signMessage === 'function') { + const expectedAddress = address; + const challenge = ethers.getBytes(ethers.id(`dkg-publisher:chain-signer-probe:${expectedAddress.toLowerCase()}`)); + try { + const compact = await this.chain.signMessage(challenge); + const recovered = recoverCompactMessageSigner(challenge, compact); + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + return undefined; + } + } catch { + return undefined; + } + + return { + address: expectedAddress, + source: 'chainAdapter', + signMessage: async (message: Uint8Array) => { + const compact = await this.chain.signMessage!(message); const signature = ethers.Signature.from({ r: ethers.hexlify(compact.r), yParityAndS: ethers.hexlify(compact.vs), @@ -1169,7 +1200,7 @@ export class DKGPublisher implements Publisher { // Descriptive SWM graph names stay on the existing tentative/mock path. } const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); - const publisherSigner = this.getPublisherSigner(resolvedPublisherAddress); + const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; @@ -1785,7 +1816,7 @@ export class DKGPublisher implements Publisher { const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); const localOnlyUpdate = this.chain.chainId === 'none'; const publisherAddress = resolvedPublisherAddress ?? ( - localOnlyUpdate ? this.localTentativePublisherAddress() : this.requirePublisherAddress('update', resolvedPublisherAddress) + localOnlyUpdate ? this.localTentativePublisherAddress() : undefined ); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); @@ -1896,7 +1927,7 @@ export class DKGPublisher implements Publisher { // the hook — it retains the coarse phase boundary that brackets // the whole adapter call. See the equivalent marker in the // publish path above for the full rationale. - let txResult: { success: boolean; hash: string; blockNumber?: number }; + let txResult: { success: boolean; hash: string; blockNumber?: number; publisherAddress?: string }; let earlyReturn: PublishResult | undefined; let wroteAhead = false; const emitWriteAheadStart = (info?: { txHash?: string }) => { @@ -1934,6 +1965,7 @@ export class DKGPublisher implements Publisher { ]; if (errorName && V10_DEFINITIVE_ERRORS.includes(errorName)) { this.log.warn(ctx, `V10 update rejected (${errorName}): ${v10Err instanceof Error ? v10Err.message : String(v10Err)}`); + if (!publisherAddress) throw v10Err; earlyReturn = { kcId, ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, @@ -1996,25 +2028,31 @@ export class DKGPublisher implements Publisher { } if (!txResult.success) { + const failedPublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? + publisherAddress; + if (!failedPublisherAddress) throw new PublisherWalletRequiredError('update'); onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); return { kcId, - ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${failedPublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', publicQuads: allSkolemizedQuads, }; } + const effectivePublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? + publisherAddress; onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); + if (!effectivePublisherAddress) throw new PublisherWalletRequiredError('update'); await storeUpdatedQuads(); const result: PublishResult = { kcId, - ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${effectivePublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'confirmed', @@ -2024,7 +2062,7 @@ export class DKGPublisher implements Publisher { txHash: txResult.hash, blockNumber: txResult.blockNumber ?? 0, blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress, + publisherAddress: effectivePublisherAddress, }, }; diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index e900e0368..451cdacf0 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -27,7 +27,7 @@ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; -import { MockChainAdapter, type ChainAdapter } from '@origintrail-official/dkg-chain'; +import { MockChainAdapter, type ChainAdapter, type TxResult, type V10UpdateKCParams } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; @@ -101,6 +101,23 @@ class ContextAwareAdapterSigningChain extends MockChainAdapter { } } +class AdapterManagedUpdateChain implements ChainAdapter { + readonly chainId = 'mock:31337'; + capturedPublisherAddress?: string; + + constructor(private readonly publisherAddress?: string) {} + + async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { + this.capturedPublisherAddress = params.publisherAddress; + return { + success: true, + hash: `0x${'12'.repeat(32)}`, + blockNumber: 1, + ...(this.publisherAddress ? { publisherAddress: this.publisherAddress } : {}), + }; + } +} + describe('DKGPublisher: no random publisher wallet without explicit key', () => { it('leaves publisherWallet and publisherAddress undefined when no key or address is supplied', async () => { const keypair = await generateEd25519Keypair(); @@ -240,6 +257,40 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => })).rejects.toThrow(/publisherPrivateKey/); }); + it('rejects unrecoverable mock signMessage adapters before local storage', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const chain = new MockChainAdapter('mock:31337', '0x0000000000000000000000000000000000001111'); + chain.seedIdentity('0x0000000000000000000000000000000000001111', 1n); + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + await expect(publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:mock-unrecoverable-signer', + predicate: 'http://schema.org/name', + object: '"MockUnrecoverableSigner"', + graph: 'did:dkg:context-graph:1', + }], + })).rejects.toThrow(/publisherPrivateKey/); + + const stored = await store.query(` + SELECT ?p ?o WHERE { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings).toHaveLength(0); + }); + it('rejects a zero publisherAddress instead of treating it as a sentinel', async () => { const keypair = await generateEd25519Keypair(); expect(() => new DKGPublisher({ @@ -399,4 +450,63 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.status).toBe('confirmed'); expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + + it('lets adapter-managed updates select their signer without local address discovery', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterManagedUpdateChain(wallet.address); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await publisher.update(11n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-update', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('confirmed'); + expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); + expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + + it('rejects unattributable adapter-managed updates before local storage', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const chain = new AdapterManagedUpdateChain(); + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + await expect(publisher.update(12n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-update-without-address', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + })).rejects.toThrow(/publisherPrivateKey/); + + const stored = await store.query(` + SELECT ?p ?o WHERE { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings).toHaveLength(0); + }); }); From 8fc382e64b3a235ef9787c4eb3d8aba570d0451c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 04:05:33 +0200 Subject: [PATCH 22/43] fix(agent): preserve adapter operational key fallback --- packages/agent/src/dkg-agent.ts | 13 ++++ packages/agent/test/agent.test.ts | 80 ++++++++++++++++++++++++- packages/publisher/src/dkg-publisher.ts | 34 ++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 59f2e4efd..4c59b29b1 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -538,6 +538,19 @@ async function inferAdapterPublisherAddress( ); if (signerAddress) return signerAddress; + const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown }) + .getOperationalPrivateKey; + if (typeof operationalKeyGetter === 'function') { + try { + const privateKey = operationalKeyGetter.call(chain); + if (typeof privateKey === 'string' && privateKey.length > 0) { + return normalizeAdapterPublisherAddress(new ethers.Wallet(privateKey).address); + } + } catch { + // Last-resort compatibility probe; fall through to adapter signatures. + } + } + if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return undefined; try { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 31afeaf5e..4acb56074 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -18,7 +18,15 @@ import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import { getGenesisQuads, computeNetworkId, PROTOCOL_SYNC, PROTOCOL_STORAGE_ACK, SYSTEM_PARANETS, DKG_ONTOLOGY, paranetDataGraphUri, paranetWorkspaceGraphUri, contextGraphMetaUri, sparqlString } from '@origintrail-official/dkg-core'; import { DKGQueryEngine } from '@origintrail-official/dkg-query'; import { sha256 } from '@noble/hashes/sha2.js'; -import { EVMChainAdapter, MockChainAdapter, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult } from '@origintrail-official/dkg-chain'; +import { + EVMChainAdapter, + MockChainAdapter, + type ChainAdapter, + type CreateOnChainContextGraphParams, + type CreateOnChainContextGraphResult, + type OnChainPublishResult, + type V10PublishDirectParams, +} from '@origintrail-official/dkg-chain'; import { createEVMAdapter, getSharedContext, createProvider, takeSnapshot, revertSnapshot, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js'; import { mintTokens } from '../../chain/test/hardhat-harness.js'; import { ethers } from 'ethers'; @@ -120,6 +128,45 @@ class ContextAuthorizedPublisherChainAdapter extends MockChainAdapter { } } +class OperationalKeyOnlyPublishChainAdapter implements ChainAdapter { + readonly chainId = 'mock:31337'; + capturedPublisherAddress?: string; + + constructor(private readonly wallet: ethers.Wallet) {} + + getOperationalPrivateKey(): string { + return this.wallet.privateKey; + } + + isV10Ready(): boolean { + return true; + } + + async getEvmChainId(): Promise { + return 31337n; + } + + async getKnowledgeAssetsV10Address(): Promise { + return '0x00000000000000000000000000000000000000A1'; + } + + async createKnowledgeAssetsV10(params: V10PublishDirectParams): Promise { + this.capturedPublisherAddress = params.publisherAddress; + if (params.publisherAddress.toLowerCase() !== this.wallet.address.toLowerCase()) { + throw new Error('publisher did not use the adapter operational key fallback'); + } + return { + batchId: 1n, + startKAId: 101n, + endKAId: 101n, + txHash: `0x${'34'.repeat(32)}`, + blockNumber: 1, + blockTimestamp: Math.floor(Date.now() / 1000), + publisherAddress: this.wallet.address, + }; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1046,6 +1093,37 @@ describe('DKGAgent ACK signer gating', () => { await agent.stop().catch(() => {}); } }); + + it('keeps getOperationalPrivateKey as a legacy adapter-backed publish fallback', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new OperationalKeyOnlyPublishChainAdapter(wallet); + + const agent = await DKGAgent.create({ + name: 'LegacyOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"OperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); }); describe('DKGAgent (integration)', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index fc895c720..5fae5c58e 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -336,7 +336,8 @@ export class DKGPublisher implements Publisher { private async resolvePublisherAddress(contextGraphId?: bigint): Promise { if (this.publisherAddress) return this.publisherAddress; if (this.publisherAddressResolver) { - return normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); + const resolved = normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); + if (resolved) return resolved; } return this.inferAdapterPublisherAddress(contextGraphId); } @@ -381,6 +382,9 @@ export class DKGPublisher implements Publisher { ); if (signerAddress) return signerAddress; + const operationalWallet = this.getAdapterOperationalWallet(); + if (operationalWallet) return operationalWallet.address; + if (this.chain.chainId === 'none' || typeof this.chain.signMessage !== 'function') return undefined; try { @@ -392,6 +396,21 @@ export class DKGPublisher implements Publisher { } } + private getAdapterOperationalWallet(): ethers.Wallet | undefined { + const operationalKeyGetter = (this.chain as unknown as { getOperationalPrivateKey?: () => unknown }) + .getOperationalPrivateKey; + if (typeof operationalKeyGetter !== 'function') return undefined; + + try { + const privateKey = operationalKeyGetter.call(this.chain); + return typeof privateKey === 'string' && privateKey.length > 0 + ? new ethers.Wallet(privateKey) + : undefined; + } catch { + return undefined; + } + } + // Local-only tentative publishes need a stable, non-zero UAL component even // when no EVM publisher key exists. This is not used for signatures. private localTentativePublisherAddress(): string { @@ -467,6 +486,19 @@ export class DKGPublisher implements Publisher { }; } + const operationalWallet = this.getAdapterOperationalWallet(); + if ( + address && + operationalWallet && + operationalWallet.address.toLowerCase() === address.toLowerCase() + ) { + return { + address: operationalWallet.address, + source: 'chainAdapter', + signMessage: (message: Uint8Array) => operationalWallet.signMessage(message), + }; + } + return undefined; } From c779b52fbfebc042f4aaaf4ef228fb0e8e51610b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 04:12:51 +0200 Subject: [PATCH 23/43] fix(agent): gate legacy op-key publisher fallback --- packages/agent/src/dkg-agent.ts | 19 ++++++- packages/agent/test/agent.test.ts | 71 +++++++++++++++++++++++++ packages/publisher/src/dkg-publisher.ts | 47 ++++++++-------- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 4c59b29b1..78fd34d36 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -493,6 +493,16 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { return address === ethers.ZeroAddress ? undefined : address; } +function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { + if (typeof chain.getAuthorizedPublisherAddress === 'function') return true; + if (typeof chain.signMessageAs === 'function') return true; + if (typeof chain.signMessage === 'function') return true; + if (typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function') return true; + if (typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function') return true; + if (typeof (chain as unknown as { getOperationalPrivateKey?: unknown }).getOperationalPrivateKey === 'function') return true; + return normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress) !== undefined; +} + async function inferAdapterPublisherAddress( chain: ChainAdapter, contextGraphId?: bigint, @@ -764,13 +774,20 @@ export class DKGAgent { const node = new DKGNode(nodeConfig); const workspaceOwnedEntities = new Map>(); const writeLocks = new Map>(); + const useLegacyAdapterOperationalKeyFallback = Boolean( + config.chainAdapter && + !config.publisherAddress && + opKeys?.[0] && + !adapterAdvertisesPublisherSigner(chain), + ); const publisher = new DKGPublisher({ store, chain, eventBus, keypair, + publisherPrivateKey: useLegacyAdapterOperationalKeyFallback ? opKeys?.[0] : undefined, publisherAddress: config.publisherAddress, - publisherAddressResolver: config.publisherAddress + publisherAddressResolver: config.publisherAddress || useLegacyAdapterOperationalKeyFallback ? undefined : (contextGraphId?: bigint) => inferAdapterPublisherAddress(chain, contextGraphId), sharedMemoryOwnedEntities: workspaceOwnedEntities, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 4acb56074..ad32e42ff 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -167,6 +167,41 @@ class OperationalKeyOnlyPublishChainAdapter implements ChainAdapter { } } +class ExternalOperationalKeyPublishChainAdapter implements ChainAdapter { + readonly chainId = 'mock:31337'; + capturedPublisherAddress?: string; + + constructor(private readonly expectedPublisherAddress: string) {} + + isV10Ready(): boolean { + return true; + } + + async getEvmChainId(): Promise { + return 31337n; + } + + async getKnowledgeAssetsV10Address(): Promise { + return '0x00000000000000000000000000000000000000A1'; + } + + async createKnowledgeAssetsV10(params: V10PublishDirectParams): Promise { + this.capturedPublisherAddress = params.publisherAddress; + if (params.publisherAddress.toLowerCase() !== this.expectedPublisherAddress.toLowerCase()) { + throw new Error('publisher did not use chainConfig.operationalKeys fallback'); + } + return { + batchId: 1n, + startKAId: 101n, + endKAId: 101n, + txHash: `0x${'56'.repeat(32)}`, + blockNumber: 1, + blockTimestamp: Math.floor(Date.now() / 1000), + publisherAddress: this.expectedPublisherAddress, + }; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1124,6 +1159,42 @@ describe('DKGAgent ACK signer gating', () => { await agent.stop().catch(() => {}); } }); + + it('keeps chainConfig.operationalKeys fallback when a custom adapter has no signer probes', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new ExternalOperationalKeyPublishChainAdapter(wallet.address); + + const agent = await DKGAgent.create({ + name: 'ExternalOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-chain-config-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"ChainConfigOperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); }); describe('DKGAgent (integration)', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 5fae5c58e..31a21e4bc 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -455,35 +455,36 @@ export class DKGPublisher implements Publisher { if (address && typeof this.chain.signMessage === 'function') { const expectedAddress = address; const challenge = ethers.getBytes(ethers.id(`dkg-publisher:chain-signer-probe:${expectedAddress.toLowerCase()}`)); + let signMessageMatches = false; try { const compact = await this.chain.signMessage(challenge); const recovered = recoverCompactMessageSigner(challenge, compact); - if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { - return undefined; - } + signMessageMatches = recovered.toLowerCase() === expectedAddress.toLowerCase(); } catch { - return undefined; + signMessageMatches = false; } - return { - address: expectedAddress, - source: 'chainAdapter', - signMessage: async (message: Uint8Array) => { - const compact = await this.chain.signMessage!(message); - const signature = ethers.Signature.from({ - r: ethers.hexlify(compact.r), - yParityAndS: ethers.hexlify(compact.vs), - }).serialized; - const recovered = ethers.verifyMessage(message, signature); - if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { - throw new Error( - `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + - `(${recovered})`, - ); - } - return signature; - }, - }; + if (signMessageMatches) { + return { + address: expectedAddress, + source: 'chainAdapter', + signMessage: async (message: Uint8Array) => { + const compact = await this.chain.signMessage!(message); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + const recovered = ethers.verifyMessage(message, signature); + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + + `(${recovered})`, + ); + } + return signature; + }, + }; + } } const operationalWallet = this.getAdapterOperationalWallet(); From 8333e806d894752487f095b172b2a4951ba60a41 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 04:35:31 +0200 Subject: [PATCH 24/43] fix(publisher): only require signer for on-chain publish --- packages/agent/src/dkg-agent.ts | 5 +- packages/agent/test/agent.test.ts | 42 ++++++++++++ packages/publisher/src/dkg-publisher.ts | 7 +- .../test/publisher-no-random-wallet.test.ts | 64 ++++++++++++++++++- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 78fd34d36..897e841f5 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -494,13 +494,10 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { } function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { - if (typeof chain.getAuthorizedPublisherAddress === 'function') return true; if (typeof chain.signMessageAs === 'function') return true; if (typeof chain.signMessage === 'function') return true; - if (typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function') return true; - if (typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function') return true; if (typeof (chain as unknown as { getOperationalPrivateKey?: unknown }).getOperationalPrivateKey === 'function') return true; - return normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress) !== undefined; + return false; } async function inferAdapterPublisherAddress( diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index ad32e42ff..49a3e9882 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -202,6 +202,12 @@ class ExternalOperationalKeyPublishChainAdapter implements ChainAdapter { } } +class AddressOnlyExternalOperationalKeyPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + getSignerAddress(): string { + return ethers.Wallet.createRandom().address; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1195,6 +1201,42 @@ describe('DKGAgent ACK signer gating', () => { await agent.stop().catch(() => {}); } }); + + it('keeps chainConfig.operationalKeys fallback when a custom adapter only exposes signer addresses', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new AddressOnlyExternalOperationalKeyPublishChainAdapter(wallet.address); + + const agent = await DKGAgent.create({ + name: 'AddressOnlyExternalOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-address-only-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"AddressOnlyOperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); }); describe('DKGAgent (integration)', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 31a21e4bc..0ded97c7d 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1235,7 +1235,8 @@ export class DKGPublisher implements Publisher { const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); - const canAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherSigner !== undefined; + const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; + const canAttemptOnChainPublish = willAttemptOnChainPublish && publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1250,7 +1251,7 @@ export class DKGPublisher implements Publisher { throw new Error('Publish rejected: "allowedPeers" is only valid when accessPolicy is "allowList"'); } - if (this.chain.chainId !== 'none' && !publisherSigner) { + if (willAttemptOnChainPublish && !publisherSigner) { throw new PublisherWalletRequiredError('publish'); } @@ -1547,6 +1548,8 @@ export class DKGPublisher implements Publisher { if (identityId === 0n) { this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); + } else if (publisherContextGraphId === undefined) { + this.log.warn(ctx, `No positive on-chain context graph id resolved from "${v10CgDomain}" — skipping on-chain publish`); } else { const tokenAmount = precomputedTokenAmount; usedV10Path = true; diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 451cdacf0..4217c15e0 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -237,13 +237,65 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.ual.toLowerCase()).toContain(publisherAddress.toLowerCase()); }); - it('rejects chain-backed publish without a publisher signer before local storage', async () => { + it('keeps chain-backed identity-less publishes tentative without a publisher signer', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ store: new OxigraphStore(), chain: makeStubChain('evm:31337'), eventBus: new TypedEventBus(), keypair, + publisherNodeIdentityId: 0n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:evm-no-identity-no-signer', + predicate: 'http://schema.org/name', + object: '"EvmNoIdentityNoSigner"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); + }); + + it('keeps descriptive-CG chain-backed publishes tentative without a publisher signer', async () => { + const keypair = await generateEd25519Keypair(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('evm:31337'), + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: 'draft-cg', + quads: [{ + subject: 'urn:test:evm-descriptive-cg-no-signer', + predicate: 'http://schema.org/name', + object: '"EvmDescriptiveCgNoSigner"', + graph: 'did:dkg:context-graph:draft-cg', + }], + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); + }); + + it('rejects chain-backed on-chain publish without a publisher signer before local storage', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const publisher = new DKGPublisher({ + store, + chain: makeStubChain('evm:31337'), + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, }); await expect(publisher.publish({ @@ -255,6 +307,16 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => graph: 'did:dkg:context-graph:1', }], })).rejects.toThrow(/publisherPrivateKey/); + + const stored = await store.query(` + SELECT ?p ?o WHERE { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings).toHaveLength(0); }); it('rejects unrecoverable mock signMessage adapters before local storage', async () => { From feacb6cc1c595147cb527deb3dd0bafd46514ca7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 04:47:53 +0200 Subject: [PATCH 25/43] fix(publisher): handle legacy adapter attribution --- packages/agent/src/dkg-agent.ts | 25 +++++++-- packages/agent/test/agent.test.ts | 37 ++++++++++++++ packages/publisher/src/dkg-publisher.ts | 22 +++++++- .../test/publisher-no-random-wallet.test.ts | 51 +++++++++++++++++-- 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 897e841f5..81838826d 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -500,6 +500,15 @@ function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { return false; } +function privateKeyAddress(privateKey: string | undefined): string | undefined { + if (!privateKey) return undefined; + try { + return normalizeAdapterPublisherAddress(new ethers.Wallet(privateKey).address); + } catch { + return undefined; + } +} + async function inferAdapterPublisherAddress( chain: ChainAdapter, contextGraphId?: bigint, @@ -771,18 +780,26 @@ export class DKGAgent { const node = new DKGNode(nodeConfig); const workspaceOwnedEntities = new Map>(); const writeLocks = new Map>(); + const legacyAdapterOperationalKey = opKeys?.[0]; + const legacyAdapterOperationalAddress = privateKeyAddress(legacyAdapterOperationalKey); + const configuredPublisherAddress = normalizeAdapterPublisherAddress(config.publisherAddress); + const publisherAddressMatchesLegacyKey = Boolean( + configuredPublisherAddress && + legacyAdapterOperationalAddress && + configuredPublisherAddress.toLowerCase() === legacyAdapterOperationalAddress.toLowerCase(), + ); const useLegacyAdapterOperationalKeyFallback = Boolean( config.chainAdapter && - !config.publisherAddress && - opKeys?.[0] && - !adapterAdvertisesPublisherSigner(chain), + legacyAdapterOperationalKey && + !adapterAdvertisesPublisherSigner(chain) && + (!configuredPublisherAddress || publisherAddressMatchesLegacyKey), ); const publisher = new DKGPublisher({ store, chain, eventBus, keypair, - publisherPrivateKey: useLegacyAdapterOperationalKeyFallback ? opKeys?.[0] : undefined, + publisherPrivateKey: useLegacyAdapterOperationalKeyFallback ? legacyAdapterOperationalKey : undefined, publisherAddress: config.publisherAddress, publisherAddressResolver: config.publisherAddress || useLegacyAdapterOperationalKeyFallback ? undefined diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 49a3e9882..016add04f 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1237,6 +1237,43 @@ describe('DKGAgent ACK signer gating', () => { await agent.stop().catch(() => {}); } }); + + it('keeps chainConfig.operationalKeys fallback when publisherAddress pins the same key', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new ExternalOperationalKeyPublishChainAdapter(wallet.address); + + const agent = await DKGAgent.create({ + name: 'PinnedOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + publisherAddress: wallet.address, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-pinned-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"PinnedOperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); }); describe('DKGAgent (integration)', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 0ded97c7d..2563f00f7 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -2078,11 +2078,29 @@ export class DKGPublisher implements Publisher { publicQuads: allSkolemizedQuads, }; } - const effectivePublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? + let effectivePublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? publisherAddress; + if (!effectivePublisherAddress && typeof this.chain.getLatestMerkleRootPublisher === 'function') { + try { + effectivePublisherAddress = coercePublisherAddress( + await this.chain.getLatestMerkleRootPublisher(kcId), + ); + } catch { + // Some legacy adapters can submit updates but cannot report the + // effective publisher. Fall back below so we do not throw after a + // successful broadcast and leave local state stale. + } + } onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); - if (!effectivePublisherAddress) throw new PublisherWalletRequiredError('update'); + if (!effectivePublisherAddress) { + effectivePublisherAddress = this.localTentativePublisherAddress(); + this.log.warn( + ctx, + 'Chain adapter returned a successful update without publisherAddress; using deterministic local attribution address. ' + + 'Upgrade the adapter to return TxResult.publisherAddress for exact on-chain attribution.', + ); + } await storeUpdatedQuads(); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 4217c15e0..c947c7d37 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -105,7 +105,10 @@ class AdapterManagedUpdateChain implements ChainAdapter { readonly chainId = 'mock:31337'; capturedPublisherAddress?: string; - constructor(private readonly publisherAddress?: string) {} + constructor( + private readonly publisherAddress?: string, + private readonly latestPublisherAddress?: string, + ) {} async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { this.capturedPublisherAddress = params.publisherAddress; @@ -116,6 +119,11 @@ class AdapterManagedUpdateChain implements ChainAdapter { ...(this.publisherAddress ? { publisherAddress: this.publisherAddress } : {}), }; } + + async getLatestMerkleRootPublisher(): Promise { + if (!this.latestPublisherAddress) throw new Error('publisher unavailable'); + return this.latestPublisherAddress; + } } describe('DKGPublisher: no random publisher wallet without explicit key', () => { @@ -540,7 +548,34 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); - it('rejects unattributable adapter-managed updates before local storage', async () => { + it('resolves adapter-managed update attribution from chain state when tx result omits publisherAddress', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterManagedUpdateChain(undefined, wallet.address); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await publisher.update(11n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-update-chain-attribution', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('confirmed'); + expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); + expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + + it('persists successful adapter-managed updates when legacy adapters omit publisherAddress', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); const chain = new AdapterManagedUpdateChain(); @@ -551,7 +586,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => keypair, }); - await expect(publisher.update(12n, { + const updated = await publisher.update(12n, { contextGraphId: '1', quads: [{ subject: 'urn:test:adapter-managed-update-without-address', @@ -559,7 +594,13 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => object: '"After"', graph: 'did:dkg:context-graph:1', }], - })).rejects.toThrow(/publisherPrivateKey/); + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('confirmed'); + expect(updated.onChainResult?.publisherAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(updated.onChainResult?.publisherAddress).not.toBe(ethers.ZeroAddress); + expect(updated.ual).toContain(updated.onChainResult!.publisherAddress); const stored = await store.query(` SELECT ?p ?o WHERE { @@ -569,6 +610,6 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => } `); expect(stored.type).toBe('bindings'); - expect(stored.bindings).toHaveLength(0); + expect(stored.bindings).toHaveLength(1); }); }); From 956e5065e44fa2f611536b5d2193f9cb9fbf15ef Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 04:57:33 +0200 Subject: [PATCH 26/43] fix(publisher): soften adapter signer failures --- packages/agent/src/dkg-agent.ts | 7 +- packages/agent/test/agent.test.ts | 109 +++++++++++++++ packages/publisher/src/dkg-publisher.ts | 32 +++-- .../test/publisher-no-random-wallet.test.ts | 126 +++++++++++++++++- 4 files changed, 259 insertions(+), 15 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 81838826d..230eadec9 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -495,7 +495,6 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { if (typeof chain.signMessageAs === 'function') return true; - if (typeof chain.signMessage === 'function') return true; if (typeof (chain as unknown as { getOperationalPrivateKey?: unknown }).getOperationalPrivateKey === 'function') return true; return false; } @@ -527,7 +526,9 @@ async function inferAdapterPublisherAddress( const signerAddressGetter = (chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress; if (typeof signerAddressGetter === 'function') { try { - const address = normalizeAdapterPublisherAddress(signerAddressGetter.call(chain)); + const address = normalizeAdapterPublisherAddress( + await Promise.resolve(signerAddressGetter.call(chain)), + ); if (address) return address; } catch { // Best-effort probe; fall through to broader adapter surfaces. @@ -537,7 +538,7 @@ async function inferAdapterPublisherAddress( const signerAddresses = (chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; if (typeof signerAddresses === 'function') { try { - const advertised = signerAddresses.call(chain); + const advertised = await Promise.resolve(signerAddresses.call(chain)); if (Array.isArray(advertised)) { for (const value of advertised) { const address = normalizeAdapterPublisherAddress(value); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 016add04f..68701fe3c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -208,6 +208,44 @@ class AddressOnlyExternalOperationalKeyPublishChainAdapter extends ExternalOpera } } +class AsyncAddressSignMessageAsPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + constructor(private readonly wallet: ethers.Wallet) { + super(wallet.address); + } + + async getSignerAddress(): Promise { + return this.wallet.address; + } + + async signMessageAs(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + if (address.toLowerCase() !== this.wallet.address.toLowerCase()) { + throw new Error(`unexpected signer ${address}`); + } + const sig = ethers.Signature.from(await this.wallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + +class GenericSignMessageExternalOperationalKeyPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + constructor( + expectedPublisherAddress: string, + private readonly genericSigner: ethers.Wallet, + ) { + super(expectedPublisherAddress); + } + + async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.genericSigner.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1135,6 +1173,37 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('awaits async adapter signer address probes', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new AsyncAddressSignMessageAsPublishChainAdapter(wallet); + + const agent = await DKGAgent.create({ + name: 'AsyncAdapterAddressPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-async-adapter-address', + predicate: 'http://schema.org/name', + object: '"AsyncAdapterAddress"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('keeps getOperationalPrivateKey as a legacy adapter-backed publish fallback', async () => { const wallet = ethers.Wallet.createRandom(); const chain = new OperationalKeyOnlyPublishChainAdapter(wallet); @@ -1238,6 +1307,46 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('keeps chainConfig.operationalKeys fallback when custom adapter only exposes generic signMessage', async () => { + const wallet = ethers.Wallet.createRandom(); + const unrelatedSigner = ethers.Wallet.createRandom(); + const chain = new GenericSignMessageExternalOperationalKeyPublishChainAdapter( + wallet.address, + unrelatedSigner, + ); + + const agent = await DKGAgent.create({ + name: 'GenericSignMessageOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-generic-sign-message-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"GenericSignMessageOperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('keeps chainConfig.operationalKeys fallback when publisherAddress pins the same key', async () => { const wallet = ethers.Wallet.createRandom(); const chain = new ExternalOperationalKeyPublishChainAdapter(wallet.address); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 2563f00f7..e6a89c15e 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -355,7 +355,9 @@ export class DKGPublisher implements Publisher { const signerAddressGetter = (this.chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress; if (typeof signerAddressGetter === 'function') { try { - const address = coercePublisherAddress(signerAddressGetter.call(this.chain)); + const address = coercePublisherAddress( + await Promise.resolve(signerAddressGetter.call(this.chain)), + ); if (address) return address; } catch { // Fall through to other common adapter surfaces. @@ -365,7 +367,7 @@ export class DKGPublisher implements Publisher { const signerAddressesGetter = (this.chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses; if (typeof signerAddressesGetter === 'function') { try { - const advertised = signerAddressesGetter.call(this.chain); + const advertised = await Promise.resolve(signerAddressesGetter.call(this.chain)); if (Array.isArray(advertised)) { for (const value of advertised) { const address = coercePublisherAddress(value); @@ -1522,15 +1524,23 @@ export class DKGPublisher implements Publisher { precomputedTokenAmount, BigInt(kcMerkleLeafCount), ); - const ackSig = ethers.Signature.from( - await publisherSigner.signMessage(ackDigest), - ); - v10ACKs = [{ - peerId: 'self', - signatureR: ethers.getBytes(ackSig.r), - signatureVS: ethers.getBytes(ackSig.yParityAndS), - nodeIdentityId: this.publisherNodeIdentityId, - }]; + try { + const ackSig = ethers.Signature.from( + await publisherSigner.signMessage(ackDigest), + ); + v10ACKs = [{ + peerId: 'self', + signatureR: ethers.getBytes(ackSig.r), + signatureVS: ethers.getBytes(ackSig.yParityAndS), + nodeIdentityId: this.publisherNodeIdentityId, + }]; + } catch (err) { + this.log.warn( + ctx, + `Self-sign ACK skipped: publisher signer failed (${err instanceof Error ? err.message : String(err)})`, + ); + v10ACKs = []; + } } else { this.log.warn(ctx, 'Self-sign ACK skipped: publisher signing key is unavailable'); } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index c947c7d37..1c8d92224 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -27,7 +27,14 @@ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; -import { MockChainAdapter, type ChainAdapter, type TxResult, type V10UpdateKCParams } from '@origintrail-official/dkg-chain'; +import { + MockChainAdapter, + type ChainAdapter, + type OnChainPublishResult, + type TxResult, + type V10PublishDirectParams, + type V10UpdateKCParams, +} from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; const TEST_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; @@ -55,6 +62,59 @@ class AdapterSigningChain extends MockChainAdapter { } } +class AsyncAddressSigningChain implements ChainAdapter { + readonly chainId = 'mock:31337'; + capturedPublisherAddress?: string; + + constructor(private readonly wallet: ethers.Wallet) {} + + isV10Ready(): boolean { + return true; + } + + async getEvmChainId(): Promise { + return 31337n; + } + + async getKnowledgeAssetsV10Address(): Promise { + return '0x00000000000000000000000000000000000000A1'; + } + + async getSignerAddress(): Promise { + return this.wallet.address; + } + + 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 createKnowledgeAssetsV10(params: V10PublishDirectParams): Promise { + this.capturedPublisherAddress = params.publisherAddress; + if (params.publisherAddress?.toLowerCase() !== this.wallet.address.toLowerCase()) { + throw new Error('publisher did not await async signer address'); + } + return { + batchId: 1n, + startKAId: 101n, + endKAId: 101n, + txHash: `0x${'78'.repeat(32)}`, + blockNumber: 1, + blockTimestamp: Math.floor(Date.now() / 1000), + publisherAddress: this.wallet.address, + }; + } +} + +class RejectingAdapterSignerChain extends AsyncAddressSigningChain { + async signMessageAs(): Promise<{ r: Uint8Array; vs: Uint8Array }> { + throw new Error('remote signer unavailable'); + } +} + class ContextAwareAdapterSigningChain extends MockChainAdapter { capturedPublisherAddress?: string; @@ -454,6 +514,70 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('awaits async adapter signer address probes', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AsyncAddressSigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-async-address', + predicate: 'http://schema.org/name', + object: '"AdapterAsyncAddress"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + + it('continues tentatively when adapter signer fails during self-ACK', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new RejectingAdapterSignerChain(wallet); + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-self-ack-failure', + predicate: 'http://schema.org/name', + object: '"AdapterSelfAckFailure"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('tentative'); + expect(chain.capturedPublisherAddress).toBeUndefined(); + + const stored = await store.query(` + SELECT ?p ?o WHERE { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings).toHaveLength(1); + }); + it('binds context-graph-aware adapter signer resolution to the V10 tx publisher address', async () => { const keypair = await generateEd25519Keypair(); const primaryWallet = new ethers.Wallet(TEST_KEY); From b15b0f93bb36de469d613b3836a9a09a93ee744d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:08:57 +0200 Subject: [PATCH 27/43] fix(publisher): require real update attribution --- packages/chain/src/evm-adapter.ts | 85 +++++++++---------- packages/chain/test/evm-adapter.unit.test.ts | 20 +++++ packages/publisher/src/dkg-publisher.ts | 13 ++- .../test/publisher-no-random-wallet.test.ts | 12 +-- 4 files changed, 71 insertions(+), 59 deletions(-) diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 63bca3245..7c14d86eb 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -253,6 +253,7 @@ export class EVMChainAdapter implements ChainAdapter { /** Admin signer — used only for profile/key-management operations. */ private readonly adminSigner?: Wallet; private signerIndex = 0; + private signerSelectionQueue: Promise = Promise.resolve(); private readonly hubAddress: string; private contracts: ContractCache; private initialized = false; @@ -368,11 +369,21 @@ export class EVMChainAdapter implements ChainAdapter { return s; } - private findSignerByAddress(address: string): { signer: Wallet; index: number } | undefined { + private findSignerByAddress(address: string): Wallet | undefined { const normalized = ethers.getAddress(address).toLowerCase(); - const index = this.signerPool.findIndex((signer) => signer.address.toLowerCase() === normalized); - if (index === -1) return undefined; - return { signer: this.signerPool[index], index }; + return this.signerPool.find((signer) => signer.address.toLowerCase() === normalized); + } + + private async withSignerSelectionLock(fn: () => Promise): Promise { + const previous = this.signerSelectionQueue; + let release!: () => void; + this.signerSelectionQueue = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await fn(); + } finally { + release(); + } } /** @@ -381,51 +392,38 @@ export class EVMChainAdapter implements ChainAdapter { * when the auth surface is unavailable. */ private async nextAuthorizedSigner(contextGraphId: bigint): Promise { - if (!this.contracts.contextGraphs) { - return this.nextSigner(); - } + return this.withSignerSelectionLock(async () => { + if (!this.contracts.contextGraphs) { + return this.nextSigner(); + } - const start = this.signerIndex % this.signerPool.length; - for (let i = 0; i < this.signerPool.length; i += 1) { - const idx = (start + i) % this.signerPool.length; - const signer = this.signerPool[idx]; - const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher(contextGraphId, signer.address); - if (authorized) { - this.signerIndex = idx + 1; - return signer; + const start = this.signerIndex % this.signerPool.length; + for (let i = 0; i < this.signerPool.length; i += 1) { + const idx = (start + i) % this.signerPool.length; + const signer = this.signerPool[idx]; + const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher(contextGraphId, signer.address); + if (authorized) { + this.signerIndex = idx + 1; + return signer; + } } - } - throw new Error( - `No authorized publisher wallet found in signer pool for context graph ${contextGraphId.toString()}. ` + - 'Ensure at least one configured wallet is permitted by on-chain publish authority.', - ); + throw new Error( + `No authorized publisher wallet found in signer pool for context graph ${contextGraphId.toString()}. ` + + 'Ensure at least one configured wallet is permitted by on-chain publish authority.', + ); + }); } /** - * Inspect the same authorized-signer order as `nextAuthorizedSigner`, but do - * not advance the round-robin cursor. The publisher uses this to bind - * off-chain signatures to the tx signer before `publishDirect` is submitted. + * Reserve the next authorized signer and return its address. The publisher + * uses this to bind off-chain signatures to the tx signer before + * `publishDirect` is submitted. */ async getAuthorizedPublisherAddress(contextGraphId: bigint): Promise { await this.init(); - if (!this.contracts.contextGraphs) { - return this.signerPool[this.signerIndex % this.signerPool.length].address; - } - - const start = this.signerIndex % this.signerPool.length; - for (let i = 0; i < this.signerPool.length; i += 1) { - const idx = (start + i) % this.signerPool.length; - const signer = this.signerPool[idx]; - const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher(contextGraphId, signer.address); - if (authorized) return signer.address; - } - - throw new Error( - `No authorized publisher wallet found in signer pool for context graph ${contextGraphId.toString()}. ` + - 'Ensure at least one configured wallet is permitted by on-chain publish authority.', - ); + return (await this.nextAuthorizedSigner(contextGraphId)).address; } /** All operational wallet addresses (for display / funding). */ @@ -1674,17 +1672,16 @@ export class EVMChainAdapter implements ChainAdapter { if (this.contracts.contextGraphs) { const authorized = await this.contracts.contextGraphs.isAuthorizedPublisher( params.contextGraphId, - selected.signer.address, + selected.address, ); if (!authorized) { throw new Error( - `Configured publisherAddress ${selected.signer.address} is not authorized to publish ` + + `Configured publisherAddress ${selected.address} is not authorized to publish ` + `to context graph ${params.contextGraphId.toString()}.`, ); } } - txSigner = selected.signer; - this.signerIndex = selected.index + 1; + txSigner = selected; } else { txSigner = await this.nextAuthorizedSigner(params.contextGraphId); } @@ -2464,7 +2461,7 @@ export class EVMChainAdapter implements ChainAdapter { throw new Error(`Cannot sign with ${address}: address is not present in the EVM signer pool.`); } const sig = ethers.Signature.from( - await selected.signer.signMessage(messageHash), + await selected.signMessage(messageHash), ); return { r: ethers.getBytes(sig.r), diff --git a/packages/chain/test/evm-adapter.unit.test.ts b/packages/chain/test/evm-adapter.unit.test.ts index 84e54f01c..17677ce65 100644 --- a/packages/chain/test/evm-adapter.unit.test.ts +++ b/packages/chain/test/evm-adapter.unit.test.ts @@ -153,6 +153,26 @@ describe('EVMChainAdapter constructor / getters (no init)', () => { expect(sig.vs).toHaveLength(32); }); + it('reserves distinct authorized publisher signers across concurrent address probes', async () => { + const a = new EVMChainAdapter(minimalConfig({ additionalKeys: [OTHER_PK] })); + const [firstAddress, secondAddress] = a.getSignerAddresses(); + (a as any).init = async () => undefined; + (a as any).contracts.contextGraphs = { + isAuthorizedPublisher: vi.fn(async () => { + await Promise.resolve(); + return true; + }), + }; + + const [firstReserved, secondReserved] = await Promise.all([ + a.getAuthorizedPublisherAddress(1n), + a.getAuthorizedPublisherAddress(1n), + ]); + + expect(firstReserved).toBe(firstAddress); + expect(secondReserved).toBe(secondAddress); + }); + it('accepts randomSamplingHubRefreshMs override without RPC contact', () => { const a = new EVMChainAdapter(minimalConfig({ randomSamplingHubRefreshMs: 60_000 })); expect(a.chainType).toBe('evm'); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index e6a89c15e..80a346a6a 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -2097,18 +2097,17 @@ export class DKGPublisher implements Publisher { ); } catch { // Some legacy adapters can submit updates but cannot report the - // effective publisher. Fall back below so we do not throw after a - // successful broadcast and leave local state stale. + // effective publisher. Refuse confirmed metadata below rather than + // inventing a publisher address that did not come from chain state. } } onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); if (!effectivePublisherAddress) { - effectivePublisherAddress = this.localTentativePublisherAddress(); - this.log.warn( - ctx, - 'Chain adapter returned a successful update without publisherAddress; using deterministic local attribution address. ' + - 'Upgrade the adapter to return TxResult.publisherAddress for exact on-chain attribution.', + throw new Error( + 'Chain adapter returned a successful update without publisherAddress, and ' + + 'getLatestMerkleRootPublisher() did not resolve a real publisher. Refusing to write ' + + 'confirmed metadata with synthetic publisher attribution.', ); } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 1c8d92224..8faa427dd 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -699,7 +699,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); - it('persists successful adapter-managed updates when legacy adapters omit publisherAddress', async () => { + it('rejects adapter-managed updates when publisher attribution is unavailable', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); const chain = new AdapterManagedUpdateChain(); @@ -710,7 +710,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => keypair, }); - const updated = await publisher.update(12n, { + await expect(publisher.update(12n, { contextGraphId: '1', quads: [{ subject: 'urn:test:adapter-managed-update-without-address', @@ -718,13 +718,9 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => object: '"After"', graph: 'did:dkg:context-graph:1', }], - }); + })).rejects.toThrow(/successful update without publisherAddress.*synthetic publisher attribution/i); expect(chain.capturedPublisherAddress).toBeUndefined(); - expect(updated.status).toBe('confirmed'); - expect(updated.onChainResult?.publisherAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); - expect(updated.onChainResult?.publisherAddress).not.toBe(ethers.ZeroAddress); - expect(updated.ual).toContain(updated.onChainResult!.publisherAddress); const stored = await store.query(` SELECT ?p ?o WHERE { @@ -734,6 +730,6 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => } `); expect(stored.type).toBe('bindings'); - expect(stored.bindings).toHaveLength(1); + expect(stored.bindings).toHaveLength(0); }); }); From 9318f04d158df547548cf3a7a773c82dea52b2d0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:14:54 +0200 Subject: [PATCH 28/43] fix(chain): keep signer lock internal --- packages/chain/src/evm-adapter.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 7c14d86eb..ce899fd5f 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -374,25 +374,17 @@ export class EVMChainAdapter implements ChainAdapter { return this.signerPool.find((signer) => signer.address.toLowerCase() === normalized); } - private async withSignerSelectionLock(fn: () => Promise): Promise { - const previous = this.signerSelectionQueue; - let release!: () => void; - this.signerSelectionQueue = new Promise((resolve) => { release = resolve; }); - await previous; - try { - return await fn(); - } finally { - release(); - } - } - /** * Pick the next signer in the pool that the on-chain ContextGraphs contract * authorizes for the target context graph. Falls back to round-robin only * when the auth surface is unavailable. */ private async nextAuthorizedSigner(contextGraphId: bigint): Promise { - return this.withSignerSelectionLock(async () => { + const previousSelection = this.signerSelectionQueue; + let releaseSelection!: () => void; + this.signerSelectionQueue = new Promise((resolve) => { releaseSelection = resolve; }); + await previousSelection; + try { if (!this.contracts.contextGraphs) { return this.nextSigner(); } @@ -412,7 +404,9 @@ export class EVMChainAdapter implements ChainAdapter { `No authorized publisher wallet found in signer pool for context graph ${contextGraphId.toString()}. ` + 'Ensure at least one configured wallet is permitted by on-chain publish authority.', ); - }); + } finally { + releaseSelection(); + } } /** From 4c41f8b14e8d03fb7d2aabb4a3d3d852b6276967 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:23:37 +0200 Subject: [PATCH 29/43] fix(publisher): preserve tentative non-v10 publishes --- packages/chain/src/chain-adapter.ts | 37 +++++++++++++++---- packages/publisher/src/dkg-publisher.ts | 10 ++++- .../test/publisher-no-random-wallet.test.ts | 12 ++++-- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 5d85f529a..7dd9777e9 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -55,6 +55,7 @@ export interface UpdateKAParams { batchId: bigint; newMerkleRoot: Uint8Array; newPublicByteSize: bigint; + /** Optional signer hint; adapters may resolve the original publisher on-chain instead. */ publisherAddress?: string; } @@ -68,7 +69,15 @@ export interface TxResult { hash: string; blockNumber: number; success: boolean; - /** Effective publisher/signing address used by update-style txs when known. */ + /** + * Effective publisher/signing address used by update-style txs. + * + * Required for successful update results that callers will persist as + * confirmed metadata, unless the adapter also exposes + * `getLatestMerkleRootPublisher(kcId)` so callers can query the same + * chain-truth address after the receipt. Publish-style result shapes + * use `OnChainPublishResult.publisherAddress` instead. + */ publisherAddress?: string; /** Set by createContextGraph when V9 registry is used (on-chain contextGraphId as hex). */ contextGraphId?: string; @@ -458,7 +467,11 @@ export interface ChainAdapter { */ getRequiredPublishTokenAmount?(publicByteSize: bigint, epochs: number): Promise; - // V9 knowledge updates + /** + * V9 knowledge updates. Successful update txs must either return + * `TxResult.publisherAddress` or support `getLatestMerkleRootPublisher` + * so callers can avoid inventing confirmed publisher attribution. + */ updateKnowledgeAssets(params: UpdateKAParams): Promise; /** @@ -540,9 +553,12 @@ export interface ChainAdapter { signMessage?(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }>; /** - * Return the adapter signer that would be used for a publish to the given - * context graph without advancing any round-robin cursor. Used by publishers - * that need the off-chain signature address to match the eventual tx signer. + * Reserve the adapter signer that should be used for a publish to the given + * context graph and return its address. Signer-pool implementations should + * advance their cursor atomically here so concurrent publishers do not bind + * multiple off-chain signatures to the same tx signer by accident. Used by + * publishers that need the off-chain signature address to match the eventual + * tx signer. */ getAuthorizedPublisherAddress?(contextGraphId: bigint): Promise; @@ -606,7 +622,12 @@ export interface ChainAdapter { /** @deprecated Use signACKDigest instead. Will be removed in V10.1. */ getACKSignerKey?(): string | undefined; - /** V10 update (works with KnowledgeCollectionStorage). */ + /** + * V10 update (works with KnowledgeCollectionStorage). Successful update txs + * must either return `TxResult.publisherAddress` or support + * `getLatestMerkleRootPublisher` so callers can persist confirmed metadata + * with real chain attribution. + */ updateKnowledgeCollectionV10?(params: V10UpdateKCParams): Promise; /** @@ -732,7 +753,9 @@ export interface ChainAdapter { * Address that signed the latest merkle root for `kcId` (the EOA that * called `KnowledgeAssetsV10.publishDirect` / update). Mostly observability * — the prover does not gate on this — but useful for trace logs and for - * future sharding / authorship-based reward heuristics. + * future sharding / authorship-based reward heuristics. Publishers also use + * this as the compatibility path for update adapters whose successful + * `TxResult` cannot directly include `publisherAddress`. */ getLatestMerkleRootPublisher?(kcId: bigint): Promise; diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 80a346a6a..03284bbfc 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1238,7 +1238,13 @@ export class DKGPublisher implements Publisher { const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; - const canAttemptOnChainPublish = willAttemptOnChainPublish && publisherSigner !== undefined; + const chainAdvertisesV10Publish = this.chain.chainId !== 'none' && + typeof this.chain.isV10Ready === 'function' && + this.chain.isV10Ready() && + typeof this.chain.createKnowledgeAssetsV10 === 'function'; + const canAttemptOnChainPublish = willAttemptOnChainPublish && + chainAdvertisesV10Publish && + publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -1253,7 +1259,7 @@ export class DKGPublisher implements Publisher { throw new Error('Publish rejected: "allowedPeers" is only valid when accessPolicy is "allowList"'); } - if (willAttemptOnChainPublish && !publisherSigner) { + if (willAttemptOnChainPublish && chainAdvertisesV10Publish && !publisherSigner) { throw new PublisherWalletRequiredError('publish'); } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 8faa427dd..da884a865 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -355,7 +355,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); }); - it('rejects chain-backed on-chain publish without a publisher signer before local storage', async () => { + it('keeps non-V10 chain-backed publishes tentative without a publisher signer', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); const publisher = new DKGPublisher({ @@ -366,7 +366,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => publisherNodeIdentityId: 1n, }); - await expect(publisher.publish({ + const result = await publisher.publish({ contextGraphId: '1', quads: [{ subject: 'urn:test:evm-no-signer', @@ -374,7 +374,11 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => object: '"EvmNoSigner"', graph: 'did:dkg:context-graph:1', }], - })).rejects.toThrow(/publisherPrivateKey/); + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); const stored = await store.query(` SELECT ?p ?o WHERE { @@ -384,7 +388,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => } `); expect(stored.type).toBe('bindings'); - expect(stored.bindings).toHaveLength(0); + expect(stored.bindings.length).toBeGreaterThan(0); }); it('rejects unrecoverable mock signMessage adapters before local storage', async () => { From 30f16eddaad83cea31c98d06deedb094f89911de Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:35:25 +0200 Subject: [PATCH 30/43] fix(publisher): precompute tokens for lazy v10 adapters --- packages/publisher/src/dkg-publisher.ts | 3 -- .../test/publisher-no-random-wallet.test.ts | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 03284bbfc..d94810fb1 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1239,11 +1239,8 @@ export class DKGPublisher implements Publisher { const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; const chainAdvertisesV10Publish = this.chain.chainId !== 'none' && - typeof this.chain.isV10Ready === 'function' && - this.chain.isV10Ready() && typeof this.chain.createKnowledgeAssetsV10 === 'function'; const canAttemptOnChainPublish = willAttemptOnChainPublish && - chainAdvertisesV10Publish && publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index da884a865..d66d7dfb7 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -109,6 +109,29 @@ class AsyncAddressSigningChain implements ChainAdapter { } } +class LazyReadySigningChain extends AsyncAddressSigningChain { + private ready = false; + capturedTokenAmount?: bigint; + + override isV10Ready(): boolean { + return this.ready; + } + + override async getEvmChainId(): Promise { + this.ready = true; + return super.getEvmChainId(); + } + + async getRequiredPublishTokenAmount(): Promise { + return 123n; + } + + override async createKnowledgeAssetsV10(params: V10PublishDirectParams): Promise { + this.capturedTokenAmount = params.tokenAmount; + return super.createKnowledgeAssetsV10(params); + } +} + class RejectingAdapterSignerChain extends AsyncAddressSigningChain { async signMessageAs(): Promise<{ r: Uint8Array; vs: Uint8Array }> { throw new Error('remote signer unavailable'); @@ -545,6 +568,32 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('precomputes token amount for adapters that become V10-ready during initialization', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new LazyReadySigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:lazy-v10-ready', + predicate: 'http://schema.org/name', + object: '"LazyV10Ready"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedTokenAmount).toBe(123n); + }); + it('continues tentatively when adapter signer fails during self-ACK', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); From f91048ebc43ff12a7239a5e1d4b703510bf0da9f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:42:44 +0200 Subject: [PATCH 31/43] fix(audit): close createRandom parser bypasses --- scripts/audit-create-random.mjs | 85 ++++++++++++++++++++++++---- scripts/audit-create-random.test.mjs | 54 ++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index f919946e4..a72158e6d 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -198,15 +198,26 @@ export function stripCommentsPreservingPositions(text) { const stack = [{ kind: 'normal' }]; const top = () => stack[stack.length - 1]; const blank = (c) => (c === '\n' ? '\n' : ' '); - const previousSignificantChar = () => { + const previousSignificantToken = () => { for (let j = out.length - 1; j >= 0; j -= 1) { - if (!/\s/.test(out[j])) return out[j]; + if (/\s/.test(out[j])) continue; + if (/[A-Za-z_$]/.test(out[j])) { + let start = j; + while (start > 0 && /[\w$]/.test(out[start - 1])) start -= 1; + return { kind: 'word', value: out.slice(start, j + 1) }; + } + return { kind: 'char', value: out[j] }; } - return ''; + return { kind: 'start', value: '' }; }; const canStartRegexLiteral = () => { - const prev = previousSignificantChar(); - return prev === '' || '([{=,:;!&|?+-*~^<>'.includes(prev); + const prev = previousSignificantToken(); + if (prev.kind === 'start') return true; + if (prev.kind === 'char') return '([{=,:;!&|?+-*~^<>%'.includes(prev.value); + return new Set([ + 'return', 'throw', 'case', 'delete', 'void', 'typeof', 'yield', + 'await', 'new', 'else', 'do', 'in', 'of', 'instanceof', + ]).has(prev.value); }; while (i < len) { @@ -360,6 +371,19 @@ function skipWhitespace(text, index) { return i; } +function skipToQuotedProperty(originalText, stripped, index) { + let i = index; + while (i < originalText.length) { + if (originalText[i] === '"' || originalText[i] === "'") return i; + if (/\s/.test(originalText[i]) || /\s/.test(stripped[i] ?? '')) { + i += 1; + continue; + } + return i; + } + return i; +} + function readQuotedProperty(originalText, index) { const quote = originalText[index]; if (quote !== '"' && quote !== "'") return null; @@ -384,9 +408,9 @@ function matchesCreateRandomCall(originalText, stripped, indexAfterAlias) { if (stripped.startsWith('?.[', i)) { i += 3; - const property = readQuotedProperty(originalText, skipWhitespace(originalText, i)); + const property = readQuotedProperty(originalText, skipToQuotedProperty(originalText, stripped, i)); if (!property || property.value !== 'createRandom') return false; - i = skipWhitespace(originalText, property.end); + i = skipWhitespace(stripped, property.end); if (stripped[i] !== ']') return false; i = skipWhitespace(stripped, i + 1); if (stripped.startsWith('?.', i)) i = skipWhitespace(stripped, i + 2); @@ -395,9 +419,9 @@ function matchesCreateRandomCall(originalText, stripped, indexAfterAlias) { if (stripped[i] === '[') { i += 1; - const property = readQuotedProperty(originalText, skipWhitespace(originalText, i)); + const property = readQuotedProperty(originalText, skipToQuotedProperty(originalText, stripped, i)); if (!property || property.value !== 'createRandom') return false; - i = skipWhitespace(originalText, property.end); + i = skipWhitespace(stripped, property.end); if (stripped[i] !== ']') return false; i = skipWhitespace(stripped, i + 1); if (stripped.startsWith('?.', i)) i = skipWhitespace(stripped, i + 2); @@ -434,6 +458,34 @@ function hitFromIndex(originalText, stripped, index, identifier) { function collectWalletAliases(stripped) { const aliases = new Set(['Wallet']); + const namespaceAliases = new Set(['ethers']); + + // Namespace imports, including aliases: import * as E from ... + // As with named imports below, the module specifier string has been + // blanked by the lexer, so this intentionally treats namespace imports + // conservatively rather than allowing `ethers` aliases to bypass the gate. + const namespaceImportPattern = new RegExp(String.raw`\bimport\s+\*\s+as\s+(${IDENT})\s+from\b`, 'g'); + for (const m of stripped.matchAll(namespaceImportPattern)) { + namespaceAliases.add(m[1]); + } + + let namespaceChanged = true; + while (namespaceChanged) { + namespaceChanged = false; + const namespacePattern = [...namespaceAliases].map(escapeRegExp).join('|'); + const namespaceAliasPattern = new RegExp( + String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*(?:${namespacePattern})\b`, + 'g', + ); + for (const m of stripped.matchAll(namespaceAliasPattern)) { + if (!namespaceAliases.has(m[1])) { + namespaceAliases.add(m[1]); + namespaceChanged = true; + } + } + } + + const namespacePattern = [...namespaceAliases].map(escapeRegExp).join('|'); // Named imports, including aliases: import { Wallet as EthersWallet } from ... // String contents are blanked by the lexer, so this intentionally keys on the @@ -452,7 +504,7 @@ function collectWalletAliases(stripped) { // Destructuring aliases from ethers: const { Wallet: W } = ethers; const destructurePattern = new RegExp( - String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*ethers\b`, + String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:${namespacePattern})\b`, 'g', ); for (const m of stripped.matchAll(destructurePattern)) { @@ -467,13 +519,14 @@ function collectWalletAliases(stripped) { // Follow simple assignment aliases transitively: // const W = Wallet; // const W = ethers.Wallet; + // const W = E.Wallet; (where E is an ethers namespace import) // const W2 = W; let changed = true; while (changed) { changed = false; for (const alias of [...aliases]) { const aliasPattern = new RegExp( - String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*(?:ethers\s*\.\s*)?${escapeRegExp(alias)}\b`, + String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*${escapeRegExp(alias)}\b`, 'g', ); for (const m of stripped.matchAll(aliasPattern)) { @@ -483,6 +536,16 @@ function collectWalletAliases(stripped) { } } } + const namespaceWalletPattern = new RegExp( + String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*(?:${namespacePattern})\s*\.\s*Wallet\b`, + 'g', + ); + for (const m of stripped.matchAll(namespaceWalletPattern)) { + if (!aliases.has(m[1])) { + aliases.add(m[1]); + changed = true; + } + } } return aliases; diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index 09655ce70..a0a3cd03b 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -124,6 +124,20 @@ describe('stripCommentsPreservingPositions', () => { assert.equal(out.length, text.length); }); + it('REGRESSION: // inside a regex literal after return does NOT start a line comment', () => { + const text = 'function f() { return /\\/\\//; Wallet.createRandom(); }'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + assert.equal(out.length, text.length); + }); + + it('REGRESSION: // inside a regex literal after throw does NOT start a line comment', () => { + const text = 'function f() { throw /\\/\\//; Wallet.createRandom(); }'; + const out = stripCommentsPreservingPositions(text); + assert.match(out, /Wallet\.createRandom\(\);/); + assert.equal(out.length, text.length); + }); + it('REGRESSION: /* inside a regex literal does NOT start a block comment', () => { const text = 'const r = /\\/\\*/; Wallet.createRandom();'; const out = stripCommentsPreservingPositions(text); @@ -207,11 +221,45 @@ describe('findHits', () => { assert.equal(hits.length, 1); }); + it('REGRESSION (PR #371 round 7): finds bracket-property calls with comments before the key', () => { + const hits = findHits("const w = Wallet[/* gap */'createRandom']();"); + assert.equal(hits.length, 1); + }); + it('REGRESSION (PR #371 round 5): finds optional bracket-property calls', () => { const hits = findHits("const w = Wallet?.['createRandom']?.();"); assert.equal(hits.length, 1); }); + it('REGRESSION (PR #371 round 7): finds optional bracket-property calls with comments before the key', () => { + const hits = findHits("const w = Wallet?.[/* gap */'createRandom']?.();"); + assert.equal(hits.length, 1); + }); + + it('REGRESSION (PR #371 round 7): finds destructured Wallet aliases from ethers namespace import aliases', () => { + const text = [ + 'import * as E from "ethers";', + 'const { Wallet: W } = E;', + 'const w = W.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 3); + assert.equal(hits[0].identifier, 'W'); + }); + + it('REGRESSION (PR #371 round 7): finds Wallet aliases assigned from ethers namespace import aliases', () => { + const text = [ + 'import * as E from "ethers";', + 'const W = E.Wallet;', + 'const w = W.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 3); + assert.equal(hits[0].identifier, 'W'); + }); + it('does NOT report calls inside string literals', () => { const hits = findHits('const s = "Wallet.createRandom()";'); assert.equal(hits.length, 0); @@ -258,4 +306,10 @@ describe('findHits', () => { assert.equal(hits.length, 1); assert.equal(hits[0].line, 1); }); + + it('REGRESSION (PR #371 round 7): finds calls after a keyword-prefixed regex containing //', () => { + const hits = findHits('function f() { return /\\/\\//; Wallet.createRandom(); }'); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 1); + }); }); From 2b9dc8cee610febd674a26f608ac665845105a18 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 05:54:37 +0200 Subject: [PATCH 32/43] fix(publisher): preserve explicit signer fallbacks --- packages/agent/src/dkg-agent.ts | 9 ++++-- packages/agent/test/v10-ack-provider.test.ts | 2 ++ packages/chain/src/mock-adapter.ts | 16 +++++++++- .../chain/test/mock-adapter-parity.test.ts | 29 +++++++++++++++++++ packages/publisher/src/dkg-publisher.ts | 2 +- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 230eadec9..4e9c257db 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -494,9 +494,12 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { } function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { - if (typeof chain.signMessageAs === 'function') return true; - if (typeof (chain as unknown as { getOperationalPrivateKey?: unknown }).getOperationalPrivateKey === 'function') return true; - return false; + const hasAddressProbe = typeof chain.getAuthorizedPublisherAddress === 'function' || + typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' || + typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function' || + Boolean(normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress)); + + return hasAddressProbe && typeof chain.signMessageAs === 'function'; } function privateKeyAddress(privateKey: string | undefined): string | undefined { diff --git a/packages/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index 1b74b75bb..77cc3ba85 100644 --- a/packages/agent/test/v10-ack-provider.test.ts +++ b/packages/agent/test/v10-ack-provider.test.ts @@ -46,6 +46,8 @@ function delayedAdapterPublisherAddress(chain: ChainAdapter, address: string): { chain: new Proxy(chain, { get(target, prop, receiver) { if (prop === 'getOperationalPrivateKey') return undefined; + if (prop === 'getAuthorizedPublisherAddress') return undefined; + if (prop === 'getSignerAddress') return undefined; if (prop === 'getSignerAddresses') { return () => { if (!unlocked) throw new Error('signer address unavailable during startup'); diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index e618c700d..3fff91c04 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -69,6 +69,8 @@ export class MockChainAdapter implements ChainAdapter { private events: ChainEvent[] = []; /** Reserved UAL ranges per publisher address for verifyPublisherOwnsRange */ private reservedRangesByPublisher = new Map>(); + /** Publisher addresses this mock is explicitly allowed to attribute V10 publishes to. */ + private allowedPublisherAddresses: Set; /** Configurable minimum receiver signatures. When > 0, publishKnowledgeAssets will check the count. Default: 1. */ minimumRequiredSignatures = 1; @@ -76,6 +78,7 @@ export class MockChainAdapter implements ChainAdapter { constructor(chainId = 'mock:31337', signerAddress = MOCK_DEFAULT_SIGNER) { this.chainId = chainId; this.signerAddress = signerAddress; + this.allowedPublisherAddresses = new Set([ethers.getAddress(signerAddress).toLowerCase()]); } async getIdentityId(): Promise { @@ -108,6 +111,9 @@ export class MockChainAdapter implements ChainAdapter { */ seedIdentity(address: string, identityId: bigint): void { this.identities.set(address, identityId); + if (ethers.isAddress(address)) { + this.allowedPublisherAddresses.add(ethers.getAddress(address).toLowerCase()); + } if (identityId >= this.nextIdentityId) { this.nextIdentityId = identityId + 1n; } @@ -923,8 +929,16 @@ export class MockChainAdapter implements ChainAdapter { ); } + const publisherAddress = params.publisherAddress + ? ethers.getAddress(params.publisherAddress) + : ethers.getAddress(this.signerAddress); + if (!this.allowedPublisherAddresses.has(publisherAddress.toLowerCase())) { + throw new Error( + `Mock publisherAddress ${publisherAddress} is not registered with this adapter. ` + + 'Seed the address first to model explicit mock support for address-specific publishing.', + ); + } const kcId = this.nextBatchId++; - const publisherAddress = params.publisherAddress ?? this.signerAddress; this.collections.set(kcId, { merkleRoot: params.merkleRoot, kaCount: params.knowledgeAssetsAmount, diff --git a/packages/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index ed73d64a8..679894de2 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -284,6 +284,35 @@ describe('MockChainAdapter API parity with EVMChainAdapter [CH-8]', () => { publishPolicy: 0, })).resolves.toMatchObject({ contextGraphId: 1n }); }); + + it('requires explicit mock support for address-specific V10 publishing', async () => { + const mock = new MockChainAdapter('mock:31337', '0x1111111111111111111111111111111111111111'); + mock.minimumRequiredSignatures = 0; + const otherPublisher = '0x2222222222222222222222222222222222222222'; + const params = { + publishOperationId: 'mock-v10-publisher-address', + contextGraphId: 1n, + publisherAddress: otherPublisher, + merkleRoot: new Uint8Array(32), + knowledgeAssetsAmount: 1, + byteSize: 1n, + epochs: 1, + tokenAmount: 1n, + isImmutable: false, + merkleLeafCount: 1, + paymaster: '0x0000000000000000000000000000000000000000', + publisherNodeIdentityId: 1n, + publisherSignature: { r: new Uint8Array(32), vs: new Uint8Array(32) }, + ackSignatures: [], + }; + + await expect(mock.createKnowledgeAssetsV10(params)).rejects.toThrow(/not registered/); + + mock.seedIdentity(otherPublisher, 7n); + await expect(mock.createKnowledgeAssetsV10(params)).resolves.toMatchObject({ + publisherAddress: otherPublisher, + }); + }); }); describe('NoChainAdapter completeness [CH-9]', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index d94810fb1..22bb6caac 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -272,7 +272,7 @@ export class DKGPublisher implements Publisher { private readonly sharedMemoryOwnedEntities: Map>; readonly knownBatchContextGraphs: Map; private publisherNodeIdentityId: bigint; - private publisherAddress?: string; + private readonly publisherAddress?: string; private readonly publisherAddressResolver?: (contextGraphId?: bigint) => Promise; private readonly publisherWallet?: ethers.Wallet; /** Additional wallets that can provide receiver signatures. */ From 324131d4ec7200fd44e519ce4e06aa47d368c2b7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:03:34 +0200 Subject: [PATCH 33/43] fix(publisher): resolve adapter attribution fallbacks --- packages/agent/src/dkg-agent.ts | 10 +- packages/agent/test/agent.test.ts | 8 +- packages/publisher/src/dkg-publisher.ts | 94 ++++++++++++------- .../test/publisher-no-random-wallet.test.ts | 29 +++++- 4 files changed, 102 insertions(+), 39 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 4e9c257db..5139c7b83 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -4498,7 +4498,7 @@ export class DKGAgent { ); } const publishAuthority = publishPolicy === EVM_PUBLISH_CURATED - ? this.getChainPublishAuthorityAddress() + ? await this.getChainPublishAuthorityAddress() : undefined; if ( publishPolicy === EVM_PUBLISH_CURATED @@ -7225,12 +7225,14 @@ export class DKGAgent { && ownerAddress.toLowerCase() === this.defaultAgentAddress.toLowerCase(); } - private getChainPublishAuthorityAddress(): string | undefined { + private async getChainPublishAuthorityAddress(): Promise { const chainWithSigner = this.chain as unknown as { - getSignerAddress?: () => string; + getSignerAddress?: () => unknown; signerAddress?: string; }; - const rawAddress = chainWithSigner.getSignerAddress?.() ?? chainWithSigner.signerAddress; + const rawAddress = chainWithSigner.getSignerAddress + ? await Promise.resolve(chainWithSigner.getSignerAddress()) + : chainWithSigner.signerAddress; if (rawAddress && ethers.isAddress(rawAddress)) { return ethers.getAddress(rawAddress); } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 68701fe3c..2dd4a7346 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -53,6 +53,12 @@ class CapturingContextGraphChainAdapter extends MockChainAdapter { } } +class AsyncSignerAddressContextGraphChainAdapter extends CapturingContextGraphChainAdapter { + async getSignerAddress(): Promise { + return this.signerAddress; + } +} + class NonRegisteringACKChainAdapter extends MockChainAdapter { async ensureOperationalWalletsRegistered(options?: { identityId?: bigint; @@ -1902,7 +1908,7 @@ decisions: [] }); it('maps local access policy to EVM publish policy and forwards participant agents on registration', async () => { - const chain = new CapturingContextGraphChainAdapter(); + const chain = new AsyncSignerAddressContextGraphChainAdapter(); const agent = await DKGAgent.create({ name: 'RegistrationPolicyBot', store: new OxigraphStore(), diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 22bb6caac..abb6289e4 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -275,6 +275,8 @@ export class DKGPublisher implements Publisher { private readonly publisherAddress?: string; private readonly publisherAddressResolver?: (contextGraphId?: bigint) => Promise; private readonly publisherWallet?: ethers.Wallet; + private adapterSignMessagePublisherAddress?: string; + private readonly adapterSignMessageProbeCache = new Map(); /** Additional wallets that can provide receiver signatures. */ private readonly additionalSignerWallets: ethers.Wallet[] = []; private readonly log = new Logger('DKGPublisher'); @@ -387,12 +389,18 @@ export class DKGPublisher implements Publisher { const operationalWallet = this.getAdapterOperationalWallet(); if (operationalWallet) return operationalWallet.address; + if (this.adapterSignMessagePublisherAddress) return this.adapterSignMessagePublisherAddress; if (this.chain.chainId === 'none' || typeof this.chain.signMessage !== 'function') return undefined; try { const challenge = ethers.getBytes(ethers.id('dkg-publisher:publisher-address-probe')); const compact = await this.chain.signMessage(challenge); - return coercePublisherAddress(recoverCompactMessageSigner(challenge, compact)); + const address = coercePublisherAddress(recoverCompactMessageSigner(challenge, compact)); + if (address) { + this.adapterSignMessagePublisherAddress = address; + this.adapterSignMessageProbeCache.set(address.toLowerCase(), true); + } + return address; } catch { return undefined; } @@ -421,6 +429,27 @@ export class DKGPublisher implements Publisher { return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address; } + private async adapterSignMessageMatchesAddress(expectedAddress: string): Promise { + if (typeof this.chain.signMessage !== 'function') return false; + + const cacheKey = expectedAddress.toLowerCase(); + const cached = this.adapterSignMessageProbeCache.get(cacheKey); + if (cached !== undefined) return cached; + + const challenge = ethers.getBytes(ethers.id(`dkg-publisher:chain-signer-probe:${cacheKey}`)); + try { + const compact = await this.chain.signMessage(challenge); + const recovered = recoverCompactMessageSigner(challenge, compact); + const matches = recovered.toLowerCase() === cacheKey; + this.adapterSignMessageProbeCache.set(cacheKey, matches); + if (matches) this.adapterSignMessagePublisherAddress = expectedAddress; + return matches; + } catch { + this.adapterSignMessageProbeCache.set(cacheKey, false); + return false; + } + } + private async getPublisherSigner(address = this.publisherAddress): Promise { if (this.publisherWallet && this.publisherAddress) { const wallet = this.publisherWallet; @@ -456,37 +485,27 @@ export class DKGPublisher implements Publisher { if (address && typeof this.chain.signMessage === 'function') { const expectedAddress = address; - const challenge = ethers.getBytes(ethers.id(`dkg-publisher:chain-signer-probe:${expectedAddress.toLowerCase()}`)); - let signMessageMatches = false; - try { - const compact = await this.chain.signMessage(challenge); - const recovered = recoverCompactMessageSigner(challenge, compact); - signMessageMatches = recovered.toLowerCase() === expectedAddress.toLowerCase(); - } catch { - signMessageMatches = false; - } - - if (signMessageMatches) { - return { - address: expectedAddress, - source: 'chainAdapter', - signMessage: async (message: Uint8Array) => { - const compact = await this.chain.signMessage!(message); - const signature = ethers.Signature.from({ - r: ethers.hexlify(compact.r), - yParityAndS: ethers.hexlify(compact.vs), - }).serialized; - const recovered = ethers.verifyMessage(message, signature); - if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { - throw new Error( - `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + - `(${recovered})`, - ); - } - return signature; - }, - }; - } + if (!(await this.adapterSignMessageMatchesAddress(expectedAddress))) return undefined; + return { + address: expectedAddress, + source: 'chainAdapter', + signMessage: async (message: Uint8Array) => { + const compact = await this.chain.signMessage!(message); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + const recovered = ethers.verifyMessage(message, signature); + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + this.adapterSignMessageProbeCache.set(expectedAddress.toLowerCase(), false); + throw new Error( + `publisherAddress (${expectedAddress}) does not match ChainAdapter.signMessage signer ` + + `(${recovered})`, + ); + } + return signature; + }, + }; } const operationalWallet = this.getAdapterOperationalWallet(); @@ -2077,8 +2096,17 @@ export class DKGPublisher implements Publisher { } if (!txResult.success) { - const failedPublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? + let failedPublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? publisherAddress; + if (!failedPublisherAddress && typeof this.chain.getLatestMerkleRootPublisher === 'function') { + try { + failedPublisherAddress = coercePublisherAddress( + await this.chain.getLatestMerkleRootPublisher(kcId), + ); + } catch { + // Fall through to the clear fail-loud path below. + } + } if (!failedPublisherAddress) throw new PublisherWalletRequiredError('update'); onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index d66d7dfb7..8f359daf9 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -191,12 +191,13 @@ class AdapterManagedUpdateChain implements ChainAdapter { constructor( private readonly publisherAddress?: string, private readonly latestPublisherAddress?: string, + private readonly success = true, ) {} async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { this.capturedPublisherAddress = params.publisherAddress; return { - success: true, + success: this.success, hash: `0x${'12'.repeat(32)}`, blockNumber: 1, ...(this.publisherAddress ? { publisherAddress: this.publisherAddress } : {}), @@ -752,6 +753,32 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('resolves failed adapter-managed update attribution from chain state', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterManagedUpdateChain(undefined, wallet.address, false); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await publisher.update(12n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-failed-update-chain-attribution', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('failed'); + expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); + }); + it('rejects adapter-managed updates when publisher attribution is unavailable', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); From 2cb78335e97cd4c6f21078524be5975e38858c20 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:17:58 +0200 Subject: [PATCH 34/43] fix(publisher): gate V10 fallback on readiness --- packages/publisher/src/dkg-publisher.ts | 56 ++++++++++-- .../test/publisher-no-random-wallet.test.ts | 90 ++++++++++++++++++- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index abb6289e4..dfbd3a9b9 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -27,6 +27,7 @@ import { generateAssertionPublishedMetadata, generateAssertionDiscardedMetadata, toHex, + resolveUalByBatchId, updateMetaMerkleRoot, type KAMetadata, } from './metadata.js'; @@ -88,6 +89,13 @@ function coercePublisherAddress(value: unknown): string | undefined { return normalized === ethers.ZeroAddress ? undefined : normalized; } +function publisherAddressFromUal(ual: string | undefined): string | undefined { + const prefix = 'did:dkg:'; + if (!ual?.startsWith(prefix)) return undefined; + const segments = ual.slice(prefix.length).split('/'); + return coercePublisherAddress(segments[1]); +} + function recoverCompactMessageSigner( message: Uint8Array, signature: { r: Uint8Array; vs: Uint8Array }, @@ -429,6 +437,40 @@ export class DKGPublisher implements Publisher { return address === ethers.ZeroAddress ? '0x0000000000000000000000000000000000000001' : address; } + private isChainV10Ready(): boolean { + return this.chain.chainId !== 'none' && + typeof this.chain.isV10Ready === 'function' && + this.chain.isV10Ready(); + } + + private async refreshChainV10Readiness(): Promise { + if (this.isChainV10Ready()) return true; + if (this.chain.chainId === 'none') return false; + try { + const chainIdGetter = (this.chain as unknown as { getEvmChainId?: () => Promise }).getEvmChainId; + const kavAddressGetter = (this.chain as unknown as { getKnowledgeAssetsV10Address?: () => Promise }) + .getKnowledgeAssetsV10Address; + if (typeof chainIdGetter === 'function') await chainIdGetter.call(this.chain); + if (typeof kavAddressGetter === 'function') await kavAddressGetter.call(this.chain); + } catch { + // V9-only or incompletely configured adapters stay off the V10 path. + } + return this.isChainV10Ready(); + } + + private async resolveKnownBatchPublisherAddress(contextGraphId: string, kcId: bigint): Promise { + try { + const ual = await resolveUalByBatchId( + this.store, + this.graphManager.metaGraphUri(contextGraphId), + kcId, + ); + return publisherAddressFromUal(ual); + } catch { + return undefined; + } + } + private async adapterSignMessageMatchesAddress(expectedAddress: string): Promise { if (typeof this.chain.signMessage !== 'function') return false; @@ -1257,9 +1299,9 @@ export class DKGPublisher implements Publisher { const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; - const chainAdvertisesV10Publish = this.chain.chainId !== 'none' && - typeof this.chain.createKnowledgeAssetsV10 === 'function'; + const chainV10Ready = await this.refreshChainV10Readiness(); const canAttemptOnChainPublish = willAttemptOnChainPublish && + chainV10Ready && publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { @@ -1275,7 +1317,7 @@ export class DKGPublisher implements Publisher { throw new Error('Publish rejected: "allowedPeers" is only valid when accessPolicy is "allowList"'); } - if (willAttemptOnChainPublish && chainAdvertisesV10Publish && !publisherSigner) { + if (willAttemptOnChainPublish && chainV10Ready && !publisherSigner) { throw new PublisherWalletRequiredError('publish'); } @@ -1582,6 +1624,8 @@ export class DKGPublisher implements Publisher { this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); } else if (publisherContextGraphId === undefined) { this.log.warn(ctx, `No positive on-chain context graph id resolved from "${v10CgDomain}" — skipping on-chain publish`); + } else if (!chainV10Ready) { + this.log.warn(ctx, 'Chain adapter is not V10-ready — skipping on-chain publish'); } else { const tokenAmount = precomputedTokenAmount; usedV10Path = true; @@ -2107,6 +2151,7 @@ export class DKGPublisher implements Publisher { // Fall through to the clear fail-loud path below. } } + failedPublisherAddress ??= await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); if (!failedPublisherAddress) throw new PublisherWalletRequiredError('update'); onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); @@ -2132,13 +2177,14 @@ export class DKGPublisher implements Publisher { // inventing a publisher address that did not come from chain state. } } + effectivePublisherAddress ??= await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); if (!effectivePublisherAddress) { throw new Error( 'Chain adapter returned a successful update without publisherAddress, and ' + - 'getLatestMerkleRootPublisher() did not resolve a real publisher. Refusing to write ' + - 'confirmed metadata with synthetic publisher attribution.', + 'neither getLatestMerkleRootPublisher() nor local KC metadata resolved a real publisher. ' + + 'Refusing to write confirmed metadata with synthetic publisher attribution.', ); } diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 8f359daf9..3138aedbf 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -25,6 +25,7 @@ */ import { describe, it, expect } from 'vitest'; import { DKGPublisher } from '../src/dkg-publisher.js'; +import { generateConfirmedFullMetadata } from '../src/metadata.js'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; import { @@ -415,6 +416,39 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(stored.bindings.length).toBeGreaterThan(0); }); + it('keeps method-present but non-ready V10 adapters tentative without a publisher signer', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const chain = { + chainId: 'evm:31337', + isV10Ready: () => false, + createKnowledgeAssetsV10: async () => { + throw new Error('non-ready V10 adapter should not be called'); + }, + } as unknown as ChainAdapter; + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:evm-method-present-not-ready-no-signer', + predicate: 'http://schema.org/name', + object: '"EvmMethodPresentNotReadyNoSigner"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); + }); + it('rejects unrecoverable mock signMessage adapters before local storage', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); @@ -779,6 +813,60 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); }); + it('resolves adapter-managed update attribution from local confirmed metadata', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const store = new OxigraphStore(); + const chain = new AdapterManagedUpdateChain(); + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + }); + const ual = `did:dkg:mock:31337/${wallet.address}/13`; + await store.insert(generateConfirmedFullMetadata( + { + ual, + contextGraphId: '1', + merkleRoot: new Uint8Array(32), + kaCount: 1, + publisherPeerId: 'peer', + timestamp: new Date(0), + }, + [{ + rootEntity: 'urn:test:adapter-managed-update-local-attribution', + kcUal: ual, + tokenId: 1n, + publicTripleCount: 1, + privateTripleCount: 0, + }], + { + txHash: `0x${'34'.repeat(32)}`, + blockNumber: 1, + blockTimestamp: 1, + publisherAddress: wallet.address, + batchId: 13n, + chainId: 'mock:31337', + }, + )); + + const updated = await publisher.update(13n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-update-local-attribution', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('confirmed'); + expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); + expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + it('rejects adapter-managed updates when publisher attribution is unavailable', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); @@ -798,7 +886,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => object: '"After"', graph: 'did:dkg:context-graph:1', }], - })).rejects.toThrow(/successful update without publisherAddress.*synthetic publisher attribution/i); + })).rejects.toThrow(/without publisherAddress.*local KC metadata.*synthetic publisher attribution/i); expect(chain.capturedPublisherAddress).toBeUndefined(); From edb939220cf3f5e6fc58919949850c3ebf0d226f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:24:54 +0200 Subject: [PATCH 35/43] fix(audit): close createRandom alias gaps --- scripts/audit-create-random.mjs | 64 ++++++++++++++++++++++++---- scripts/audit-create-random.test.mjs | 45 +++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs index a72158e6d..c57ea3008 100755 --- a/scripts/audit-create-random.mjs +++ b/scripts/audit-create-random.mjs @@ -374,7 +374,7 @@ function skipWhitespace(text, index) { function skipToQuotedProperty(originalText, stripped, index) { let i = index; while (i < originalText.length) { - if (originalText[i] === '"' || originalText[i] === "'") return i; + if (originalText[i] === '"' || originalText[i] === "'" || originalText[i] === '`') return i; if (/\s/.test(originalText[i]) || /\s/.test(stripped[i] ?? '')) { i += 1; continue; @@ -386,7 +386,7 @@ function skipToQuotedProperty(originalText, stripped, index) { function readQuotedProperty(originalText, index) { const quote = originalText[index]; - if (quote !== '"' && quote !== "'") return null; + if (quote !== '"' && quote !== "'" && quote !== '`') return null; let value = ''; let i = index + 1; while (i < originalText.length) { @@ -396,6 +396,7 @@ function readQuotedProperty(originalText, index) { i += 2; continue; } + if (quote === '`' && c === '$' && originalText[i + 1] === '{') return null; if (c === quote) return { value, end: i + 1 }; value += c; i += 1; @@ -459,6 +460,7 @@ function hitFromIndex(originalText, stripped, index, identifier) { function collectWalletAliases(stripped) { const aliases = new Set(['Wallet']); const namespaceAliases = new Set(['ethers']); + const importPattern = new RegExp(String.raw`\bimport\s*\{([^}]*)\}\s*from\b`, 'g'); // Namespace imports, including aliases: import * as E from ... // As with named imports below, the module specifier string has been @@ -469,6 +471,42 @@ function collectWalletAliases(stripped) { namespaceAliases.add(m[1]); } + // Named namespace imports, including aliases: import { ethers as E } from ... + // The source string is blanked, so treat the binding shape conservatively. + for (const m of stripped.matchAll(importPattern)) { + for (const rawSpecifier of m[1].split(',')) { + const specifier = rawSpecifier.trim(); + const aliasMatch = specifier.match(new RegExp(String.raw`^ethers\s+as\s+(${IDENT})$`)); + if (aliasMatch) namespaceAliases.add(aliasMatch[1]); + if (specifier === 'ethers') namespaceAliases.add('ethers'); + } + } + + // CommonJS namespace aliases: const E = require('ethers') + // String contents are blanked, so any require namespace alias is treated as + // potentially ethers. This may false-positive, but avoids a CI gate bypass in + // JS/CJS files. + const requireNamespacePattern = new RegExp( + String.raw`\b(?:const|let|var)\s+(${IDENT})\s*=\s*require\s*\(`, + 'g', + ); + for (const m of stripped.matchAll(requireNamespacePattern)) { + namespaceAliases.add(m[1]); + } + + const requireDestructurePattern = new RegExp( + String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\s*\(`, + 'g', + ); + for (const m of stripped.matchAll(requireDestructurePattern)) { + for (const rawSpecifier of m[1].split(',')) { + const specifier = rawSpecifier.trim(); + const aliasMatch = specifier.match(new RegExp(String.raw`^ethers\s*:\s*(${IDENT})$`)); + if (aliasMatch) namespaceAliases.add(aliasMatch[1]); + if (specifier === 'ethers') namespaceAliases.add('ethers'); + } + } + let namespaceChanged = true; while (namespaceChanged) { namespaceChanged = false; @@ -492,7 +530,6 @@ function collectWalletAliases(stripped) { // imported binding shape rather than the module specifier. The audit is a // fail-safe for high-impact key loss, so a conservative false positive is // preferable to an alias bypass. - const importPattern = new RegExp(String.raw`\bimport\s*\{([^}]*)\}\s*from\b`, 'g'); for (const m of stripped.matchAll(importPattern)) { for (const rawSpecifier of m[1].split(',')) { const specifier = rawSpecifier.trim(); @@ -502,18 +539,27 @@ function collectWalletAliases(stripped) { } } + const addWalletDestructureAliases = (bindingList) => { + for (const rawSpecifier of bindingList.split(',')) { + const specifier = rawSpecifier.trim(); + const aliasMatch = specifier.match(new RegExp(String.raw`^Wallet\s*:\s*(${IDENT})$`)); + if (aliasMatch) aliases.add(aliasMatch[1]); + if (specifier === 'Wallet') aliases.add('Wallet'); + } + }; + // Destructuring aliases from ethers: const { Wallet: W } = ethers; const destructurePattern = new RegExp( String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:${namespacePattern})\b`, 'g', ); for (const m of stripped.matchAll(destructurePattern)) { - for (const rawSpecifier of m[1].split(',')) { - const specifier = rawSpecifier.trim(); - const aliasMatch = specifier.match(new RegExp(String.raw`^Wallet\s*:\s*(${IDENT})$`)); - if (aliasMatch) aliases.add(aliasMatch[1]); - if (specifier === 'Wallet') aliases.add('Wallet'); - } + addWalletDestructureAliases(m[1]); + } + + // Direct CommonJS destructuring: const { Wallet: W } = require('ethers'); + for (const m of stripped.matchAll(requireDestructurePattern)) { + addWalletDestructureAliases(m[1]); } // Follow simple assignment aliases transitively: diff --git a/scripts/audit-create-random.test.mjs b/scripts/audit-create-random.test.mjs index a0a3cd03b..13c771ec3 100644 --- a/scripts/audit-create-random.test.mjs +++ b/scripts/audit-create-random.test.mjs @@ -221,6 +221,11 @@ describe('findHits', () => { assert.equal(hits.length, 1); }); + it('REGRESSION (PR #371 round 10): finds template-literal bracket-property calls', () => { + const hits = findHits('const w = Wallet[`createRandom`]();'); + assert.equal(hits.length, 1); + }); + it('REGRESSION (PR #371 round 7): finds bracket-property calls with comments before the key', () => { const hits = findHits("const w = Wallet[/* gap */'createRandom']();"); assert.equal(hits.length, 1); @@ -231,6 +236,11 @@ describe('findHits', () => { assert.equal(hits.length, 1); }); + it('REGRESSION (PR #371 round 10): finds optional template-literal bracket-property calls', () => { + const hits = findHits('const w = Wallet?.[`createRandom`]?.();'); + assert.equal(hits.length, 1); + }); + it('REGRESSION (PR #371 round 7): finds optional bracket-property calls with comments before the key', () => { const hits = findHits("const w = Wallet?.[/* gap */'createRandom']?.();"); assert.equal(hits.length, 1); @@ -260,6 +270,41 @@ describe('findHits', () => { assert.equal(hits[0].identifier, 'W'); }); + it('REGRESSION (PR #371 round 10): finds Wallet aliases from named ethers namespace imports', () => { + const text = [ + 'import { ethers as E } from "ethers";', + 'const W = E.Wallet;', + 'const w = W.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 3); + assert.equal(hits[0].identifier, 'W'); + }); + + it('REGRESSION (PR #371 round 10): finds Wallet aliases from CommonJS ethers namespace requires', () => { + const text = [ + 'const E = require("ethers");', + 'const W = E.Wallet;', + 'const w = W.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 3); + assert.equal(hits[0].identifier, 'W'); + }); + + it('REGRESSION (PR #371 round 10): finds destructured Wallet aliases from CommonJS requires', () => { + const text = [ + 'const { Wallet: W } = require("ethers");', + 'const w = W.createRandom();', + ].join('\n'); + const hits = findHits(text); + assert.equal(hits.length, 1); + assert.equal(hits[0].line, 2); + assert.equal(hits[0].identifier, 'W'); + }); + it('does NOT report calls inside string literals', () => { const hits = findHits('const s = "Wallet.createRandom()";'); assert.equal(hits.length, 0); From 986d84bbb628b3645e9f4fa2331794bac050b784 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:32:08 +0200 Subject: [PATCH 36/43] fix(publisher): defer legacy update attribution failures --- packages/agent/src/dkg-agent.ts | 21 +++---- packages/agent/test/agent.test.ts | 35 +++++++++++- packages/publisher/src/dkg-publisher.ts | 25 +++++++-- .../test/publisher-no-random-wallet.test.ts | 55 +++++++++++++++++-- 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 5139c7b83..a0d53f67f 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -4498,7 +4498,7 @@ export class DKGAgent { ); } const publishAuthority = publishPolicy === EVM_PUBLISH_CURATED - ? await this.getChainPublishAuthorityAddress() + ? await this.getChainPublishAuthorityAddress(id) : undefined; if ( publishPolicy === EVM_PUBLISH_CURATED @@ -7225,18 +7225,15 @@ export class DKGAgent { && ownerAddress.toLowerCase() === this.defaultAgentAddress.toLowerCase(); } - private async getChainPublishAuthorityAddress(): Promise { - const chainWithSigner = this.chain as unknown as { - getSignerAddress?: () => unknown; - signerAddress?: string; - }; - const rawAddress = chainWithSigner.getSignerAddress - ? await Promise.resolve(chainWithSigner.getSignerAddress()) - : chainWithSigner.signerAddress; - if (rawAddress && ethers.isAddress(rawAddress)) { - return ethers.getAddress(rawAddress); + private async getChainPublishAuthorityAddress(contextGraphId?: string): Promise { + let publisherContextGraphId: bigint | undefined; + try { + const parsed = BigInt(contextGraphId ?? ''); + if (parsed > 0n) publisherContextGraphId = parsed; + } catch { + // Local descriptive CG ids cannot be used as adapter context hints. } - return undefined; + return inferAdapterPublisherAddress(this.chain, publisherContextGraphId); } /** diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 2dd4a7346..06cc523d8 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -59,6 +59,16 @@ class AsyncSignerAddressContextGraphChainAdapter extends CapturingContextGraphCh } } +class SignerListContextGraphChainAdapter extends CapturingContextGraphChainAdapter { + async getSignerAddress(): Promise { + throw new Error('signer address unavailable until publish'); + } + + async getSignerAddresses(): Promise { + return [this.signerAddress]; + } +} + class NonRegisteringACKChainAdapter extends MockChainAdapter { async ensureOperationalWalletsRegistered(options?: { identityId?: bigint; @@ -1991,7 +2001,30 @@ decisions: [] await agent.stop().catch(() => {}); }); - it('requires address-scoped curator authority for on-chain registration', async () => { + it('uses best-effort adapter publisher-address inference for curated CG registration', async () => { + const chain = new SignerListContextGraphChainAdapter(); + const agent = await DKGAgent.create({ + name: 'RegistrationSignerListBot', + store: new OxigraphStore(), + chainAdapter: chain, + nodeRole: 'core', + }); + await agent.start(); + + const ownerAgent = ethers.getAddress(chain.signerAddress); + await agent.createContextGraph({ + id: 'register-curated-signer-list-policy', + name: 'Curated Signer List Policy', + accessPolicy: 1, + callerAgentAddress: ownerAgent, + }); + await agent.registerContextGraph('register-curated-signer-list-policy', { callerAgentAddress: ownerAgent }); + + expect(chain.createOnChainContextGraphCalls[0]?.publishAuthority).toBe(ownerAgent); + await agent.stop().catch(() => {}); + }); + + it('requires address-scoped curator authority for on-chain registration', async () => { const store = new OxigraphStore(); const chain = new CapturingContextGraphChainAdapter(); const agent = await DKGAgent.create({ diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index dfbd3a9b9..e1c3008f9 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1295,11 +1295,11 @@ export class DKGPublisher implements Publisher { } catch { // Descriptive SWM graph names stay on the existing tentative/mock path. } + const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; + const chainV10Ready = await this.refreshChainV10Readiness(); const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); - const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; - const chainV10Ready = await this.refreshChainV10Readiness(); const canAttemptOnChainPublish = willAttemptOnChainPublish && chainV10Ready && publisherSigner !== undefined; @@ -2181,11 +2181,24 @@ export class DKGPublisher implements Publisher { onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); if (!effectivePublisherAddress) { - throw new Error( - 'Chain adapter returned a successful update without publisherAddress, and ' + - 'neither getLatestMerkleRootPublisher() nor local KC metadata resolved a real publisher. ' + - 'Refusing to write confirmed metadata with synthetic publisher attribution.', + const tentativePublisherAddress = this.localTentativePublisherAddress(); + this.log.warn( + ctx, + 'Chain adapter returned a successful update without publisherAddress, and neither ' + + 'getLatestMerkleRootPublisher() nor local KC metadata resolved a real publisher. ' + + 'Applying local data update as tentative instead of writing synthetic confirmed attribution.', ); + await storeUpdatedQuads(); + const result: PublishResult = { + kcId, + ual: `did:dkg:${this.chain.chainId}/${tentativePublisherAddress}/${kcId}`, + merkleRoot: kcMerkleRoot, + kaManifest: manifestEntries, + status: 'tentative', + publicQuads: allSkolemizedQuads, + }; + this.eventBus.emit(DKGEvent.KA_UPDATED, result); + return result; } await storeUpdatedQuads(); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 3138aedbf..1e34c9e13 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -133,6 +133,24 @@ class LazyReadySigningChain extends AsyncAddressSigningChain { } } +class InitGatedSigningChain extends AsyncAddressSigningChain { + private ready = false; + + override isV10Ready(): boolean { + return this.ready; + } + + override async getEvmChainId(): Promise { + this.ready = true; + return super.getEvmChainId(); + } + + override async getSignerAddress(): Promise { + if (!this.ready) throw new Error('signer unavailable before V10 init'); + return super.getSignerAddress(); + } +} + class RejectingAdapterSignerChain extends AsyncAddressSigningChain { async signMessageAs(): Promise<{ r: Uint8Array; vs: Uint8Array }> { throw new Error('remote signer unavailable'); @@ -629,6 +647,32 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(chain.capturedTokenAmount).toBe(123n); }); + it('initializes V10 readiness before resolving adapter-backed signer addresses', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new InitGatedSigningChain(wallet); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:init-before-signer-resolution', + predicate: 'http://schema.org/name', + object: '"InitBeforeSignerResolution"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + it('continues tentatively when adapter signer fails during self-ACK', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); @@ -867,7 +911,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); - it('rejects adapter-managed updates when publisher attribution is unavailable', async () => { + it('keeps adapter-managed updates tentative when publisher attribution is unavailable', async () => { const keypair = await generateEd25519Keypair(); const store = new OxigraphStore(); const chain = new AdapterManagedUpdateChain(); @@ -878,7 +922,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => keypair, }); - await expect(publisher.update(12n, { + const updated = await publisher.update(12n, { contextGraphId: '1', quads: [{ subject: 'urn:test:adapter-managed-update-without-address', @@ -886,9 +930,12 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => object: '"After"', graph: 'did:dkg:context-graph:1', }], - })).rejects.toThrow(/without publisherAddress.*local KC metadata.*synthetic publisher attribution/i); + }); expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('tentative'); + expect(updated.onChainResult).toBeUndefined(); + expect(updated.ual).toMatch(/^did:dkg:mock:31337\/0x[0-9a-fA-F]{40}\/12$/); const stored = await store.query(` SELECT ?p ?o WHERE { @@ -898,6 +945,6 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => } `); expect(stored.type).toBe('bindings'); - expect(stored.bindings).toHaveLength(0); + expect(stored.bindings.length).toBeGreaterThan(0); }); }); From 77f91d60446994679b36346424e57759390e7b2c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:43:15 +0200 Subject: [PATCH 37/43] fix(agent): prefer adapter single-signer publishers --- packages/agent/src/dkg-agent.ts | 4 +- packages/agent/test/agent.test.ts | 56 +++++++++++++++ packages/chain/src/mock-adapter.ts | 35 ++++++++-- .../chain/test/mock-adapter-parity.test.ts | 69 +++++++++++++++++++ 4 files changed, 158 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index a0d53f67f..8c5ef1e3c 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -498,8 +498,10 @@ function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' || typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function' || Boolean(normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress)); + const hasSigningProbe = typeof chain.signMessageAs === 'function' || + typeof chain.signMessage === 'function'; - return hasAddressProbe && typeof chain.signMessageAs === 'function'; + return hasAddressProbe && hasSigningProbe; } function privateKeyAddress(privateKey: string | undefined): string | undefined { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 06cc523d8..614dccd2a 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -262,6 +262,24 @@ class GenericSignMessageExternalOperationalKeyPublishChainAdapter extends Extern } } +class SingleSignerAdapterPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + constructor(private readonly adapterWallet: ethers.Wallet) { + super(adapterWallet.address); + } + + getSignerAddress(): string { + return this.adapterWallet.address; + } + + async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.adapterWallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1363,6 +1381,44 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('uses a single-signer adapter instead of chainConfig.operationalKeys fallback', async () => { + const adapterWallet = ethers.Wallet.createRandom(); + const staleChainConfigSigner = ethers.Wallet.createRandom(); + const chain = new SingleSignerAdapterPublishChainAdapter(adapterWallet); + + const agent = await DKGAgent.create({ + name: 'SingleSignerAdapterPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [staleChainConfigSigner.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-single-signer-adapter', + predicate: 'http://schema.org/name', + object: '"SingleSignerAdapter"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(adapterWallet.address.toLowerCase()); + expect(chain.capturedPublisherAddress?.toLowerCase()).not.toBe(staleChainConfigSigner.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(adapterWallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('keeps chainConfig.operationalKeys fallback when publisherAddress pins the same key', async () => { const wallet = ethers.Wallet.createRandom(); const chain = new ExternalOperationalKeyPublishChainAdapter(wallet.address); diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 3fff91c04..99b40c546 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -38,6 +38,12 @@ import { ethers } from 'ethers'; export const MOCK_DEFAULT_SIGNER = '0x' + '1'.repeat(40); +interface MockBatch { + merkleRoot: Uint8Array; + kaCount: number; + publisherAddress: string; +} + /** * In-memory mock chain adapter for off-chain development. * Implements both V9 (UAL-based) and V8 (legacy KC) interfaces. @@ -54,7 +60,7 @@ export class MockChainAdapter implements ChainAdapter { private identities = new Map(); private namespaceNextId = new Map(); private namespaceOwner = new Map(); - private batches = new Map(); + private batches = new Map(); private collections = new Map { @@ -313,6 +314,74 @@ describe('MockChainAdapter API parity with EVMChainAdapter [CH-8]', () => { publisherAddress: otherPublisher, }); }); + + it('preserves delegated V10 publisher attribution across mock updates', async () => { + const mock = new MockChainAdapter('mock:31337', '0x1111111111111111111111111111111111111111'); + mock.minimumRequiredSignatures = 0; + const delegatedPublisher = '0x2222222222222222222222222222222222222222'; + mock.seedIdentity(delegatedPublisher, 7n); + + const created = await mock.createKnowledgeAssetsV10({ + publishOperationId: 'mock-v10-delegated-update', + contextGraphId: 1n, + publisherAddress: delegatedPublisher, + merkleRoot: new Uint8Array(32), + knowledgeAssetsAmount: 1, + byteSize: 1n, + epochs: 1, + tokenAmount: 1n, + isImmutable: false, + merkleLeafCount: 1, + paymaster: ethers.ZeroAddress, + publisherNodeIdentityId: 1n, + publisherSignature: { r: new Uint8Array(32), vs: new Uint8Array(32) }, + ackSignatures: [], + }); + + const newMerkleRoot = ethers.getBytes(ethers.keccak256(ethers.toUtf8Bytes('mock-v10-update'))); + const update = await mock.updateKnowledgeCollectionV10({ + kcId: created.batchId, + newMerkleRoot, + newByteSize: 2n, + newMerkleLeafCount: 1, + }); + + expect(update.publisherAddress?.toLowerCase()).toBe(delegatedPublisher.toLowerCase()); + await expect(mock.getLatestMerkleRootPublisher(created.batchId)).resolves.toBe(delegatedPublisher); + await expect(mock.getLatestMerkleRoot(created.batchId)).resolves.toEqual(newMerkleRoot); + }); + + it('uses stored publisher attribution on the V9 mock update path', async () => { + const mock = new MockChainAdapter('mock:31337', '0x1111111111111111111111111111111111111111'); + const delegatedPublisher = '0x2222222222222222222222222222222222222222'; + mock.minimumRequiredSignatures = 0; + mock.seedIdentity(delegatedPublisher, 7n); + + const created = await mock.createKnowledgeAssetsV10({ + publishOperationId: 'mock-v9-fallback-delegated-update', + contextGraphId: 1n, + publisherAddress: delegatedPublisher, + merkleRoot: new Uint8Array(32), + knowledgeAssetsAmount: 1, + byteSize: 1n, + epochs: 1, + tokenAmount: 1n, + isImmutable: false, + merkleLeafCount: 1, + paymaster: ethers.ZeroAddress, + publisherNodeIdentityId: 1n, + publisherSignature: { r: new Uint8Array(32), vs: new Uint8Array(32) }, + ackSignatures: [], + }); + + const update = await mock.updateKnowledgeAssets({ + batchId: created.batchId, + newMerkleRoot: ethers.getBytes(ethers.keccak256(ethers.toUtf8Bytes('mock-v9-update'))), + newPublicByteSize: 2n, + }); + + expect(update.publisherAddress?.toLowerCase()).toBe(delegatedPublisher.toLowerCase()); + }); }); describe('NoChainAdapter completeness [CH-9]', () => { From 2751758c77d83a8c3eba3b1473a5f44645d035fd Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 06:50:55 +0200 Subject: [PATCH 38/43] fix(publisher): require chain update attribution --- packages/agent/src/dkg-agent.ts | 13 ++++++++ packages/agent/test/agent.test.ts | 32 +++++++++++++++++++ packages/publisher/src/dkg-publisher.ts | 12 +++---- .../test/publisher-no-random-wallet.test.ts | 18 ++++++++--- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 8c5ef1e3c..eeb293139 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -7228,6 +7228,19 @@ export class DKGAgent { } private async getChainPublishAuthorityAddress(contextGraphId?: string): Promise { + const configuredPublisherAddress = normalizeAdapterPublisherAddress(this.config.publisherAddress); + if (configuredPublisherAddress) return configuredPublisherAddress; + + const legacyAdapterOperationalKey = this.config.chainConfig?.operationalKeys?.[0]; + const legacyAdapterOperationalAddress = privateKeyAddress(legacyAdapterOperationalKey); + if ( + this.config.chainAdapter && + legacyAdapterOperationalAddress && + !adapterAdvertisesPublisherSigner(this.chain) + ) { + return legacyAdapterOperationalAddress; + } + let publisherContextGraphId: bigint | undefined; try { const parsed = BigInt(contextGraphId ?? ''); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 614dccd2a..479e8e755 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1381,6 +1381,38 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('uses chainConfig fallback authority when generic signMessage is not the publish signer', async () => { + const wallet = ethers.Wallet.createRandom(); + const unrelatedSigner = ethers.Wallet.createRandom(); + const chain = new GenericSignMessageExternalOperationalKeyPublishChainAdapter( + wallet.address, + unrelatedSigner, + ); + + const agent = await DKGAgent.create({ + name: 'GenericSignMessageRegistrationAuthority', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + const authority = await (agent as unknown as { + getChainPublishAuthorityAddress(contextGraphId?: string): Promise; + }).getChainPublishAuthorityAddress('42'); + + expect(authority?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(authority?.toLowerCase()).not.toBe(unrelatedSigner.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('uses a single-signer adapter instead of chainConfig.operationalKeys fallback', async () => { const adapterWallet = ethers.Wallet.createRandom(); const staleChainConfigSigner = ethers.Wallet.createRandom(); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index e1c3008f9..42eaa0f5c 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -2164,8 +2164,7 @@ export class DKGPublisher implements Publisher { publicQuads: allSkolemizedQuads, }; } - let effectivePublisherAddress = coercePublisherAddress(txResult.publisherAddress) ?? - publisherAddress; + let effectivePublisherAddress = coercePublisherAddress(txResult.publisherAddress); if (!effectivePublisherAddress && typeof this.chain.getLatestMerkleRootPublisher === 'function') { try { effectivePublisherAddress = coercePublisherAddress( @@ -2174,19 +2173,18 @@ export class DKGPublisher implements Publisher { } catch { // Some legacy adapters can submit updates but cannot report the // effective publisher. Refuse confirmed metadata below rather than - // inventing a publisher address that did not come from chain state. + // inventing a publisher address that did not come from chain state. } } - effectivePublisherAddress ??= await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); if (!effectivePublisherAddress) { - const tentativePublisherAddress = this.localTentativePublisherAddress(); + const tentativePublisherAddress = publisherAddress ?? this.localTentativePublisherAddress(); this.log.warn( ctx, 'Chain adapter returned a successful update without publisherAddress, and neither ' + - 'getLatestMerkleRootPublisher() nor local KC metadata resolved a real publisher. ' + - 'Applying local data update as tentative instead of writing synthetic confirmed attribution.', + 'getLatestMerkleRootPublisher() nor the tx result resolved a chain publisher. ' + + 'Applying local data update as tentative instead of confirming unproven attribution.', ); await storeUpdatedQuads(); const result: PublishResult = { diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 1e34c9e13..e85767c0b 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -857,7 +857,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); }); - it('resolves adapter-managed update attribution from local confirmed metadata', async () => { + it('does not confirm adapter-managed updates from local metadata alone', async () => { const keypair = await generateEd25519Keypair(); const wallet = new ethers.Wallet(TEST_KEY); const store = new OxigraphStore(); @@ -906,9 +906,19 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => }); expect(chain.capturedPublisherAddress).toBeUndefined(); - expect(updated.status).toBe('confirmed'); - expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); - expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(updated.status).toBe('tentative'); + expect(updated.onChainResult).toBeUndefined(); + expect(updated.ual).toMatch(/^did:dkg:mock:31337\/0x[0-9a-fA-F]{40}\/13$/); + + const stored = await store.query(` + SELECT ?p ?o WHERE { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings.length).toBeGreaterThan(0); }); it('keeps adapter-managed updates tentative when publisher attribution is unavailable', async () => { From 3191fd4c791229b1d38637879854e7e3dc266c24 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 07:02:16 +0200 Subject: [PATCH 39/43] fix(agent): avoid reserving publish signers in read paths --- packages/agent/src/dkg-agent.ts | 25 ++-- packages/agent/test/agent.test.ts | 117 ++++++++++++++++++ packages/publisher/src/dkg-publisher.ts | 17 ++- .../test/publisher-no-random-wallet.test.ts | 41 +++++- 4 files changed, 188 insertions(+), 12 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index eeb293139..8f7ecd567 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -494,14 +494,16 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { } function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { - const hasAddressProbe = typeof chain.getAuthorizedPublisherAddress === 'function' || - typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' || - typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function' || + const hasReservingOrMultiAddressProbe = typeof chain.getAuthorizedPublisherAddress === 'function' || + typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function'; + const hasSingleAddressProbe = typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' || Boolean(normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress)); - const hasSigningProbe = typeof chain.signMessageAs === 'function' || - typeof chain.signMessage === 'function'; + const hasAnyAddressProbe = hasReservingOrMultiAddressProbe || hasSingleAddressProbe; - return hasAddressProbe && hasSigningProbe; + if (typeof chain.signMessageAs === 'function') return hasAnyAddressProbe; + return !hasReservingOrMultiAddressProbe && + hasSingleAddressProbe && + typeof chain.signMessage === 'function'; } function privateKeyAddress(privateKey: string | undefined): string | undefined { @@ -516,8 +518,13 @@ function privateKeyAddress(privateKey: string | undefined): string | undefined { async function inferAdapterPublisherAddress( chain: ChainAdapter, contextGraphId?: bigint, + options?: { includeReservingPublisherProbe?: boolean }, ): Promise { - if (contextGraphId !== undefined && typeof chain.getAuthorizedPublisherAddress === 'function') { + if ( + options?.includeReservingPublisherProbe !== false && + contextGraphId !== undefined && + typeof chain.getAuthorizedPublisherAddress === 'function' + ) { try { const address = normalizeAdapterPublisherAddress( await chain.getAuthorizedPublisherAddress(contextGraphId), @@ -7248,7 +7255,9 @@ export class DKGAgent { } catch { // Local descriptive CG ids cannot be used as adapter context hints. } - return inferAdapterPublisherAddress(this.chain, publisherContextGraphId); + return inferAdapterPublisherAddress(this.chain, publisherContextGraphId, { + includeReservingPublisherProbe: false, + }); } /** diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 479e8e755..1d297ede6 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -262,6 +262,28 @@ class GenericSignMessageExternalOperationalKeyPublishChainAdapter extends Extern } } +class MultiSignerGenericSignMessagePublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + constructor( + expectedPublisherAddress: string, + private readonly genericSigner: ethers.Wallet, + private readonly advertisedSigner: ethers.Wallet, + ) { + super(expectedPublisherAddress); + } + + async getSignerAddresses(): Promise { + return [this.advertisedSigner.address]; + } + + async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.genericSigner.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + class SingleSignerAdapterPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { constructor(private readonly adapterWallet: ethers.Wallet) { super(adapterWallet.address); @@ -280,6 +302,34 @@ class SingleSignerAdapterPublishChainAdapter extends ExternalOperationalKeyPubli } } +class ReservingAuthorityContextGraphChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + reservations = 0; + + constructor(private readonly wallet: ethers.Wallet) { + super(wallet.address); + } + + async getAuthorizedPublisherAddress(): Promise { + this.reservations += 1; + return ethers.Wallet.createRandom().address; + } + + getSignerAddress(): string { + return this.wallet.address; + } + + async signMessageAs(address: string, messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + if (address.toLowerCase() !== this.wallet.address.toLowerCase()) { + throw new Error(`unexpected signer ${address}`); + } + const sig = ethers.Signature.from(await this.wallet.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + let _fileSnapshot: string; beforeAll(async () => { _fileSnapshot = await takeSnapshot(); @@ -1413,6 +1463,73 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('keeps chainConfig.operationalKeys fallback when multi-signer adapter lacks signMessageAs', async () => { + const wallet = ethers.Wallet.createRandom(); + const genericSigner = ethers.Wallet.createRandom(); + const advertisedSigner = ethers.Wallet.createRandom(); + const chain = new MultiSignerGenericSignMessagePublishChainAdapter( + wallet.address, + genericSigner, + advertisedSigner, + ); + + const agent = await DKGAgent.create({ + name: 'MultiSignerGenericSignMessageOperationalKeyPublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-multi-signer-generic-sign-message-operational-key-fallback', + predicate: 'http://schema.org/name', + object: '"MultiSignerGenericSignMessageOperationalKeyFallback"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(chain.capturedPublisherAddress?.toLowerCase()).not.toBe(genericSigner.address.toLowerCase()); + expect(chain.capturedPublisherAddress?.toLowerCase()).not.toBe(advertisedSigner.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + + it('does not reserve a publish signer while resolving curated registration authority', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new ReservingAuthorityContextGraphChainAdapter(wallet); + + const agent = await DKGAgent.create({ + name: 'NonReservingRegistrationAuthority', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + }); + + try { + const authority = await (agent as unknown as { + getChainPublishAuthorityAddress(contextGraphId?: string): Promise; + }).getChainPublishAuthorityAddress('42'); + + expect(authority?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(chain.reservations).toBe(0); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('uses a single-signer adapter instead of chainConfig.operationalKeys fallback', async () => { const adapterWallet = ethers.Wallet.createRandom(); const staleChainConfigSigner = ethers.Wallet.createRandom(); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 42eaa0f5c..248971441 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1925,8 +1925,23 @@ export class DKGPublisher implements Publisher { } catch { // Descriptive SWM graph names are valid local/mock update scopes. } - const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); const localOnlyUpdate = this.chain.chainId === 'none'; + let resolvedPublisherAddress: string | undefined; + if (localOnlyUpdate) { + resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); + } else if (typeof this.chain.getLatestMerkleRootPublisher === 'function') { + try { + resolvedPublisherAddress = coercePublisherAddress( + await this.chain.getLatestMerkleRootPublisher(kcId), + ); + } catch { + // Adapter-managed updates can still let the adapter resolve the + // original publisher while submitting the transaction. + } + } + if (!resolvedPublisherAddress && !localOnlyUpdate) { + resolvedPublisherAddress = await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); + } const publisherAddress = resolvedPublisherAddress ?? ( localOnlyUpdate ? this.localTentativePublisherAddress() : undefined ); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index e85767c0b..ffbbc3073 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -229,6 +229,15 @@ class AdapterManagedUpdateChain implements ChainAdapter { } } +class ReservingUpdateChain extends AdapterManagedUpdateChain { + reservations = 0; + + async getAuthorizedPublisherAddress(): Promise { + this.reservations += 1; + return new ethers.Wallet(TEST_KEY).address; + } +} + describe('DKGPublisher: no random publisher wallet without explicit key', () => { it('leaves publisherWallet and publisherAddress undefined when no key or address is supplied', async () => { const keypair = await generateEd25519Keypair(); @@ -804,6 +813,32 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('does not reserve a new publish signer for adapter-managed updates', async () => { + const keypair = await generateEd25519Keypair(); + const chain = new ReservingUpdateChain(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await publisher.update(14n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-update-without-reservation', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.reservations).toBe(0); + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('tentative'); + expect(updated.onChainResult).toBeUndefined(); + }); + it('resolves adapter-managed update attribution from chain state when tx result omits publisherAddress', async () => { const keypair = await generateEd25519Keypair(); const wallet = new ethers.Wallet(TEST_KEY); @@ -825,7 +860,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => }], }); - expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); expect(updated.status).toBe('confirmed'); expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); @@ -852,7 +887,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => }], }); - expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); expect(updated.status).toBe('failed'); expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); }); @@ -905,7 +940,7 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => }], }); - expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); expect(updated.status).toBe('tentative'); expect(updated.onChainResult).toBeUndefined(); expect(updated.ual).toMatch(/^did:dkg:mock:31337\/0x[0-9a-fA-F]{40}\/13$/); From dddcef2831d524a083be6ef1052c936813652802 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 07:12:51 +0200 Subject: [PATCH 40/43] fix(publisher): report definitive update rejections --- packages/publisher/src/dkg-publisher.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 248971441..b9b7a1293 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -2092,10 +2092,11 @@ export class DKGPublisher implements Publisher { ]; if (errorName && V10_DEFINITIVE_ERRORS.includes(errorName)) { this.log.warn(ctx, `V10 update rejected (${errorName}): ${v10Err instanceof Error ? v10Err.message : String(v10Err)}`); - if (!publisherAddress) throw v10Err; + const rejectedPublisherAddress = publisherAddress ?? this.publisherAddress; + if (!rejectedPublisherAddress) throw v10Err; earlyReturn = { kcId, - ual: `did:dkg:${this.chain.chainId}/${publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${rejectedPublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', From 7a71ed4927ec588b31866310aad60a2b33ba0c3a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 07:26:56 +0200 Subject: [PATCH 41/43] fix(publisher): avoid tentative signer reservations --- packages/agent/src/dkg-agent.ts | 7 +- packages/chain/src/mock-adapter.ts | 14 +++- .../chain/test/mock-adapter-parity.test.ts | 8 +-- packages/publisher/src/dkg-publisher.ts | 69 +++++++++++++++---- .../test/publisher-no-random-wallet.test.ts | 67 ++++++++++++++++++ 5 files changed, 144 insertions(+), 21 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 8f7ecd567..21027da78 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -518,7 +518,10 @@ function privateKeyAddress(privateKey: string | undefined): string | undefined { async function inferAdapterPublisherAddress( chain: ChainAdapter, contextGraphId?: bigint, - options?: { includeReservingPublisherProbe?: boolean }, + options?: { + includeReservingPublisherProbe?: boolean; + includeGenericSignMessageProbe?: boolean; + }, ): Promise { if ( options?.includeReservingPublisherProbe !== false && @@ -580,6 +583,7 @@ async function inferAdapterPublisherAddress( } } + if (options?.includeGenericSignMessageProbe === false) return undefined; if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return undefined; try { @@ -7257,6 +7261,7 @@ export class DKGAgent { } return inferAdapterPublisherAddress(this.chain, publisherContextGraphId, { includeReservingPublisherProbe: false, + includeGenericSignMessageProbe: false, }); } diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 99b40c546..67db2c797 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -118,13 +118,21 @@ export class MockChainAdapter implements ChainAdapter { seedIdentity(address: string, identityId: bigint): void { this.identities.set(address, identityId); if (ethers.isAddress(address)) { - this.allowedPublisherAddresses.add(ethers.getAddress(address).toLowerCase()); + this.allowPublisherAddress(address); } if (identityId >= this.nextIdentityId) { this.nextIdentityId = identityId + 1n; } } + /** + * Test helper: allow delegated V10 publishes to be attributed to an address + * without also pretending that address owns a node identity. + */ + allowPublisherAddress(address: string): void { + this.allowedPublisherAddresses.add(ethers.getAddress(address).toLowerCase()); + } + // --- V9 UAL-based methods --- async reserveUALRange(count: number): Promise { @@ -958,8 +966,8 @@ export class MockChainAdapter implements ChainAdapter { : ethers.getAddress(this.signerAddress); if (!this.allowedPublisherAddresses.has(publisherAddress.toLowerCase())) { throw new Error( - `Mock publisherAddress ${publisherAddress} is not registered with this adapter. ` + - 'Seed the address first to model explicit mock support for address-specific publishing.', + `Mock publisherAddress ${publisherAddress} is not allowed with this adapter. ` + + 'Allow the address first to model explicit mock support for address-specific publishing.', ); } const kcId = this.nextBatchId++; diff --git a/packages/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index 8ac6d9543..2da02bb95 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -307,9 +307,9 @@ describe('MockChainAdapter API parity with EVMChainAdapter [CH-8]', () => { ackSignatures: [], }; - await expect(mock.createKnowledgeAssetsV10(params)).rejects.toThrow(/not registered/); + await expect(mock.createKnowledgeAssetsV10(params)).rejects.toThrow(/not allowed/); - mock.seedIdentity(otherPublisher, 7n); + mock.allowPublisherAddress(otherPublisher); await expect(mock.createKnowledgeAssetsV10(params)).resolves.toMatchObject({ publisherAddress: otherPublisher, }); @@ -319,7 +319,7 @@ describe('MockChainAdapter API parity with EVMChainAdapter [CH-8]', () => { const mock = new MockChainAdapter('mock:31337', '0x1111111111111111111111111111111111111111'); mock.minimumRequiredSignatures = 0; const delegatedPublisher = '0x2222222222222222222222222222222222222222'; - mock.seedIdentity(delegatedPublisher, 7n); + mock.allowPublisherAddress(delegatedPublisher); const created = await mock.createKnowledgeAssetsV10({ publishOperationId: 'mock-v10-delegated-update', @@ -355,7 +355,7 @@ describe('MockChainAdapter API parity with EVMChainAdapter [CH-8]', () => { const mock = new MockChainAdapter('mock:31337', '0x1111111111111111111111111111111111111111'); const delegatedPublisher = '0x2222222222222222222222222222222222222222'; mock.minimumRequiredSignatures = 0; - mock.seedIdentity(delegatedPublisher, 7n); + mock.allowPublisherAddress(delegatedPublisher); const created = await mock.createKnowledgeAssetsV10({ publishOperationId: 'mock-v9-fallback-delegated-update', diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index b9b7a1293..accb3fb9b 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -60,6 +60,11 @@ export interface DKGPublisherConfig { writeLocks?: Map>; } +interface PublisherAddressResolutionOptions { + includeReservingPublisherProbe?: boolean; + includeGenericSignMessageProbe?: boolean; +} + export class PublisherWalletRequiredError extends Error { constructor(operation: string) { super( @@ -343,17 +348,27 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } - private async resolvePublisherAddress(contextGraphId?: bigint): Promise { + private async resolvePublisherAddress( + contextGraphId?: bigint, + options: PublisherAddressResolutionOptions = {}, + ): Promise { if (this.publisherAddress) return this.publisherAddress; if (this.publisherAddressResolver) { const resolved = normalizePublisherAddress(await this.publisherAddressResolver(contextGraphId)); if (resolved) return resolved; } - return this.inferAdapterPublisherAddress(contextGraphId); + return this.inferAdapterPublisherAddress(contextGraphId, options); } - private async inferAdapterPublisherAddress(contextGraphId?: bigint): Promise { - if (contextGraphId !== undefined && typeof this.chain.getAuthorizedPublisherAddress === 'function') { + private async inferAdapterPublisherAddress( + contextGraphId?: bigint, + options: PublisherAddressResolutionOptions = {}, + ): Promise { + if ( + options.includeReservingPublisherProbe !== false && + contextGraphId !== undefined && + typeof this.chain.getAuthorizedPublisherAddress === 'function' + ) { try { const address = coercePublisherAddress(await this.chain.getAuthorizedPublisherAddress(contextGraphId)); if (address) return address; @@ -398,6 +413,7 @@ export class DKGPublisher implements Publisher { if (operationalWallet) return operationalWallet.address; if (this.adapterSignMessagePublisherAddress) return this.adapterSignMessagePublisherAddress; + if (options.includeGenericSignMessageProbe === false) return undefined; if (this.chain.chainId === 'none' || typeof this.chain.signMessage !== 'function') return undefined; try { @@ -458,11 +474,15 @@ export class DKGPublisher implements Publisher { return this.isChainV10Ready(); } - private async resolveKnownBatchPublisherAddress(contextGraphId: string, kcId: bigint): Promise { + private async resolveKnownBatchPublisherAddress( + contextGraphId: string, + kcId: bigint, + metaGraphUri = this.graphManager.metaGraphUri(contextGraphId), + ): Promise { try { const ual = await resolveUalByBatchId( this.store, - this.graphManager.metaGraphUri(contextGraphId), + metaGraphUri, kcId, ); return publisherAddressFromUal(ual); @@ -487,7 +507,6 @@ export class DKGPublisher implements Publisher { if (matches) this.adapterSignMessagePublisherAddress = expectedAddress; return matches; } catch { - this.adapterSignMessageProbeCache.set(cacheKey, false); return false; } } @@ -1297,8 +1316,16 @@ export class DKGPublisher implements Publisher { } const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; const chainV10Ready = await this.refreshChainV10Readiness(); - const resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); - const publisherSigner = await this.getPublisherSigner(resolvedPublisherAddress); + const canResolveOnChainPublisher = willAttemptOnChainPublish && chainV10Ready; + const resolvedPublisherAddress = canResolveOnChainPublisher + ? await this.resolvePublisherAddress(publisherContextGraphId) + : await this.resolvePublisherAddress(undefined, { + includeReservingPublisherProbe: false, + includeGenericSignMessageProbe: false, + }); + const publisherSigner = canResolveOnChainPublisher + ? await this.getPublisherSigner(resolvedPublisherAddress) + : undefined; const publisherAddress = resolvedPublisherAddress ?? this.localTentativePublisherAddress(); const canAttemptOnChainPublish = willAttemptOnChainPublish && chainV10Ready && @@ -1928,7 +1955,7 @@ export class DKGPublisher implements Publisher { const localOnlyUpdate = this.chain.chainId === 'none'; let resolvedPublisherAddress: string | undefined; if (localOnlyUpdate) { - resolvedPublisherAddress = await this.resolvePublisherAddress(publisherContextGraphId); + resolvedPublisherAddress = this.publisherAddress; } else if (typeof this.chain.getLatestMerkleRootPublisher === 'function') { try { resolvedPublisherAddress = coercePublisherAddress( @@ -1940,7 +1967,11 @@ export class DKGPublisher implements Publisher { } } if (!resolvedPublisherAddress && !localOnlyUpdate) { - resolvedPublisherAddress = await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); + resolvedPublisherAddress = await this.resolveKnownBatchPublisherAddress( + contextGraphId, + kcId, + options.targetMetaGraphUri, + ); } const publisherAddress = resolvedPublisherAddress ?? ( localOnlyUpdate ? this.localTentativePublisherAddress() : undefined @@ -2167,8 +2198,20 @@ export class DKGPublisher implements Publisher { // Fall through to the clear fail-loud path below. } } - failedPublisherAddress ??= await this.resolveKnownBatchPublisherAddress(contextGraphId, kcId); - if (!failedPublisherAddress) throw new PublisherWalletRequiredError('update'); + failedPublisherAddress ??= await this.resolveKnownBatchPublisherAddress( + contextGraphId, + kcId, + options.targetMetaGraphUri, + ); + if (!failedPublisherAddress) { + failedPublisherAddress = this.localTentativePublisherAddress(); + this.log.warn( + ctx, + 'Chain adapter returned a failed update without publisherAddress, and neither ' + + 'chain state nor local metadata resolved the publisher. Returning the failed ' + + 'update status with a local tentative UAL placeholder.', + ); + } onPhase?.('chain:submit', 'end'); onPhase?.('chain', 'end'); return { diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index ffbbc3073..669acc5b3 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -110,6 +110,21 @@ class AsyncAddressSigningChain implements ChainAdapter { } } +class ReservingPublishChain extends AsyncAddressSigningChain { + reservations = 0; + private readonly authorizedAddress: string; + + constructor(wallet: ethers.Wallet) { + super(wallet); + this.authorizedAddress = wallet.address; + } + + async getAuthorizedPublisherAddress(): Promise { + this.reservations += 1; + return this.authorizedAddress; + } +} + class LazyReadySigningChain extends AsyncAddressSigningChain { private ready = false; capturedTokenAmount?: bigint; @@ -382,6 +397,33 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(result.ual).toMatch(/^did:dkg:evm:31337\/0x[0-9a-fA-F]{40}\/t/); }); + it('does not reserve adapter publisher signers for identity-less tentative publishes', async () => { + const keypair = await generateEd25519Keypair(); + const chain = new ReservingPublishChain(new ethers.Wallet(TEST_KEY)); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 0n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:evm-identityless-no-reserve', + predicate: 'http://schema.org/name', + object: '"EvmIdentitylessNoReserve"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(result.status).toBe('tentative'); + expect(result.onChainResult).toBeUndefined(); + expect(chain.reservations).toBe(0); + expect(chain.capturedPublisherAddress).toBeUndefined(); + }); + it('keeps descriptive-CG chain-backed publishes tentative without a publisher signer', async () => { const keypair = await generateEd25519Keypair(); const publisher = new DKGPublisher({ @@ -892,6 +934,31 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.ual.toLowerCase()).toContain(wallet.address.toLowerCase()); }); + it('preserves failed adapter-managed updates when publisher attribution is unavailable', async () => { + const keypair = await generateEd25519Keypair(); + const chain = new AdapterManagedUpdateChain(undefined, undefined, false); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await publisher.update(12n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:adapter-managed-failed-update-without-address', + predicate: 'http://schema.org/name', + object: '"After"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress).toBeUndefined(); + expect(updated.status).toBe('failed'); + expect(updated.ual).toMatch(/^did:dkg:mock:31337\/0x[0-9a-fA-F]{40}\/12$/); + }); + it('does not confirm adapter-managed updates from local metadata alone', async () => { const keypair = await generateEd25519Keypair(); const wallet = new ethers.Wallet(TEST_KEY); From cdeea87c5f18b5fae0da7c2dd1b44bb5d345b814 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 07:37:16 +0200 Subject: [PATCH 42/43] fix(agent): verify adapter publisher signer before fallback --- packages/agent/src/dkg-agent.ts | 63 ++++++++++++++---- packages/agent/test/agent.test.ts | 66 +++++++++++++++++++ packages/publisher/src/dkg-publisher.ts | 6 ++ .../test/publisher-no-random-wallet.test.ts | 27 ++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 21027da78..1a0e944d9 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -493,7 +493,45 @@ function normalizeAdapterPublisherAddress(value: unknown): string | undefined { return address === ethers.ZeroAddress ? undefined : address; } -function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { +function recoverCompactSigner(message: Uint8Array, compact: { r: Uint8Array; vs: Uint8Array }): string { + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + return ethers.verifyMessage(message, signature); +} + +function adapterHasOperationalPrivateKey(chain: ChainAdapter): boolean { + const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown }) + .getOperationalPrivateKey; + if (typeof operationalKeyGetter !== 'function') return false; + try { + const privateKey = operationalKeyGetter.call(chain); + return typeof privateKey === 'string' && privateKey.length > 0 && privateKeyAddress(privateKey) !== undefined; + } catch { + return false; + } +} + +async function adapterGenericSignMessageMatchesAddress( + chain: ChainAdapter, + expectedAddress: string, +): Promise { + if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return false; + const normalized = normalizeAdapterPublisherAddress(expectedAddress); + if (!normalized) return false; + + try { + const challenge = ethers.getBytes(ethers.id(`dkg-agent:chain-signer-probe:${normalized.toLowerCase()}`)); + const compact = await chain.signMessage(challenge); + const recovered = normalizeAdapterPublisherAddress(recoverCompactSigner(challenge, compact)); + return recovered?.toLowerCase() === normalized.toLowerCase(); + } catch { + return false; + } +} + +async function adapterAdvertisesPublisherSigner(chain: ChainAdapter): Promise { const hasReservingOrMultiAddressProbe = typeof chain.getAuthorizedPublisherAddress === 'function' || typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function'; const hasSingleAddressProbe = typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' || @@ -501,9 +539,15 @@ function adapterAdvertisesPublisherSigner(chain: ChainAdapter): boolean { const hasAnyAddressProbe = hasReservingOrMultiAddressProbe || hasSingleAddressProbe; if (typeof chain.signMessageAs === 'function') return hasAnyAddressProbe; - return !hasReservingOrMultiAddressProbe && - hasSingleAddressProbe && - typeof chain.signMessage === 'function'; + if (adapterHasOperationalPrivateKey(chain)) return true; + if (typeof chain.signMessage !== 'function') return false; + + const advertisedAddress = await inferAdapterPublisherAddress(chain, undefined, { + includeReservingPublisherProbe: false, + includeGenericSignMessageProbe: false, + }); + if (!advertisedAddress) return false; + return adapterGenericSignMessageMatchesAddress(chain, advertisedAddress); } function privateKeyAddress(privateKey: string | undefined): string | undefined { @@ -589,11 +633,7 @@ async function inferAdapterPublisherAddress( try { const challenge = ethers.getBytes(ethers.id('dkg-agent:publisher-address-probe')); const compact = await chain.signMessage(challenge); - const signature = ethers.Signature.from({ - r: ethers.hexlify(compact.r), - yParityAndS: ethers.hexlify(compact.vs), - }).serialized; - return normalizeAdapterPublisherAddress(ethers.verifyMessage(challenge, signature)); + return normalizeAdapterPublisherAddress(recoverCompactSigner(challenge, compact)); } catch { return undefined; } @@ -805,10 +845,11 @@ export class DKGAgent { legacyAdapterOperationalAddress && configuredPublisherAddress.toLowerCase() === legacyAdapterOperationalAddress.toLowerCase(), ); + const adapterCanPublishFromAdvertisedSigner = await adapterAdvertisesPublisherSigner(chain); const useLegacyAdapterOperationalKeyFallback = Boolean( config.chainAdapter && legacyAdapterOperationalKey && - !adapterAdvertisesPublisherSigner(chain) && + !adapterCanPublishFromAdvertisedSigner && (!configuredPublisherAddress || publisherAddressMatchesLegacyKey), ); const publisher = new DKGPublisher({ @@ -7247,7 +7288,7 @@ export class DKGAgent { if ( this.config.chainAdapter && legacyAdapterOperationalAddress && - !adapterAdvertisesPublisherSigner(this.chain) + !(await adapterAdvertisesPublisherSigner(this.chain)) ) { return legacyAdapterOperationalAddress; } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 1d297ede6..c8885700e 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -284,6 +284,28 @@ class MultiSignerGenericSignMessagePublishChainAdapter extends ExternalOperation } } +class SingleAddressMismatchedGenericSignMessagePublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + constructor( + expectedPublisherAddress: string, + private readonly advertisedSigner: ethers.Wallet, + private readonly genericSigner: ethers.Wallet, + ) { + super(expectedPublisherAddress); + } + + getSignerAddress(): string { + return this.advertisedSigner.address; + } + + async signMessage(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }> { + const sig = ethers.Signature.from(await this.genericSigner.signMessage(messageHash)); + return { + r: ethers.getBytes(sig.r), + vs: ethers.getBytes(sig.yParityAndS), + }; + } +} + class SingleSignerAdapterPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { constructor(private readonly adapterWallet: ethers.Wallet) { super(adapterWallet.address); @@ -1507,6 +1529,50 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('keeps chainConfig.operationalKeys fallback when single-address adapter signMessage uses another key', async () => { + const wallet = ethers.Wallet.createRandom(); + const advertisedSigner = ethers.Wallet.createRandom(); + const genericSigner = ethers.Wallet.createRandom(); + const chain = new SingleAddressMismatchedGenericSignMessagePublishChainAdapter( + wallet.address, + advertisedSigner, + genericSigner, + ); + + const agent = await DKGAgent.create({ + name: 'SingleAddressMismatchedGenericSignMessagePublisher', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + chainConfig: { + rpcUrl: 'http://127.0.0.1:0', + hubAddress: '0x00000000000000000000000000000000000000A1', + operationalKeys: [wallet.privateKey], + }, + }); + + try { + agent.publisher.setIdentityId(1n); + const result = await agent.publisher.publish({ + contextGraphId: '42', + quads: [{ + subject: 'urn:test:agent-single-address-mismatched-generic-sign-message', + predicate: 'http://schema.org/name', + object: '"SingleAddressMismatchedGenericSignMessage"', + graph: 'did:dkg:context-graph:42', + }], + }); + + expect(result.status).toBe('confirmed'); + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(chain.capturedPublisherAddress?.toLowerCase()).not.toBe(advertisedSigner.address.toLowerCase()); + expect(chain.capturedPublisherAddress?.toLowerCase()).not.toBe(genericSigner.address.toLowerCase()); + expect(result.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('does not reserve a publish signer while resolving curated registration authority', async () => { const wallet = ethers.Wallet.createRandom(); const chain = new ReservingAuthorityContextGraphChainAdapter(wallet); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index accb3fb9b..6c5ea6390 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1973,6 +1973,12 @@ export class DKGPublisher implements Publisher { options.targetMetaGraphUri, ); } + if (!resolvedPublisherAddress && !localOnlyUpdate) { + resolvedPublisherAddress = await this.resolvePublisherAddress(undefined, { + includeReservingPublisherProbe: false, + includeGenericSignMessageProbe: false, + }); + } const publisherAddress = resolvedPublisherAddress ?? ( localOnlyUpdate ? this.localTentativePublisherAddress() : undefined ); diff --git a/packages/publisher/test/publisher-no-random-wallet.test.ts b/packages/publisher/test/publisher-no-random-wallet.test.ts index 669acc5b3..2168833aa 100644 --- a/packages/publisher/test/publisher-no-random-wallet.test.ts +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -828,6 +828,33 @@ describe('DKGPublisher: no random publisher wallet without explicit key', () => expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); }); + it('uses configured publisherAddress as update fallback when chain state and metadata miss', async () => { + const keypair = await generateEd25519Keypair(); + const wallet = new ethers.Wallet(TEST_KEY); + const chain = new AdapterManagedUpdateChain(wallet.address); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + publisherAddress: wallet.address, + }); + + const updated = await publisher.update(99n, { + contextGraphId: '1', + quads: [{ + subject: 'urn:test:update-configured-publisher-fallback', + predicate: 'http://schema.org/name', + object: '"ConfiguredPublisherFallback"', + graph: 'did:dkg:context-graph:1', + }], + }); + + expect(chain.capturedPublisherAddress?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(updated.status).toBe('confirmed'); + expect(updated.onChainResult?.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + it('lets adapter-managed updates select their signer without local address discovery', async () => { const keypair = await generateEd25519Keypair(); const wallet = new ethers.Wallet(TEST_KEY); From e857d3b13944b39ea48072cdf40e9f8327eff767 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 6 May 2026 07:43:10 +0200 Subject: [PATCH 43/43] fix(agent): share adapter operational authority resolution --- packages/agent/src/dkg-agent.ts | 30 ++++++++++++++---------------- packages/agent/test/agent.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 1a0e944d9..4cada6e74 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -501,18 +501,24 @@ function recoverCompactSigner(message: Uint8Array, compact: { r: Uint8Array; vs: return ethers.verifyMessage(message, signature); } -function adapterHasOperationalPrivateKey(chain: ChainAdapter): boolean { +function adapterOperationalPrivateKeyAddress(chain: ChainAdapter): string | undefined { const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown }) .getOperationalPrivateKey; - if (typeof operationalKeyGetter !== 'function') return false; + if (typeof operationalKeyGetter !== 'function') return undefined; try { const privateKey = operationalKeyGetter.call(chain); - return typeof privateKey === 'string' && privateKey.length > 0 && privateKeyAddress(privateKey) !== undefined; + return typeof privateKey === 'string' && privateKey.length > 0 + ? privateKeyAddress(privateKey) + : undefined; } catch { - return false; + return undefined; } } +function adapterHasOperationalPrivateKey(chain: ChainAdapter): boolean { + return adapterOperationalPrivateKeyAddress(chain) !== undefined; +} + async function adapterGenericSignMessageMatchesAddress( chain: ChainAdapter, expectedAddress: string, @@ -614,18 +620,8 @@ async function inferAdapterPublisherAddress( ); if (signerAddress) return signerAddress; - const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown }) - .getOperationalPrivateKey; - if (typeof operationalKeyGetter === 'function') { - try { - const privateKey = operationalKeyGetter.call(chain); - if (typeof privateKey === 'string' && privateKey.length > 0) { - return normalizeAdapterPublisherAddress(new ethers.Wallet(privateKey).address); - } - } catch { - // Last-resort compatibility probe; fall through to adapter signatures. - } - } + const adapterOperationalAddress = adapterOperationalPrivateKeyAddress(chain); + if (adapterOperationalAddress) return adapterOperationalAddress; if (options?.includeGenericSignMessageProbe === false) return undefined; if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return undefined; @@ -7300,6 +7296,8 @@ export class DKGAgent { } catch { // Local descriptive CG ids cannot be used as adapter context hints. } + // This mirrors the publisher resolver, including the adapter-only + // `getOperationalPrivateKey()` fallback used by custom ChainAdapters. return inferAdapterPublisherAddress(this.chain, publisherContextGraphId, { includeReservingPublisherProbe: false, includeGenericSignMessageProbe: false, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index c8885700e..4f2719f10 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1341,6 +1341,28 @@ describe('DKGAgent ACK signer gating', () => { } }); + it('uses getOperationalPrivateKey as curated registration authority for adapter-only publishers', async () => { + const wallet = ethers.Wallet.createRandom(); + const chain = new OperationalKeyOnlyPublishChainAdapter(wallet); + + const agent = await DKGAgent.create({ + name: 'LegacyOperationalKeyRegistrationAuthority', + listenHost: '127.0.0.1', + listenPort: 0, + chainAdapter: chain, + }); + + try { + const authority = await (agent as unknown as { + getChainPublishAuthorityAddress(contextGraphId?: string): Promise; + }).getChainPublishAuthorityAddress('42'); + + expect(authority?.toLowerCase()).toBe(wallet.address.toLowerCase()); + } finally { + await agent.stop().catch(() => {}); + } + }); + it('keeps chainConfig.operationalKeys fallback when a custom adapter has no signer probes', async () => { const wallet = ethers.Wallet.createRandom(); const chain = new ExternalOperationalKeyPublishChainAdapter(wallet.address);