diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b9e1a57a..e25aa61a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,21 @@ 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 + + # 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/CHANGELOG.md b/CHANGELOG.md index da9d72c24..68c922336 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, 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. + --- ## [10.0.0-rc.4] - 2026-05-04 diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 652bf149c..4cada6e74 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,154 @@ 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; +} + +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 adapterOperationalPrivateKeyAddress(chain: ChainAdapter): string | undefined { + const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown }) + .getOperationalPrivateKey; + if (typeof operationalKeyGetter !== 'function') return undefined; + try { + const privateKey = operationalKeyGetter.call(chain); + return typeof privateKey === 'string' && privateKey.length > 0 + ? privateKeyAddress(privateKey) + : undefined; + } catch { + return undefined; + } +} + +function adapterHasOperationalPrivateKey(chain: ChainAdapter): boolean { + return adapterOperationalPrivateKeyAddress(chain) !== undefined; +} + +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' || + Boolean(normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress)); + const hasAnyAddressProbe = hasReservingOrMultiAddressProbe || hasSingleAddressProbe; + + if (typeof chain.signMessageAs === 'function') return hasAnyAddressProbe; + 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 { + if (!privateKey) return undefined; + try { + return normalizeAdapterPublisherAddress(new ethers.Wallet(privateKey).address); + } catch { + return undefined; + } +} + +async function inferAdapterPublisherAddress( + chain: ChainAdapter, + contextGraphId?: bigint, + options?: { + includeReservingPublisherProbe?: boolean; + includeGenericSignMessageProbe?: boolean; + }, +): Promise { + if ( + options?.includeReservingPublisherProbe !== false && + 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( + await Promise.resolve(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 { + const advertised = await Promise.resolve(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. + } + } + + const signerAddress = normalizeAdapterPublisherAddress( + (chain as unknown as { signerAddress?: unknown }).signerAddress, + ); + if (signerAddress) return signerAddress; + + const adapterOperationalAddress = adapterOperationalPrivateKeyAddress(chain); + if (adapterOperationalAddress) return adapterOperationalAddress; + + if (options?.includeGenericSignMessageProbe === false) return undefined; + 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); + return normalizeAdapterPublisherAddress(recoverCompactSigner(challenge, compact)); + } catch { + return undefined; + } +} + /** * High-level facade that ties together all DKG agent capabilities: * identity, networking, publishing, querying, discovery, and messaging. @@ -680,12 +833,31 @@ 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 adapterCanPublishFromAdvertisedSigner = await adapterAdvertisesPublisherSigner(chain); + const useLegacyAdapterOperationalKeyFallback = Boolean( + config.chainAdapter && + legacyAdapterOperationalKey && + !adapterCanPublishFromAdvertisedSigner && + (!configuredPublisherAddress || publisherAddressMatchesLegacyKey), + ); const publisher = new DKGPublisher({ store, chain, eventBus, keypair, - publisherPrivateKey: opKeys?.[0], + publisherPrivateKey: useLegacyAdapterOperationalKeyFallback ? legacyAdapterOperationalKey : undefined, + publisherAddress: config.publisherAddress, + publisherAddressResolver: config.publisherAddress || useLegacyAdapterOperationalKeyFallback + ? undefined + : (contextGraphId?: bigint) => inferAdapterPublisherAddress(chain, contextGraphId), sharedMemoryOwnedEntities: workspaceOwnedEntities, writeLocks, }); @@ -4376,7 +4548,7 @@ export class DKGAgent { ); } const publishAuthority = publishPolicy === EVM_PUBLISH_CURATED - ? this.getChainPublishAuthorityAddress() + ? await this.getChainPublishAuthorityAddress(id) : undefined; if ( publishPolicy === EVM_PUBLISH_CURATED @@ -7103,16 +7275,33 @@ export class DKGAgent { && ownerAddress.toLowerCase() === this.defaultAgentAddress.toLowerCase(); } - private getChainPublishAuthorityAddress(): string | undefined { - const chainWithSigner = this.chain as unknown as { - getSignerAddress?: () => string; - signerAddress?: string; - }; - const rawAddress = chainWithSigner.getSignerAddress?.() ?? chainWithSigner.signerAddress; - if (rawAddress && ethers.isAddress(rawAddress)) { - return ethers.getAddress(rawAddress); + 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 && + !(await adapterAdvertisesPublisherSigner(this.chain)) + ) { + return legacyAdapterOperationalAddress; } - return undefined; + + 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. + } + // 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 eef34fcdc..4f2719f10 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'; @@ -45,6 +53,22 @@ class CapturingContextGraphChainAdapter extends MockChainAdapter { } } +class AsyncSignerAddressContextGraphChainAdapter extends CapturingContextGraphChainAdapter { + async getSignerAddress(): Promise { + return this.signerAddress; + } +} + +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; @@ -78,6 +102,256 @@ 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); + } +} + +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, + }; + } +} + +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, + }; + } +} + +class AddressOnlyExternalOperationalKeyPublishChainAdapter extends ExternalOperationalKeyPublishChainAdapter { + getSignerAddress(): string { + return ethers.Wallet.createRandom().address; + } +} + +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), + }; + } +} + +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 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); + } + + 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), + }; + } +} + +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(); @@ -313,7 +587,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 +627,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 +718,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 +749,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 +821,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); @@ -942,6 +1246,452 @@ 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(() => {}); + } + }); + + 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); + + 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(() => {}); + } + }); + + 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); + + 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(() => {}); + } + }); + + 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(() => {}); + } + }); + + 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('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('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('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); + + 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(); + 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); + + 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)', () => { @@ -1461,7 +2211,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(), @@ -1544,7 +2294,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/agent/test/v10-ack-provider.test.ts b/packages/agent/test/v10-ack-provider.test.ts index ff7107f43..77cc3ba85 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,12 +27,48 @@ 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(); return { agent, store, chain: chainAdapter }; } +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 === 'getAuthorizedPublisherAddress') return undefined; + if (prop === 'getSignerAddress') 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, + unlock: () => { unlocked = true; }, + }; +} + describe('v10 ACK provider wiring', () => { let agent: DKGAgent | undefined; @@ -60,8 +96,27 @@ 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('uses adapter-backed publisher signing when chainAdapter does not expose a private key', async () => { + 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'; + 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: '' }, + ]); + + 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])); 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 +125,18 @@ describe('v10 ACK provider wiring', () => { expect(result.status).toBe('tentative'); expect(result.onChainResult).toBeUndefined(); }); + + it('publishes tentatively without chain config using a non-zero local publisher address', async () => { + ({ agent } = await createAgent(new NoChainAdapter())); + + 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/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 15316d53e..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,6 +69,16 @@ export interface TxResult { hash: string; blockNumber: number; success: boolean; + /** + * 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; } @@ -192,6 +203,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; @@ -448,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; /** @@ -529,6 +552,23 @@ export interface ChainAdapter { */ signMessage?(messageHash: Uint8Array): Promise<{ r: Uint8Array; vs: Uint8Array }>; + /** + * 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; + + /** + * 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; @@ -582,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; /** @@ -708,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/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 078c775dc..ce899fd5f 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,31 +369,55 @@ export class EVMChainAdapter implements ChainAdapter { return s; } + private findSignerByAddress(address: string): Wallet | undefined { + const normalized = ethers.getAddress(address).toLowerCase(); + return this.signerPool.find((signer) => signer.address.toLowerCase() === normalized); + } + /** * 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 { - if (!this.contracts.contextGraphs) { - return this.nextSigner(); - } + const previousSelection = this.signerSelectionQueue; + let releaseSelection!: () => void; + this.signerSelectionQueue = new Promise((resolve) => { releaseSelection = resolve; }); + await previousSelection; + try { + 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.', + ); + } finally { + releaseSelection(); } + } - 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.', - ); + /** + * 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(); + + return (await this.nextAuthorizedSigner(contextGraphId)).address; } /** All operational wallet addresses (for display / funding). */ @@ -1002,6 +1027,7 @@ export class EVMChainAdapter implements ChainAdapter { hash: receipt.hash, blockNumber: receipt.blockNumber, success: receipt.status === 1, + publisherAddress: signer.address, }; } @@ -1629,7 +1655,30 @@ 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.address, + ); + if (!authorized) { + throw new Error( + `Configured publisherAddress ${selected.address} is not authorized to publish ` + + `to context graph ${params.contextGraphId.toString()}.`, + ); + } + } + txSigner = selected; + } else { + txSigner = await this.nextAuthorizedSigner(params.contextGraphId); + } const ka = this.contracts.knowledgeAssetsV10.connect(txSigner) as Contract; const kaAddress = await ka.getAddress(); @@ -2066,6 +2115,7 @@ export class EVMChainAdapter implements ChainAdapter { hash: receipt.hash, blockNumber: receipt.blockNumber, success: receipt.status === 1, + publisherAddress: signer.address, }; } @@ -2399,6 +2449,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.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..67db2c797 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>(); + /** 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 +84,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,11 +117,22 @@ export class MockChainAdapter implements ChainAdapter { */ seedIdentity(address: string, identityId: bigint): void { this.identities.set(address, identityId); + if (ethers.isAddress(address)) { + 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 { @@ -146,6 +166,7 @@ export class MockChainAdapter implements ChainAdapter { this.batches.set(batchId, { merkleRoot: params.merkleRoot, kaCount, + publisherAddress: this.signerAddress, }); this.pushEvent('KnowledgeBatchCreated', { @@ -175,6 +196,7 @@ export class MockChainAdapter implements ChainAdapter { this.batches.set(batchId, { merkleRoot: params.merkleRoot, kaCount: params.kaCount, + publisherAddress: this.signerAddress, }); const txHash = this.peekTxHash(); @@ -242,6 +264,7 @@ export class MockChainAdapter implements ChainAdapter { this.batches.set(batchId, { merkleRoot: params.merkleRoot, kaCount: params.kaCount, + publisherAddress: this.signerAddress, }); const txHash = this.peekTxHash(); @@ -308,18 +331,25 @@ export class MockChainAdapter implements ChainAdapter { } existing.merkleRoot = params.newMerkleRoot; + const hintedPublisherAddress = params.publisherAddress + ? ethers.getAddress(params.publisherAddress) + : undefined; + const publisherAddress = existing.publisherAddress ?? hintedPublisherAddress; const txIndex = this.txIndexInBlock; const blockNumber = this.nextBlock; const txHash = `0x${blockNumber.toString(16).padStart(64, '0')}${txIndex.toString(16).padStart(4, '0')}`; this.pushEvent('KnowledgeBatchUpdated', { batchId: params.batchId.toString(), newMerkleRoot: toHex(params.newMerkleRoot), - publisherAddress: this.signerAddress, + publisherAddress, txHash, txIndex, }); - return this.txResult(true); + return { + ...this.txResult(true), + publisherAddress, + }; } async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { @@ -351,18 +381,32 @@ export class MockChainAdapter implements ChainAdapter { } existing.merkleRoot = params.newMerkleRoot; + const collection = this.collections.get(params.kcId); + if (collection) { + collection.merkleRoot = params.newMerkleRoot; + collection.merkleLeafCount = params.newMerkleLeafCount; + } + const hintedPublisherAddress = params.publisherAddress + ? ethers.getAddress(params.publisherAddress) + : undefined; + const publisherAddress = collection?.publisherAddress ?? existing.publisherAddress ?? hintedPublisherAddress; + if (collection) collection.publisherAddress = publisherAddress; + existing.publisherAddress = publisherAddress; const txIndex = this.txIndexInBlock; const blockNumber = this.nextBlock; const txHash = `0x${blockNumber.toString(16).padStart(64, '0')}${txIndex.toString(16).padStart(4, '0')}`; this.pushEvent('KnowledgeBatchUpdated', { batchId: params.kcId.toString(), newMerkleRoot: toHex(params.newMerkleRoot), - publisherAddress: this.signerAddress, + publisherAddress, txHash, txIndex, }); - return this.txResult(true); + return { + ...this.txResult(true), + publisherAddress, + }; } async verifyKAUpdate(txHash: string, batchId: bigint, publisherAddress: string): Promise { @@ -917,18 +961,28 @@ 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 allowed with this adapter. ` + + 'Allow the address first to model explicit mock support for address-specific publishing.', + ); + } const kcId = this.nextBatchId++; 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 this.batches.set(kcId, { merkleRoot: params.merkleRoot, kaCount: params.knowledgeAssetsAmount, + publisherAddress, }); const txHash = this.peekTxHash(); @@ -941,7 +995,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 +1011,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/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/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index 1302c8ae6..2da02bb95 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -45,6 +45,7 @@ import { describe, it, expect } from 'vitest'; import { EVMChainAdapter } from '../src/evm-adapter.js'; import { MockChainAdapter } from '../src/mock-adapter.js'; import { NoChainAdapter } from '../src/no-chain-adapter.js'; +import { ethers } from 'ethers'; /** Collect all own method names across the whole prototype chain, minus `constructor`. */ function collectMethodNames(ctor: Function): Set { @@ -77,6 +78,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 +87,7 @@ const MOCK_EXEMPT_FROM_EVM = new Set([ // ChainAdapter contract. They must remain EVM-only. 'nextSigner', 'nextAuthorizedSigner', + 'findSignerByAddress', 'walletKeyHash', 'hasAdminPurpose', 'hasOperationalPurpose', @@ -281,6 +285,103 @@ 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 allowed/); + + mock.allowPublisherAddress(otherPublisher); + await expect(mock.createKnowledgeAssetsV10(params)).resolves.toMatchObject({ + 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.allowPublisherAddress(delegatedPublisher); + + 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.allowPublisherAddress(delegatedPublisher); + + 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]', () => { diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index e1d352906..6c5ea6390 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'; @@ -42,6 +43,8 @@ export interface DKGPublisherConfig { keypair: Ed25519Keypair; publisherNodeIdentityId?: bigint; publisherAddress?: string; + /** Retryable publisher address resolver for adapter-backed signing. */ + publisherAddressResolver?: (contextGraphId?: bigint) => Promise; /** EVM private key for signing publish requests (hex string with 0x prefix) */ publisherPrivateKey?: string; /** @@ -57,6 +60,58 @@ export interface DKGPublisherConfig { writeLocks?: Map>; } +interface PublisherAddressResolutionOptions { + includeReservingPublisherProbe?: boolean; + includeGenericSignMessageProbe?: boolean; +} + +export class PublisherWalletRequiredError extends Error { + constructor(operation: string) { + super( + `${operation} requires "publisherPrivateKey" or a non-zero "publisherAddress" ` + + 'backed by ChainAdapter.signMessageAs()/signMessage(). Publishing without a publisher 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; +} + +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; +} + +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 }, +): 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; @@ -178,6 +233,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; } @@ -224,8 +285,11 @@ 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 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'); @@ -239,16 +303,38 @@ 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) { this.publisherWallet = new ethers.Wallet(config.publisherPrivateKey); this.publisherAddress = this.publisherWallet.address; - } else { - 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); + if ( + configuredPublisherAddress && + configuredPublisherAddress.toLowerCase() !== this.publisherAddress.toLowerCase() + ) { + throw new Error( + `publisherAddress (${configuredPublisherAddress}) does not match publisherPrivateKey signer ` + + `(${this.publisherAddress})`, + ); } + } else { + // 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). Chain-backed publish still fails + // 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 + // 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). + this.publisherAddress = configuredPublisherAddress; } for (const key of config.additionalSignerKeys ?? []) { @@ -262,6 +348,243 @@ export class DKGPublisher implements Publisher { this.writeLocks = config.writeLocks ?? new Map(); } + 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, options); + } + + 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; + } 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( + await Promise.resolve(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 = await Promise.resolve(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; + + const operationalWallet = this.getAdapterOperationalWallet(); + 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 { + const challenge = ethers.getBytes(ethers.id('dkg-publisher:publisher-address-probe')); + const compact = await this.chain.signMessage(challenge); + const address = coercePublisherAddress(recoverCompactMessageSigner(challenge, compact)); + if (address) { + this.adapterSignMessagePublisherAddress = address; + this.adapterSignMessageProbeCache.set(address.toLowerCase(), true); + } + return address; + } catch { + return undefined; + } + } + + 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 { + const digest = ethers.keccak256(this.keypair.publicKey); + const address = ethers.getAddress(ethers.dataSlice(digest, 12)); + 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, + metaGraphUri = this.graphManager.metaGraphUri(contextGraphId), + ): Promise { + try { + const ual = await resolveUalByBatchId( + this.store, + metaGraphUri, + kcId, + ); + return publisherAddressFromUal(ual); + } catch { + return undefined; + } + } + + 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 { + return false; + } + } + + private async getPublisherSigner(address = this.publisherAddress): Promise { + if (this.publisherWallet && this.publisherAddress) { + const wallet = this.publisherWallet; + return { + address: this.publisherAddress, + source: 'publisherPrivateKey', + signMessage: (message: Uint8Array) => wallet.signMessage(message), + }; + } + + if (address && typeof this.chain.signMessageAs === 'function') { + const expectedAddress = address; + return { + address: expectedAddress, + source: 'chainAdapter', + signMessage: async (message: Uint8Array) => { + 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; + 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(); + if ( + address && + operationalWallet && + operationalWallet.address.toLowerCase() === address.toLowerCase() + ) { + return { + address: operationalWallet.address, + source: 'chainAdapter', + signMessage: (message: Uint8Array) => operationalWallet.signMessage(message), + }; + } + + return undefined; + } + 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())); @@ -984,6 +1307,29 @@ 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(); + 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. + } + const willAttemptOnChainPublish = this.publisherNodeIdentityId > 0n && publisherContextGraphId !== undefined; + const chainV10Ready = await this.refreshChainV10Readiness(); + 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 && + publisherSigner !== undefined; if (effectiveAccessPolicy !== 'public' && normalizedPublisherPeerId.length === 0) { throw new Error( @@ -998,6 +1344,10 @@ export class DKGPublisher implements Publisher { throw new Error('Publish rejected: "allowedPeers" is only valid when accessPolicy is "allowList"'); } + if (willAttemptOnChainPublish && chainV10Ready && !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`); @@ -1117,11 +1467,19 @@ export class DKGPublisher implements Publisher { // `KnowledgeAssetsV10._executePublishCore`. const publishEpochs = 1; let precomputedTokenAmount = 0n; - if (this.publisherWallet && 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)}`, + ); } } @@ -1239,33 +1597,44 @@ 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 ) { - 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 this.publisherWallet.signMessage(ackDigest), - ); - v10ACKs = [{ - peerId: 'self', - signatureR: ethers.getBytes(ackSig.r), - signatureVS: ethers.getBytes(ackSig.yParityAndS), - nodeIdentityId: this.publisherNodeIdentityId, - }]; + 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), + ); + 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'); + } } onPhase?.('chain', 'start'); @@ -1273,26 +1642,37 @@ 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 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 { - onPhase?.('chain:sign', 'start'); - this.log.info(ctx, `Signing on-chain publish (identityId=${identityId}, signer=${this.publisherWallet.address})`); - 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; + if (!publisherSigner) throw new PublisherWalletRequiredError('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'); } @@ -1322,7 +1702,7 @@ export class DKGPublisher implements Publisher { kcMerkleRoot, ); const pubSig = ethers.Signature.from( - await this.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, @@ -1383,6 +1763,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, @@ -1446,38 +1827,38 @@ 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 publisherSigner.signMessage(ethers.getBytes(merkleHashBytes)); + const proofQuads = generateAuthorshipProof({ + kcUal: ual, + contextGraphId, + agentAddress: publisherSigner.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 ${publisherSigner.address}`); + } catch (proofErr) { + this.log.warn(ctx, `Failed to generate authorship proof: ${proofErr instanceof Error ? proofErr.message : String(proofErr)}`); } 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)}`); } } @@ -1564,6 +1945,43 @@ export class DKGPublisher implements Publisher { if (privateQuads.length > 0) rejectReservedSubjectPrefixes(privateQuads); } const ctx: OperationContext = operationCtx ?? createOperationContext('publish'); + 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. + } + const localOnlyUpdate = this.chain.chainId === 'none'; + let resolvedPublisherAddress: string | undefined; + if (localOnlyUpdate) { + resolvedPublisherAddress = this.publisherAddress; + } 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, + options.targetMetaGraphUri, + ); + } + if (!resolvedPublisherAddress && !localOnlyUpdate) { + resolvedPublisherAddress = await this.resolvePublisherAddress(undefined, { + includeReservingPublisherProbe: false, + includeGenericSignMessageProbe: false, + }); + } + const publisherAddress = resolvedPublisherAddress ?? ( + localOnlyUpdate ? this.localTentativePublisherAddress() : undefined + ); this.log.info(ctx, `Updating kcId=${kcId} with ${quads.length} triples`); const dataGraph = this.graphManager.dataGraphUri(contextGraphId); @@ -1606,6 +2024,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'); @@ -1631,7 +2091,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 }) => { @@ -1657,7 +2117,7 @@ export class DKGPublisher implements Publisher { newByteSize: updateByteSize, newMerkleLeafCount: kcMerkleLeafCount, mintAmount: 0, - publisherAddress: this.publisherAddress, + publisherAddress, v10Origin: true, onBroadcast: emitWriteAheadStart, }); @@ -1669,9 +2129,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)}`); + const rejectedPublisherAddress = publisherAddress ?? this.publisherAddress; + if (!rejectedPublisherAddress) throw v10Err; earlyReturn = { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${rejectedPublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', @@ -1697,7 +2159,7 @@ export class DKGPublisher implements Publisher { batchId: kcId, newMerkleRoot: kcMerkleRoot, newPublicByteSize: updateByteSize, - publisherAddress: this.publisherAddress, + publisherAddress, }); } catch (v9Err) { enrichEvmError(v9Err); @@ -1715,7 +2177,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)'); @@ -1731,48 +2193,82 @@ export class DKGPublisher implements Publisher { } if (!txResult.success) { + 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. + } + } + 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 { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${failedPublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'failed', publicQuads: allSkolemizedQuads, }; } - 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); + let effectivePublisherAddress = coercePublisherAddress(txResult.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. Refuse confirmed metadata below rather than + // inventing a publisher address that did not come from chain state. } } - - try { - await updateMetaMerkleRoot(this.store, this.graphManager, contextGraphId, kcId, kcMerkleRoot); - } catch (err) { + onPhase?.('chain:submit', 'end'); + onPhase?.('chain', 'end'); + if (!effectivePublisherAddress) { + const tentativePublisherAddress = publisherAddress ?? this.localTentativePublisherAddress(); this.log.warn( ctx, - `Failed to sync _meta merkleRoot for kcId=${kcId}: ${err instanceof Error ? err.message : String(err)}`, + 'Chain adapter returned a successful update without publisherAddress, and neither ' + + '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 = { + 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; } - onPhase?.('store', 'end'); + + await storeUpdatedQuads(); const result: PublishResult = { kcId, - ual: `did:dkg:${this.chain.chainId}/${this.publisherAddress}/${kcId}`, + ual: `did:dkg:${this.chain.chainId}/${effectivePublisherAddress}/${kcId}`, merkleRoot: kcMerkleRoot, kaManifest: manifestEntries, status: 'confirmed', @@ -1782,7 +2278,7 @@ export class DKGPublisher implements Publisher { txHash: txResult.hash, blockNumber: txResult.blockNumber ?? 0, blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress: this.publisherAddress, + publisherAddress: effectivePublisherAddress, }, }; diff --git a/packages/publisher/test/phase-sequences.test.ts b/packages/publisher/test/phase-sequences.test.ts index b9f55f09e..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 — tentative path) ----------------------------- + // -- Publish (adapter-backed signer, no identity — tentative) ----------- - it('publish: tentative path omits sign/submit sub-phases', 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(); @@ -141,15 +141,19 @@ describe('Phase-sequence contracts', () => { chain, eventBus: new TypedEventBus(), keypair, - // No publisherPrivateKey → tentative only + // EVM adapter but no publisherPrivateKey / adapter-backed signer. }); const quads = [q(ENTITY, 'http://schema.org/name', '"Tentative"')]; const { calls, fn } = recorder(); - await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); + const result = await publisher.publish({ contextGraphId: PARANET, quads, onPhase: fn }); + const adapterAddress = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address; - const phases = calls.map(([p, s]) => `${p}:${s}`); + 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', 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..2168833aa --- /dev/null +++ b/packages/publisher/test/publisher-no-random-wallet.test.ts @@ -0,0 +1,1089 @@ +/** + * Regression test: DKGPublisher must NOT auto-mint a random publisher wallet + * when no `publisherPrivateKey` is supplied, and must not use the zero + * address as a placeholder publisher. + * + * 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; 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 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'; +import { generateConfirmedFullMetadata } from '../src/metadata.js'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { TypedEventBus, generateEd25519Keypair } from '@origintrail-official/dkg-core'; +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'; +const TEST_KEY_ALT = '0x5de4111a56f4c24611d9ed4d5318a7e03f9b9a9d73f3a5f3f6324a2a0e6fbb36'; + +// 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; +} + +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), + }; + } +} + +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 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; + + 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 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'); + } +} + +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); + } +} + +class AdapterManagedUpdateChain implements ChainAdapter { + readonly chainId = 'mock:31337'; + capturedPublisherAddress?: string; + + constructor( + private readonly publisherAddress?: string, + private readonly latestPublisherAddress?: string, + private readonly success = true, + ) {} + + async updateKnowledgeCollectionV10(params: V10UpdateKCParams): Promise { + this.capturedPublisherAddress = params.publisherAddress; + return { + success: this.success, + hash: `0x${'12'.repeat(32)}`, + blockNumber: 1, + ...(this.publisherAddress ? { publisherAddress: this.publisherAddress } : {}), + }; + } + + async getLatestMerkleRootPublisher(): Promise { + if (!this.latestPublisherAddress) throw new Error('publisher unavailable'); + return this.latestPublisherAddress; + } +} + +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(); + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain: makeStubChain('test-evm-chain'), + eventBus: new TypedEventBus(), + keypair, + }); + + // 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).toBeUndefined(); + }); + + it('publishes tentatively with a deterministic non-zero local address on no-chain publishes', async () => { + const keypair = await generateEd25519Keypair(); + const chain = { + ...makeStubChain('none'), + getRequiredPublishTokenAmount: async () => { + throw new Error('RPC unavailable'); + }, + } as ChainAdapter; + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + 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: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('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'; + const chain = { + ...makeStubChain('none'), + getRequiredPublishTokenAmount: async () => { + throw new Error('RPC unavailable'); + }, + } as ChainAdapter; + const publisher = new DKGPublisher({ + store: new OxigraphStore(), + 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('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('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({ + 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('keeps non-V10 chain-backed publishes tentative without a publisher signer', async () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const publisher = new DKGPublisher({ + store, + chain: makeStubChain('evm:31337'), + eventBus: new TypedEventBus(), + keypair, + publisherNodeIdentityId: 1n, + }); + + const result = await publisher.publish({ + contextGraphId: '1', + quads: [{ + subject: 'urn:test:evm-no-signer', + predicate: 'http://schema.org/name', + object: '"EvmNoSigner"', + 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/); + + 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 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(); + 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({ + 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 () => { + const keypair = await generateEd25519Keypair(); + 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'); + }); + + it('rejects publisherAddress values that do not match the supplied private key', async () => { + const keypair = await generateEd25519Keypair(); + + 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); + }); + + 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('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('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('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('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(); + 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); + 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); + 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()); + }); + + 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); + 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('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); + 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?.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()); + }); + + 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?.toLowerCase()).toBe(wallet.address.toLowerCase()); + expect(updated.status).toBe('failed'); + 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); + 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?.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 () => { + const keypair = await generateEd25519Keypair(); + const store = new OxigraphStore(); + const chain = new AdapterManagedUpdateChain(); + const publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + }); + + const updated = await 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', + }], + }); + + 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 { + GRAPH { + ?p ?o . + } + } + `); + expect(stored.type).toBe('bindings'); + expect(stored.bindings.length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/audit-create-random.mjs b/scripts/audit-create-random.mjs new file mode 100755 index 000000000..c57ea3008 --- /dev/null +++ b/scripts/audit-create-random.mjs @@ -0,0 +1,690 @@ +#!/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` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / `.mjs` / + * `.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. + * + * 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. + * + * Bypass-resistance notes + * ----------------------- + * - 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. + * * 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 + * 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. + * - Template-literal substitutions (`${ … }`) ARE scanned as code, so a + * `\`${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'; +import { resolve, relative, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +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', '.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; + 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 (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; + } + } +} + +// 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', + { + expectedHits: 1, + justification: 'first-run admin+op wallet generation, persisted to wallets.json (chmod 0600)', + }, + ], + [ + 'packages/agent/src/agent-keystore.ts', + { + expectedHits: 1, + justification: 'custodial chat-agent keypair, returned to caller and persisted in keystore', + }, + ], + [ + 'packages/evm-module/utils/helpers.ts', + { + expectedHits: 1, + justification: 'hardhat deploy-script utility, key returned to operator (`generateEvmWallet`)', + }, + ], +]); + +const IDENT = String.raw`[A-Za-z_$][\w$]*`; +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * 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. + * + * 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 (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 `}` + * 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 + * 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 + * the regex either. + * + * 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; + let out = ''; + let i = 0; + + // 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' : ' '); + const previousSignificantToken = () => { + for (let j = out.length - 1; j >= 0; j -= 1) { + 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 { kind: 'start', value: '' }; + }; + const canStartRegexLiteral = () => { + 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) { + const cur = top(); + const c = text[i]; + const next = i + 1 < len ? text[i + 1] : ''; + + 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; + stack.push({ kind: 'line-comment' }); + } 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' }); + } else if (c === '`') { + out += ' '; i += 1; + stack.push({ kind: 'tpl-string' }); + } else { + out += c; i += 1; + } + continue; + } + + if (cur.kind === 'line-comment') { + if (c === '\n') { + out += '\n'; i += 1; + stack.pop(); + } else { + out += ' '; i += 1; + } + continue; + } + + if (cur.kind === 'block-comment') { + if (c === '*' && next === '/') { + out += ' '; i += 2; + stack.pop(); + } else { + out += blank(c); i += 1; + } + 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) { + out += blank(c) + blank(text[i + 1]); + i += 2; + } else if (c === quote) { + out += ' '; i += 1; + stack.pop(); + } else { + out += blank(c); i += 1; + } + continue; + } + + 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; + stack.pop(); + } else if (c === '$' && next === '{') { + out += ' '; i += 2; + stack.push({ kind: 'tpl-substitution', braceDepth: 1 }); + } else { + out += blank(c); i += 1; + } + continue; + } + } + return out; +} + +export function findHits(originalText) { + const stripped = stripCommentsPreservingPositions(originalText); + const walletAliases = collectWalletAliases(stripped); + const hits = []; + const seen = new Set(); + + for (const alias of walletAliases) { + 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)); + } + } + + hits.sort((a, b) => a.index - b.index); + 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 skipToQuotedProperty(originalText, stripped, index) { + let i = index; + while (i < originalText.length) { + if (originalText[i] === '"' || 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 !== "'" && 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 (quote === '`' && c === '$' && originalText[i + 1] === '{') return null; + 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, skipToQuotedProperty(originalText, stripped, i)); + if (!property || property.value !== 'createRandom') return false; + i = skipWhitespace(stripped, 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, skipToQuotedProperty(originalText, stripped, i)); + if (!property || property.value !== 'createRandom') return false; + i = skipWhitespace(stripped, 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; + 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']); + 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 + // 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]); + } + + // 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; + 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 + // 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. + 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'); + } + } + + 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)) { + 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: + // 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*${escapeRegExp(alias)}\b`, + 'g', + ); + for (const m of stripped.matchAll(aliasPattern)) { + if (!aliases.has(m[1])) { + aliases.add(m[1]); + changed = true; + } + } + } + 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; +} + +async function main() { + const packagesDir = join(REPO_ROOT, 'packages'); + 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')); + 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. + 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, { expectedHits, justification }] of ALLOWLIST) { + console.log(` - ${p} (${expectedHits} hit${expectedHits === 1 ? '' : 's'}): ${justification}`); + } + } + return 0; + } + + 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 +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; +} + +// 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..13c771ec3 --- /dev/null +++ b/scripts/audit-create-random.test.mjs @@ -0,0 +1,360 @@ +/** + * 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\(\)/); + }); + + 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\(\)/); + }); + + 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 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); + 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', () => { + 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('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('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 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); + }); + + 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 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); + }); + + 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('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); + }); + + 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\(\);/); + }); + + 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); + }); + + 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); + }); +}); 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);