Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions packages/random-sampling/src/kc-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<did:dkg:context-graph:<name>/...>`),
// 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 `<owner_address>/<slug>` 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;
}

Expand Down
14 changes: 13 additions & 1 deletion packages/random-sampling/test/e2e-hardhat-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<owner_address>/<slug>` 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([
{
Expand Down
29 changes: 29 additions & 0 deletions packages/random-sampling/test/kc-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,35 @@ describe('extractV10KCFromStore — happy path / publisher round-trip parity', (
}
});

it('resolves CG names that contain "/" (v9-style <owner>/<slug> 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:<owner>/<slug>/context/<cgId>/_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 = {
Expand Down
18 changes: 9 additions & 9 deletions scripts/devnet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)'],
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading