diff --git a/packages/random-sampling/src/kc-extractor.ts b/packages/random-sampling/src/kc-extractor.ts index 1061c27ce..862e46f46 100644 --- a/packages/random-sampling/src/kc-extractor.ts +++ b/packages/random-sampling/src/kc-extractor.ts @@ -153,9 +153,10 @@ export async function extractV10KCFromStore( } const metaGraph = contextGraphMetaUri(cgName, cgIdStr); const dataGraph = contextGraphDataUri(cgName, cgIdStr); - // No assertSafeIri on derived URIs — they are constructed from a - // numeric bigint stringification + a CG name we just round-tripped - // through SPARQL, and the helpers are part of the trusted core surface. + // No assertSafeIri on derived URIs — `cgIdStr` is a bigint stringification + // and `cgName` was already gated by an `assertSafeIri(contextGraphMetaUri(name, '0'))` + // probe inside `resolveContextGraphNameFromOnChainId`, so the derived URIs + // are safe by construction. // 1. Resolve UAL via dkg:batchId. Use a typed integer literal to // avoid string-prefix collisions (kcId 1 vs 10) — same lookup @@ -277,9 +278,21 @@ async function resolveContextGraphNameFromOnChainId( const cgUri = stripQuotes(result.bindings[0]['cgUri'] ?? ''); if (!cgUri.startsWith(CG_URI_PREFIX)) return null; const name = cgUri.slice(CG_URI_PREFIX.length); - // Reject empty / dangerous names. CG names are normally safe slugs - // (lowercase + hyphens) but we don't gate that here. - if (!name || name.includes('/') || name.includes(' ')) return null; + if (!name) return null; + // The name is later interpolated into a SPARQL IRI (`/...>`), + // so we must reject anything that would break out of an IRI literal — but we cannot + // reject `/` here, because v9-style CG names use the `/` namespacing + // pattern (e.g. "0xb08…4794c/laptop-smoke") and would otherwise be silently dropped, + // surfacing as `KCNotFoundError` even when the FinalizationHandler has already promoted + // the data to canonical. That mismatch broke RS proof submission across the entire + // testnet — see PR notes for the on-chain reproduction. + // Validate via `assertSafeIri` on the *derived* meta-graph URI, which is the actual + // injection surface; rely on it instead of an overly tight name-level allowlist. + try { + assertSafeIri(contextGraphMetaUri(name, '0')); + } catch { + return null; + } return name; } diff --git a/packages/random-sampling/test/e2e-hardhat-chain.test.ts b/packages/random-sampling/test/e2e-hardhat-chain.test.ts index b5b7c7fda..cc54d874a 100644 --- a/packages/random-sampling/test/e2e-hardhat-chain.test.ts +++ b/packages/random-sampling/test/e2e-hardhat-chain.test.ts @@ -232,7 +232,19 @@ describe('Random Sampling E2E (Hardhat)', () => { // We mirror that here so the extractor finds the KC. const store = new OxigraphStore(); const cgIdStr = cgId.toString(); - const cgName = `cg-${cgIdStr}`; + // Use a v9-style `/` cgName to exercise the + // FinalizationHandler ↔ kc-extractor seam end-to-end with the + // namespacing pattern that real beacons register on testnet + // (e.g. "0xb08…4794c/laptop-smoke"). A pre-PR-#377 build would + // short-circuit here in `resolveContextGraphNameFromOnChainId`, + // making `prover.tick()` return `kc-not-synced` instead of + // `submitted` — exactly the failure mode that suppressed every + // RS proof on testnet beacon-04 for 40+ minutes despite chain + // challenges firing within seconds of each `Finalization: promoted + // SWM snapshot` log line. The unit test in `kc-extractor.test.ts` + // covers the resolver in isolation; this one pins the whole + // chain-publish → local-store → prover-tick path. + const cgName = `0xb08A0F66d5A225D57Dee5fFa6C442e4DC2a4794c/cg-${cgIdStr}`; const cgUri = `did:dkg:context-graph:${cgName}`; await store.insert([ { diff --git a/packages/random-sampling/test/kc-extractor.test.ts b/packages/random-sampling/test/kc-extractor.test.ts index 8e8a39ac7..c5b1fc3f2 100644 --- a/packages/random-sampling/test/kc-extractor.test.ts +++ b/packages/random-sampling/test/kc-extractor.test.ts @@ -222,6 +222,35 @@ describe('extractV10KCFromStore — happy path / publisher round-trip parity', ( } }); + it('resolves CG names that contain "/" (v9-style / namespacing)', async () => { + // Regression for a testnet incident where every random-sampling proof on every + // beacon went unsubmitted: the CG name resolver in `kc-extractor.ts` rejected + // any name containing "/", but real CGs were registered as + // "0xb08…4794c/laptop-smoke" — owner-prefixed slugs. The FinalizationHandler + // wrote canonical data under `did:dkg:context-graph://context//_meta` + // (which is correct), but the extractor returned `null` from name resolution + // and threw `KCNotFoundError` even after data was promoted, blocking proofs + // forever. The relevant log line on every beacon was: + // `Finalization: promoted SWM snapshot to context graph 12 …` + // `[rs.tick.kc-not-synced] {"kcId":"6","cgId":"12","err":"KCNotFoundError"}` + const fixture: KCFixture = { + cgId: 12n, + cgName: '0xb08A0F66d5A225D57Dee5fFa6C442e4DC2a4794c/laptop-smoke', + kcId: 6n, + ual: 'did:dkg:base:84532/0xb08A0F66d5A225D57Dee5fFa6C442e4DC2a4794c/5000001', + rootEntities: ['urn:entity:slash-cg'], + publicTriples: [ + { subject: 'urn:entity:slash-cg', predicate: 'urn:p:name', object: '"slash"' }, + ], + }; + await seedKC(store, fixture); + + const result = await extractV10KCFromStore(store, fixture.cgId, fixture.kcId); + expect(result.contextGraphName).toBe(fixture.cgName); + expect(result.ual).toBe(fixture.ual); + expect(result.triples).toHaveLength(1); + }); + it('mixes public-triple leaves with private sub-root leaves (publisher symmetry)', async () => { const PRIVATE_ROOT = new Uint8Array(32).fill(0xab); const fixture: KCFixture = { diff --git a/scripts/devnet.sh b/scripts/devnet.sh index 342f9f099..c89acfed7 100755 --- a/scripts/devnet.sh +++ b/scripts/devnet.sh @@ -713,12 +713,12 @@ cmd_start() { provider ); // Identity-scoped read used by the duplicate-stake guard below. - // ERC-721 balanceOf(opWallet) would also signal "this wallet - // owns at least one position", but that's not what the guard - // needs to know — an op wallet can hold positions for OTHER - // identities, which would skip a needed createConviction for - // THIS identity. getNodeStakeV10(idId) reads the per-identity - // stake total directly. Codex round 7 on PR #368. + // ERC-721 balanceOf(opWallet) would also signal that the op wallet + // owns at least one position, but that is not what the guard needs + // to know - an op wallet can hold positions for OTHER identities, + // which would skip a needed createConviction for THIS identity. + // getNodeStakeV10(idId) reads the per-identity stake total + // directly. Codex round 7 on PR #368. const css = new ethers.Contract( c('ConvictionStakingStorage'), ['function getNodeStakeV10(uint72) view returns (uint256)'], @@ -744,8 +744,8 @@ cmd_start() { // wallets.json was malformed silently kept the 'edge' default, // dropped out of coreIdxs, and the lostCores guard never fired. // Reading config first decouples the two failures: now lostCores - // correctly catches "I was supposed to be a core but my wallets - // are broken". + // correctly catches the case where a node was supposed to be a + // core but its wallets are broken. const configErrors = []; for (let i = 1; i <= n; i++) { const cPath = '$DEVNET_DIR/node' + i + '/config.json'; @@ -886,7 +886,7 @@ cmd_start() { // devnet stake. We detect this by reading the per-identity V10 // stake total — if the daemon's stake succeeded it's >0 and we skip. // - // Codex round 5 on PR #368: do NOT fall through to "not staked" + // Codex round 5 on PR #368: do NOT fall through to a not-staked // on probe failure (transient RPC, decode error). That would // re-introduce the double-stake bug this guard exists to prevent. // Retry once with backoff; if the probe still fails, refuse to