From fd686d833a980891594e73d3f46dbcc5eb0d11f2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 5 May 2026 14:31:30 +0200 Subject: [PATCH] fix: support edge publishes with dynamic core quorum Reject unsolicited PCA/paymaster from publish-gateway responses unless the caller requested them; strip the same fields in the agent gateway provider as defense in depth. Add `dkg publisher local-gateway set|clear|status` for publisher.localGateway (handler advertisement). Co-authored-by: Cursor --- packages/agent/src/dkg-agent.ts | 348 ++++++++++++++++-- packages/agent/src/profile.ts | 19 + packages/chain/src/chain-adapter.ts | 9 + packages/chain/src/evm-adapter.ts | 162 ++++++-- packages/chain/src/mock-adapter.ts | 80 +++- packages/chain/src/no-chain-adapter.ts | 1 + .../chain/test/mock-adapter-parity.test.ts | 5 + packages/cli/src/api-client.ts | 6 +- packages/cli/src/cli.ts | 268 +++++++++++++- packages/cli/src/config.ts | 45 +++ packages/cli/src/daemon/lifecycle.ts | 43 +++ .../cli/src/daemon/routes/context-graph.ts | 70 ++-- packages/cli/src/daemon/routes/memory.ts | 88 +++++ packages/core/src/constants.ts | 1 + .../evm-module/abi/KnowledgeAssetsV10.json | 13 + .../evm-module/contracts/ContextGraphs.sol | 4 +- .../contracts/KnowledgeAssetsV10.sol | 12 +- .../contracts/storage/ContextGraphStorage.sol | 30 +- .../test/unit/ContextGraphStorage.test.ts | 72 ++-- .../test/unit/ContextGraphs.test.ts | 65 ++-- .../KnowledgeAssetsV10-edge-quorum.test.ts | 284 ++++++++++++++ packages/publisher/src/dkg-publisher.ts | 115 +++++- packages/publisher/src/index.ts | 6 + .../publisher/src/publish-gateway-handler.ts | 258 +++++++++++++ packages/publisher/src/publisher.ts | 37 ++ packages/publisher/src/storage-ack-handler.ts | 30 ++ .../test/publish-gateway-handler.test.ts | 253 +++++++++++++ .../test/storage-ack-handler.test.ts | 52 +++ 28 files changed, 2141 insertions(+), 235 deletions(-) create mode 100644 packages/evm-module/test/unit/KnowledgeAssetsV10-edge-quorum.test.ts create mode 100644 packages/publisher/src/publish-gateway-handler.ts create mode 100644 packages/publisher/test/publish-gateway-handler.test.ts diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 652bf149c..a5cbb4985 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1,6 +1,6 @@ import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, - PROTOCOL_ACCESS, PROTOCOL_PUBLISH, PROTOCOL_SYNC, PROTOCOL_QUERY_REMOTE, PROTOCOL_STORAGE_ACK, PROTOCOL_VERIFY_PROPOSAL, PROTOCOL_JOIN_REQUEST, + PROTOCOL_ACCESS, PROTOCOL_PUBLISH, PROTOCOL_SYNC, PROTOCOL_QUERY_REMOTE, PROTOCOL_STORAGE_ACK, PROTOCOL_PUBLISH_GATEWAY, PROTOCOL_VERIFY_PROPOSAL, PROTOCOL_JOIN_REQUEST, paranetPublishTopic, paranetWorkspaceTopic, paranetAppTopic, paranetUpdateTopic, paranetFinalizationTopic, paranetDataGraphUri, paranetMetaGraphUri, paranetWorkspaceGraphUri, paranetWorkspaceMetaGraphUri, contextGraphSharedMemoryUri, @@ -8,6 +8,7 @@ import { contextGraphDataUri, contextGraphMetaUri, assertionLifecycleUri, contextGraphAssertionUri, MemoryLayer, computeACKDigest, + computePublishPublisherDigest, encodePublishRequest, encodeKAUpdateRequest, encodeFinalizationMessage, type FinalizationMessageMsg, @@ -21,12 +22,13 @@ import { EVMChainAdapter, NoChainAdapter, enrichEvmError, type EVMAdapterConfig, import { DKGPublisher, PublishHandler, SharedMemoryHandler, UpdateHandler, ChainEventPoller, AccessHandler, AccessClient, PublishJournal, StaleWriteError, - ACKCollector, StorageACKHandler, + ACKCollector, StorageACKHandler, PublishGatewayHandler, VerifyCollector, VerifyProposalHandler, buildVerificationMetadata, computeTripleHashV10 as computeTripleHash, computeFlatKCRootV10 as computeFlatKCRoot, autoPartition, TripleStoreAsyncLiftPublisher, type PublishResult, type PhaseCallback, type KAMetadata, type CASCondition, type CollectedACK, type LiftAuthorityProof, type LiftTransitionType, + type PublishGatewayConfig, type PublishGatewayResponseWire, } from '@origintrail-official/dkg-publisher'; import { ethers } from 'ethers'; import { @@ -245,6 +247,54 @@ class SyncAccessDeniedError extends Error { this.contextGraphId = contextGraphId; } } + +function parsePublishGatewayResponse(data: Uint8Array): PublishGatewayResponseWire { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(data)); + } catch { + throw new Error('Invalid publish gateway response: expected JSON'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Invalid publish gateway response: expected object'); + } + const response = parsed as Record; + if (typeof response.error === 'string' && response.error.length > 0) { + throw new Error(response.error); + } + for (const field of ['nodeIdentityId', 'signer', 'signatureR', 'signatureVS']) { + if (typeof response[field] !== 'string') { + throw new Error(`Invalid publish gateway response: missing string ${field}`); + } + } + for (const field of ['pcaAccountId', 'paymaster']) { + if (response[field] !== undefined && typeof response[field] !== 'string') { + throw new Error(`Invalid publish gateway response: ${field} must be a string`); + } + } + return response as unknown as PublishGatewayResponseWire; +} + +function parsePositiveBigIntLiteral(value: string, field: string): bigint { + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new Error(`Invalid publish gateway response: ${field} must be an integer string`); + } + if (parsed <= 0n) { + throw new Error(`Invalid publish gateway response: ${field} must be positive`); + } + return parsed; +} + +function parseBytes32Literal(value: string, field: string): Uint8Array { + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new Error(`Invalid publish gateway response: ${field} must be a 32-byte hex string`); + } + return ethers.getBytes(value); +} + const META_REFRESH_COOLDOWN_MS = 30_000; const SYNC_MIN_GRAPH_BUDGET_MS = 10_000; const DEBUG_SYNC_PROGRESS = process.env.DKG_DEBUG_SYNC_PROGRESS === '1'; @@ -452,6 +502,26 @@ export interface DKGAgentConfig { chainAdapter?: ChainAdapter; /** Private key for the V10 ACK signer. When omitted, falls back to chainConfig.operationalKeys[0]. */ ackSignerKey?: string; + /** + * OUTBOUND preference: preferred core-node publish gateway THIS node + * routes V10 publisher-digest signing requests to. Edge nodes use this + * core for the V10 publisher-node signature while keeping the local + * operational wallet as the transaction sender / publisher of record. + */ + publishGateway?: PublishGatewayConfig; + /** + * LOCAL ADVERTISEMENT: parameters this node's PublishGatewayHandler + * accepts when acting as a gateway for OTHER nodes. Distinct from + * `publishGateway` (outbound preference) so a core that points at an + * upstream gateway does not accidentally re-advertise the upstream's + * pcaAccountId/paymaster on its own handler. `allowedPeers` (optional) + * gates incoming requests to a libp2p peer-id allowlist. + */ + localPublishGateway?: { + pcaAccountId?: bigint; + paymaster?: string; + allowedPeers?: string[]; + }; /** * EVM chain configuration. If omitted, publishing won't have on-chain finality. * `adminPrivateKey` is the private key for the profile admin wallet used @@ -522,6 +592,13 @@ export class DKGAgent { private randomSamplingBindRetryInFlight = false; private storageACKRegistrationRetryTimer: ReturnType | null = null; private storageACKRegistrationRetryInFlight = false; + // Tracks the live state of the V10 publish-gateway protocol handler. + // Set true when the handler is registered on the libp2p router, set + // false when it is unregistered (signer evicted from chain, etc.). + // `publishProfile` reads this so the broadcast profile only advertises + // a working gateway — peers that discover a gateway from a stale + // profile would otherwise hit a missing-protocol or timeout. + private publishGatewayHandlerActive = false; private readonly config: DKGAgentConfig; private started = false; private readonly subscribedContextGraphs = new Map(); @@ -916,6 +993,7 @@ export class DKGAgent { if (effectiveRole === 'core') { if (ackSignerCandidates.length > 0) { let storageACKProtocolRegistered = false; + let publishGatewayProtocolRegistered = false; let storageACKFailoverInFlight = false; const attemptStorageACKRegistration = async ( attemptCtx: OperationContext, @@ -958,6 +1036,15 @@ export class DKGAgent { return 'disabled'; } + const isGatewayActiveCore = async () => { + const verifyACKIdentity = this.chain.verifyACKIdentity; + if (typeof verifyACKIdentity !== 'function') return false; + return verifyACKIdentity.call( + this.chain, + ackSignerWallet.address, + onChainIdentityId, + ); + }; const ackHandler = new StorageACKHandler(this.store, { nodeRole: effectiveRole, nodeIdentityId: onChainIdentityId, @@ -974,14 +1061,20 @@ export class DKGAgent { ackSignerWallet.address, ); }, + isActiveCore: typeof this.chain.verifyACKIdentity === 'function' + ? isGatewayActiveCore + : undefined, onSignerUnregistered: () => { if (storageACKFailoverInFlight) return; storageACKFailoverInFlight = true; storageACKProtocolRegistered = false; + publishGatewayProtocolRegistered = false; + this.publishGatewayHandlerActive = false; this.router.unregister(PROTOCOL_STORAGE_ACK); + this.router.unregister(PROTOCOL_PUBLISH_GATEWAY); this.log.warn( attemptCtx, - `Unregistered V10 StorageACK handler: signer ${ackSignerWallet.address} ` + + `Unregistered V10 StorageACK/publish-gateway handlers: signer ${ackSignerWallet.address} ` + `is no longer confirmed on-chain for identity=${onChainIdentityId}`, ); attemptStorageACKRegistration( @@ -1013,12 +1106,41 @@ export class DKGAgent { ); }, }, this.eventBus); + // Local handler advertisement comes from `localPublishGateway`, + // NOT `publishGateway` (which is the OUTBOUND preference). Reusing + // one config field for both broke isolation: a core that pointed + // at an upstream gateway would re-advertise the upstream's + // pcaAccountId/paymaster on its own PublishGatewayHandler. + const localGw = this.config.localPublishGateway; + const gatewayHandler = new PublishGatewayHandler({ + nodeRole: effectiveRole, + nodeIdentityId: onChainIdentityId, + signerWallet: ackSignerWallet, + chainId: chainIdForHandler, + kav10Address: kav10AddressForHandler, + pcaAccountId: localGw?.pcaAccountId, + paymaster: localGw?.paymaster, + allowedPeers: localGw?.allowedPeers && localGw.allowedPeers.length > 0 + ? new Set(localGw.allowedPeers) + : undefined, + isActiveCore: isGatewayActiveCore, + isPaymasterValid: typeof this.chain.isPaymasterValid === 'function' + ? (paymaster: string) => this.chain.isPaymasterValid!(paymaster) + : undefined, + getConvictionAccountInfo: typeof this.chain.getConvictionAccountInfo === 'function' + ? (accountId: bigint) => this.chain.getConvictionAccountInfo!(accountId) + : undefined, + }); this.router.register(PROTOCOL_STORAGE_ACK, ackHandler.handler); + this.router.register(PROTOCOL_PUBLISH_GATEWAY, gatewayHandler.handler); storageACKProtocolRegistered = true; + publishGatewayProtocolRegistered = true; + this.publishGatewayHandlerActive = true; this.clearStorageACKRegistrationRetry(); this.log.info( attemptCtx, - `Registered V10 StorageACK handler (identity=${onChainIdentityId}, signer=${ackSignerWallet.address})`, + `Registered V10 StorageACK and publish-gateway handlers ` + + `(identity=${onChainIdentityId}, signer=${ackSignerWallet.address})`, ); return 'registered'; } else { @@ -2381,13 +2503,34 @@ export class DKGAgent { async publishProfile(): Promise { const pubKeyBase64 = Buffer.from(this.wallet.keypair.publicKey).toString('base64'); const relayAddrs = this.config.relayPeers; + const role = this.config.nodeRole ?? 'edge'; + const nodeIdentityId = this.publisher.getIdentityId(); const profileConfig: AgentProfileConfig = { peerId: this.node.peerId, name: this.config.name, description: this.config.description, framework: this.config.framework, - nodeRole: this.config.nodeRole ?? 'edge', + nodeRole: role, + nodeIdentityId: nodeIdentityId > 0n ? nodeIdentityId : undefined, + // Only advertise the publish gateway when the local handler is + // actually registered on the libp2p router. Tying advertisement + // to `role && nodeIdentityId > 0` would publish a gateway flag for + // nodes whose handler-registration loop is still retrying or has + // been torn down because the ACK signer is no longer confirmed + // on-chain — peers would discover a gateway and then hit a + // missing-protocol / timeout. + publishGateway: role === 'core' && nodeIdentityId > 0n && this.publishGatewayHandlerActive + ? { + enabled: true, + // Profile advertises THIS node's local gateway terms — sourced + // from `localPublishGateway`, NOT `publishGateway` (the latter + // is the outbound preference and must not leak into what we + // advertise to peers). + pcaAccountId: this.config.localPublishGateway?.pcaAccountId, + paymaster: this.config.localPublishGateway?.paymaster, + } + : undefined, publicKey: pubKeyBase64, relayAddress: relayAddrs?.[0], agentAddress: this.defaultAgentAddress, @@ -3120,6 +3263,7 @@ export class DKGAgent { contextGraphId?: string | bigint; subContextGraphId?: string | bigint; contextGraphSignatures?: Array<{ identityId: bigint; r: Uint8Array; vs: Uint8Array }>; + publishGateway?: PublishGatewayConfig; /** Target sub-graph within the context graph (e.g. "code", "decisions"). */ subGraphName?: string; }, @@ -3131,6 +3275,8 @@ export class DKGAgent { const onChainId = ctxGraphIdStr ?? (await this.getContextGraphOnChainId(contextGraphId)) ?? undefined; const v10ACKProvider = this.createV10ACKProvider(contextGraphId); + const publishGateway = options?.publishGateway ?? this.config.publishGateway; + const publishGatewayProvider = this.createPublishGatewayProvider(publishGateway); const result = await this.publisher.publishFromSharedMemory(contextGraphId, selection, { operationCtx: ctx, clearSharedMemoryAfter: options?.clearSharedMemoryAfter, @@ -3139,6 +3285,8 @@ export class DKGAgent { onChainContextGraphId: onChainId, contextGraphSignatures: options?.contextGraphSignatures, v10ACKProvider, + publishGateway, + publishGatewayProvider, subGraphName: options?.subGraphName, }); @@ -3988,12 +4136,6 @@ export class DKGAgent { } if (participantIdentityIds.size > 0 && typeof opts.requiredSignatures === 'number' && opts.requiredSignatures > 0) { const reqSig = Math.floor(opts.requiredSignatures); - if (reqSig < 1) { - throw new Error(`requiredSignatures must be >= 1, got ${opts.requiredSignatures}`); - } - if (reqSig > participantIdentityIds.size) { - throw new Error(`requiredSignatures (${reqSig}) exceeds participant count (${participantIdentityIds.size})`); - } quads.push({ subject: paranetUri, predicate: `${DKG_ONTOLOGY.DKG_PARANET}RequiredSignatures`, @@ -4353,19 +4495,14 @@ export class DKGAgent { return { onChainId: existingOnChainId, txHash: undefined }; } - let effectiveParticipantIdentityIds = participantIdentityIds; - if (effectiveParticipantIdentityIds.length === 0) { - const selfIdentityId = await this.ensureIdentity(); - if (selfIdentityId === 0n) { - throw new Error( - `Context graph "${id}" cannot be registered on-chain without an on-chain identity. ` + - 'Create/ensure the curator identity first.', - ); - } - effectiveParticipantIdentityIds = [selfIdentityId]; - } - - const effectiveRequiredSignatures = Number.isInteger(storedRequiredSignatures) && storedRequiredSignatures > 0 + // V10 VM publish ACK quorum is dynamic: KnowledgeAssetsV10 accepts any + // globally configured number of active sharding-table core signatures. + // Do not force an edge-owned CG to mint or reuse a node identity just to + // satisfy legacy ContextGraphStorage.hostingNodes metadata. Keep the + // per-CG legacy quorum metadata positive so existing verify flows that + // still read ContextGraphStorage do not mint unusable zero-quorum CGs. + const effectiveParticipantIdentityIds = participantIdentityIds; + const effectiveRequiredSignatures = Number.isInteger(storedRequiredSignatures) && storedRequiredSignatures >= 1 ? storedRequiredSignatures : 1; const participantAgents = await this.getContextGraphParticipantAgentAddresses(id); @@ -5566,10 +5703,15 @@ export class DKGAgent { const cgConfig = await (this.chain as any).getContextGraphConfig(contextGraphIdOnChain); const raw = cgConfig?.requiredSignatures; const parsed = raw != null ? Number(raw) : 0; - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error(`getContextGraphConfig returned invalid requiredSignatures: ${raw} (must be a positive integer)`); + // On-chain legacy quorum may be 0 for edge-owned CGs; VM ACK quorum + // is global. Treat 0 as "unspecified" and fall through to the + // floor below instead of throwing. + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 255) { + throw new Error(`getContextGraphConfig returned invalid requiredSignatures: ${raw} (must be an integer 0..255)`); + } + if (parsed >= 1) { + requiredSignatures = parsed; } - requiredSignatures = parsed; } catch (err: any) { throw new Error( `Cannot determine requiredSignatures for context graph ${contextGraphIdOnChain}: ${err?.message ?? err}. ` + @@ -7630,11 +7772,11 @@ export class DKGAgent { // Two kinds of discovered CG, two different opt-in semantics: // - // - Open / public CG (no curated _meta graph locally): Viktor's - // v10-rc hardening (commit b9a73e7e "better sync") says do - // NOT auto-subscribe — a node shouldn't auto-ingest every - // public CG a peer happens to know about. Explicit subscribe - // (UI "Join" / `subscribeToContextGraph`) is the opt-in. + // - Open / public CG (no curated _meta graph locally): core nodes + // auto-subscribe because public VM data is the core replication + // surface and RandomSampling assumes active cores can host public + // KCs. Edge nodes still add these as discoverable only; explicit + // subscribe remains the opt-in for personal storage. // // - Curated / private CG (access policy "private" or has an // allowlist): auto-subscribe so `trySyncFromPeer`'s @@ -7680,6 +7822,16 @@ export class DKGAgent { }, { persist: false }); this.subscribeToContextGraph(id); this.log.info(ctx, `Discovered invited context graph "${name}" (${id}) — auto-subscribed (private/allowlisted)`); + } else if ((this.config.nodeRole ?? 'edge') === 'core') { + this.setContextGraphSubscription(id, { + name, + subscribed: false, + synced: true, + metaSynced: source === 'meta', + onChainId: undefined, + }, { persist: false }); + this.subscribeToContextGraph(id); + this.log.info(ctx, `Discovered public context graph "${name}" (${id}) — core auto-subscribed`); } else { this.setContextGraphSubscription(id, { name, @@ -7739,12 +7891,17 @@ export class DKGAgent { continue; } + const accessPolicy = Number(p.accessPolicy); + const isCuratedOnChain = accessPolicy === LOCAL_ACCESS_CURATED; + let shouldSubscribe = accessPolicy === LOCAL_ACCESS_OPEN && (this.config.nodeRole ?? 'edge') === 'core'; + let subscribeReason = 'core auto-subscribed'; + // Curated CGs (accessPolicy=1) must not silently land in non-participants' lists. // We can't query the V10 ContextGraphs participant set from a NameRegistry event alone, // so apply the strict default: only auto-subscribe when this node's wallet matches // `creator` (the address that called claimName). Real participants will have the CG // surfaced through manual subscribe / catch-up triggered by their curator. - if (Number(p.accessPolicy) === 1) { + if (accessPolicy === LOCAL_ACCESS_CURATED) { const isCurator = !!this.defaultAgentAddress && typeof p.creator === 'string' && p.creator.toLowerCase() === this.defaultAgentAddress.toLowerCase(); @@ -7753,16 +7910,20 @@ export class DKGAgent { knownOnChainIds.add(p.contextGraphId); continue; } + shouldSubscribe = true; + subscribeReason = 'curator auto-subscribed'; } this.setContextGraphSubscription(p.name, { name: p.name, - subscribed: true, + subscribed: shouldSubscribe, synced: false, metaSynced: false, onChainId: p.contextGraphId, }); - this.subscribeToContextGraph(p.name, { trackSyncScope: false }); + if (shouldSubscribe) { + this.subscribeToContextGraph(p.name, { trackSyncScope: false }); + } // Persist the on-chain ID to the ontology graph so the publisher's // VM registration guard can find it via RDF (it has no access to @@ -7776,7 +7937,13 @@ export class DKGAgent { graph: ontoGraph, }]); - this.log.info(ctx, `Discovered on-chain context graph "${p.name}" (${p.contextGraphId.slice(0, 16)}…) — auto-subscribed (synced=false)`); + this.log.info( + ctx, + shouldSubscribe + ? `Discovered ${isCuratedOnChain ? 'curated' : 'public'} on-chain context graph "${p.name}" ` + + `(${p.contextGraphId.slice(0, 16)}…) — ${subscribeReason} (synced=false)` + : `Discovered on-chain context graph "${p.name}" (${p.contextGraphId.slice(0, 16)}…) — added as discoverable only`, + ); discovered++; } @@ -7998,6 +8165,115 @@ export class DKGAgent { }; } + private createPublishGatewayProvider(gateway: PublishGatewayConfig | undefined) { + if (!gateway) return undefined; + if (!this.router) { + throw new Error('Publish gateway requested but P2P router is not started'); + } + + return async (request: { + chainId: bigint; + kav10Address: string; + contextGraphId: bigint; + merkleRoot: Uint8Array; + gateway: PublishGatewayConfig; + }) => { + const configured = request.gateway; + const payload = { + chainId: request.chainId.toString(), + kav10Address: request.kav10Address, + contextGraphId: request.contextGraphId.toString(), + merkleRoot: ethers.hexlify(request.merkleRoot), + nodeIdentityId: configured.nodeIdentityId.toString(), + ...(configured.pcaAccountId !== undefined ? { pcaAccountId: configured.pcaAccountId.toString() } : {}), + ...(configured.paymaster ? { paymaster: configured.paymaster } : {}), + }; + + const responseBytes = await this.router.send( + configured.peerId, + PROTOCOL_PUBLISH_GATEWAY, + new TextEncoder().encode(JSON.stringify(payload)), + 120_000, + ); + const response = parsePublishGatewayResponse(responseBytes); + const nodeIdentityId = parsePositiveBigIntLiteral(response.nodeIdentityId!, 'nodeIdentityId'); + if (nodeIdentityId !== configured.nodeIdentityId) { + throw new Error( + `Publish gateway ${configured.peerId} returned identity ${nodeIdentityId}, ` + + `expected ${configured.nodeIdentityId}`, + ); + } + const pcaAccountId = response.pcaAccountId + ? parsePositiveBigIntLiteral(response.pcaAccountId, 'pcaAccountId') + : undefined; + const signatureR = parseBytes32Literal(response.signatureR!, 'signatureR'); + const signatureVS = parseBytes32Literal(response.signatureVS!, 'signatureVS'); + const paymaster = response.paymaster ? ethers.getAddress(response.paymaster) : undefined; + if (!ethers.isAddress(response.signer!)) { + throw new Error(`Publish gateway ${configured.peerId} returned invalid signer address`); + } + const signer = ethers.getAddress(response.signer!); + const digest = computePublishPublisherDigest( + request.chainId, + request.kav10Address, + nodeIdentityId, + request.contextGraphId, + request.merkleRoot, + ); + let recovered: string; + try { + recovered = ethers.getAddress(ethers.verifyMessage( + digest, + ethers.Signature.from({ + r: ethers.hexlify(signatureR), + yParityAndS: ethers.hexlify(signatureVS), + }), + )); + } catch { + throw new Error(`Publish gateway ${configured.peerId} returned an invalid publisher signature`); + } + if (recovered !== signer) { + throw new Error( + `Publish gateway ${configured.peerId} signature recovered ${recovered}, ` + + `expected signer ${signer}`, + ); + } + + const isOperationalWalletRegistered = this.chain.isOperationalWalletRegistered; + if (typeof isOperationalWalletRegistered === 'function') { + let registered: boolean; + try { + registered = await isOperationalWalletRegistered.call(this.chain, nodeIdentityId, signer); + } catch (err) { + throw new Error( + `Publish gateway ${configured.peerId} signer registration lookup failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!registered) { + throw new Error( + `Publish gateway ${configured.peerId} signer ${signer} is not registered ` + + `as an operational wallet for identity ${nodeIdentityId}`, + ); + } + } + + // Never forward PCA / paymaster fields the publisher did not ask for. + // A compromised gateway could otherwise steer the edge onto a conviction + // or paymaster path the caller never opted into — DKGPublisher also + // rejects unsolicited values; stripping here is defense-in-depth. + return { + peerId: configured.peerId, + signer, + nodeIdentityId, + signatureR, + signatureVS, + ...(configured.pcaAccountId !== undefined && pcaAccountId !== undefined ? { pcaAccountId } : {}), + ...(configured.paymaster && paymaster ? { paymaster } : {}), + }; + }; + } + private async broadcastPublish(contextGraphId: string, result: PublishResult, ctx: OperationContext): Promise { // Use the public quads from the publish result to avoid leaking private // triples that are stored in the same data graph. diff --git a/packages/agent/src/profile.ts b/packages/agent/src/profile.ts index 246c150e6..781a4ce62 100644 --- a/packages/agent/src/profile.ts +++ b/packages/agent/src/profile.ts @@ -55,6 +55,12 @@ export interface AgentProfileConfig { /** @deprecated Use contextGraphsServed */ paranetsServed?: string[]; nodeRole?: 'core' | 'edge'; + nodeIdentityId?: string | bigint; + publishGateway?: { + enabled?: boolean; + pcaAccountId?: string | bigint; + paymaster?: string; + }; publicKey?: string; relayAddress?: string; agentAddress?: string; @@ -104,6 +110,19 @@ export function buildAgentProfile(config: AgentProfileConfig): { // DKG P2P properties q(entity, `${DKG}peerId`, `"${config.peerId}"`); q(entity, `${DKG}nodeRole`, `"${role}"`); + if (config.nodeIdentityId !== undefined) { + q(entity, `${DKG}nodeIdentityId`, `"${String(config.nodeIdentityId)}"`); + } + if (config.publishGateway?.enabled) { + q(entity, `${DKG}publishGateway`, `${DKG}PublishGateway`); + q(entity, `${DKG}publishGatewayEnabled`, `"true"`); + if (config.publishGateway.pcaAccountId !== undefined) { + q(entity, `${DKG}publisherConvictionAccountId`, `"${String(config.publishGateway.pcaAccountId)}"`); + } + if (config.publishGateway.paymaster) { + q(entity, `${DKG}publishPaymaster`, `"${config.publishGateway.paymaster}"`); + } + } if (config.publicKey) { q(entity, `${DKG}publicKey`, `"${config.publicKey}"`); diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 15316d53e..42ce3960b 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -207,6 +207,13 @@ export interface V10PublishDirectParams { * argument to `KnowledgeAssetsV10.publishDirect(PublishParams, paymaster)`. */ paymaster: string; + /** + * Publisher conviction account requested for the discounted V10 publish + * path. The on-chain contract resolves the actual paying account from + * `msg.sender`; the adapter uses this value for preflight/observability and + * calls `KnowledgeAssetsV10.publish(PublishParams)` when it is set. + */ + publisherConvictionAccountId?: bigint; publisherNodeIdentityId: bigint; publisherSignature: { r: Uint8Array; vs: Uint8Array }; ackSignatures: Array<{ identityId: bigint; r: Uint8Array; vs: Uint8Array }>; @@ -491,6 +498,8 @@ export interface ChainAdapter { extendConvictionLock?(accountId: bigint, additionalEpochs: number): Promise; getConvictionDiscount?(accountId: bigint): Promise<{ discountBps: number; conviction: bigint }>; getConvictionAccountInfo?(accountId: bigint): Promise; + /** Return true when a paymaster address is currently accepted by PaymasterManager. */ + isPaymasterValid?(paymaster: string): Promise; // Permanent Publishing publishKnowledgeAssetsPermanent?(params: PermanentPublishParams): Promise; diff --git a/packages/chain/src/evm-adapter.ts b/packages/chain/src/evm-adapter.ts index 078c775dc..dc10d124a 100644 --- a/packages/chain/src/evm-adapter.ts +++ b/packages/chain/src/evm-adapter.ts @@ -109,7 +109,8 @@ const ERROR_ABI_CONTRACTS = [ 'ConvictionStakingStorage', 'DKGStakingConvictionNFT', 'DKGPublishingConvictionNFT', 'Hub', 'Token', 'Ask', 'AskStorage', - 'Paymaster', 'ShardingTable', 'ParametersStorage', + 'Paymaster', 'PaymasterManager', 'ShardingTable', 'ParametersStorage', + 'ShardingTableStorage', 'PublishingConvictionAccount', 'RandomSampling', 'RandomSamplingStorage', ]; @@ -233,6 +234,7 @@ interface ContractCache { contextGraphStorage?: Contract; knowledgeAssetsV10?: Contract; publishingConvictionAccount?: Contract; + publishingConvictionNFT?: Contract; randomSampling?: Contract; randomSamplingStorage?: Contract; } @@ -599,6 +601,12 @@ export class EVMChainAdapter implements ChainAdapter { // PublishingConvictionAccount not deployed — conviction account operations unavailable } + try { + this.contracts.publishingConvictionNFT = await this.resolveContract('DKGPublishingConvictionNFT'); + } catch { + // DKGPublishingConvictionNFT not deployed — V10 publisher conviction path unavailable + } + try { await this.resolveAndAssignRandomSamplingPair(); } catch { @@ -1629,11 +1637,41 @@ export class EVMChainAdapter implements ChainAdapter { ); } + const usePublisherConviction = params.publisherConvictionAccountId !== undefined; + if (usePublisherConviction && ethers.getAddress(params.paymaster) !== ethers.ZeroAddress) { + throw new Error( + 'V10 conviction publish cannot also use a paymaster; choose one gateway payment path', + ); + } + const txSigner = await this.nextAuthorizedSigner(params.contextGraphId); const ka = this.contracts.knowledgeAssetsV10.connect(txSigner) as Contract; const kaAddress = await ka.getAddress(); - // Approval policy: always approve TRAC from the operational signer. + if (usePublisherConviction) { + const publishingConvictionNFT = await this.resolvePublishingConvictionNFT(); + if (!publishingConvictionNFT) { + throw new Error( + `Publishing conviction account ${params.publisherConvictionAccountId} requested but ` + + 'DKGPublishingConvictionNFT contract is not deployed.', + ); + } + const registeredAccountId = BigInt( + await publishingConvictionNFT.agentToAccountId(txSigner.address), + ); + if (registeredAccountId !== params.publisherConvictionAccountId) { + const registeredLabel = registeredAccountId === 0n ? 'none' : registeredAccountId.toString(); + throw new Error( + `Publisher wallet ${txSigner.address} is not registered under requested ` + + `publisher conviction account ${params.publisherConvictionAccountId}; ` + + `registered account: ${registeredLabel}`, + ); + } + } + + // Approval policy: always approve TRAC from the operational signer for + // market-rate publishDirect. The conviction path spends the account's + // pre-funded allowance inside DKGPublishingConvictionNFT instead. // // `KnowledgeAssetsV10._publishDirect` (KnowledgeAssetsV10.sol:613-628) // only routes payment to `IPaymaster(paymaster).coverCost(...)` when @@ -1646,7 +1684,7 @@ export class EVMChainAdapter implements ChainAdapter { // zero allowance → publish reverts. A redundant allowance is cheap // and idle when the paymaster does cover the cost, so we always // approve and drop the probe entirely. - if (this.contracts.token) { + if (!usePublisherConviction && this.contracts.token) { const tokenWithSigner = this.contracts.token.connect(txSigner) as Contract; const currentAllowance = await tokenWithSigner.allowance(txSigner.address, kaAddress); if (currentAllowance < params.tokenAmount) { @@ -1694,10 +1732,12 @@ export class EVMChainAdapter implements ChainAdapter { // This also gives the WAL the pre-broadcast tx hash (ethers v6 // exposes it on the returned TransactionResponse), so recovery can // reconcile an in-flight tx after a daemon crash. - const populated = await (ka as any).publishDirect.populateTransaction( - publishParamsStruct, - params.paymaster, - ); + const populated = usePublisherConviction + ? await (ka as any).publish.populateTransaction(publishParamsStruct) + : await (ka as any).publishDirect.populateTransaction( + publishParamsStruct, + params.paymaster, + ); const filled = await txSigner.populateTransaction(populated); const signedTx = await txSigner.signTransaction(filled); // Derive the pre-broadcast tx hash from the signed raw hex so WAL @@ -1716,8 +1756,9 @@ export class EVMChainAdapter implements ChainAdapter { // Fail closed: the signed tx is still in this function's local // scope — it has not been sent. Surface the hook error to the // caller so they know WAL persistence failed BEFORE broadcast. + const method = usePublisherConviction ? 'publish' : 'publishDirect'; throw new Error( - `chain:writeahead hook failed before publishDirect broadcast: ` + + `chain:writeahead hook failed before ${method} broadcast: ` + `${hookErr instanceof Error ? hookErr.message : String(hookErr)}`, ); } @@ -1950,6 +1991,13 @@ export class EVMChainAdapter implements ChainAdapter { } const baseTokenAmount = params.newTokenAmount ?? currentTokenAmount; const newTokenAmount = baseTokenAmount > requiredForNewSize ? baseTokenAmount : requiredForNewSize; + if (!Number.isInteger(params.newMerkleLeafCount) || params.newMerkleLeafCount < 1) { + throw new Error( + 'V10 update requires a positive newMerkleLeafCount so RandomSampling ' + + 'can address the updated flat-KC Merkle tree correctly.', + ); + } + const newMerkleLeafCount = BigInt(params.newMerkleLeafCount); // Look up the contextGraphId for this KC const contextGraphStorage = this.contracts.contextGraphStorage; @@ -1990,7 +2038,6 @@ export class EVMChainAdapter implements ChainAdapter { ? ethers.solidityPacked(burnIds.map(() => 'uint256'), burnIds) : new Uint8Array(0), ); - const newMerkleLeafCount = BigInt(params.newMerkleLeafCount ?? 0); const ackDigest = ethers.getBytes(ethers.solidityPackedKeccak256( ['uint256', 'address', 'uint256', 'uint256', 'uint256', 'bytes32', 'uint256', 'uint256', 'uint256', 'bytes32', 'uint256'], [evmChainId, kav10Address, contextGraphId, params.kcId, preUpdateMerkleRootCount, @@ -2007,7 +2054,7 @@ export class EVMChainAdapter implements ChainAdapter { newMerkleRoot: ethers.hexlify(params.newMerkleRoot), newByteSize: params.newByteSize, newTokenAmount, - newMerkleLeafCount: params.newMerkleLeafCount, + newMerkleLeafCount, mintKnowledgeAssetsAmount: params.mintAmount ?? 0, knowledgeAssetsToBurn: burnIds, publisherNodeIdentityId: identityId, @@ -2245,6 +2292,47 @@ export class EVMChainAdapter implements ChainAdapter { async getConvictionAccountInfo(accountId: bigint): Promise { await this.init(); + // Two on-chain conviction-account stores can coexist on a single + // deployment: the legacy `PublishingConvictionAccount` (PCA) and the + // V10 `DKGPublishingConvictionNFT` (NFT). Existing CLI flows still + // create accounts via PCA, so when the NFT lookup misses we fall + // through to the PCA path instead of immediately reporting null — + // returning null prematurely silently hides accounts that exist in + // the legacy store and breaks every test that creates via PCA then + // queries via this method. + const publishingConvictionNFT = await this.resolvePublishingConvictionNFT(); + if (publishingConvictionNFT) { + try { + const [ + owner, + committedTRAC, + baseEpochAllowance, + createdAtEpoch, + expiresAtEpoch, + discountBps, + topUpBuffer, + ] = await publishingConvictionNFT.getAccountInfo(accountId); + + if (owner !== ethers.ZeroAddress) { + return { + accountId, + admin: owner, + balance: BigInt(committedTRAC) + BigInt(topUpBuffer), + initialDeposit: BigInt(committedTRAC), + lockEpochs: Number(BigInt(expiresAtEpoch) - BigInt(createdAtEpoch)), + conviction: BigInt(baseEpochAllowance), + discountBps: Number(discountBps), + }; + } + // owner == zero means the NFT does not own this accountId; + // fall through to the PCA store below. + } catch (err: any) { + // NFT reverts (CALL_EXCEPTION) on a non-existent accountId — + // fall through to the PCA store; surface anything else. + if (err?.code !== 'CALL_EXCEPTION') throw err; + } + } + if (!this.contracts.publishingConvictionAccount) return null; try { @@ -2290,10 +2378,29 @@ export class EVMChainAdapter implements ChainAdapter { } } + async isPaymasterValid(paymaster: string): Promise { + await this.init(); + if (!ethers.isAddress(paymaster) || ethers.getAddress(paymaster) === ethers.ZeroAddress) { + return false; + } + const paymasterManager = await this.resolveContract('PaymasterManager'); + return paymasterManager.validPaymasters(ethers.getAddress(paymaster)); + } + // ===================================================================== // Utilities // ===================================================================== + private async resolvePublishingConvictionNFT(): Promise { + if (this.contracts.publishingConvictionNFT) return this.contracts.publishingConvictionNFT; + try { + this.contracts.publishingConvictionNFT = await this.resolveContract('DKGPublishingConvictionNFT'); + return this.contracts.publishingConvictionNFT; + } catch { + return null; + } + } + getSignerAddress(): string { return this.signer.address; } @@ -2318,36 +2425,17 @@ export class EVMChainAdapter implements ChainAdapter { ); if (!hasPurpose) return false; - // Verify the identity is a staked core node (spec §9.0: "Core nodes MUST be staked"). - // v4.0.0 — read V10 canonical stake (`ConvictionStakingStorage.getNodeStakeV10`) - // instead of the V8 `StakingStorage.getNodeStake` archive: under mandatory - // migration the V8 `nodeStake` field is unmaintained for V10 nodes and - // would zero-gate every legitimate V10 ACK signer (this exactly mirrors - // the on-chain `KnowledgeAssetsV10` ACK-signer gate, also rewired in - // v4.0.0). Falls back to V8 if CSS is not registered (older deploys). - let cs: Contract | null = null; + // ACK eligibility is dynamic: any active core node in the sharding table + // can ACK any public VM publish. Stake alone is stale as an eligibility + // source because a staked identity may not currently be an active core. + let shardingTableStorage: Contract | null = null; try { - cs = await this.resolveContract('ConvictionStakingStorage'); + shardingTableStorage = await this.resolveContract('ShardingTableStorage'); } catch { - cs = null; - } - if (cs) { - const stake: bigint = await cs.getNodeStakeV10(claimedIdentityId); - if (stake === 0n) return false; - return true; + shardingTableStorage = null; } - - let ss: Contract | null = null; - try { - ss = await this.resolveContract('StakingStorage'); - } catch { - ss = null; - } - if (!ss) return false; - const v8Stake: bigint = await ss.getNodeStake(claimedIdentityId); - if (v8Stake === 0n) return false; - - return true; + if (!shardingTableStorage) return false; + return shardingTableStorage.nodeExists(claimedIdentityId); } async verifySyncIdentity(recoveredAddress: string, claimedIdentityId: bigint): Promise { diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 891a39cde..1aad1ec75 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -52,6 +52,7 @@ export class MockChainAdapter implements ChainAdapter { private nextBlock = 1; private txIndexInBlock = 0; private identities = new Map(); + private activeCoreIdentities = new Set(); private namespaceNextId = new Map(); private namespaceOwner = new Map(); private batches = new Map(); @@ -88,6 +89,7 @@ export class MockChainAdapter implements ChainAdapter { if (existing > 0n) return existing; const id = this.nextIdentityId++; this.identities.set(this.signerAddress, id); + this.activeCoreIdentities.add(id); return id; } @@ -98,6 +100,7 @@ export class MockChainAdapter implements ChainAdapter { const id = this.nextIdentityId++; this.identities.set(key, id); + this.activeCoreIdentities.add(id); this.pushEvent('IdentityRegistered', { identityId: id.toString() }); return id; } @@ -106,13 +109,23 @@ export class MockChainAdapter implements ChainAdapter { * Test helper: seed a deterministic identity for an address in this in-memory adapter. * Used by black-box daemon tests that need stable participant IDs across processes. */ - seedIdentity(address: string, identityId: bigint): void { + seedIdentity(address: string, identityId: bigint, options?: { activeCore?: boolean }): void { this.identities.set(address, identityId); + if (options?.activeCore === false) { + this.activeCoreIdentities.delete(identityId); + } else { + this.activeCoreIdentities.add(identityId); + } if (identityId >= this.nextIdentityId) { this.nextIdentityId = identityId + 1n; } } + setActiveCoreIdentity(identityId: bigint, active: boolean): void { + if (active) this.activeCoreIdentities.add(identityId); + else this.activeCoreIdentities.delete(identityId); + } + // --- V9 UAL-based methods --- async reserveUALRange(count: number): Promise { @@ -615,11 +628,8 @@ export class MockChainAdapter implements ChainAdapter { private nextContextGraphId = 1n; async createOnChainContextGraph(params: CreateOnChainContextGraphParams): Promise { - if (params.requiredSignatures < 1) { - throw new Error('Mock: requiredSignatures must be >= 1'); - } - if (params.requiredSignatures > params.participantIdentityIds.length) { - throw new Error(`Mock: requiredSignatures (${params.requiredSignatures}) exceeds participant count (${params.participantIdentityIds.length})`); + if (!Number.isInteger(params.requiredSignatures) || params.requiredSignatures < 0 || params.requiredSignatures > 255) { + throw new Error('Mock: requiredSignatures must fit uint8'); } for (let i = 1; i < params.participantIdentityIds.length; i++) { if (params.participantIdentityIds[i] <= params.participantIdentityIds[i - 1]) { @@ -715,14 +725,24 @@ export class MockChainAdapter implements ChainAdapter { const normalizedAddress = recoveredAddress.toLowerCase(); for (const [addr, id] of this.identities) { if (id === claimedIdentityId && addr.toLowerCase() === normalizedAddress) { - return true; + return this.activeCoreIdentities.has(claimedIdentityId); } } return false; } async isOperationalWalletRegistered(identityId: bigint, address: string): Promise { - return this.verifyACKIdentity(address, identityId); + const normalizedAddress = address.toLowerCase(); + for (const [addr, id] of this.identities) { + if (id === identityId && addr.toLowerCase() === normalizedAddress) { + return true; + } + } + return false; + } + + async isPaymasterValid(paymaster: string): Promise { + return ethers.isAddress(paymaster) && ethers.getAddress(paymaster) !== ethers.ZeroAddress; } async ensureOperationalWalletsRegistered(options?: { @@ -889,6 +909,49 @@ export class MockChainAdapter implements ChainAdapter { if (params.ackSignatures.length < this.minimumRequiredSignatures) { throw new Error('MinSignaturesRequirementNotMet'); } + if ( + params.publisherConvictionAccountId !== undefined && + params.paymaster !== '0x0000000000000000000000000000000000000000' + ) { + throw new Error( + 'V10 conviction publish cannot also use a paymaster; choose one gateway payment path', + ); + } + if (params.publisherConvictionAccountId !== undefined) { + const convictionAccount = this.convictionAccounts.get(params.publisherConvictionAccountId); + if (!convictionAccount) { + throw new Error(`Publishing conviction account ${params.publisherConvictionAccountId} unavailable`); + } + // Mirror DKGPublishingConvictionNFT.agentToAccountId semantics: an + // agent address is bound to AT MOST ONE account, and `publish()` + // resolves the paying account through that 1:1 reverse map. The + // EVM adapter's preflight at evm-adapter.ts:1648-1665 enforces + // `agentToAccountId(signer) === publisherConvictionAccountId`, so + // the mock has to fail under the same conditions or mock-backed + // tests will pass for a publish that reverts on a real chain. + const signerAddress = ethers.getAddress(this.signerAddress); + let registeredAccountId: bigint | undefined; + for (const [id, acct] of this.convictionAccounts) { + if (acct.authorizedKeys.has(signerAddress)) { + registeredAccountId = id; + break; + } + } + if (registeredAccountId === undefined) { + throw new Error( + `Publisher wallet ${this.signerAddress} is not registered under requested ` + + `publisher conviction account ${params.publisherConvictionAccountId}; ` + + `registered account: none`, + ); + } + if (registeredAccountId !== params.publisherConvictionAccountId) { + throw new Error( + `Publisher wallet ${this.signerAddress} is not registered under requested ` + + `publisher conviction account ${params.publisherConvictionAccountId}; ` + + `registered account: ${registeredAccountId}`, + ); + } + } // P-1 review (follow-up): mirror the EVM adapter's write-ahead // hook so mock-backed publisher tests observe the same phase @@ -947,6 +1010,7 @@ export class MockChainAdapter implements ChainAdapter { isImmutable: params.isImmutable, contextGraphId: params.contextGraphId.toString(), paymaster: params.paymaster, + publisherConvictionAccountId: params.publisherConvictionAccountId?.toString(), }); const result = this.txResult(true); diff --git a/packages/chain/src/no-chain-adapter.ts b/packages/chain/src/no-chain-adapter.ts index fe8735111..a9357e4c0 100644 --- a/packages/chain/src/no-chain-adapter.ts +++ b/packages/chain/src/no-chain-adapter.ts @@ -46,6 +46,7 @@ export class NoChainAdapter implements ChainAdapter { async revealContextGraphMetadata(_contextGraphId: string, _name: string, _description: string): Promise { noChain(); } async createKnowledgeAssetsV10(_params: V10PublishDirectParams): Promise { noChain(); } async isOperationalWalletRegistered(_identityId: bigint, _address: string): Promise { return false; } + async isPaymasterValid(_paymaster: string): Promise { return false; } async getKnowledgeAssetsV10Address(): Promise { noChain(); } async getEvmChainId(): Promise { noChain(); } isV10Ready(): boolean { return false; } diff --git a/packages/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index 1302c8ae6..385ea2ab3 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -115,6 +115,11 @@ const MOCK_EXEMPT_FROM_EVM = new Set([ // getKCContextGraphId) ARE mirrored on MockChainAdapter. 'requireKCStorage', 'requireContextGraphStorage', + // V10 publisher-conviction NFT (PR #405) — TS-private helper that + // lazily resolves the DKGPublishingConvictionNFT contract from the + // Hub. The mock keeps a `convictionAccounts` Map directly and has no + // need for a Contract resolver. + 'resolvePublishingConvictionNFT', ]); const NO_CHAIN_EXEMPT_FROM_EVM = new Set([ diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 1072b912a..df22508c6 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { readApiPort, readPid, isProcessRunning } from './config.js'; +import type { PublisherGatewayConfig } from './config.js'; import { loadTokens } from './auth.js'; export type QueryResult = @@ -190,7 +191,7 @@ export class ApiClient { contextGraphId: string, selection: 'all' | { rootEntities: string[] } = 'all', clearAfter = true, - options?: { subGraphName?: string }, + options?: { subGraphName?: string; publishGateway?: PublisherGatewayConfig }, ): Promise<{ kcId: string; status: 'tentative' | 'confirmed'; @@ -203,6 +204,7 @@ export class ApiClient { selection, clearAfter, ...(options?.subGraphName ? { subGraphName: options.subGraphName } : {}), + ...(options?.publishGateway ? { publishGateway: options.publishGateway } : {}), }); } @@ -211,7 +213,7 @@ export class ApiClient { contextGraphId: string, selection: 'all' | { rootEntities: string[] } = 'all', clearAfter = true, - options?: { subGraphName?: string }, + options?: { subGraphName?: string; publishGateway?: PublisherGatewayConfig }, ): Promise<{ kcId: string; status: 'tentative' | 'confirmed'; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a461524dc..6ddd151e4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,7 +18,7 @@ import { releasesDir, activeSlot, swapSlot, slotEntryPoint, isStandaloneInstall, resolveContextGraphs, resolveNetworkDefaultContextGraphs, - type AutoUpdateConfig, + type AutoUpdateConfig, type PublisherGatewayConfig, type LocalPublisherGatewayConfig, } from './config.js'; import { ApiClient } from './api-client.js'; import { parsePositiveIntegerOption, parsePositiveMsOption } from './publisher-runner.js'; @@ -60,6 +60,123 @@ import { registerIntegrationCommands } from './integrations/commands.js'; /** Commander action callbacks receive parsed .option() values with loose types. */ type ActionOpts = Record; // eslint-disable-line @typescript-eslint/no-explicit-any +function parsePositiveIntegerString(value: string, flag: string): string { + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new Error(`${flag} must be a positive integer`); + } + if (parsed <= 0n) throw new Error(`${flag} must be a positive integer`); + return parsed.toString(); +} + +function buildPublisherGatewayConfig(args: { + peerId: string; + identity: string; + pcaAccount?: string; + paymaster?: string; +}): PublisherGatewayConfig { + // The V10 publish path rejects PCA + paymaster on the same call (see + // EVMChainAdapter.publishToContextGraph and DKGPublisher's gateway + // request builder), so reject the combination at config-build time. + // Otherwise the CLI would happily persist or serialize an impossible + // config and the user would only find out after a network round-trip. + if (args.pcaAccount && args.paymaster) { + throw new Error( + 'Publish gateway cannot use both --pca-account and --paymaster; choose one payment path', + ); + } + return { + peerId: args.peerId, + nodeIdentityId: parsePositiveIntegerString(args.identity, '--identity'), + ...(args.pcaAccount ? { pcaAccountId: parsePositiveIntegerString(args.pcaAccount, '--pca-account') } : {}), + ...(args.paymaster ? { paymaster: ethers.getAddress(args.paymaster) } : {}), + }; +} + +function mergeLocalPublisherGatewayCliOpts( + opts: ActionOpts, + prev?: LocalPublisherGatewayConfig, +): { pcaAccount?: string; paymaster?: string; allowedPeers?: string } { + const pcaAccount = + opts.pcaAccount !== undefined + ? (String(opts.pcaAccount).trim() || undefined) + : prev?.pcaAccountId; + const paymaster = + opts.paymaster !== undefined + ? (String(opts.paymaster).trim() || undefined) + : prev?.paymaster; + const allowedPeers = + opts.allowedPeers !== undefined + ? String(opts.allowedPeers) + : prev?.allowedPeers?.join(','); + return { pcaAccount, paymaster, allowedPeers }; +} + +function buildLocalPublisherGatewayConfig(args: { + pcaAccount?: string; + paymaster?: string; + allowedPeers?: string; +}): LocalPublisherGatewayConfig { + if (args.pcaAccount && args.paymaster) { + throw new Error( + 'publisher.localGateway cannot combine --pca-account and --paymaster; choose one', + ); + } + const allowedPeers = + typeof args.allowedPeers === 'string' && args.allowedPeers.trim().length > 0 + ? args.allowedPeers.split(',').map((p) => p.trim()).filter((p) => p.length > 0) + : undefined; + const paymasterAddr = args.paymaster ? ethers.getAddress(String(args.paymaster)) : undefined; + if (paymasterAddr && paymasterAddr !== ethers.ZeroAddress && (!allowedPeers || allowedPeers.length === 0)) { + throw new Error( + 'publisher.localGateway: when setting a paymaster, --allowed-peers must list at least one libp2p peer id', + ); + } + const pca = + args.pcaAccount && String(args.pcaAccount).trim().length > 0 + ? parsePositiveIntegerString(String(args.pcaAccount), '--pca-account') + : undefined; + if (!pca && !paymasterAddr && (!allowedPeers || allowedPeers.length === 0)) { + throw new Error( + 'publisher.localGateway would be empty (need --pca-account, --paymaster, and/or --allowed-peers)', + ); + } + return { + ...(pca ? { pcaAccountId: pca } : {}), + ...(paymasterAddr ? { paymaster: paymasterAddr } : {}), + ...(allowedPeers && allowedPeers.length > 0 ? { allowedPeers } : {}), + }; +} + +function resolvePublishGatewayForCommand(opts: ActionOpts, stored?: PublisherGatewayConfig): PublisherGatewayConfig | undefined { + const hasCommandOverride = !!( + opts.publishGatewayPeer || + opts.publishGatewayIdentity || + opts.pcaAccount || + opts.paymaster + ); + if (!hasCommandOverride) return stored; + const peerId = String(opts.publishGatewayPeer ?? stored?.peerId ?? ''); + if (!peerId) { + throw new Error('--publish-gateway-peer is required when gateway override options are supplied'); + } + const identity = String(opts.publishGatewayIdentity ?? (stored?.peerId === peerId ? stored.nodeIdentityId : '') ?? ''); + if (!identity) { + throw new Error( + `No identity configured for publish gateway ${peerId}. ` + + 'Run `dkg publisher gateway set` first or pass --publish-gateway-identity.', + ); + } + return buildPublisherGatewayConfig({ + peerId, + identity, + pcaAccount: opts.pcaAccount ?? (stored?.peerId === peerId ? stored.pcaAccountId : undefined), + paymaster: opts.paymaster ?? (stored?.peerId === peerId ? stored.paymaster : undefined), + }); +} + const STARTUP_BANNER = ` \x1b[36m██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██╔══██╗██║ ██╔╝██╔════╝ ██║ ██║██╔══██╗ @@ -2254,15 +2371,22 @@ sharedMemoryCmd .option('--keep', 'Keep shared memory triples after publishing') .option('--root ', 'Publish only specific root entities') .option('--sub-graph-name ', 'Publish from a specific shared-memory sub-graph') + .option('--publish-gateway-peer ', 'Preferred core peer to sign the V10 publisher digest') + .option('--publish-gateway-identity ', 'Gateway core node identity ID (uses saved gateway config when omitted)') + .option('--pca-account ', 'Publisher conviction account ID requested from the gateway') + .option('--paymaster
', 'Paymaster address requested from the gateway') .action(async (contextGraph: string | undefined, opts: ActionOpts) => { try { const targetContextGraph = contextGraph ?? 'dev-coordination'; const client = await ApiClient.connect(); + const config = await loadConfig(); + const publishGateway = resolvePublishGatewayForCommand(opts, config.publisher?.gateway); const selection = opts.root?.length ? { rootEntities: opts.root as string[] } : 'all'; const result = await client.publishFromSharedMemory(targetContextGraph, selection, !opts.keep, { subGraphName: opts.subGraphName, + publishGateway, }); console.log(`Published from shared memory to "${targetContextGraph}":`); console.log(` Status: ${result.status}`); @@ -2271,6 +2395,9 @@ sharedMemoryCmd if (opts.subGraphName) { console.log(` Sub-graph: ${opts.subGraphName}`); } + if (publishGateway) { + console.log(` Gateway: ${publishGateway.peerId} (identity ${publishGateway.nodeIdentityId})`); + } if (result.txHash) { console.log(` TX: ${result.txHash}`); } @@ -2364,6 +2491,144 @@ publisherWalletCmd } }); +const publisherGatewayCmd = publisherCmd + .command('gateway') + .description('Manage preferred publish gateway core node'); + +publisherGatewayCmd + .command('set ') + .description('Set preferred publish gateway core node') + .requiredOption('--identity ', 'Gateway core node identity ID') + .option('--pca-account ', 'Publisher conviction account ID exposed by the gateway') + .option('--paymaster
', 'Paymaster address exposed by the gateway') + .action(async (peerId: string, opts: ActionOpts) => { + try { + const config = await loadConfig(); + const gateway = buildPublisherGatewayConfig({ + peerId, + identity: String(opts.identity), + pcaAccount: opts.pcaAccount, + paymaster: opts.paymaster, + }); + config.publisher = { ...(config.publisher ?? {}), gateway }; + await saveConfig(config); + console.log('Publish gateway configured.'); + console.log(` Peer: ${gateway.peerId}`); + console.log(` Identity: ${gateway.nodeIdentityId}`); + if (gateway.pcaAccountId) console.log(` PCA: ${gateway.pcaAccountId}`); + if (gateway.paymaster) console.log(` Paymaster: ${gateway.paymaster}`); + } catch (err) { + console.error(toErrorMessage(err)); + process.exit(1); + } + }); + +publisherGatewayCmd + .command('status') + .description('Show preferred publish gateway core node') + .action(async () => { + try { + const config = await loadConfig(); + const gateway = config.publisher?.gateway; + if (!gateway) { + console.log('No publish gateway configured.'); + return; + } + console.log('Publish gateway:'); + console.log(` Peer: ${gateway.peerId}`); + console.log(` Identity: ${gateway.nodeIdentityId}`); + if (gateway.pcaAccountId) console.log(` PCA: ${gateway.pcaAccountId}`); + if (gateway.paymaster) console.log(` Paymaster: ${gateway.paymaster}`); + } catch (err) { + console.error(toErrorMessage(err)); + process.exit(1); + } + }); + +const publisherLocalGatewayCmd = publisherCmd + .command('local-gateway') + .description('Configure what THIS core advertises on PublishGatewayHandler (PCA/paymaster/allowlist)'); + +publisherLocalGatewayCmd + .command('set') + .description('Set or update publisher.localGateway (omit a flag to keep its previous value)') + .option('--pca-account ', 'Publisher conviction account ID to advertise') + .option('--paymaster
', 'Paymaster address to advertise') + .option('--allowed-peers ', 'Comma-separated libp2p peer IDs (required when advertising a paymaster)') + .action(async (opts: ActionOpts) => { + try { + const config = await loadConfig(); + const hasExplicit = + opts.pcaAccount !== undefined || + opts.paymaster !== undefined || + opts.allowedPeers !== undefined; + if (!hasExplicit && !config.publisher?.localGateway) { + throw new Error( + 'Pass at least one of --pca-account, --paymaster, or --allowed-peers (nothing to merge yet)', + ); + } + const merged = mergeLocalPublisherGatewayCliOpts(opts, config.publisher?.localGateway); + const local = buildLocalPublisherGatewayConfig(merged); + config.publisher = { ...(config.publisher ?? {}), localGateway: local }; + await saveConfig(config); + console.log('Local publish gateway (handler advertisement) configured.'); + if (local.pcaAccountId) console.log(` PCA: ${local.pcaAccountId}`); + if (local.paymaster) console.log(` Paymaster: ${local.paymaster}`); + if (local.allowedPeers?.length) { + console.log(` Allowed peers: ${local.allowedPeers.join(', ')}`); + } + } catch (err) { + console.error(toErrorMessage(err)); + process.exit(1); + } + }); + +publisherLocalGatewayCmd + .command('clear') + .description('Remove publisher.localGateway advertisement block') + .action(async () => { + try { + const config = await loadConfig(); + if (!config.publisher?.localGateway) { + console.log('No local publish gateway configuration to clear.'); + return; + } + config.publisher = { ...config.publisher }; + delete config.publisher.localGateway; + await saveConfig(config); + console.log('Local publish gateway configuration cleared.'); + } catch (err) { + console.error(toErrorMessage(err)); + process.exit(1); + } + }); + +publisherLocalGatewayCmd + .command('status') + .description('Show publisher.localGateway advertisement') + .action(async () => { + try { + const config = await loadConfig(); + const lg = config.publisher?.localGateway; + if (!lg) { + console.log('No local publish gateway configured.'); + return; + } + console.log('Local publish gateway (handler advertisement):'); + if (lg.pcaAccountId) console.log(` PCA: ${lg.pcaAccountId}`); + if (lg.paymaster) console.log(` Paymaster: ${lg.paymaster}`); + if (lg.allowedPeers?.length) { + console.log(` Allowed peers: ${lg.allowedPeers.join(', ')}`); + } + if (!lg.pcaAccountId && !lg.paymaster && !lg.allowedPeers?.length) { + console.log(' (empty block — use `dkg publisher local-gateway clear` to remove)'); + } + } catch (err) { + console.error(toErrorMessage(err)); + process.exit(1); + } + }); + publisherCmd .command('enable') .description('Enable async publisher runtime') @@ -2374,6 +2639,7 @@ publisherCmd try { const config = await loadConfig(); config.publisher = { + ...(config.publisher ?? {}), enabled: true, pollIntervalMs: parsePositiveMsOption(String(opts.pollInterval ?? '12000'), '--poll-interval'), errorBackoffMs: parsePositiveMsOption(String(opts.errorBackoff ?? '5000'), '--error-backoff'), diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index e63d73571..702962ea3 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -129,6 +129,36 @@ export interface ChainConfig { mockIdentityId?: string; } +export interface PublisherGatewayConfig { + peerId: string; + nodeIdentityId: string; + pcaAccountId?: string; + paymaster?: string; +} + +/** + * Local advertisement for THIS node's PublishGatewayHandler — the + * pcaAccountId / paymaster a core node is willing to sign requests + * against. Distinct from `publisher.gateway`, which is the OUTBOUND + * preference (which remote core to publish through). Reusing one + * config object for both broke isolation: a core that pointed at an + * upstream gateway would also start advertising the upstream's + * pcaAccountId / paymaster on its own PublishGatewayHandler. + */ +export interface LocalPublisherGatewayConfig { + pcaAccountId?: string; + paymaster?: string; + /** + * Optional libp2p peer-id allowlist. When present (and non-empty), + * the local PublishGatewayHandler refuses to sign requests from any + * peer not in this set. Defaults to "open" (no allowlist) for back- + * compat with the existing devnet setup; production cores that + * advertise a paymaster MUST set this to avoid accidental sponsoring + * of arbitrary peers. + */ + allowedPeers?: string[]; +} + /** Optional LLM config for the Node UI chatbot (OpenAI-compatible API). */ export interface LlmConfig { /** API key (e.g. OpenAI, Anthropic, or compatible provider). */ @@ -249,6 +279,21 @@ export interface DkgConfig { pollIntervalMs?: number; errorBackoffMs?: number; maxRetries?: number; + /** + * Outbound preference: where THIS node's edge-side publisher + * routes V10 publisher-digest signing requests. Set via + * `dkg publisher gateway set --identity `. + */ + gateway?: PublisherGatewayConfig; + /** + * Local advertisement for THIS node's PublishGatewayHandler when + * acting as a gateway for OTHER nodes. Set via + * `dkg publisher local-gateway set|clear|status`. Distinct from `gateway` + * above so a core that points at an upstream gateway does not + * accidentally re-advertise the upstream's PCA/paymaster on its + * own handler. + */ + localGateway?: LocalPublisherGatewayConfig; }; /** Allowed CORS origins. Defaults to '*' when apiHost is '127.0.0.1', otherwise restrictive. */ corsOrigins?: string | string[]; diff --git a/packages/cli/src/daemon/lifecycle.ts b/packages/cli/src/daemon/lifecycle.ts index 3f7c7ccb0..31ea8b9c3 100644 --- a/packages/cli/src/daemon/lifecycle.ts +++ b/packages/cli/src/daemon/lifecycle.ts @@ -81,6 +81,7 @@ import { TELEMETRY_ENDPOINTS, type DkgConfig, type AutoUpdateConfig, + type PublisherGatewayConfig, type LocalAgentIntegrationCapabilities, type LocalAgentIntegrationConfig, type LocalAgentIntegrationManifest, @@ -121,6 +122,46 @@ import { FileStore } from '../file-store.js'; import { VectorStore, OpenAIEmbeddingProvider, type EmbeddingProvider } from '../vector-store.js'; import { parseBoundary, parseMultipart, MultipartParseError } from '../http/multipart.js'; import { handleCapture, EpcisValidationError, handleEventsQuery, EpcisQueryError, type Publisher as EpcisPublisher } from '@origintrail-official/dkg-epcis'; + +function normalizePublisherGatewayConfig(gateway?: PublisherGatewayConfig) { + if (!gateway) return undefined; + return { + peerId: gateway.peerId, + nodeIdentityId: BigInt(gateway.nodeIdentityId), + ...(gateway.pcaAccountId ? { pcaAccountId: BigInt(gateway.pcaAccountId) } : {}), + ...(gateway.paymaster ? { paymaster: ethers.getAddress(gateway.paymaster) } : {}), + }; +} + +function normalizeLocalPublisherGatewayConfig(local?: { pcaAccountId?: string; paymaster?: string; allowedPeers?: string[] }) { + if (!local) return undefined; + if (local.pcaAccountId === undefined && local.paymaster === undefined && (!local.allowedPeers || local.allowedPeers.length === 0)) { + return undefined; + } + const allowedPeers = local.allowedPeers + ? local.allowedPeers.map((p) => p.trim()).filter((p) => p.length > 0) + : []; + // Fail-closed at config-load time so operators get an immediate clear + // error instead of the gateway handler's retry-loop warning. A + // configured paymaster without a non-empty allowlist would let any + // connected peer get sponsored publishes. + if ( + local.paymaster + && ethers.getAddress(local.paymaster) !== ethers.ZeroAddress + && allowedPeers.length === 0 + ) { + throw new Error( + 'publisher.localGateway.paymaster is configured but allowedPeers is empty; ' + + 'set allowedPeers (libp2p peer-ids) before advertising a paymaster', + ); + } + return { + ...(local.pcaAccountId ? { pcaAccountId: BigInt(local.pcaAccountId) } : {}), + ...(local.paymaster ? { paymaster: ethers.getAddress(local.paymaster) } : {}), + ...(allowedPeers.length > 0 ? { allowedPeers } : {}), + }; +} + // Phase 8 — project-manifest publish + install (UI-driven onboarding flow). // Daemon constructs a self-pointing DkgClient (localhost:listenPort) and // reuses the same publish/fetch/plan/write helpers the CLI uses, so wire @@ -582,6 +623,8 @@ export async function runDaemonInner( randomSamplingWalPath: config.randomSampling?.walPath, randomSamplingTickIntervalMs: config.randomSampling?.tickIntervalMs, randomSamplingUseWorkerThread: config.randomSampling?.useWorkerThread, + publishGateway: normalizePublisherGatewayConfig(config.publisher?.gateway), + localPublishGateway: normalizeLocalPublisherGatewayConfig(config.publisher?.localGateway), contextGraphSubscriptionStore: { loadAll: async () => dashDb.listContextGraphSubscriptions().map((row) => ({ id: row.context_graph_id, diff --git a/packages/cli/src/daemon/routes/context-graph.ts b/packages/cli/src/daemon/routes/context-graph.ts index 3d999b512..28fe82ced 100644 --- a/packages/cli/src/daemon/routes/context-graph.ts +++ b/packages/cli/src/daemon/routes/context-graph.ts @@ -384,44 +384,50 @@ export async function handleContextGraphRoutes(ctx: RequestContext): Promise= 1)", }); } - if (requiredSignatures > participantIdentityIds.length) { - return jsonResponse(res, 400, { - error: `requiredSignatures (${requiredSignatures}) cannot exceed participantIdentityIds count (${participantIdentityIds.length})`, - }); - } - for (let i = 0; i < participantIdentityIds.length; i++) { - const id = participantIdentityIds[i]; - if (typeof id === "number") { - if ( - !Number.isInteger(id) || - id <= 0 || - id > Number.MAX_SAFE_INTEGER - ) { - return jsonResponse(res, 400, { - error: `participantIdentityIds[${i}] must be a positive safe integer`, - }); - } - } else if (typeof id === "string") { - if (!/^\d+$/.test(id) || id === "0") { + // Legacy quorum metadata is independent of `participantIdentityIds` + // length for edge-owned / empty-hosting CGs (on-chain hosting list may + // be empty while `requiredSignatures` still records metadata). Only + // enforce M<=N when there is a non-empty participant list. + if (participantIdentityIds.length > 0) { + if (requiredSignatures > participantIdentityIds.length) { + return jsonResponse(res, 400, { + error: `requiredSignatures (${requiredSignatures}) cannot exceed participantIdentityIds count (${participantIdentityIds.length})`, + }); + } + for (let i = 0; i < participantIdentityIds.length; i++) { + const id = participantIdentityIds[i]; + if (typeof id === "number") { + if ( + !Number.isInteger(id) || + id <= 0 || + id > Number.MAX_SAFE_INTEGER + ) { + return jsonResponse(res, 400, { + error: `participantIdentityIds[${i}] must be a positive safe integer`, + }); + } + } else if (typeof id === "string") { + if (!/^\d+$/.test(id) || id === "0") { + return jsonResponse(res, 400, { + error: `participantIdentityIds[${i}] must be a positive decimal integer string`, + }); + } + } else { return jsonResponse(res, 400, { - error: `participantIdentityIds[${i}] must be a positive decimal integer string`, + error: `participantIdentityIds[${i}] must be a number or string`, }); } - } else { - return jsonResponse(res, 400, { - error: `participantIdentityIds[${i}] must be a number or string`, - }); } } try { - const mappedIds = participantIdentityIds.map((id: number | string) => - BigInt(id), - ); - const uniqueIds: bigint[] = Array.from(new Set(mappedIds)); - const sortedUniqueIds = uniqueIds.sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - ); - if (requiredSignatures > sortedUniqueIds.length) { + const sortedUniqueIds = participantIdentityIds.length === 0 + ? [] + : Array.from( + new Set( + (participantIdentityIds as (number | string)[]).map((id) => BigInt(id)), + ), + ).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + if (sortedUniqueIds.length > 0 && requiredSignatures > sortedUniqueIds.length) { return jsonResponse(res, 400, { error: `requiredSignatures (${requiredSignatures}) exceeds unique participant count (${sortedUniqueIds.length}) after deduplication`, }); diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index 7e8b3f30c..e6fc97e4e 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -84,6 +84,7 @@ import { TELEMETRY_ENDPOINTS, type DkgConfig, type AutoUpdateConfig, + type PublisherGatewayConfig, type LocalAgentIntegrationCapabilities, type LocalAgentIntegrationConfig, type LocalAgentIntegrationManifest, @@ -119,6 +120,77 @@ import { hasVerifiedBundledBinary as hasVerifiedBundledMarkItDownBinary, metadataPathFor as markItDownMetadataPath, } from '../../../scripts/markitdown-bundle-validation.mjs'; + +function normalizePublishGatewayFromBody(input: unknown): PublisherGatewayConfig | undefined { + if (input === undefined || input === null) return undefined; + if (typeof input !== 'object') { + throw new Error('"publishGateway" must be an object'); + } + const gateway = input as Record; + if (typeof gateway.peerId !== 'string' || gateway.peerId.trim().length === 0) { + throw new Error('"publishGateway.peerId" is required'); + } + if (gateway.nodeIdentityId === undefined || gateway.nodeIdentityId === null) { + throw new Error('"publishGateway.nodeIdentityId" is required'); + } + const nodeIdentityId = parsePositiveIntegerForGateway(gateway.nodeIdentityId, 'publishGateway.nodeIdentityId'); + const pcaAccountId = gateway.pcaAccountId === undefined + ? undefined + : parsePositiveIntegerForGateway(gateway.pcaAccountId, 'publishGateway.pcaAccountId'); + const paymaster = gateway.paymaster === undefined + ? undefined + : ethers.getAddress(String(gateway.paymaster)); + if (pcaAccountId && paymaster) { + // The V10 publish path rejects PCA + paymaster on the same call + // (EVMChainAdapter.publishToContextGraph), so refuse the impossible + // combination here instead of persisting/serializing it for a + // network round-trip that will fail. + throw new Error( + '"publishGateway" cannot set both pcaAccountId and paymaster; choose one payment path', + ); + } + return { + peerId: gateway.peerId.trim(), + nodeIdentityId, + ...(pcaAccountId ? { pcaAccountId } : {}), + ...(paymaster ? { paymaster } : {}), + }; +} + +/** + * Parse a positive integer ID from a JSON-decoded body field. Strings + * and bigints are accepted as-is; numbers are accepted ONLY when they + * fit in `Number.MAX_SAFE_INTEGER` because JSON.parse rounds anything + * larger to the nearest IEEE-754 double before we ever see it. An + * `nodeIdentityId` like 9007199254740993 (= 2^53 + 1) submitted as a + * raw JSON number arrives as 9007199254740992 — the request would then + * pass validation against the rounded ID, never the one the caller + * meant. Force callers to use strings (or safe-range numbers) so the + * value the daemon validates is the value that actually appeared on + * the wire. + */ +function parsePositiveIntegerForGateway(value: unknown, field: string): string { + if (typeof value === 'number') { + if (!Number.isInteger(value) || !Number.isSafeInteger(value)) { + throw new Error( + `"${field}" exceeds Number.MAX_SAFE_INTEGER; pass it as a string`, + ); + } + if (value <= 0) throw new Error(`"${field}" must be a positive integer`); + return String(value); + } + if (typeof value !== 'string' && typeof value !== 'bigint') { + throw new Error(`"${field}" must be a positive integer`); + } + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new Error(`"${field}" must be a positive integer`); + } + if (parsed <= 0n) throw new Error(`"${field}" must be a positive integer`); + return parsed.toString(); +} import { type ExtractionStatusRecord, getExtractionStatusRecord, setExtractionStatusRecord } from '../../extraction-status.js'; import { FileStore } from '../../file-store.js'; import { VectorStore, OpenAIEmbeddingProvider, type EmbeddingProvider } from '../../vector-store.js'; @@ -549,6 +621,14 @@ WHERE { if (!parsed) return; const { selection, clearAfter, publishContextGraphId, subGraphName } = parsed; + let publishGateway: PublisherGatewayConfig | undefined; + try { + publishGateway = normalizePublishGatewayFromBody(parsed.publishGateway); + } catch (err) { + return jsonResponse(res, 400, { + error: err instanceof Error ? err.message : String(err), + }); + } const paranetId = parsed.contextGraphId ?? parsed.paranetId; if (!paranetId) return jsonResponse(res, 400, { @@ -584,6 +664,14 @@ WHERE { clearSharedMemoryAfter: clearAfter ?? true, operationCtx: ctx, subGraphName, + publishGateway: publishGateway + ? { + peerId: publishGateway.peerId, + nodeIdentityId: BigInt(publishGateway.nodeIdentityId), + ...(publishGateway.pcaAccountId ? { pcaAccountId: BigInt(publishGateway.pcaAccountId) } : {}), + ...(publishGateway.paymaster ? { paymaster: publishGateway.paymaster } : {}), + } + : undefined, ...(resolvedPublishContextGraphId != null ? { contextGraphId: resolvedPublishContextGraphId } : {}), diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6ce86e3ef..1af945c61 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -13,6 +13,7 @@ export const PROTOCOL_JOIN_REQUEST = '/dkg/10.0.0/join-request'; export const PROTOCOL_VERIFY_PROPOSAL = '/dkg/10.0.0/verify-proposal'; export const PROTOCOL_VERIFY_APPROVAL = '/dkg/10.0.0/verify-approval'; export const PROTOCOL_STORAGE_ACK = '/dkg/10.0.0/storage-ack'; +export const PROTOCOL_PUBLISH_GATEWAY = '/dkg/10.0.0/publish-gateway'; export const DHT_PROTOCOL = '/dkg/kad/1.0.0'; diff --git a/packages/evm-module/abi/KnowledgeAssetsV10.json b/packages/evm-module/abi/KnowledgeAssetsV10.json index 2ece09b0f..89275b70a 100644 --- a/packages/evm-module/abi/KnowledgeAssetsV10.json +++ b/packages/evm-module/abi/KnowledgeAssetsV10.json @@ -728,6 +728,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "shardingTableStorage", + "outputs": [ + { + "internalType": "contract ShardingTableStorage", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "status", diff --git a/packages/evm-module/contracts/ContextGraphs.sol b/packages/evm-module/contracts/ContextGraphs.sol index 08e62d2e1..3f89ec834 100644 --- a/packages/evm-module/contracts/ContextGraphs.sol +++ b/packages/evm-module/contracts/ContextGraphs.sol @@ -75,9 +75,9 @@ contract ContextGraphs is INamed, IVersioned, ContractStatus, IInitializable { /** * @notice Create a new context graph. Mints an ERC-721 to msg.sender. - * @param hostingNodes Sorted ascending node identity IDs (storage attestation set) + * @param hostingNodes Legacy seed list. May be empty. * @param participantAgents EOA allow-list (no zeros, no dups) - * @param requiredSignatures M-of-N quorum (≤ hostingNodes.length) + * @param requiredSignatures Legacy metadata. VM ACK quorum is global/dynamic. * @param metadataBatchId Batch ID describing the CG metadata (0 if none) * @param publishPolicy 0 = curated, 1 = open * @param publishAuthority Curator address (required when curated; ignored when open) diff --git a/packages/evm-module/contracts/KnowledgeAssetsV10.sol b/packages/evm-module/contracts/KnowledgeAssetsV10.sol index b7d96f485..72b11c3ca 100644 --- a/packages/evm-module/contracts/KnowledgeAssetsV10.sol +++ b/packages/evm-module/contracts/KnowledgeAssetsV10.sol @@ -10,6 +10,7 @@ import {KnowledgeCollectionStorage} from "./storage/KnowledgeCollectionStorage.s import {IdentityStorage} from "./storage/IdentityStorage.sol"; import {ParametersStorage} from "./storage/ParametersStorage.sol"; import {ConvictionStakingStorage} from "./storage/ConvictionStakingStorage.sol"; +import {ShardingTableStorage} from "./storage/ShardingTableStorage.sol"; import {ContextGraphs} from "./ContextGraphs.sol"; import {ContextGraphStorage} from "./storage/ContextGraphStorage.sol"; import {ContextGraphValueStorage} from "./storage/ContextGraphValueStorage.sol"; @@ -152,6 +153,7 @@ contract KnowledgeAssetsV10 is INamed, IVersioned, ContractStatus, IInitializabl /// @notice v4.0.0 — TRAC vault + V10 stake reads. Replaces the prior /// `stakingStorage` field; CSS is the V10 source of truth. ConvictionStakingStorage public convictionStakingStorage; + ShardingTableStorage public shardingTableStorage; ContextGraphs public contextGraphs; ContextGraphStorage public contextGraphStorage; ContextGraphValueStorage public contextGraphValueStorage; @@ -199,6 +201,7 @@ contract KnowledgeAssetsV10 is INamed, IVersioned, ContractStatus, IInitializabl parametersStorage = ParametersStorage(hub.getContractAddress("ParametersStorage")); identityStorage = IdentityStorage(hub.getContractAddress("IdentityStorage")); convictionStakingStorage = ConvictionStakingStorage(hub.getContractAddress("ConvictionStakingStorage")); + shardingTableStorage = ShardingTableStorage(hub.getContractAddress("ShardingTableStorage")); // V10 new dependencies — fail-fast. Each MUST be Hub-registered at // KAV10 initialize() time. The Phase 7 transitional try/catch tolerance @@ -552,11 +555,10 @@ contract KnowledgeAssetsV10 is INamed, IVersioned, ContractStatus, IInitializabl revert KnowledgeCollectionLib.SignerIsNotNodeOperator(identityId, signer); } - // Core nodes must be staked (spec §9.0). v4.0.0 — read V10 canonical - // stake (`nodeStakeV10`) instead of the V8 archive aggregate; under - // mandatory migration `getNodeStake` is unmaintained for V10 nodes - // and would zero-gate every legitimate V10 ACK signer. - require(convictionStakingStorage.getNodeStakeV10(identityId) > 0, "ACK signer has no stake"); + // VM ACKs may come from any active core node, not from a fixed per-CG + // host list. Active sharding-table membership is the on-chain source + // of truth for "core node currently eligible to host and be challenged". + require(shardingTableStorage.nodeExists(identityId), "ACK signer is not an active core node"); } // ======================================================================== diff --git a/packages/evm-module/contracts/storage/ContextGraphStorage.sol b/packages/evm-module/contracts/storage/ContextGraphStorage.sol index a3cd9b698..870d49ed2 100644 --- a/packages/evm-module/contracts/storage/ContextGraphStorage.sol +++ b/packages/evm-module/contracts/storage/ContextGraphStorage.sol @@ -23,8 +23,9 @@ import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ * are tracked as two SEPARATE lists. Decision #21 — nodes and agents are * different principals; the old conflated `participantIdentityIds` field * is gone. - * - Quorum (`requiredSignatures`) is bound to hosting nodes only — ACK - * signatures attest storage and come from hosting nodes. + * - `hostingNodes` is legacy registration metadata. VM publish ACK quorum is + * dynamic and enforced by KnowledgeAssetsV10 against active sharding-table + * core nodes, not against this per-CG list. * - 3 curator types are supported via (publishAuthority, publishAuthorityAccountId): * EOA -> publishAuthority = wallet, accountId = 0 * Safe -> publishAuthority = multisig contract, accountId = 0 @@ -162,9 +163,9 @@ contract ContextGraphStorage is INamed, IVersioned, Guardian, ERC721Enumerable { /** * @notice Create a new context graph and mint its governance NFT. * @param owner_ Recipient of the ERC-721 (token holder == manager). - * @param hostingNodes Sorted ascending, no zeros, no duplicates. + * @param hostingNodes Legacy seed list. May be empty. * @param participantAgents EOA allow-list (no zeros, no duplicates). - * @param requiredSignatures ACK quorum, must be in (0, hostingNodes.length]. + * @param requiredSignatures Legacy metadata. VM ACK quorum is global. * @param metadataBatchId Batch ID describing the CG metadata (0 if none). * @param publishPolicy 0 = curated, 1 = open. * @param publishAuthority Curator address. Required when curated; ignored @@ -185,18 +186,12 @@ contract ContextGraphStorage is INamed, IVersioned, Guardian, ERC721Enumerable { if (owner_ == address(0)) { revert KnowledgeAssetsLib.InvalidContextGraphConfig("zero address owner"); } - if (hostingNodes.length == 0) { - revert KnowledgeAssetsLib.InvalidContextGraphConfig("empty hosting nodes"); - } if (hostingNodes.length > MAX_HOSTING_NODES) { revert KnowledgeAssetsLib.InvalidContextGraphConfig("hosting nodes cap"); } if (participantAgents.length > MAX_PARTICIPANT_AGENTS) { revert KnowledgeAssetsLib.InvalidContextGraphConfig("agents cap"); } - if (requiredSignatures == 0 || requiredSignatures > hostingNodes.length) { - revert KnowledgeAssetsLib.InvalidContextGraphConfig("invalid M/N threshold"); - } if (publishPolicy > 1) { revert KnowledgeAssetsLib.InvalidContextGraphConfig("invalid publishPolicy"); } @@ -448,24 +443,17 @@ contract ContextGraphStorage is INamed, IVersioned, Guardian, ERC721Enumerable { // ----------------------------------------------------------------------- /** - * @notice Replace the hosting node list entirely. New list is validated - * (sorted, no zeros, no duplicates) and the existing quorum must - * still fit in the new size. + * @notice Replace the legacy hosting node list entirely. New list may be + * empty; VM ACK eligibility is dynamic and enforced at publish time. */ function setHostingNodes( uint256 contextGraphId, uint72[] calldata nodes ) external onlyContracts { _requireExists(contextGraphId); - if (nodes.length == 0) { - revert KnowledgeAssetsLib.InvalidContextGraphConfig("empty hosting nodes"); - } if (nodes.length > MAX_HOSTING_NODES) { revert KnowledgeAssetsLib.InvalidContextGraphConfig("hosting nodes cap"); } - if (_contextGraphs[contextGraphId].requiredSignatures > nodes.length) { - revert KnowledgeAssetsLib.InvalidContextGraphConfig("setHostingNodes would break quorum"); - } _validateHostingNodes(nodes); // Full replace. @@ -539,10 +527,6 @@ contract ContextGraphStorage is INamed, IVersioned, Guardian, ERC721Enumerable { uint8 requiredSignatures ) external onlyContracts { _requireExists(contextGraphId); - uint256 hostCount = _hostingNodes[contextGraphId].length; - if (requiredSignatures == 0 || requiredSignatures > hostCount) { - revert KnowledgeAssetsLib.InvalidContextGraphConfig("invalid M/N threshold"); - } _contextGraphs[contextGraphId].requiredSignatures = requiredSignatures; emit QuorumUpdated(contextGraphId, requiredSignatures); } diff --git a/packages/evm-module/test/unit/ContextGraphStorage.test.ts b/packages/evm-module/test/unit/ContextGraphStorage.test.ts index e1650b9a6..a7e8b478a 100644 --- a/packages/evm-module/test/unit/ContextGraphStorage.test.ts +++ b/packages/evm-module/test/unit/ContextGraphStorage.test.ts @@ -129,12 +129,12 @@ describe('@unit ContextGraphStorage', () => { ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); }); - it('reverts on empty hosting nodes', async () => { - await expect( - StorageContract.connect(opSigner).createContextGraph( - accounts[1].address, [], baseAgents(), 1, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows empty hosting nodes for edge-owned CGs', async () => { + await StorageContract.connect(opSigner).createContextGraph( + accounts[1].address, [], baseAgents(), 3, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await StorageContract.getHostingNodes(1)).to.deep.equal([]); + expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(3); }); it('reverts on unsorted hosting nodes', async () => { @@ -183,20 +183,18 @@ describe('@unit ContextGraphStorage', () => { ).to.be.revertedWithCustomError(StorageContract, 'AgentParticipantAlreadyExists'); }); - it('reverts when requiredSignatures > hosting nodes length', async () => { - await expect( - StorageContract.connect(opSigner).createContextGraph( - accounts[1].address, [10n, 20n], baseAgents(), 3, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows requiredSignatures greater than hosting nodes length', async () => { + await StorageContract.connect(opSigner).createContextGraph( + accounts[1].address, [10n, 20n], baseAgents(), 3, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(3); }); - it('reverts when requiredSignatures == 0', async () => { - await expect( - StorageContract.connect(opSigner).createContextGraph( - accounts[1].address, baseHostingNodes(), baseAgents(), 0, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows requiredSignatures == 0 as legacy metadata', async () => { + await StorageContract.connect(opSigner).createContextGraph( + accounts[1].address, baseHostingNodes(), baseAgents(), 0, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(0); }); it('reverts on invalid publishPolicy (>1)', async () => { @@ -267,10 +265,9 @@ describe('@unit ContextGraphStorage', () => { ).to.be.revertedWithCustomError(HubContract, 'UnauthorizedAccess'); }); - it('reverts on empty list', async () => { - await expect( - StorageContract.connect(opSigner).setHostingNodes(1, []), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows clearing hosting nodes', async () => { + await StorageContract.connect(opSigner).setHostingNodes(1, []); + expect(await StorageContract.getHostingNodes(1)).to.deep.equal([]); }); it('reverts on unsorted list', async () => { @@ -291,11 +288,9 @@ describe('@unit ContextGraphStorage', () => { ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); }); - it('reverts when new size would break quorum', async () => { - // CG has requiredSignatures=2, replacing with 1 node breaks the quorum - await expect( - StorageContract.connect(opSigner).setHostingNodes(1, [42n]), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows hosting node list to shrink below legacy quorum', async () => { + await StorageContract.connect(opSigner).setHostingNodes(1, [42n]); + expect(await StorageContract.getHostingNodes(1)).to.deep.equal([42n]); }); it('reverts for nonexistent CG', async () => { @@ -621,30 +616,23 @@ describe('@unit ContextGraphStorage', () => { }); // ------------------------------------------------------------------------- - // updateQuorum: still hosting-node based + // updateQuorum: legacy metadata only; VM ACK quorum is global/dynamic // ------------------------------------------------------------------------- - describe('updateQuorum (now bound to hosting nodes)', () => { + describe('updateQuorum (legacy metadata)', () => { beforeEach(async () => { await StorageContract.connect(opSigner).createContextGraph( accounts[0].address, [10n, 20n, 30n], [], 1, 0, 1, ethers.ZeroAddress, 0, ); }); - it('updates threshold within hosting node count', async () => { - await StorageContract.connect(opSigner).updateQuorum(1, 3); - expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(3); + it('updates threshold independently from hosting node count', async () => { + await StorageContract.connect(opSigner).updateQuorum(1, 4); + expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(4); }); - it('reverts when threshold > hosting nodes length', async () => { - await expect( - StorageContract.connect(opSigner).updateQuorum(1, 4), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); - }); - - it('reverts on zero threshold', async () => { - await expect( - StorageContract.connect(opSigner).updateQuorum(1, 0), - ).to.be.revertedWithCustomError(StorageContract, 'InvalidContextGraphConfig'); + it('allows threshold == 0', async () => { + await StorageContract.connect(opSigner).updateQuorum(1, 0); + expect(await StorageContract.getContextGraphRequiredSignatures(1)).to.equal(0); }); }); diff --git a/packages/evm-module/test/unit/ContextGraphs.test.ts b/packages/evm-module/test/unit/ContextGraphs.test.ts index a9d8beafc..9a7ece912 100644 --- a/packages/evm-module/test/unit/ContextGraphs.test.ts +++ b/packages/evm-module/test/unit/ContextGraphs.test.ts @@ -238,12 +238,12 @@ describe('@unit ContextGraphs (facade)', () => { }); // --- Validation (storage-level reverts bubble through the facade) --- - it('reverts on empty hosting nodes', async () => { - await expect( - Facade.connect(accounts[0]).createContextGraph( - [], noAgents(), 1, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows empty hosting nodes for edge-owned CGs', async () => { + await Facade.connect(accounts[0]).createContextGraph( + [], noAgents(), 3, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await Storage.getHostingNodes(1)).to.deep.equal([]); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(3); }); it('reverts on zero hosting node id', async () => { @@ -319,20 +319,18 @@ describe('@unit ContextGraphs (facade)', () => { ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); }); - it('reverts when requiredSignatures > hostingNodes.length', async () => { - await expect( - Facade.connect(accounts[0]).createContextGraph( - [10n, 20n], noAgents(), 3, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows requiredSignatures greater than hostingNodes.length', async () => { + await Facade.connect(accounts[0]).createContextGraph( + [10n, 20n], noAgents(), 3, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(3); }); - it('reverts when requiredSignatures == 0', async () => { - await expect( - Facade.connect(accounts[0]).createContextGraph( - hosts(), noAgents(), 0, 0, 1, ethers.ZeroAddress, 0, - ), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows requiredSignatures == 0 as legacy metadata', async () => { + await Facade.connect(accounts[0]).createContextGraph( + hosts(), noAgents(), 0, 0, 1, ethers.ZeroAddress, 0, + ); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(0); }); it('reverts on invalid publishPolicy (>1)', async () => { @@ -1140,12 +1138,9 @@ describe('@unit ContextGraphs (facade)', () => { ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); }); - it('reverts when new size would break quorum (requiredSignatures=2)', async () => { - // CG was created with requiredSignatures = 2, so shrinking to 1 node - // is invalid. - await expect( - Facade.connect(accounts[0]).setHostingNodes(1, [42n]), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows hosting node list to shrink below legacy quorum', async () => { + await Facade.connect(accounts[0]).setHostingNodes(1, [42n]); + expect(await Storage.getHostingNodes(1)).to.deep.equal([42n]); }); }); @@ -1244,11 +1239,11 @@ describe('@unit ContextGraphs (facade)', () => { ); }); - it('owner can raise quorum within hosting nodes count', async () => { + it('owner can raise legacy quorum independently from hosting nodes count', async () => { await expect( - Facade.connect(accounts[0]).updateQuorum(1, 3), - ).to.emit(Storage, 'QuorumUpdated').withArgs(1, 3); - expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(3); + Facade.connect(accounts[0]).updateQuorum(1, 4), + ).to.emit(Storage, 'QuorumUpdated').withArgs(1, 4); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(4); }); it('owner can lower quorum', async () => { @@ -1262,16 +1257,14 @@ describe('@unit ContextGraphs (facade)', () => { ).to.be.revertedWithCustomError(Facade, 'NotContextGraphOwner'); }); - it('reverts when quorum > hostingNodes.length', async () => { - await expect( - Facade.connect(accounts[0]).updateQuorum(1, 4), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows quorum > hostingNodes.length as legacy metadata', async () => { + await Facade.connect(accounts[0]).updateQuorum(1, 4); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(4); }); - it('reverts when quorum == 0', async () => { - await expect( - Facade.connect(accounts[0]).updateQuorum(1, 0), - ).to.be.revertedWithCustomError(Storage, 'InvalidContextGraphConfig'); + it('allows quorum == 0 as legacy metadata', async () => { + await Facade.connect(accounts[0]).updateQuorum(1, 0); + expect(await Storage.getContextGraphRequiredSignatures(1)).to.equal(0); }); }); diff --git a/packages/evm-module/test/unit/KnowledgeAssetsV10-edge-quorum.test.ts b/packages/evm-module/test/unit/KnowledgeAssetsV10-edge-quorum.test.ts new file mode 100644 index 000000000..cf89dbf30 --- /dev/null +++ b/packages/evm-module/test/unit/KnowledgeAssetsV10-edge-quorum.test.ts @@ -0,0 +1,284 @@ +/** + * KnowledgeAssetsV10-edge-quorum.test.ts + * + * Tests added by PR #405 ("Fix edge publishes with dynamic core ACK quorum"): + * - empty-host context graphs accept any active sharding-table core ACKs + * (edge-owned CGs with no fixed hostingNodes / no legacy quorum coupling). + * - ACKs from operational keys whose identity is NOT in the active + * sharding-table core set are rejected (`_verifySignature` checks active + * core membership, not just stake-eligibility). + * + * The broader KAV10-extra audit suite was deleted on main as part of the + * "fix(cli,storage,query,publisher): 6 critical source fixes" cleanup + * (commit 3f496377). Only the two cases above are kept here because they + * cover behaviour that this PR introduces and that no other test in the + * suite exercises. + */ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { ethers } from 'ethers'; +import hre from 'hardhat'; + +import { + AskStorage, + Chronos, + ContextGraphStorage, + ContextGraphs, + DKGStakingConvictionNFT, + EpochStorage, + Hub, + KnowledgeAssetsV10, + KnowledgeCollectionStorage, + ParametersStorage, + Profile, + Staking, + StakingV10, + Token, +} from '../../typechain'; +import { + buildPublishAckDigest, + buildPublishParams, + buildPublisherDigest, + DEFAULT_CHAIN_ID, + signPublishDigests, +} from '../helpers/v10-kc-helpers'; +import { createProfile, createProfiles } from '../helpers/profile-helpers'; +import { + getDefaultKCCreator, + getDefaultPublishingNode, + getDefaultReceivingNodes, +} from '../helpers/setup-helpers'; +import { NodeAccounts } from '../helpers/types'; + +describe('@unit KnowledgeAssetsV10 — edge-CG ACK quorum (PR #405)', () => { + let accounts: SignerWithAddress[]; + let HubContract: Hub; + let KAV10: KnowledgeAssetsV10; + let KCS: KnowledgeCollectionStorage; + let TokenContract: Token; + let ProfileContract: Profile; + let StakingContract: Staking; + let StakingV10Contract: StakingV10; + let StakingNFT: DKGStakingConvictionNFT; + let ParametersStorageContract: ParametersStorage; + let Facade: ContextGraphs; + let CGStorageContract: ContextGraphStorage; + + let kav10Address: string; + const chainId = DEFAULT_CHAIN_ID; + const MIN_STAKE = ethers.parseEther('50000'); + + async function deployFixture() { + await hre.deployments.fixture([ + 'Token', + 'Hub', + 'AskStorage', + 'EpochStorage', + 'Chronos', + 'Profile', + 'Identity', + 'Staking', + 'ParametersStorage', + 'IdentityStorage', + 'KnowledgeCollectionStorage', + 'PaymasterManager', + 'ContextGraphStorage', + 'ContextGraphs', + 'ContextGraphValueStorage', + 'DKGPublishingConvictionNFT', + 'StakingV10', + 'DKGStakingConvictionNFT', + 'KnowledgeAssetsV10', + ]); + const signers = await hre.ethers.getSigners(); + const Hub = await hre.ethers.getContract('Hub'); + await Hub.setContractAddress('HubOwner', signers[0].address); + return { + accounts: signers, + Hub, + KAV10: await hre.ethers.getContract('KnowledgeAssetsV10'), + KCS: await hre.ethers.getContract( + 'KnowledgeCollectionStorage', + ), + Token: await hre.ethers.getContract('Token'), + Profile: await hre.ethers.getContract('Profile'), + Staking: await hre.ethers.getContract('Staking'), + StakingV10: await hre.ethers.getContract('StakingV10'), + StakingNFT: await hre.ethers.getContract( + 'DKGStakingConvictionNFT', + ), + ParametersStorage: await hre.ethers.getContract( + 'ParametersStorage', + ), + AskStorage: await hre.ethers.getContract('AskStorage'), + Chronos: await hre.ethers.getContract('Chronos'), + EpochStorage: await hre.ethers.getContract('EpochStorageV8'), + Facade: await hre.ethers.getContract('ContextGraphs'), + CGStorage: await hre.ethers.getContract( + 'ContextGraphStorage', + ), + }; + } + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + const f = await loadFixture(deployFixture); + accounts = f.accounts; + HubContract = f.Hub; + KAV10 = f.KAV10; + KCS = f.KCS; + TokenContract = f.Token; + ProfileContract = f.Profile; + StakingContract = f.Staking; + StakingV10Contract = f.StakingV10; + StakingNFT = f.StakingNFT; + ParametersStorageContract = f.ParametersStorage; + Facade = f.Facade; + CGStorageContract = f.CGStorage; + kav10Address = await KAV10.getAddress(); + }); + + async function fundAndStakeNode(node: NodeAccounts, identityId: number) { + await TokenContract.mint(node.operational.address, MIN_STAKE); + await TokenContract.connect(node.operational).approve( + await StakingV10Contract.getAddress(), + MIN_STAKE, + ); + await StakingNFT.connect(node.operational).createConviction( + identityId, + MIN_STAKE, + 1, + ); + } + + async function setupNodes(receivingNodesCount = 3) { + const publishingNode = getDefaultPublishingNode(accounts); + const receivingNodes = getDefaultReceivingNodes(accounts, receivingNodesCount); + const { identityId: publisherIdentityId } = await createProfile( + ProfileContract, + publishingNode, + ); + await fundAndStakeNode(publishingNode, publisherIdentityId); + const receiverProfiles = await createProfiles(ProfileContract, receivingNodes); + const receiverIdentityIds = receiverProfiles.map((p) => p.identityId); + for (let i = 0; i < receivingNodes.length; i++) { + await fundAndStakeNode(receivingNodes[i], receiverProfiles[i].identityId); + } + return { publishingNode, publisherIdentityId, receivingNodes, receiverIdentityIds }; + } + + async function createOpenCG(creator: SignerWithAddress): Promise { + await Facade.connect(creator).createContextGraph( + [10n, 20n, 30n], + [], + 2, + 0, + 1, + ethers.ZeroAddress, + 0, + ); + return CGStorageContract.getLatestContextGraphId(); + } + + it('empty-host CG accepts any 3 active sharding-table core ACKs', async () => { + const creator = getDefaultKCCreator(accounts); + const { publishingNode, publisherIdentityId, receivingNodes, receiverIdentityIds } = + await setupNodes(3); + + await Facade.connect(creator).createContextGraph( + [], + [], + 0, + 0, + 1, + ethers.ZeroAddress, + 0, + ); + const cgId = await CGStorageContract.getLatestContextGraphId(); + expect(await CGStorageContract.getHostingNodes(cgId)).to.deep.equal([]); + + const p = await buildPublishParams({ + chainId, + kav10Address, + publishingNode, + receivingNodes, + publisherIdentityId, + receiverIdentityIds, + contextGraphId: cgId, + merkleRoot: ethers.keccak256(ethers.toUtf8Bytes('e8-empty-hosts')), + knowledgeAssetsAmount: 10, + byteSize: 1000, + epochs: 2, + tokenAmount: ethers.parseEther('100'), + isImmutable: false, + publishOperationId: 'e8-empty-hosts-op', + }); + + await TokenContract.connect(creator).approve(kav10Address, p.tokenAmount); + await expect(KAV10.connect(creator).publishDirect(p, ethers.ZeroAddress)).to.not.be + .reverted; + }); + + it('rejects an ACK from an operational key whose identity is not active in the sharding table', async () => { + const creator = getDefaultKCCreator(accounts); + const { publishingNode, publisherIdentityId } = await setupNodes(0); + const inactiveNode = { admin: accounts[13], operational: accounts[14] }; + const { identityId: inactiveIdentityId } = await createProfile( + ProfileContract, + inactiveNode, + ); + const cgId = await createOpenCG(creator); + + await ParametersStorageContract.connect(accounts[0]).setMinimumRequiredSignatures(1); + + const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('e8-inactive-ack')); + const tokenAmount = ethers.parseEther('100'); + const publisherDigest = buildPublisherDigest( + chainId, + kav10Address, + publisherIdentityId, + cgId, + merkleRoot, + ); + const ackDigest = buildPublishAckDigest( + chainId, + kav10Address, + cgId, + merkleRoot, + 10, + 1000, + 2, + tokenAmount, + 1, + ); + const sig = await signPublishDigests( + publishingNode, + [inactiveNode], + publisherDigest, + ackDigest, + ); + const p = { + publishOperationId: 'e8-inactive-ack-op', + contextGraphId: cgId, + merkleRoot, + knowledgeAssetsAmount: 10, + byteSize: 1000, + epochs: 2, + tokenAmount, + isImmutable: false, + merkleLeafCount: 1, + publisherNodeIdentityId: publisherIdentityId, + publisherNodeR: sig.publisherR, + publisherNodeVS: sig.publisherVS, + identityIds: [inactiveIdentityId], + r: sig.receiverRs, + vs: sig.receiverVSs, + }; + + await TokenContract.connect(creator).approve(kav10Address, tokenAmount); + await expect( + KAV10.connect(creator).publishDirect(p, ethers.ZeroAddress), + ).to.be.revertedWith('ACK signer is not an active core node'); + }); +}); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index e1d352906..3649b5fa9 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -4,7 +4,7 @@ import { enrichEvmError } from '@origintrail-official/dkg-chain'; import type { EventBus, OperationContext } from '@origintrail-official/dkg-core'; import { DKGEvent, Logger, createOperationContext, sha256, encodeWorkspacePublishRequest, contextGraphDataUri, contextGraphMetaUri, contextGraphAssertionUri, assertionLifecycleUri, contextGraphSubGraphUri, contextGraphSubGraphMetaUri, validateSubGraphName, isSafeIri, assertSafeIri, assertSafeRdfTerm, type Ed25519Keypair, computePublishACKDigest, computePublishPublisherDigest } from '@origintrail-official/dkg-core'; import { GraphManager, PrivateContentStore } from '@origintrail-official/dkg-storage'; -import type { Publisher, PublishOptions, PublishResult, KAManifestEntry, PhaseCallback } from './publisher.js'; +import type { Publisher, PublishOptions, PublishResult, KAManifestEntry, PhaseCallback, PublishGatewaySignature } from './publisher.js'; import { autoPartition } from './auto-partition.js'; import { RESERVED_SUBJECT_PREFIXES, findReservedSubjectPrefix, isReservedSubject } from './reserved-subjects.js'; import { skolemize } from './skolemize.js'; @@ -584,6 +584,8 @@ export class DKGPublisher implements Publisher { onChainContextGraphId?: string; contextGraphSignatures?: Array<{ identityId: bigint; r: Uint8Array; vs: Uint8Array }>; v10ACKProvider?: PublishOptions['v10ACKProvider']; + publishGateway?: PublishOptions['publishGateway']; + publishGatewayProvider?: PublishOptions['publishGatewayProvider']; subGraphName?: string; }, ): Promise { @@ -695,6 +697,8 @@ export class DKGPublisher implements Publisher { operationCtx: ctx, onPhase: options?.onPhase, v10ACKProvider: options?.v10ACKProvider, + publishGateway: options?.publishGateway, + publishGatewayProvider: options?.publishGatewayProvider, publishContextGraphId: chainCgId ?? undefined, fromSharedMemory: true, subGraphName: options?.subGraphName, @@ -1275,13 +1279,92 @@ export class DKGPublisher implements Publisher { const tentativeSeq = ++this.tentativeCounter; let ual = `did:dkg:${this.chain.chainId}/${this.publisherAddress}/t${this.sessionId}-${tentativeSeq}`; - const identityId = this.publisherNodeIdentityId; + let identityId = this.publisherNodeIdentityId; + let gatewaySignature: PublishGatewaySignature | undefined; + let paymaster = ethers.ZeroAddress; + let publisherConvictionAccountId: bigint | undefined; + if (options.publishGateway) { + if (!options.publishGatewayProvider) { + throw new Error( + `Publish gateway ${options.publishGateway.peerId} requested but no gateway transport is configured`, + ); + } + if (v10ChainId === undefined || v10KavAddress === undefined) { + throw new Error( + 'Publish gateway requested but V10 chain id / KnowledgeAssetsV10 address could not be resolved', + ); + } + onPhase?.('chain:gateway', 'start'); + try { + gatewaySignature = await options.publishGatewayProvider({ + chainId: v10ChainId, + kav10Address: v10KavAddress, + contextGraphId: v10CgId, + merkleRoot: kcMerkleRoot, + gateway: options.publishGateway, + }); + if (gatewaySignature.nodeIdentityId !== options.publishGateway.nodeIdentityId) { + throw new Error( + `Publish gateway returned identity ${gatewaySignature.nodeIdentityId}, ` + + `expected ${options.publishGateway.nodeIdentityId}`, + ); + } + if (options.publishGateway.pcaAccountId !== undefined) { + if (gatewaySignature.pcaAccountId === undefined) { + throw new Error( + `Publish gateway did not return requested PCA account ${options.publishGateway.pcaAccountId}`, + ); + } + if (gatewaySignature.pcaAccountId !== options.publishGateway.pcaAccountId) { + throw new Error( + `Publish gateway did not confirm requested PCA account ${options.publishGateway.pcaAccountId}`, + ); + } + publisherConvictionAccountId = gatewaySignature.pcaAccountId; + } else if (gatewaySignature.pcaAccountId !== undefined) { + throw new Error( + `Publish gateway returned unsolicited pcaAccountId=${gatewaySignature.pcaAccountId}; ` + + 'omit it from the response or request it via publishGateway.pcaAccountId', + ); + } + if (options.publishGateway.paymaster) { + const expectedPaymaster = ethers.getAddress(options.publishGateway.paymaster); + const returnedPaymaster = gatewaySignature.paymaster + ? ethers.getAddress(gatewaySignature.paymaster) + : undefined; + if (returnedPaymaster !== expectedPaymaster) { + throw new Error( + `Publish gateway did not confirm requested paymaster ${expectedPaymaster}`, + ); + } + paymaster = expectedPaymaster; + } else if (gatewaySignature.paymaster) { + throw new Error( + `Publish gateway returned unsolicited paymaster=${gatewaySignature.paymaster}; ` + + 'omit it from the response or request it via publishGateway.paymaster', + ); + } + if (publisherConvictionAccountId !== undefined && paymaster !== ethers.ZeroAddress) { + throw new Error( + 'Publish gateway cannot use both a publisher conviction account and a paymaster in one V10 publish', + ); + } + identityId = gatewaySignature.nodeIdentityId; + this.log.info( + ctx, + `Using publish gateway ${gatewaySignature.peerId} ` + + `(identityId=${identityId}, signer=${gatewaySignature.signer})`, + ); + } finally { + onPhase?.('chain:gateway', 'end'); + } + } let usedV10Path = false; if (!this.publisherWallet) { this.log.warn(ctx, `No EVM wallet configured — skipping on-chain publish`); } else if (identityId === 0n) { - this.log.warn(ctx, `Identity not set (0) — skipping on-chain publish`); + this.log.warn(ctx, `Publisher node identity not set (0) — skipping on-chain publish`); } else { onPhase?.('chain:sign', 'start'); this.log.info(ctx, `Signing on-chain publish (identityId=${identityId}, signer=${this.publisherWallet.address})`); @@ -1321,9 +1404,21 @@ export class DKGPublisher implements Publisher { v10CgId, kcMerkleRoot, ); - const pubSig = ethers.Signature.from( - await this.publisherWallet.signMessage(pubMsgHash), - ); + let publisherSignature: { r: Uint8Array; vs: Uint8Array }; + if (gatewaySignature) { + publisherSignature = { + r: gatewaySignature.signatureR, + vs: gatewaySignature.signatureVS, + }; + } else { + const pubSig = ethers.Signature.from( + await this.publisherWallet.signMessage(pubMsgHash), + ); + publisherSignature = { + r: ethers.getBytes(pubSig.r), + vs: ethers.getBytes(pubSig.yParityAndS), + }; + } // P-1 review (iter-2): `chain:writeahead:start` now fires // *from inside* the adapter via the `onBroadcast` callback, // which the adapter invokes immediately before the real @@ -1390,12 +1485,10 @@ export class DKGPublisher implements Publisher { tokenAmount, merkleLeafCount: kcMerkleLeafCount, isImmutable: false, - paymaster: ethers.ZeroAddress, + paymaster, + publisherConvictionAccountId, publisherNodeIdentityId: identityId, - publisherSignature: { - r: ethers.getBytes(pubSig.r), - vs: ethers.getBytes(pubSig.yParityAndS), - }, + publisherSignature, ackSignatures: v10ACKs.map(ack => ({ identityId: ack.nodeIdentityId, r: ack.signatureR, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 850bfc813..693457126 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -34,6 +34,12 @@ export { type ACKCollectionResult, } from './ack-collector.js'; export { StorageACKHandler, type StorageACKHandlerConfig } from './storage-ack-handler.js'; +export { + PublishGatewayHandler, + type PublishGatewayHandlerConfig, + type PublishGatewayRequestWire, + type PublishGatewayResponseWire, +} from './publish-gateway-handler.js'; export { VerifyCollector, type VerifyCollectorDeps, diff --git a/packages/publisher/src/publish-gateway-handler.ts b/packages/publisher/src/publish-gateway-handler.ts new file mode 100644 index 000000000..0b273af7c --- /dev/null +++ b/packages/publisher/src/publish-gateway-handler.ts @@ -0,0 +1,258 @@ +import { computePublishPublisherDigest } from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +type PeerId = { toString(): string }; + +export interface PublishGatewayHandlerConfig { + nodeRole: 'core' | 'edge'; + nodeIdentityId: bigint; + signerWallet: ethers.Wallet; + chainId: bigint; + kav10Address: string; + pcaAccountId?: bigint; + paymaster?: string; + /** + * Optional libp2p peer-id allowlist. When set (and non-empty), the + * handler refuses to sign requests from peers not in this set. Without + * an allowlist any connected peer can ask this core to sign publisher + * digests under its identity — for an open context graph that means + * arbitrary peers can attribute produced value to this node, and a + * gateway that advertises a paymaster effectively gets its sponsor + * free publishes from every peer it ever talks to. Operators advertising + * a paymaster MUST configure this; cores operating without paymaster + * may keep it open to preserve the current devnet flow at the cost of + * the produced-value attribution risk above. + */ + allowedPeers?: Set; + isActiveCore?: () => Promise; + isPaymasterValid?: (paymaster: string) => Promise; + getConvictionAccountInfo?: (accountId: bigint) => Promise; +} + +export interface PublishGatewayRequestWire { + chainId?: string; + kav10Address?: string; + contextGraphId: string; + merkleRoot: string; + nodeIdentityId?: string; + pcaAccountId?: string; + paymaster?: string; +} + +export interface PublishGatewayResponseWire { + error?: string; + nodeIdentityId?: string; + signer?: string; + signatureR?: string; + signatureVS?: string; + pcaAccountId?: string; + paymaster?: string; +} + +/** + * Core-node side of the preferred publish gateway protocol. + * + * A gateway signs only the V10 publisher digest. It does not submit the + * publish transaction, so the edge publisher remains `msg.sender` while the + * gateway's node identity receives produced-value attribution on-chain. + */ +export class PublishGatewayHandler { + private readonly config: PublishGatewayHandlerConfig; + + constructor(config: PublishGatewayHandlerConfig) { + // Fail-closed invariant: a configured paymaster without a non-empty + // peer allowlist would let any connected peer get sponsored + // publishes, turning this core into an open sponsor endpoint. Catch + // it at construction so the misconfiguration is surfaced before any + // request reaches `handler` (and before the handler is registered + // on the libp2p protocol surface). + if ( + config.paymaster + && ethers.getAddress(config.paymaster) !== ethers.ZeroAddress + && (!config.allowedPeers || config.allowedPeers.size === 0) + ) { + throw new Error( + 'PublishGatewayHandler: paymaster is configured but allowedPeers is empty; ' + + 'refusing to construct an open sponsor endpoint', + ); + } + this.config = config; + } + + handler = async (data: Uint8Array, peerId: PeerId): Promise => { + try { + if (this.config.nodeRole !== 'core') { + throw new Error('Only core nodes can act as publish gateways'); + } + if (this.config.nodeIdentityId <= 0n) { + throw new Error('Publish gateway is unavailable: core node identity is not provisioned'); + } + + // Peer allowlist gate. We perform the check before parsing the + // request body so unauthenticated peers cannot force unbounded + // CPU work on the gateway. + if (this.config.allowedPeers && this.config.allowedPeers.size > 0) { + const requesterPeerId = peerId?.toString?.() ?? ''; + if (!requesterPeerId || !this.config.allowedPeers.has(requesterPeerId)) { + throw new Error( + `Publish gateway peer ${requesterPeerId || ''} is not allowed`, + ); + } + } + + const request = decodeRequest(data); + if (request.chainId !== undefined) { + const requestedChainId = parsePositiveBigInt(request.chainId, 'chainId'); + if (requestedChainId !== this.config.chainId) { + throw new Error( + `Publish gateway chain mismatch: requested ${requestedChainId}, ` + + `this core node signs for ${this.config.chainId}`, + ); + } + } + if (request.kav10Address !== undefined) { + const requestedKav = ethers.getAddress(request.kav10Address); + if (requestedKav !== ethers.getAddress(this.config.kav10Address)) { + throw new Error( + `Publish gateway KnowledgeAssetsV10 mismatch: requested ${requestedKav}, ` + + `this core node signs for ${ethers.getAddress(this.config.kav10Address)}`, + ); + } + } + const requestedIdentity = request.nodeIdentityId ? parsePositiveBigInt(request.nodeIdentityId, 'nodeIdentityId') : undefined; + if (requestedIdentity !== undefined && requestedIdentity !== this.config.nodeIdentityId) { + throw new Error( + `Publish gateway identity mismatch: requested ${requestedIdentity}, ` + + `this core node is ${this.config.nodeIdentityId}`, + ); + } + + const contextGraphId = parsePositiveBigInt(request.contextGraphId, 'contextGraphId'); + const merkleRoot = parseBytes32(request.merkleRoot, 'merkleRoot'); + const pcaAccountId = request.pcaAccountId + ? parsePositiveBigInt(request.pcaAccountId, 'pcaAccountId') + : undefined; + const paymaster = request.paymaster ? ethers.getAddress(request.paymaster) : undefined; + + if (pcaAccountId !== undefined) { + if (this.config.pcaAccountId !== pcaAccountId) { + throw new Error(`Publish gateway PCA account ${pcaAccountId} unavailable`); + } + if (!this.config.getConvictionAccountInfo) { + throw new Error( + `Publish gateway PCA account ${pcaAccountId} unavailable: ` + + 'chain adapter cannot read conviction accounts', + ); + } + const account = await this.config.getConvictionAccountInfo(pcaAccountId); + if (!account) { + throw new Error(`Publish gateway PCA account ${pcaAccountId} unavailable`); + } + } + + if (paymaster) { + const configuredPaymaster = this.config.paymaster + ? ethers.getAddress(this.config.paymaster) + : undefined; + if (configuredPaymaster !== paymaster) { + throw new Error(`Publish gateway paymaster ${paymaster} unavailable`); + } + if (!this.config.isPaymasterValid) { + throw new Error( + `Publish gateway paymaster ${paymaster} unavailable: ` + + 'chain adapter cannot verify paymaster status', + ); + } + if (!(await this.config.isPaymasterValid(paymaster))) { + throw new Error(`Publish gateway paymaster ${paymaster} unavailable`); + } + } + + if (!this.config.isActiveCore) { + throw new Error( + 'Publish gateway cannot confirm active sharding-table membership; refusing to sign', + ); + } + let activeCore: boolean; + try { + activeCore = await this.config.isActiveCore(); + } catch { + throw new Error('Publish gateway active-core lookup failed; refusing to sign'); + } + if (!activeCore) { + throw new Error('Publish gateway identity is not an active sharding-table core node'); + } + + const digest = computePublishPublisherDigest( + this.config.chainId, + this.config.kav10Address, + this.config.nodeIdentityId, + contextGraphId, + merkleRoot, + ); + const signature = ethers.Signature.from( + await this.config.signerWallet.signMessage(digest), + ); + + const response: PublishGatewayResponseWire = { + nodeIdentityId: this.config.nodeIdentityId.toString(), + signer: this.config.signerWallet.address, + signatureR: signature.r, + signatureVS: signature.yParityAndS, + ...(pcaAccountId !== undefined ? { pcaAccountId: pcaAccountId.toString() } : {}), + ...(paymaster ? { paymaster } : {}), + }; + + return new TextEncoder().encode(JSON.stringify(response)); + } catch (err) { + return new TextEncoder().encode(JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + })); + } + }; +} + +function decodeRequest(data: Uint8Array): PublishGatewayRequestWire { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(data)); + } catch { + throw new Error('Invalid publish gateway request: expected JSON'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Invalid publish gateway request: expected object'); + } + const request = parsed as Record; + if (typeof request.contextGraphId !== 'string') { + throw new Error('Invalid publish gateway request: missing string contextGraphId'); + } + if (typeof request.merkleRoot !== 'string') { + throw new Error('Invalid publish gateway request: missing string merkleRoot'); + } + for (const key of ['chainId', 'kav10Address', 'nodeIdentityId', 'pcaAccountId', 'paymaster']) { + if (request[key] !== undefined && typeof request[key] !== 'string') { + throw new Error(`Invalid publish gateway request: ${key} must be a string`); + } + } + return request as unknown as PublishGatewayRequestWire; +} + +function parsePositiveBigInt(value: string, field: string): bigint { + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new Error(`Invalid publish gateway request: ${field} must be an integer string`); + } + if (parsed <= 0n) { + throw new Error(`Invalid publish gateway request: ${field} must be positive`); + } + return parsed; +} + +function parseBytes32(value: string, field: string): Uint8Array { + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new Error(`Invalid publish gateway request: ${field} must be a 32-byte hex string`); + } + return ethers.getBytes(value); +} diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts index 386948c76..f1840835b 100644 --- a/packages/publisher/src/publisher.ts +++ b/packages/publisher/src/publisher.ts @@ -13,6 +13,13 @@ export type PhaseCallback = (phase: string, status: 'start' | 'end') => void; export type ReceiverSignature = { identityId: bigint; r: Uint8Array; vs: Uint8Array }; +export interface PublishGatewayConfig { + peerId: string; + nodeIdentityId: bigint; + pcaAccountId?: bigint; + paymaster?: string; +} + /** * Callback that collects receiver signatures from peers. * Called AFTER data preparation, BEFORE on-chain tx. @@ -62,6 +69,28 @@ export type V10ACKProvider = ( merkleLeafCount: number, ) => Promise; +export interface PublishGatewaySignatureRequest { + chainId: bigint; + kav10Address: string; + contextGraphId: bigint; + merkleRoot: Uint8Array; + gateway: PublishGatewayConfig; +} + +export interface PublishGatewaySignature { + peerId: string; + signer: string; + nodeIdentityId: bigint; + signatureR: Uint8Array; + signatureVS: Uint8Array; + pcaAccountId?: bigint; + paymaster?: string; +} + +export type PublishGatewayProvider = ( + request: PublishGatewaySignatureRequest, +) => Promise; + /** * Callback that collects participant signatures for context graph governance. */ @@ -109,6 +138,14 @@ export interface PublishOptions { * When provided, ACKs are collected and stored in the result. */ v10ACKProvider?: V10ACKProvider; + /** + * Optional preferred core-node publish gateway. The gateway signs the V10 + * publisher digest so produced-value attribution lands on its + * `publisherNodeIdentityId`; the local publisher wallet still submits the + * tx and remains `msg.sender` / publisher of record. + */ + publishGateway?: PublishGatewayConfig; + publishGatewayProvider?: PublishGatewayProvider; /** * When publishing into a specific context graph (publishFromSharedMemory), * this overrides contextGraphId as the ACK domain and on-chain contextGraphId. diff --git a/packages/publisher/src/storage-ack-handler.ts b/packages/publisher/src/storage-ack-handler.ts index 502ff87bb..531f4fdd9 100644 --- a/packages/publisher/src/storage-ack-handler.ts +++ b/packages/publisher/src/storage-ack-handler.ts @@ -43,6 +43,14 @@ export interface StorageACKHandlerConfig { * producing ACKs without needing a process restart. */ isSignerRegistered?: () => Promise; + /** + * Optional: confirms this node's ACK-signer identity is currently an + * active sharding-table core (same probe as `PublishGatewayHandler`). + * When set, runs after `isSignerRegistered` succeeds so evicted cores stop + * emitting ACKs that would revert on-chain with + * `ACK signer is not an active core node`. + */ + isActiveCore?: () => Promise; /** * Called when the live confirmation hook reports the signer is no longer * registered. Agents can use this to stop advertising StorageACK support. @@ -281,6 +289,28 @@ export class StorageACKHandler { } } + if (this.config.isActiveCore) { + let active: boolean; + try { + active = await this.config.isActiveCore(); + } catch (err) { + try { + await this.config.onSignerRegistrationLookupFailed?.(err); + } catch { + // Keep ACK availability independent from logging/callback failures. + } + throw new Error('StorageACK active-core lookup failed; refusing to sign'); + } + if (!active) { + try { + await this.config.onSignerUnregistered?.(); + } catch { + // Keep the signing refusal deterministic even if protocol cleanup fails. + } + throw new Error('StorageACK signer is not an active sharding-table core node'); + } + } + const signature = ethers.Signature.from( await this.config.signerWallet.signMessage(digest), ); diff --git a/packages/publisher/test/publish-gateway-handler.test.ts b/packages/publisher/test/publish-gateway-handler.test.ts new file mode 100644 index 000000000..3ae46f550 --- /dev/null +++ b/packages/publisher/test/publish-gateway-handler.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; +import { computePublishPublisherDigest } from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; +import { PublishGatewayHandler } from '../src/index.js'; + +const CHAIN_ID = 31337n; +const KAV10_ADDRESS = '0x000000000000000000000000000000000000c10a'; +const CG_ID = 42n; +const MERKLE_ROOT = ethers.keccak256(ethers.toUtf8Bytes('gateway-root')); + +async function callGateway(handler: PublishGatewayHandler, request: Record) { + const bytes = await handler.handler( + new TextEncoder().encode(JSON.stringify(request)), + { toString: () => 'peer-test' }, + ); + return JSON.parse(new TextDecoder().decode(bytes)) as Record; +} + +describe('PublishGatewayHandler', () => { + it('signs the V10 publisher digest with the configured core identity', async () => { + const wallet = ethers.Wallet.createRandom(); + const nodeIdentityId = 7n; + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId, + signerWallet: wallet, + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + isActiveCore: async () => true, + }); + + const response = await callGateway(handler, { + chainId: CHAIN_ID.toString(), + kav10Address: KAV10_ADDRESS, + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + nodeIdentityId: nodeIdentityId.toString(), + }); + + expect(response.error).toBeUndefined(); + expect(response.nodeIdentityId).toBe(nodeIdentityId.toString()); + expect(ethers.getAddress(response.signer)).toBe(wallet.address); + + const digest = computePublishPublisherDigest( + CHAIN_ID, + KAV10_ADDRESS, + nodeIdentityId, + CG_ID, + ethers.getBytes(MERKLE_ROOT), + ); + const recovered = ethers.verifyMessage( + digest, + ethers.Signature.from({ r: response.signatureR, yParityAndS: response.signatureVS }), + ); + expect(ethers.getAddress(recovered)).toBe(wallet.address); + }); + + it('refuses to sign from an edge node', async () => { + const handler = new PublishGatewayHandler({ + nodeRole: 'edge', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + }); + + expect(response.error).toContain('Only core nodes can act as publish gateways'); + }); + + it('fails clearly when a requested PCA account is unavailable', async () => { + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + pcaAccountId: 100n, + isActiveCore: async () => true, + getConvictionAccountInfo: async () => ({ id: 99n }), + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + pcaAccountId: '99', + }); + + expect(response.error).toContain('Publish gateway PCA account 99 unavailable'); + }); + + it('confirms a configured PCA account when available', async () => { + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + pcaAccountId: 99n, + isActiveCore: async () => true, + getConvictionAccountInfo: async (id) => id === 99n ? { id } : null, + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + pcaAccountId: '99', + }); + + expect(response.error).toBeUndefined(); + expect(response.pcaAccountId).toBe('99'); + }); + + it('fails clearly when a requested paymaster is unavailable', async () => { + const paymaster = '0x000000000000000000000000000000000000beef'; + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + paymaster: '0x000000000000000000000000000000000000feed', + // Required in combination with `paymaster` to satisfy the + // fail-closed invariant introduced in PR #405. + allowedPeers: new Set(['peer-test']), + isActiveCore: async () => true, + isPaymasterValid: async () => false, + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + paymaster, + }); + + expect(response.error).toContain(`Publish gateway paymaster ${ethers.getAddress(paymaster)} unavailable`); + }); + + it('confirms a configured paymaster when valid', async () => { + const paymaster = '0x000000000000000000000000000000000000beef'; + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + paymaster, + // Required in combination with `paymaster` to satisfy the + // fail-closed invariant introduced in PR #405. + allowedPeers: new Set(['peer-test']), + isActiveCore: async () => true, + isPaymasterValid: async (candidate) => ethers.getAddress(candidate) === ethers.getAddress(paymaster), + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + paymaster, + }); + + expect(response.error).toBeUndefined(); + expect(ethers.getAddress(response.paymaster)).toBe(ethers.getAddress(paymaster)); + }); + + it('refuses to sign when the identity is not an active sharding-table core', async () => { + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + isActiveCore: async () => false, + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + }); + + expect(response.error).toContain('Publish gateway identity is not an active sharding-table core node'); + }); + + it('refuses to sign for peers outside the allowlist', async () => { + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + isActiveCore: async () => true, + allowedPeers: new Set(['allowed-peer-1', 'allowed-peer-2']), + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + }); + + expect(response.error).toContain('Publish gateway peer peer-test is not allowed'); + }); + + it('refuses to construct when paymaster is set without an allowlist (fail-closed)', () => { + expect(() => new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + paymaster: '0x000000000000000000000000000000000000beef', + isActiveCore: async () => true, + isPaymasterValid: async () => true, + })).toThrow(/paymaster is configured but allowedPeers is empty/); + }); + + it('refuses to construct when paymaster is set with an empty allowlist (fail-closed)', () => { + expect(() => new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: ethers.Wallet.createRandom(), + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + paymaster: '0x000000000000000000000000000000000000beef', + allowedPeers: new Set(), + isActiveCore: async () => true, + isPaymasterValid: async () => true, + })).toThrow(/paymaster is configured but allowedPeers is empty/); + }); + + it('signs for peers inside the allowlist', async () => { + const wallet = ethers.Wallet.createRandom(); + const handler = new PublishGatewayHandler({ + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: wallet, + chainId: CHAIN_ID, + kav10Address: KAV10_ADDRESS, + isActiveCore: async () => true, + allowedPeers: new Set(['peer-test']), + }); + + const response = await callGateway(handler, { + contextGraphId: CG_ID.toString(), + merkleRoot: MERKLE_ROOT, + }); + + expect(response.error).toBeUndefined(); + expect(ethers.getAddress(response.signer)).toBe(wallet.address); + }); +}); diff --git a/packages/publisher/test/storage-ack-handler.test.ts b/packages/publisher/test/storage-ack-handler.test.ts index 5189a1aff..72caca50a 100644 --- a/packages/publisher/test/storage-ack-handler.test.ts +++ b/packages/publisher/test/storage-ack-handler.test.ts @@ -161,6 +161,58 @@ describe('StorageACKHandler', () => { expect(unregistered).not.toHaveBeenCalled(); }); + it('refuses to sign when isActiveCore reports false', async () => { + const unregistered = vi.fn(); + const handler = await createHandler(swmQuads, { + isSignerRegistered: async () => true, + isActiveCore: async () => false, + onSignerUnregistered: unregistered, + }); + const intent = encodePublishIntent({ + merkleRoot, + contextGraphId, + publisherPeerId: 'publisher-0', + publicByteSize: 300, + isPrivate: false, + kaCount: 2, + rootEntities: ['urn:entity:1', 'urn:entity:2'], + epochs: 1, + tokenAmountStr: '1000', + merkleLeafCount: swmMerkleLeafCount, + }); + + await expect(handler.handler(intent, fakePeerId)).rejects.toThrow( + 'StorageACK signer is not an active sharding-table core node', + ); + expect(unregistered).toHaveBeenCalledOnce(); + }); + + it('refuses to sign when isActiveCore lookup throws', async () => { + const lookupFailed = vi.fn(); + const handler = await createHandler(swmQuads, { + isSignerRegistered: async () => true, + isActiveCore: async () => { throw new Error('rpc unavailable'); }, + onSignerRegistrationLookupFailed: lookupFailed, + }); + const intent = encodePublishIntent({ + merkleRoot, + contextGraphId, + publisherPeerId: 'publisher-0', + publicByteSize: 300, + isPrivate: false, + kaCount: 2, + rootEntities: ['urn:entity:1', 'urn:entity:2'], + epochs: 1, + tokenAmountStr: '1000', + merkleLeafCount: swmMerkleLeafCount, + }); + + await expect(handler.handler(intent, fakePeerId)).rejects.toThrow( + 'StorageACK active-core lookup failed; refusing to sign', + ); + expect(lookupFailed).toHaveBeenCalledOnce(); + }); + it('rejects when SWM has no data', async () => { const handler = await createHandler([]); const intent = encodePublishIntent({