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
7 changes: 7 additions & 0 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ payloads, internal URLs and stack traces are never returned.
to sellers via the buyer-anchored agreement. Agents nobody registered appear as
"discovered on-chain". Depth: `DACS_SCAN_MAX_TXS` (default 100000); a pass that
hits the cap fails rather than advancing the cursor and silently skipping history.
Revocation discovery retains at most 16 candidates per listing hash, except for
locators that already passed RB-4 verification; truncation is recorded in reindex logs.
New scan observations precede prior unverified state in that window; within each
group, the first distinct locators in scan iteration order survive. A valid marker
outside the retained window is not evaluated. Its publisher can anchor a fresh
marker to re-enter discovery, but continued overflow can exclude that marker again.
After one marker verifies, later pruning cannot make it disappear.
3. **Evidence graph**: current bundles recursively resolve and validate listings,
agreements, settlement evidence and amendment chains, composite/VerifyResult vet
records, and ratings. Legacy SDK artifacts remain on an explicitly-labelled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* and by POST /api/dacs/reindex (the UI's refresh button).
*/
import { indexRegistration, type ResolveIdentities } from "./indexer";
import { readChainTip, scanChain } from "./scan";
import { boundedRevocationCandidates, readChainTip, scanChain } from "./scan";
import { chainResetRequired, chainResetThreshold } from "./chainContinuity";
import { crawlDomains } from "./wellknown";
import { upsertCounterpartyEvidenceSeller } from "./counterpartyEvidence";
Expand Down Expand Up @@ -73,6 +73,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise<ReindexSumm
deals: {},
programs: {},
revocations: {},
verifiedRevocations: {},
};
saveScanState(state);
log(
Expand All @@ -88,10 +89,21 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise<ReindexSumm
const configuredOverlap = Number(process.env.DACS_SCAN_REPLAY_DEPTH ?? 2);
const overlap = Number.isSafeInteger(configuredOverlap) && configuredOverlap >= 0 ? configuredOverlap : 2;
const sinceTxId = needsBindingBackfill ? 0 : Math.max(0, state.lastSeenTxId - overlap);
state.verifiedRevocations ??= {};
for (const seller of prior.sellers) for (const listing of seller.listings) {
const locator = listing.revocationBinding?.markerAnchor.locator;
if (!locator) continue;
const verified = state.verifiedRevocations[listing.contentHash] ?? [];
if (!verified.includes(locator)) verified.push(locator);
state.verifiedRevocations[listing.contentHash] = verified;
}
const verifiedRevocations = new Map(
Object.entries(state.verifiedRevocations).map(([hash, addresses]) => [hash, new Set(addresses)]),
);
const runId = beginScanRun(sinceTxId);
let scan;
try {
scan = await scanChain(null, { maxTxs, sinceTxId, retryLocators: loadRetryableArtifacts() });
scan = await scanChain(null, { maxTxs, sinceTxId, retryLocators: loadRetryableArtifacts(), verifiedRevocations });
} catch (error) {
finishScanRun(runId, { toTx: state.lastSeenTxId, txs: 0, artifacts: 0, rejected: 0, error: error instanceof Error ? error.message : String(error) });
throw error;
Expand All @@ -111,12 +123,26 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise<ReindexSumm
for (const [key, address] of scan.programs) state.programs[key] = address;
if (needsBindingBackfill) state.revocations = {};
state.revocations ??= {};
let revocationCandidatesTruncated = scan.revocationCandidatesTruncated;
for (const [hash, addresses] of scan.revocations) {
const priorCandidates = state.revocations[hash];
const prior = Array.isArray(priorCandidates)
? priorCandidates
: priorCandidates ? [priorCandidates] : [];
state.revocations[hash] = [...new Set([...addresses, ...prior])];
const merged = boundedRevocationCandidates(
[...addresses, ...prior],
verifiedRevocations.get(hash),
);
state.revocations[hash] = merged.candidates;
revocationCandidatesTruncated += merged.truncated;
}
// Bound legacy and inactive hashes too; a hash need not reappear in the
// current scan window for old persisted state to remain attacker-inflated.
for (const [hash, stored] of Object.entries(state.revocations)) {
const candidates = Array.isArray(stored) ? stored : [stored];
const bounded = boundedRevocationCandidates(candidates, verifiedRevocations.get(hash));
state.revocations[hash] = bounded.candidates;
revocationCandidatesTruncated += bounded.truncated;
}
for (const observation of scan.observations) recordArtifact(observation);
for (const failure of scan.failures) recordArtifactFailure(failure.locator, failure.kind, failure.code, failure.message);
Expand All @@ -139,6 +165,9 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise<ReindexSumm
`+${scan.listings.size} listing(s), +${scan.deals.size} deal(s); ` +
`accumulated: ${Object.keys(state.listings).length} listing(s), ${Object.keys(state.deals).length} deal(s)`,
);
if (revocationCandidatesTruncated > 0) {
log(`revocation candidates: truncated ${revocationCandidatesTruncated} unverified locator(s) at the per-listing bound`);
}
const didOf = (addr: string) => `did:demos:agent:${addr.replace(/^0x/, "")}`;
const known = new Set(regs.map((r) => r.primaryClaim));

Expand Down Expand Up @@ -215,6 +244,21 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise<ReindexSumm
log("fixture: Counterparty Evidence Desk preserved");
}

for (const seller of catalogSellers) for (const listing of seller.listings) {
const locator = listing.revocationBinding?.markerAnchor.locator;
if (!locator) continue;
const verified = state.verifiedRevocations[listing.contentHash] ?? [];
if (!verified.includes(locator)) verified.push(locator);
state.verifiedRevocations[listing.contentHash] = verified;
const stored = state.revocations?.[listing.contentHash];
const candidates = Array.isArray(stored) ? stored : stored ? [stored] : [];
state.revocations![listing.contentHash] = boundedRevocationCandidates(
[locator, ...candidates],
new Set(verified),
).candidates;
}
saveScanState(state);

saveCatalog({ catalogVersion: "1", generatedAt, sellers: catalogSellers });
log(`catalog written: ${catalogSellers.length} seller(s)`);
return { sellers: catalogSellers.length, newTxs: scan.txsScanned, cursor: state.lastSeenTxId };
Expand Down
55 changes: 48 additions & 7 deletions reference-implementations/dacs-directory/src/catalog/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export interface ScannedArtifacts {
deals: Map<string, RegisteredDeal & { sellerFromAgreement?: string }>;
/** owner + programName → observed native address. */
programs: Map<string, string>;
/** listing content hash → every observed revocation marker candidate. */
/** listing content hash → bounded, deterministic revocation candidates. */
revocations: Map<string, string[]>;
/** Candidate locators discarded by the per-listing resource bound. */
revocationCandidatesTruncated: number;
txsScanned: number;
/** Highest tx id observed — the next pass's cursor. */
highestTxId: number;
Expand Down Expand Up @@ -94,10 +96,36 @@ export function addRevocationCandidate(
revocations: Map<string, string[]>,
listingHash: string,
address: string,
): void {
verifiedAddresses: ReadonlySet<string> = new Set(),
): number {
const candidates = revocations.get(listingHash) ?? [];
if (!candidates.includes(address)) candidates.push(address);
revocations.set(listingHash, candidates);
const merged = boundedRevocationCandidates([...candidates, address], verifiedAddresses);
revocations.set(listingHash, merged.candidates);
return merged.truncated;
}

export const MAX_REVOCATION_CANDIDATES_PER_LISTING = 16;

/**
* Keep discovery state deterministic and bounded while never evicting a marker
* that already passed RB-4 verification. Verified markers are signer-controlled
* rather than public-shape-controlled, so they form the explicit bound exception.
*/
export function boundedRevocationCandidates(
addresses: Iterable<string>,
verifiedAddresses: ReadonlySet<string> = new Set(),
limit = MAX_REVOCATION_CANDIDATES_PER_LISTING,
): { candidates: string[]; truncated: number } {
// Include the persisted verified set even when an older scan-state snapshot
// omitted that locator from its candidate array.
const unique = [...new Set([...verifiedAddresses, ...addresses])];
const verified = unique.filter((address) => verifiedAddresses.has(address));
const unverified = unique.filter((address) => !verifiedAddresses.has(address));
const candidates = [
...verified,
...unverified.slice(0, Math.max(0, limit - verified.length)),
];
return { candidates, truncated: unique.length - candidates.length };
}

/**
Expand Down Expand Up @@ -163,7 +191,12 @@ export async function readChainTip(): Promise<number> {
*/
export async function scanChain(
_demos: unknown,
opts: { maxTxs?: number; sinceTxId?: number; retryLocators?: string[] } = {},
opts: {
maxTxs?: number;
sinceTxId?: number;
retryLocators?: string[];
verifiedRevocations?: ReadonlyMap<string, ReadonlySet<string>>;
} = {},
): Promise<ScannedArtifacts> {
// Incremental: walk latest → sinceTxId (exclusive) and stop. First run
// (no cursor) backfills the whole history up to maxTxs.
Expand Down Expand Up @@ -218,6 +251,7 @@ export async function scanChain(
const listings = new Map<string, string>();
const programs = new Map<string, string>();
const revocations = new Map<string, string[]>();
let revocationCandidatesTruncated = 0;
const observations: ScannedArtifacts["observations"] = [];
const failures: ScannedArtifacts["failures"] = [];
const bundleOwners = new Map<string, { address: string; owner: string }>(); // jobId → buyer bundle
Expand All @@ -239,7 +273,13 @@ export async function scanChain(
let artifactKind = "other";
if (isListingRevocationCandidate(data)) {
artifactKind = "listing-revocation";
addRevocationCandidate(revocations, String(data!.listingContentHash).toLowerCase(), address);
const listingHash = String(data!.listingContentHash).toLowerCase();
revocationCandidatesTruncated += addRevocationCandidate(
revocations,
listingHash,
address,
opts.verifiedRevocations?.get(listingHash),
);
} else if (name.startsWith("dacs1:listing:") || name.startsWith("dacs1-") || currentListing) {
artifactKind = "listing";
listings.set(address, read.owner);
Expand Down Expand Up @@ -287,5 +327,6 @@ export async function scanChain(
});
}

return { listings, deals, programs, revocations, txsScanned: scanned, highestTxId, complete, chainTip, observations, failures, scanError };
return { listings, deals, programs, revocations, revocationCandidatesTruncated,
txsScanned: scanned, highestTxId, complete, chainTip, observations, failures, scanError };
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,10 @@ export interface ScanState {
cursorAdvancedAt?: number;
/** owner + programName → observed native address (nonce-safe binding). */
programs?: Record<string, string>;
/** listing content hash → every observed revocation marker candidate. */
/** listing content hash → bounded, deterministic revocation candidates. */
revocations?: Record<string, string[] | string>;
/** RB-4-verified marker locators that candidate pruning must preserve. */
verifiedRevocations?: Record<string, string[]>;
/** listing anchor address → owner address */
listings: Record<string, string>;
/** jobId → discovered deal */
Expand Down
45 changes: 43 additions & 2 deletions reference-implementations/dacs-directory/test/verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
revocationLogicalAddress,
verifyListing,
} from "../src/catalog/listingVerification.js";
import { addRevocationCandidate, isListingRevocationCandidate } from "../src/catalog/scan.js";
import {
addRevocationCandidate,
isListingRevocationCandidate,
MAX_REVOCATION_CANDIDATES_PER_LISTING,
} from "../src/catalog/scan.js";
import { deriveSellerReputation, flipOutcome } from "../src/catalog/reputation.js";
import type { Catalog, DealRecord, SellerRecord } from "../src/catalog/types.js";
import type { BundleVerification } from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js";
Expand Down Expand Up @@ -518,7 +522,7 @@ test("evidence ref must be signed by a bundle party", async () => {
);
});

test("any valid revocation candidate wins and scanner candidates accumulate", async () => {
test("any valid revocation candidate wins and scanner candidates deduplicate", async () => {
const listingMessage = Buffer.from(`dacs-listing:v1:${contentHash(listing)}`, "utf8");
const listingSignature = Buffer.from(
await ed25519Sign(listingMessage, privateKeyFromSeed(seed)),
Expand Down Expand Up @@ -571,6 +575,43 @@ test("any valid revocation candidate wins and scanner candidates accumulate", as
assert.equal(isListingRevocationCandidate({ ...valid, listingContentHash: "not-a-hash" }), false);
});

test("revocation candidate discovery is bounded per listing hash", () => {
const candidates = new Map<string, string[]>();
const listingHash = "a".repeat(64);
for (let index = 0; index < 20; index++) {
addRevocationCandidate(candidates, listingHash, `stor-${index.toString(16).padStart(40, "0")}`);
}
assert.equal(candidates.get(listingHash)?.length, MAX_REVOCATION_CANDIDATES_PER_LISTING);
});

test("candidate pruning preserves an RB-4-verified locator across restart", () => {
const listingHash = "b".repeat(64);
const verified = `stor-${"f".repeat(40)}`;
const beforeRestart = new Map<string, string[]>();
for (let index = 0; index < MAX_REVOCATION_CANDIDATES_PER_LISTING; index++) {
addRevocationCandidate(beforeRestart, listingHash, `stor-${index.toString(16).padStart(40, "0")}`);
}
addRevocationCandidate(beforeRestart, listingHash, verified, new Set([verified]));

const persisted = JSON.parse(JSON.stringify(Object.fromEntries(beforeRestart))) as Record<string, string[]>;
const afterRestart = new Map(Object.entries(persisted));
for (let index = 16; index < 32; index++) {
addRevocationCandidate(
afterRestart,
listingHash,
`stor-${index.toString(16).padStart(40, "0")}`,
new Set([verified]),
);
}

assert.equal(afterRestart.get(listingHash)?.length, MAX_REVOCATION_CANDIDATES_PER_LISTING);
assert.equal(afterRestart.get(listingHash)?.includes(verified), true);

const staleState = new Map([[listingHash, afterRestart.get(listingHash)!.filter((address) => address !== verified)]]);
addRevocationCandidate(staleState, listingHash, `stor-${"e".repeat(40)}`, new Set([verified]));
assert.equal(staleState.get(listingHash)?.includes(verified), true);
});

test("public discovery excludes revoked listings and empty sellers", () => {
const listingSummary = (status: "active" | "revoked") => ({
listingId: status,
Expand Down