From 985df933a9a22083f0e78a1815df7eeaae8a801c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 03:55:15 +0200 Subject: [PATCH 1/2] fix(agent): refuse to register a CG that can never satisfy global quorum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator footgun observed during the Base Sepolia cgId=10/cgId=11 incident: running `dkg context-graph register ` against a CG with no recorded `participantIdentityIds` in `_meta` silently fell back to `[selfIdentityId]` plus `requiredSignatures = 1`, then succeeded on-chain because `ContextGraphStorage.createContextGraph` only enforces `requiredSignatures <= hostingNodes.length` — it never reads `ParametersStorage.minimumRequiredSignatures`. The minimum-signatures check happens later, inside `KnowledgeAssetsV10.publishDirect`, so every publish to that CG reverts forever with `MinSignaturesRequirementNotMet` and the on-chain CG is paid-for and permanently broken. Pre-flight in `DKGAgent.registerContextGraph`: - When `chain.getMinimumRequiredSignatures` is implemented, refuse when `effectiveParticipantIdentityIds.length < globalMin` or `effectiveRequiredSignatures < globalMin`, with explicit hints naming the future revert (`MinSignaturesRequirementNotMet(globalMin, X)`). - Surface the silent `[self]` fallback as a structured WARN log so operators can scrape for it instead of finding out at publish time. - Skip the check when the adapter does not expose `getMinimumRequiredSignatures` (mock/legacy adapters), keeping existing fixtures green. `MockChainAdapter.minimumRequiredSignatures` defaults to 1, so the check is a no-op for the existing agent-test fixtures (verified locally: Genesis Knowledge registration tests still pass). Adds 5 focused tests covering all four branches plus the legacy single-host backwards-compatible case. Co-authored-by: Cursor --- packages/agent/src/dkg-agent.ts | 65 +++++++ .../test/register-quorum-preflight.test.ts | 166 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 packages/agent/test/register-quorum-preflight.test.ts diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 5dc9e60b2..ebe7c2719 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -4164,11 +4164,76 @@ export class DKGAgent { ); } effectiveParticipantIdentityIds = [selfIdentityId]; + // Silent fallback to [self] is preserved for backwards-compatible solo + // dev/test flows, but operators on production chains hit this path by + // accident and end up with a CG that's permanently broken (every publish + // reverts with MinSignaturesRequirementNotMet because the global quorum + // floor exceeds 1). Surface the fallback explicitly so log scraping + // catches it, and let the global-quorum pre-flight below decide whether + // to refuse outright. + this.log.warn( + ctx, + `Context graph "${id}" has no recorded participant identity IDs in local _meta — ` + + `falling back to [self=${selfIdentityId}] for on-chain registration. ` + + `If you intended a multi-host CG, recreate it with explicit participants ` + + `before registering (existing on-chain registration will not be reused).`, + ); } const effectiveRequiredSignatures = Number.isInteger(storedRequiredSignatures) && storedRequiredSignatures > 0 ? storedRequiredSignatures : 1; + + // Global-quorum pre-flight. ContextGraphStorage.createContextGraph itself + // only enforces `requiredSignatures <= hostingNodes.length` — it does NOT + // check the global ParametersStorage.minimumRequiredSignatures floor. That + // check happens later, inside KnowledgeAssetsV10.publishDirect. The + // result: a CG can be successfully created on-chain with hostingNodes=1 + // and requiredSignatures=1, after which every publish reverts with + // `MinSignaturesRequirementNotMet(globalMin, hostingNodes.length)`. + // + // Refuse here when the chain adapter exposes the floor and the proposed + // CG configuration cannot satisfy it. Skip when the adapter does not + // implement `getMinimumRequiredSignatures` (mock/legacy adapters): the + // existing on-chain create call is still subject to its own constraints, + // and we don't want to regress test fixtures that use a permissive mock. + if (typeof this.chain.getMinimumRequiredSignatures === 'function') { + let globalMin: number; + try { + globalMin = await this.chain.getMinimumRequiredSignatures(); + } catch (err) { + this.log.warn( + ctx, + `Could not read minimumRequiredSignatures from ParametersStorage while ` + + `validating registration of "${id}": ${(err as Error).message}. ` + + `Proceeding without global-quorum pre-flight.`, + ); + globalMin = 0; + } + if (globalMin > 0) { + if (effectiveParticipantIdentityIds.length < globalMin) { + throw new Error( + `Context graph "${id}" cannot be registered on-chain: it has ` + + `${effectiveParticipantIdentityIds.length} hosting node` + + `${effectiveParticipantIdentityIds.length === 1 ? '' : 's'} ` + + `but the global minimum quorum (ParametersStorage.minimumRequiredSignatures) ` + + `is ${globalMin}. Recreate the CG with at least ${globalMin} hosting nodes — ` + + `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + + `${globalMin}, ${effectiveParticipantIdentityIds.length}).`, + ); + } + if (effectiveRequiredSignatures < globalMin) { + throw new Error( + `Context graph "${id}" cannot be registered on-chain: requiredSignatures=` + + `${effectiveRequiredSignatures} is below the global minimum quorum of ${globalMin}. ` + + `Recreate the CG with --required-signatures ${globalMin} (or higher) before registering — ` + + `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + + `${globalMin}, ${effectiveRequiredSignatures}).`, + ); + } + } + } + const participantAgents = await this.getContextGraphParticipantAgentAddresses(id); if (participantAgents.length > MAX_CONTEXT_GRAPH_PARTICIPANT_AGENTS) { throw new Error( diff --git a/packages/agent/test/register-quorum-preflight.test.ts b/packages/agent/test/register-quorum-preflight.test.ts new file mode 100644 index 000000000..f42985e21 --- /dev/null +++ b/packages/agent/test/register-quorum-preflight.test.ts @@ -0,0 +1,166 @@ +/** + * Pre-flight quorum check for `DKGAgent.registerContextGraph`. + * + * Reproduces the operator footgun observed during the Base Sepolia + * cgId=10/cgId=11 incident: + * + * 1. Operator runs `dkg context-graph register ` against an + * existing CG that has no recorded participantIdentityIds in `_meta`. + * 2. The agent silently falls back to `[selfIdentityId]` and a default + * `requiredSignatures = 1`. + * 3. `ContextGraphStorage.createContextGraph` accepts that on-chain + * (it only enforces `requiredSignatures <= hostingNodes.length`). + * 4. Every subsequent publish reverts inside `KnowledgeAssetsV10.publishDirect` + * with `MinSignaturesRequirementNotMet(globalMin, 1)`, where `globalMin` + * comes from `ParametersStorage.minimumRequiredSignatures` (e.g. 3 on + * production deployments). + * + * Outcome: the on-chain CG is permanently broken, the operator paid gas to + * mint it, and the only error visible to them is the on-chain revert at + * publish time — minutes/hours/days after the misconfigured registration. + * + * The pre-flight check refuses registration when the proposed configuration + * cannot satisfy the global quorum floor, with an explicit hint. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { DKGAgent } from '../src/index.js'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + MockChainAdapter, + type CreateOnChainContextGraphParams, + type CreateOnChainContextGraphResult, +} from '@origintrail-official/dkg-chain'; + +class CapturingMockChainAdapter extends MockChainAdapter { + createOnChainContextGraphCalls: CreateOnChainContextGraphParams[] = []; + + async createOnChainContextGraph( + params: CreateOnChainContextGraphParams, + ): Promise { + this.createOnChainContextGraphCalls.push({ + ...params, + participantIdentityIds: [...params.participantIdentityIds], + participantAgents: params.participantAgents ? [...params.participantAgents] : undefined, + }); + return super.createOnChainContextGraph(params); + } +} + +async function makeAgent(opts: { + globalMin: number; +}): Promise<{ agent: DKGAgent; chain: CapturingMockChainAdapter; ownerAgent: string }> { + const chain = new CapturingMockChainAdapter(); + chain.minimumRequiredSignatures = opts.globalMin; + const agent = await DKGAgent.create({ + name: 'QuorumPreflightBot', + store: new OxigraphStore(), + chainAdapter: chain, + nodeRole: 'core', + }); + await agent.start(); + const ownerAgent = ethers.getAddress(chain.signerAddress); + return { agent, chain, ownerAgent }; +} + +describe('DKGAgent.registerContextGraph — global quorum pre-flight', () => { + const agents: DKGAgent[] = []; + afterEach(async () => { + while (agents.length) { + const a = agents.pop()!; + await a.stop().catch(() => {}); + } + }); + + it('refuses registration when the silent [self] fallback would violate the global minimum', async () => { + const { agent, chain, ownerAgent } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await agent.createContextGraph({ + id: 'preflight-self-fallback', + name: 'Self Fallback', + callerAgentAddress: ownerAgent, + }); + + await expect(agent.registerContextGraph('preflight-self-fallback', { callerAgentAddress: ownerAgent })) + .rejects.toThrow( + /global minimum quorum.*is 3.*MinSignaturesRequirementNotMet\(3, 1\)/s, + ); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('refuses registration when participantIdentityIds.length < globalMin even with explicit participants', async () => { + const { agent, chain, ownerAgent } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await agent.createContextGraph({ + id: 'preflight-too-few-participants', + name: 'Too Few Participants', + participantIdentityIds: [1n, 2n], + requiredSignatures: 2, + callerAgentAddress: ownerAgent, + }); + + await expect(agent.registerContextGraph('preflight-too-few-participants', { callerAgentAddress: ownerAgent })) + .rejects.toThrow(/2 hosting nodes but the global minimum quorum.*is 3/s); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('refuses registration when requiredSignatures < globalMin even with enough participants', async () => { + const { agent, chain, ownerAgent } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await agent.createContextGraph({ + id: 'preflight-low-quorum', + name: 'Low Quorum', + participantIdentityIds: [1n, 2n, 3n, 4n], + requiredSignatures: 1, + callerAgentAddress: ownerAgent, + }); + + await expect(agent.registerContextGraph('preflight-low-quorum', { callerAgentAddress: ownerAgent })) + .rejects.toThrow(/requiredSignatures=1 is below the global minimum quorum of 3/); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('accepts registration when both participants and requiredSignatures meet the global minimum', async () => { + const { agent, chain, ownerAgent } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await agent.createContextGraph({ + id: 'preflight-ok', + name: 'OK', + participantIdentityIds: [1n, 2n, 3n], + requiredSignatures: 3, + callerAgentAddress: ownerAgent, + }); + + const result = await agent.registerContextGraph('preflight-ok', { callerAgentAddress: ownerAgent }); + expect(result.onChainId).toMatch(/^\d+$/); + + expect(chain.createOnChainContextGraphCalls).toHaveLength(1); + expect(chain.createOnChainContextGraphCalls[0]).toMatchObject({ + requiredSignatures: 3, + }); + expect(chain.createOnChainContextGraphCalls[0]?.participantIdentityIds.length).toBe(3); + }); + + it('preserves legacy single-host behaviour when the global minimum is 1', async () => { + const { agent, chain, ownerAgent } = await makeAgent({ globalMin: 1 }); + agents.push(agent); + + await agent.createContextGraph({ + id: 'preflight-solo-ok', + name: 'Solo OK', + callerAgentAddress: ownerAgent, + }); + + const result = await agent.registerContextGraph('preflight-solo-ok', { callerAgentAddress: ownerAgent }); + expect(result.onChainId).toMatch(/^\d+$/); + + expect(chain.createOnChainContextGraphCalls).toHaveLength(1); + }); +}); From 74202c1031d1fd7e4fb1dc8a860491d558b0450e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Mon, 4 May 2026 14:32:16 +0200 Subject: [PATCH 2/2] fix(agent): close daemon-bypass + fail-closed on RPC error in CG quorum gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on PR #374 flagged two real bugs in the global-quorum pre-flight added in the previous commit: 1. The validation only ran inside `registerContextGraph(id, opts)`. The daemon's `/api/context-graph` route calls `registerContextGraphOnChain` directly (`packages/cli/src/daemon/routes/context-graph.ts:429`) and therefore bypasses the check entirely — an HTTP client could still mint a permanently-broken CG by submitting bad params at the lower entry point. The footgun the PR was supposed to close was only half-closed. 2. The `try { getMinimumRequiredSignatures() } catch { warn-and-continue }` shape silently skipped the pre-flight on a transient RPC failure, after which the chain's own `createContextGraph` would happily mint the CG (it doesn't check the global floor) and every subsequent publish would revert. That is exactly the bug the pre-flight exists to prevent: a single flaky `eth_call` reintroduces the footgun. Refactor the validation into a private `assertGlobalQuorumOrThrow` helper, called from both `registerContextGraph` (preserves existing test messages, gives operators a nicer error with the local CG `id` and an early failure before the lazy curator-stamping work) AND `registerContextGraphOnChain` (closes the daemon-HTTP bypass). Belt + braces; the lower-level chokepoint is the security boundary that all callers must traverse, the upper-level call is the better UX surface. Switch the RPC-error branch from `warn-and-continue (globalMin = 0)` to `throw`. Skip silently only when the adapter does not implement `getMinimumRequiredSignatures` at all — i.e. legacy / pre-V10 mock adapters that don't model the floor. This keeps the existing test fixtures working without re-introducing the swallowed-error footgun on real chain adapters. Tests: - Existing 5 high-level tests still pass. - 3 new tests assert the bypass is closed (call `registerContextGraphOnChain` directly with bad hostingNodes / requiredSignatures combos and assert it throws before any chain mutation). - 2 new tests assert fail-closed semantics (override `getMinimumRequiredSignatures` to reject; assert both the high-level and direct-call paths throw with a clear "Refusing to proceed (fail-closed)" message instead of silently proceeding). Verified by sabotage: removing the inner-call validation makes exactly the 3 new direct-call + RPC-error tests fail, leaving the original 7 passing — proving the new tests genuinely catch the bugs, not just the happy path. Co-authored-by: Cursor --- packages/agent/src/dkg-agent.ts | 122 +++++++++----- .../test/register-quorum-preflight.test.ts | 150 ++++++++++++++++++ 2 files changed, 231 insertions(+), 41 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index ebe7c2719..599f1c3bc 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -2993,11 +2993,83 @@ export class DKGAgent { /** * Register a new M/N signature-gated context graph on-chain. */ + /** + * Refuse on-chain context-graph registration when the proposed + * configuration cannot satisfy the global + * `ParametersStorage.minimumRequiredSignatures` floor that + * `KnowledgeAssetsV10.publishDirect` enforces at publish time. + * + * Fail-closed semantics: if the adapter exposes + * `getMinimumRequiredSignatures` but the RPC call fails, we throw rather + * than continue. Silently skipping on a transient RPC failure would let + * an operator mint a CG that is never publishable — exactly the bug this + * pre-flight exists to prevent. The previous `try { ... } catch { + * warn-and-continue }` shape reintroduced that footgun. + * + * Skip silently only when the adapter does not implement the getter at + * all — i.e. legacy / pre-V10 adapters or test fixtures that don't + * model the floor. The chain's own validation still applies in that + * case (and the `createOnChainContextGraph` call may still revert on + * its own internal invariants). + */ + private async assertGlobalQuorumOrThrow( + hostingNodeCount: number, + requiredSignatures: number, + contextLabel: string, + ): Promise { + if (typeof this.chain.getMinimumRequiredSignatures !== 'function') return; + + let globalMin: number; + try { + globalMin = await this.chain.getMinimumRequiredSignatures(); + } catch (err) { + throw new Error( + `${contextLabel} cannot be registered on-chain: failed to read ` + + `minimumRequiredSignatures floor from ParametersStorage ` + + `(${(err as Error).message}). Refusing to proceed (fail-closed) — ` + + `creating the CG without verifying the global quorum could leave it ` + + `permanently unpublishable. Retry once chain RPC is healthy.`, + ); + } + + if (globalMin <= 0) return; + + if (hostingNodeCount < globalMin) { + throw new Error( + `${contextLabel} cannot be registered on-chain: it has ` + + `${hostingNodeCount} hosting node${hostingNodeCount === 1 ? '' : 's'} ` + + `but the global minimum quorum (ParametersStorage.minimumRequiredSignatures) ` + + `is ${globalMin}. Recreate the CG with at least ${globalMin} hosting nodes — ` + + `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + + `${globalMin}, ${hostingNodeCount}).`, + ); + } + if (requiredSignatures < globalMin) { + throw new Error( + `${contextLabel} cannot be registered on-chain: requiredSignatures=` + + `${requiredSignatures} is below the global minimum quorum of ${globalMin}. ` + + `Recreate the CG with --required-signatures ${globalMin} (or higher) before registering — ` + + `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + + `${globalMin}, ${requiredSignatures}).`, + ); + } + } + async registerContextGraphOnChain(params: CreateOnChainContextGraphParams): Promise { const ctx = createOperationContext('system'); if (typeof this.chain.createOnChainContextGraph !== 'function') { throw new Error('createOnChainContextGraph not available on chain adapter'); } + // Defense-in-depth: also gate this lower-level entry point so the + // daemon's HTTP/API path (`/api/context-graph` → `registerContextGraphOnChain`) + // can't bypass the high-level pre-flight by submitting bad params + // directly. The high-level `registerContextGraph` runs the same check + // earlier in its own flow (with the local `id` for nicer error context). + await this.assertGlobalQuorumOrThrow( + params.participantIdentityIds.length, + params.requiredSignatures, + 'Context graph (on-chain registration)', + ); const result = await this.chain.createOnChainContextGraph(params); const contextGraphId = result.contextGraphId.toString(); for (const identityId of params.participantIdentityIds) { @@ -4192,47 +4264,15 @@ export class DKGAgent { // and requiredSignatures=1, after which every publish reverts with // `MinSignaturesRequirementNotMet(globalMin, hostingNodes.length)`. // - // Refuse here when the chain adapter exposes the floor and the proposed - // CG configuration cannot satisfy it. Skip when the adapter does not - // implement `getMinimumRequiredSignatures` (mock/legacy adapters): the - // existing on-chain create call is still subject to its own constraints, - // and we don't want to regress test fixtures that use a permissive mock. - if (typeof this.chain.getMinimumRequiredSignatures === 'function') { - let globalMin: number; - try { - globalMin = await this.chain.getMinimumRequiredSignatures(); - } catch (err) { - this.log.warn( - ctx, - `Could not read minimumRequiredSignatures from ParametersStorage while ` + - `validating registration of "${id}": ${(err as Error).message}. ` + - `Proceeding without global-quorum pre-flight.`, - ); - globalMin = 0; - } - if (globalMin > 0) { - if (effectiveParticipantIdentityIds.length < globalMin) { - throw new Error( - `Context graph "${id}" cannot be registered on-chain: it has ` + - `${effectiveParticipantIdentityIds.length} hosting node` + - `${effectiveParticipantIdentityIds.length === 1 ? '' : 's'} ` + - `but the global minimum quorum (ParametersStorage.minimumRequiredSignatures) ` + - `is ${globalMin}. Recreate the CG with at least ${globalMin} hosting nodes — ` + - `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + - `${globalMin}, ${effectiveParticipantIdentityIds.length}).`, - ); - } - if (effectiveRequiredSignatures < globalMin) { - throw new Error( - `Context graph "${id}" cannot be registered on-chain: requiredSignatures=` + - `${effectiveRequiredSignatures} is below the global minimum quorum of ${globalMin}. ` + - `Recreate the CG with --required-signatures ${globalMin} (or higher) before registering — ` + - `otherwise every publish would revert with MinSignaturesRequirementNotMet(` + - `${globalMin}, ${effectiveRequiredSignatures}).`, - ); - } - } - } + // Validate here AND inside `registerContextGraphOnChain`: this call gives + // operators an early failure with the local `id` for context, the lower + // call closes the bypass for any caller that hits it directly (e.g. the + // daemon's `/api/context-graph` route). + await this.assertGlobalQuorumOrThrow( + effectiveParticipantIdentityIds.length, + effectiveRequiredSignatures, + `Context graph "${id}"`, + ); const participantAgents = await this.getContextGraphParticipantAgentAddresses(id); if (participantAgents.length > MAX_CONTEXT_GRAPH_PARTICIPANT_AGENTS) { diff --git a/packages/agent/test/register-quorum-preflight.test.ts b/packages/agent/test/register-quorum-preflight.test.ts index f42985e21..2a37065a6 100644 --- a/packages/agent/test/register-quorum-preflight.test.ts +++ b/packages/agent/test/register-quorum-preflight.test.ts @@ -164,3 +164,153 @@ describe('DKGAgent.registerContextGraph — global quorum pre-flight', () => { expect(chain.createOnChainContextGraphCalls).toHaveLength(1); }); }); + +/** + * Covers the codex-flagged bypass on PR #374: + * + * The daemon's `/api/context-graph` route calls `registerContextGraphOnChain` + * directly (see `packages/cli/src/daemon/routes/context-graph.ts`). Before + * this fix the pre-flight only fired in `registerContextGraph`, so an HTTP + * client could still mint a permanently-broken CG by hitting the lower + * entry point with bad params. + * + * The two tests below assert the validation is enforced at the lower layer + * too, so the bypass is closed at the only chokepoint that all callers must + * pass through. + */ +describe('DKGAgent.registerContextGraphOnChain — direct-call quorum gate', () => { + const agents: DKGAgent[] = []; + afterEach(async () => { + while (agents.length) { + const a = agents.pop()!; + await a.stop().catch(() => {}); + } + }); + + it('refuses direct calls with hostingNodes < globalMin (closes the daemon HTTP bypass)', async () => { + const { agent, chain } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await expect( + agent.registerContextGraphOnChain({ + participantIdentityIds: [1n, 2n], + requiredSignatures: 2, + }), + ).rejects.toThrow(/2 hosting nodes but the global minimum quorum.*is 3/s); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('refuses direct calls with requiredSignatures < globalMin', async () => { + const { agent, chain } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + await expect( + agent.registerContextGraphOnChain({ + participantIdentityIds: [1n, 2n, 3n, 4n], + requiredSignatures: 1, + }), + ).rejects.toThrow(/requiredSignatures=1 is below the global minimum quorum of 3/); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('accepts direct calls when both hostingNodes and requiredSignatures meet globalMin', async () => { + const { agent, chain } = await makeAgent({ globalMin: 3 }); + agents.push(agent); + + const result = await agent.registerContextGraphOnChain({ + participantIdentityIds: [1n, 2n, 3n], + requiredSignatures: 3, + }); + expect(result.contextGraphId).toBeDefined(); + + expect(chain.createOnChainContextGraphCalls).toHaveLength(1); + }); +}); + +/** + * Covers the codex-flagged "swallowed RPC error" footgun on PR #374: + * + * The previous `try { getMinimumRequiredSignatures() } catch { warn-and-continue }` + * shape meant that a single transient `eth_call` failure would silently + * skip the global-quorum pre-flight, after which the chain's own + * `createContextGraph` would happily mint the CG (it doesn't check the + * global floor) and every subsequent publish would revert. That's + * exactly the bug the pre-flight was supposed to prevent. + * + * After the fix, RPC failures throw — fail-closed — so the operator gets + * an actionable error and no on-chain mint happens. They can retry once + * RPC is healthy. + */ +describe('DKGAgent — RPC failure when reading minimumRequiredSignatures (fail-closed)', () => { + const agents: DKGAgent[] = []; + afterEach(async () => { + while (agents.length) { + const a = agents.pop()!; + await a.stop().catch(() => {}); + } + }); + + /** + * Wraps the mock chain to make `getMinimumRequiredSignatures` reject + * with a synthetic RPC error. + */ + function makeRpcFailingChain(): CapturingMockChainAdapter { + const chain = new CapturingMockChainAdapter(); + chain.getMinimumRequiredSignatures = async () => { + throw new Error('synthetic RPC failure: eth_call timed out'); + }; + return chain; + } + + it('throws (fail-closed) instead of silently proceeding when getMinimumRequiredSignatures rejects', async () => { + const chain = makeRpcFailingChain(); + const agent = await DKGAgent.create({ + name: 'FailClosedBot', + store: new OxigraphStore(), + chainAdapter: chain, + nodeRole: 'core', + }); + await agent.start(); + agents.push(agent); + + await expect( + agent.registerContextGraphOnChain({ + participantIdentityIds: [1n, 2n, 3n], + requiredSignatures: 3, + }), + ).rejects.toThrow( + /failed to read minimumRequiredSignatures floor.*synthetic RPC failure.*Refusing to proceed \(fail-closed\)/s, + ); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); + + it('also fails closed in the high-level registerContextGraph path', async () => { + const chain = makeRpcFailingChain(); + const agent = await DKGAgent.create({ + name: 'FailClosedBotHL', + store: new OxigraphStore(), + chainAdapter: chain, + nodeRole: 'core', + }); + await agent.start(); + agents.push(agent); + const ownerAgent = ethers.getAddress(chain.signerAddress); + + await agent.createContextGraph({ + id: 'fail-closed-hl', + name: 'Fail Closed HL', + participantIdentityIds: [1n, 2n, 3n], + requiredSignatures: 3, + callerAgentAddress: ownerAgent, + }); + + await expect( + agent.registerContextGraph('fail-closed-hl', { callerAgentAddress: ownerAgent }), + ).rejects.toThrow(/failed to read minimumRequiredSignatures floor.*Refusing to proceed \(fail-closed\)/s); + + expect(chain.createOnChainContextGraphCalls).toEqual([]); + }); +});