From 4a193e9560fbec5b6b6357bef3596913b2625597 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 01/42] fix(dacs-directory): accept only canonical signature values on the conforming path (SIG-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORE §B.7 SIG-6 says a signature value is unpadded Base64URL. A verifier must reject every other spelling and must not auto-detect by trying decoders. The current-listing, revocation-marker, evidence-graph component and bundle verifiers all accepted hex, 0x hex, padded and standard Base64. Only BundleBinding was strict. - Add src/catalog/signatureValue.ts: one strict decoder used by every current-profile envelope, the declared-encoding legacy import from the standard, and an explicit legacy reader used only by the legacy-sdk-v0.1 paths (draft SDK listings and the SDK bundle policy). - The register page now re-encodes the wallet's hex listing signature into the canonical value before anchoring, as SIG-6 requires of producers. Listings already anchored with a hex value must be re-published; their content hash does not change. - Per-claim presentation signatures are bare strings, not SIG-6 envelope values, so they keep the existing decoder. - Vendor the pinned signature-value-encoding and revocation-binding vector files with a Directory SPEC_PIN (v0.4, 4bb9e48). Switch the test helpers to base64url and pin the seven non-canonical spellings on each verifier. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/SPEC_PIN | 8 + .../dacs-directory/app/register/page.tsx | 6 +- .../src/catalog/bundleBinding.ts | 15 +- .../src/catalog/bundlePolicy.ts | 14 +- .../src/catalog/evidenceGraph.ts | 11 +- .../src/catalog/listingVerification.ts | 30 +-- .../src/catalog/signatureValue.ts | 93 +++++++ .../dacs-directory/test/build-listing.test.ts | 4 +- .../test/confirm-listing.test.ts | 2 +- .../test/current-indexer.test.ts | 4 +- .../revocation-binding-v0.3.json | 229 ++++++++++++++++++ .../signature-value-encoding-v0.1.json | 122 ++++++++++ .../test/malformed-artifacts.test.ts | 2 +- .../dacs-directory/test/scan-fairness.test.ts | 2 +- .../test/scan-robustness.test.ts | 2 +- .../dacs-directory/test/sig6.test.ts | 142 +++++++++++ .../dacs-directory/test/verification.test.ts | 16 +- 17 files changed, 634 insertions(+), 68 deletions(-) create mode 100644 reference-implementations/dacs-directory/SPEC_PIN create mode 100644 reference-implementations/dacs-directory/src/catalog/signatureValue.ts create mode 100644 reference-implementations/dacs-directory/test/fixtures/dacs-standard/revocation-binding-v0.3.json create mode 100644 reference-implementations/dacs-directory/test/fixtures/dacs-standard/signature-value-encoding-v0.1.json create mode 100644 reference-implementations/dacs-directory/test/sig6.test.ts diff --git a/reference-implementations/dacs-directory/SPEC_PIN b/reference-implementations/dacs-directory/SPEC_PIN new file mode 100644 index 0000000..c141c2d --- /dev/null +++ b/reference-implementations/dacs-directory/SPEC_PIN @@ -0,0 +1,8 @@ +# DACS Standard revision this Directory implements and tests against +# github.com/DACS-Agent-commerce/DACS-Standard, v0.4 (annotated tag 0be9e80b342d0f3e269ae9ab45c2bbc372f161d4) +4bb9e48a1095ab32c06c25b7c0b52018d3ce4091 +# Profile notes: primary claims use the did:demos:agent ClaimReference profile +# (src/catalog/claimRef.ts), which the pinned revision does not yet define. +# Vendored conformance vectors (byte-identical copies; sha256 checked by test/sig6.test.ts): +# conformance/vectors/security/signature-value-encoding-v0.1.json bf47cd0a42b0e0e30f2ff032327395d5a1b820758e6188c51212eb9129ba8312 +# conformance/vectors/security/revocation-binding-v0.3.json 3e83dfe5cb603e3dce98a4280e2456bb30bcae9979f4a0db1ae322982bdaf1ce diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index 477e9d3..57115f1 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -10,6 +10,7 @@ import { type PendingListingPublication, } from "@/src/components/listing-publication-recovery"; import { safePublicEndpoint } from "@/src/catalog/publicEndpoint"; +import { importLegacySignatureValue } from "@/src/catalog/signatureValue"; import { negotiationPhaseForPricing, publishableRail, @@ -284,9 +285,12 @@ export default function Register() { setStatus("Now approve the complete structured listing."); const signature = await wallet.sign(publication.message); if (!signature) throw new Error(wallet.error ?? "The listing signature was declined."); + // The wallet returns hex; a ListingSignature value is unpadded Base64URL (CORE SIG-6). + const value = importLegacySignatureValue(signature.replace(/^0x/i, "").toLowerCase(), "lowercase-hex"); + if (!value) throw new Error("The wallet returned a listing signature that is not a 64-byte Ed25519 signature."); signedListing = { ...publication.listing, - signature: { algorithm: "ed25519", signer: claim, value: signature.replace(/^(0x)+/i, "") }, + signature: { algorithm: "ed25519", signer: claim, value }, }; transaction = structuredClone(transaction); const content = transaction.content as Record | undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts index dce01f5..7fcee87 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts @@ -9,6 +9,7 @@ import { verifyResolvedPrimaryClaimSignature, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; +import { canonicalSignatureBytes } from "./signatureValue.js"; import type { BundleBinding } from "./types.js"; const BINDING_DOMAIN = "dacs-bundle-binding:v1:"; @@ -39,18 +40,6 @@ export function bundleBindingOverflowKey(jobId: string, role: BundleBinding["rol return `${bundleBindingRoleKey(jobId, role)}\n${canonicalDemosAgentClaim(signer) ?? signer}`; } -function strictSignatureBytes(value: unknown): Uint8Array | null { - if (typeof value !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(value)) return null; - try { - const bytes = Buffer.from(value, "base64url"); - return bytes.length === 64 && bytes.toString("base64url") === value - ? Uint8Array.from(bytes) - : null; - } catch { - return null; - } -} - /** The whole value, signature envelope included, must serialize within the bound. */ function withinBindingLimit(raw: Record): boolean { return boundedJson(raw, 16_384) !== null; @@ -83,7 +72,7 @@ export async function verifyBundleBinding( const signer = await resolvePrimaryClaimKey(raw.signer, signature.algorithm, resolveKey); const signatureSigner = await resolvePrimaryClaimKey(signature.signer, signature.algorithm, resolveKey); if (!signer || !signatureSigner || !sameResolvedPrimaryClaim(signer, signatureSigner)) return null; - const sig = strictSignatureBytes(signature.value); + const sig = canonicalSignatureBytes(signature.value); if (!sig) return null; const scope = { ...raw }; diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index 20fb467..40a48d3 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -10,6 +10,7 @@ import { verifyPrimaryClaimSignature, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; +import { legacySignatureBytes } from "./signatureValue.js"; import type { DealRecord, RegisteredDeal } from "./types.js"; // This is the legacy SDK compatibility path. Current-profile agreement artifacts, @@ -22,17 +23,6 @@ const SEPARATORS: Record = { "dacs-2-verifyresult": "dacs-composite:v1:", }; -function decode(value: string): Uint8Array | null { - const hex = value.replace(/^(0x)+/i, ""); - if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); - try { - const bytes = Buffer.from(value, "base64url"); - return bytes.length === 64 ? Uint8Array.from(bytes) : null; - } catch { - return null; - } -} - export function hasRequiredBundleSignatures(result: BundleVerification, rawBundle: unknown): boolean { const bundle = result.bundle; if (!bundle || !rawBundle || typeof rawBundle !== "object" || Array.isArray(rawBundle)) return false; @@ -199,7 +189,7 @@ export async function verifyReferencedArtifactSignature( const s = entry as Record; const signer = typeof s.signer === "string" ? s.signer : typeof s.party === "string" ? s.party : null; if (s.algorithm !== "ed25519" || !signer || typeof s.value !== "string") return false; - const signature = decode(s.value); + const signature = legacySignatureBytes(s.value); if (!signature) return false; const verified = await verifyPrimaryClaimSignature( message, signature, signer, s.algorithm, resolveKey, diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 1b22956..af5d439 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -1,6 +1,7 @@ import { contentHash } from "@kynesyslabs/dacs/canonical"; import { bundleSignerPolicy } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; +import { canonicalSignatureBytes } from "./signatureValue.js"; import { canonicalSigningIdentity, resolveDemosPrimaryClaimKey, @@ -120,12 +121,6 @@ export function signedScope(raw: Record, kind: ArtifactKind): R export const artifactHash = (raw: Record, kind: ArtifactKind): string => contentHash(signedScope(raw, kind)); const normalizedHash = (value: unknown): string => typeof value === "string" ? value.replace(/^sha256[:-]/i, "").toLowerCase() : ""; -function decodeSignature(value: unknown): Uint8Array | null { - if (typeof value !== "string") return null; - const hex = value.replace(/^(0x)+/i, ""); - if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); - try { const bytes = Buffer.from(value, "base64url"); return bytes.length === 64 ? Uint8Array.from(bytes) : null; } catch { return null; } -} export async function verifyComponentSignature( raw: Record, kind: ArtifactKind, @@ -134,7 +129,7 @@ export async function verifyComponentSignature( ): Promise { if (kind === "attestation") return false; if (signature.algorithm !== "ed25519" || typeof signature.signer !== "string") return false; - const value = decodeSignature(signature.value); + const value = canonicalSignatureBytes(signature.value); const separator = separatorFor(raw, kind); if (!value || !separator) return false; return Boolean(await verifyPrimaryClaimSignature( @@ -162,7 +157,7 @@ async function verifyBundleSignature( ): Promise { const party = signature.party; if (signature.algorithm !== "ed25519" || typeof party !== "string") return false; - const value = decodeSignature(signature.value); + const value = canonicalSignatureBytes(signature.value); const profile = bundleProfile(raw); if (!value || !profile) return false; const separator = profile === "fault" ? FAULT_BUNDLE_SEPARATOR : SEPARATORS.bundle; diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index 5863ac6..2eab316 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -10,21 +10,11 @@ import { type ResolvedPrimaryClaimKey, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; +import { canonicalSignatureBytes, legacySignatureBytes } from "./signatureValue.js"; import type { RevocationBinding } from "./types.js"; const SEPARATOR = "dacs-listing:v1:"; -function decodeSignature(value: string): Uint8Array | null { - const hex = value.replace(/^(0x)+/i, ""); - if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); - try { - const decoded = Buffer.from(value, "base64url"); - return decoded.length === 64 ? Uint8Array.from(decoded) : null; - } catch { - return null; - } -} - export interface VerifiedListing { listing: Listing | Record; scope: Record; @@ -124,10 +114,9 @@ function currentListing(scope: Record): { async function verifyEd25519( message: Uint8Array, signer: string, - value: unknown, + sig: Uint8Array | null, resolveKey: ResolvePrimaryClaimKey, ): Promise { - const sig = typeof value === "string" ? decodeSignature(value) : null; return sig ? verifyPrimaryClaimSignature(message, sig, signer, "ed25519", resolveKey) : null; @@ -147,7 +136,10 @@ async function verifyIdentityPresentation( const bundleScope = { ...identity }; delete bundleScope.presentation; const message = Buffer.from(`dacs-bundle-presentation:v1:${contentHash(bundleScope)}`, "utf8"); - return verifyEd25519(message, sellerClaim, signature.signature, resolveKey); + // A per-claim PresentationSignature carries a bare `signature` string, not a + // SIG-6 envelope `value`; the Directory's publisher writes the wallet's hex. + const value = typeof signature.signature === "string" ? legacySignatureBytes(signature.signature) : null; + return verifyEd25519(message, sellerClaim, value, resolveKey); } /** Verify either the current normative Listing or the pinned SDK compatibility profile. */ @@ -161,10 +153,10 @@ export async function verifyListing( const scope = { ...raw }; delete scope.signature; if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) return null; - if (current.signature.algorithm !== "ed25519" || typeof current.signature.value !== "string") return null; + if (current.signature.algorithm !== "ed25519") return null; const hash = contentHash(scope); const verifiedSigner = await verifyEd25519( - Buffer.from(SEPARATOR + hash, "utf8"), current.signer, current.signature.value, resolveKey, + Buffer.from(SEPARATOR + hash, "utf8"), current.signer, canonicalSignatureBytes(current.signature.value), resolveKey, ); const seller = record(raw.seller); const identity = record(seller?.identity); @@ -199,7 +191,8 @@ export async function verifyListing( typeof s.signer !== "string" || typeof s.value !== "string" ) return null; - const sig = decodeSignature(s.value); + // Draft SDK listings predate SIG-6; this profile is read through the legacy decoder only. + const sig = legacySignatureBytes(s.value); if (!sig) return null; const hash = contentHash(scope); const message = Buffer.from(SEPARATOR + hash, "utf8"); @@ -323,7 +316,8 @@ export async function verifyListingRevocation( s.algorithm !== "ed25519" || typeof s.signer !== "string" || typeof s.value !== "string" ) return false; - const sig = decodeSignature(s.value); + // A marker is a current-profile artifact whatever the listing's profile: SIG-6 applies. + const sig = canonicalSignatureBytes(s.value); if (!sig) return false; let hash: string; try { diff --git a/reference-implementations/dacs-directory/src/catalog/signatureValue.ts b/reference-implementations/dacs-directory/src/catalog/signatureValue.ts new file mode 100644 index 0000000..9120393 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/signatureValue.ts @@ -0,0 +1,93 @@ +/** + * CORE §B.7 SIG-6 signature `value` wire encoding. + * + * Every DACS signature envelope (ListingSignature, RevocationSignature, + * AgreementSignature, ComponentSignature, BundleSignature, BundleBinding) + * carries the raw signature bytes as unpadded Base64URL. A conforming verifier + * rejects every other spelling before cryptographic verification and never + * auto-detects by trying decoders. Dependency-free so the browser can use it. + */ +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +const INDEX = new Map([...ALPHABET].map((character, index) => [character, index])); + +export const ED25519_SIGNATURE_BYTES = 64; + +/** Unpadded Base64URL of raw bytes: the only conforming signature `value`. */ +export function canonicalSignatureValue(bytes: Uint8Array): string { + let out = ""; + for (let index = 0; index < bytes.length; index += 3) { + const chunk = (bytes[index] << 16) | ((bytes[index + 1] ?? 0) << 8) | (bytes[index + 2] ?? 0); + const characters = Math.min(4, Math.ceil(((bytes.length - index) * 8) / 6)); + for (let position = 0; position < characters; position++) out += ALPHABET[(chunk >> (18 - 6 * position)) & 63]; + } + return out; +} + +/** + * Strictly decode a conforming signature `value`, or null. Padding, + * whitespace, `+` or `/`, impossible lengths and non-zero residual bits are + * all rejected by the exact re-encoding comparison; `length` is the + * algorithm-specific decoded size (64 bytes for Ed25519). + */ +export function canonicalSignatureBytes(value: unknown, length = ED25519_SIGNATURE_BYTES): Uint8Array | null { + if (typeof value !== "string" || value.length === 0 || value.length % 4 === 1) return null; + const sextets: number[] = []; + for (const character of value) { + const sextet = INDEX.get(character); + if (sextet === undefined) return null; + sextets.push(sextet); + } + const bytes = new Uint8Array(Math.floor((sextets.length * 6) / 8)); + for (let index = 0; index < bytes.length; index++) { + const bit = index * 8; + const first = sextets[Math.floor(bit / 6)]; + const second = sextets[Math.floor(bit / 6) + 1] ?? 0; + bytes[index] = ((first << 6 | second) >> (4 - (bit % 6))) & 255; + } + return bytes.length === length && canonicalSignatureValue(bytes) === value ? bytes : null; +} + +/** Source encodings the SIG-6 legacy-import path accepts when declared out of band. */ +export type LegacySignatureEncoding = "lowercase-hex" | "standard-base64-padded"; + +/** + * The SIG-6 legacy-import path: strictly decode a value in its declared source + * encoding and emit the canonical value, or null. Nothing is auto-detected; an + * undeclared encoding is rejected. The publisher uses it for wallet hex. + */ +export function importLegacySignatureValue( + value: string, + declared: LegacySignatureEncoding | undefined, + length = ED25519_SIGNATURE_BYTES, +): string | null { + if (declared === "lowercase-hex") { + if (value.length !== length * 2 || !/^[0-9a-f]*$/.test(value)) return null; + return canonicalSignatureValue(Uint8Array.from({ length }, (_, index) => parseInt(value.slice(index * 2, index * 2 + 2), 16))); + } + if (declared === "standard-base64-padded") { + if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return null; + const bytes = canonicalSignatureBytes(value.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"), length); + if (!bytes) return null; + const canonical = canonicalSignatureValue(bytes); + const padded = canonical.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (canonical.length % 4)) % 4); + return padded === value ? canonical : null; + } + return null; +} + +/** + * legacy-sdk-v0.1 compatibility reader only. Draft SDK producers wrote hex, + * `0x` hex or Base64URL into signature values; this path accepts those + * spellings for that profile's artifacts and must never be used to verify a + * dacs-v0.1 artifact (SIG-6: no auto-detection on the conforming path). + */ +export function legacySignatureBytes(value: string): Uint8Array | null { + const hex = value.replace(/^(0x)+/i, ""); + if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); + try { + const decoded = Buffer.from(value, "base64url"); + return decoded.length === ED25519_SIGNATURE_BYTES ? Uint8Array.from(decoded) : null; + } catch { + return null; + } +} diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 12b8293..c29d5aa 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -115,7 +115,7 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind const listingSignature = Buffer.from( ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), - ).toString("hex"); + ).toString("base64url"); assert.ok(await verifyListing({ ...built.listing, signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, @@ -155,7 +155,7 @@ test("publisher recovers an immutable owner-bound listing instead of creating a }; const listingSignature = Buffer.from( ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), - ).toString("hex"); + ).toString("base64url"); const signedListing = { ...built.listing, signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 37882e2..22173d9 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -60,7 +60,7 @@ const listing = { signature: { algorithm: "ed25519", signer: sellerClaim, - value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("hex"), + value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("base64url"), }, }; const coordinates = { diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 33949cb..d796f0f 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -26,7 +26,7 @@ const sign = async (raw: Obj, kind: "listing" | "agreement" | "evidence" | "rati : kind === "bundle" && raw.faultBundleVersion === "1" ? "dacs-fault-bundle:v1:" : prefixes[kind]; - const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString("hex"); + const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString("base64url"); return party ? { party: dids[signer], algorithm: "ed25519", value } : { signer: dids[signer], algorithm: "ed25519", value }; }; const ref = (at: string, raw: Obj, kind: Parameters[1], extra: Obj = {}) => ({ @@ -112,7 +112,7 @@ async function installAgreement( const value = Buffer.from(await ed25519Sign( Buffer.from(signaturePrefix + artifactHash(agreementScope, "agreement")), privateKeyFromSeed(seeds[signer]), - )).toString("hex"); + )).toString("base64url"); return { party: dids[signer], algorithm: "ed25519", value }; })); const agreement = { ...agreementScope, signatures: agreementSignatures }; diff --git a/reference-implementations/dacs-directory/test/fixtures/dacs-standard/revocation-binding-v0.3.json b/reference-implementations/dacs-directory/test/fixtures/dacs-standard/revocation-binding-v0.3.json new file mode 100644 index 0000000..e2ff252 --- /dev/null +++ b/reference-implementations/dacs-directory/test/fixtures/dacs-standard/revocation-binding-v0.3.json @@ -0,0 +1,229 @@ +{ + "set": "revocation-binding-v0.3", + "spec": "DACS-1 §6.3.4 RB-1..RB-6 revocation-marker discovery and fail-closed resolution", + "provenance": { + "generator": "DACS steward scenario matrix", + "command": "independent runners apply each case's fixture and overrides", + "commit": "pending independent cross-run" + }, + "gaps": [ + "#249 revocation marker logical-to-native binding", + "live convention-named fixture incorporation pending contributor handoff" + ], + "decisionModel": "A valid marker yields a revoked check and refuses the new session. A current integrity-consistent active record without a binding yields absent and permits revocation step 5 to pass. Missing required bindings, tuple/logical/hash/signature failures, unreachable anchors, stale records, and surface-integrity failures yield indeterminate and refuse the new session.", + "publicKeys": { + "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05": "iw64cX4_FjVt3PVtQh-2o90lEdCjSvhQfXYBIPpCOwU" + }, + "fixtures": { + "listing": { + "sellerPrimaryClaim": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05", + "listingId": "opaque-service", + "listingVersion": 3, + "listingContentHash": "38505a4a62f31a3be18cfdec8d00aca911d2e2e43e3a2d917c08d4644e9e11ab", + "signer": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05" + }, + "markers": { + "valid-v3": { + "listingId": "opaque-service", + "listingVersion": 3, + "listingContentHash": "38505a4a62f31a3be18cfdec8d00aca911d2e2e43e3a2d917c08d4644e9e11ab", + "revokedAt": 1784073600000, + "reason": "withdrawn", + "signature": { + "algorithm": "ed25519", + "signer": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05", + "value": "Jtwo824p5mXNVW7jNM33kuSIdoqI-3hxXoaAEAQSa67AW1bXH8dgsGHMtTK3ThJC9O5m3Je3-yHtc_WzOqVSAQ" + } + }, + "valid-wrong-v4": { + "listingId": "opaque-service", + "listingVersion": 4, + "listingContentHash": "38505a4a62f31a3be18cfdec8d00aca911d2e2e43e3a2d917c08d4644e9e11ab", + "revokedAt": 1784073600000, + "reason": "withdrawn", + "signature": { + "algorithm": "ed25519", + "signer": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05", + "value": "CfjCsGEGOJC52u-c4pGyF9xJlq-P8Typ6iszj7fJw4BT1edyLPvXk6L1rueAe0iHv4zwqbYhCMdLLcpMroDmDQ" + } + } + }, + "bindings": { + "opaque-name": { + "sellerPrimaryClaim": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05", + "listingId": "opaque-service", + "listingVersion": 3, + "listingContentHash": "38505a4a62f31a3be18cfdec8d00aca911d2e2e43e3a2d917c08d4644e9e11ab", + "logicalAddress": "dacs1-revoked:cci%3A8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05:opaque-service:v3", + "markerAnchor": { + "kind": "storage-program", + "locator": "stor-472f25e7244f8c8d9e3e1aa05146d01a901ff33b" + }, + "producerWriteInputs": { + "deployerAddress": "demos:0x9f4a", + "storageProgramName": "rvk_Z9q_opaque", + "nonce": 481, + "salt": "salt-rvk-01" + }, + "markerContentHash": "11dd099a871ed2021a39c9d0da7a2b3db60efe3dfe336a959df7c8d1ed2bb7dd" + }, + "convention-name": { + "sellerPrimaryClaim": "cci:8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05", + "listingId": "opaque-service", + "listingVersion": 3, + "listingContentHash": "38505a4a62f31a3be18cfdec8d00aca911d2e2e43e3a2d917c08d4644e9e11ab", + "logicalAddress": "dacs1-revoked:cci%3A8b0eb8717e3f16356ddcf56d421fb6a3dd2511d0a34af8507d760120fa423b05:opaque-service:v3", + "markerAnchor": { + "kind": "storage-program", + "locator": "stor-81788ebd9e9f7b8d4d7b4ef768a6cd2020a36304" + }, + "producerWriteInputs": { + "deployerAddress": "demos:0x9f4a", + "storageProgramName": "dacs1-revoked_opaque-service_v3", + "nonce": 482, + "salt": "salt-rvk-02" + }, + "markerContentHash": "11dd099a871ed2021a39c9d0da7a2b3db60efe3dfe336a959df7c8d1ed2bb7dd" + } + } + }, + "hash": "2c38f4450ec9cf60a2dc0bfbf2ad4bbcbd3f00ca720bb6938d813f98349ddf40", + "count": 14, + "vectors": [ + { + "name": "opaque-name-binding-resolves-revoked", + "rule": "RB-1..RB-4", + "expected": "fail", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "markerRead": {"status": "ok", "fixture": "valid-v3"}, + "want": {"revocationCheck": "revoked", "session": "refuse"}, + "note": "The reader receives no programName and resolves the unrelated opaque native locator solely through the binding." + }, + { + "name": "convention-name-binding-resolves-revoked", + "rule": "RB-1..RB-4", + "expected": "fail", + "surface": {"kind": "catalog", "status": "revoked", "catalogAgeHours": 1, "binding": "convention-name"}, + "markerRead": {"status": "ok", "fixture": "valid-v3"}, + "want": {"revocationCheck": "revoked", "session": "refuse"}, + "note": "A convention-shaped producer still resolves through the binding; readers do not infer the native locator from its name." + }, + { + "name": "binding-logical-address-mismatch", + "rule": "RB-4 step 2", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "bindingOverrides": {"logicalAddress": "dacs1-revoked:cci%3Awrong:opaque-service:v3"}, + "markerRead": {"status": "ok", "fixture": "valid-v3"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "logicalAddress"}, + "note": "A binding whose logical address is not derived from its listing tuple does not establish marker absence or revocation." + }, + { + "name": "binding-marker-content-hash-mismatch", + "rule": "RB-4 step 3", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "bindingOverrides": {"markerContentHash": "0000000000000000000000000000000000000000000000000000000000000000"}, + "markerRead": {"status": "ok", "fixture": "valid-v3"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "markerContentHash"}, + "note": "Catalog or index poisoning cannot turn a different fetched artifact into a valid marker." + }, + { + "name": "validly-signed-marker-wrong-listing-version", + "rule": "RB-4 step 5", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "bindingOverrides": {"markerContentHash": "a0a735d6e41fe883ce0aa1430537b4875abe8338aab018c103a4d850f6fa19b7"}, + "markerRead": {"status": "ok", "fixture": "valid-wrong-v4"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "markerTuple"}, + "note": "A valid signature for version 4 cannot revoke version 3." + }, + { + "name": "marker-signature-invalid", + "rule": "RB-4 step 4", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "markerRead": {"status": "ok", "fixture": "valid-v3", "signatureOverride": "Atwo824p5mXNVW7jNM33kuSIdoqI+3hxXoaAEAQSa67AW1bXH8dgsGHMtTK3ThJC9O5m3Je3+yHtc/WzOqVSAQ=="}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "markerSignature"}, + "note": "The discovery binding cannot establish authority when the listing key did not sign the marker." + }, + { + "name": "revoked-status-missing-binding", + "rule": "RB-3/RB-5", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": null}, + "markerRead": {"status": "not-attempted"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "requiredBinding"}, + "note": "A revoked tombstone without its required binding is not a clean marker-absent result." + }, + { + "name": "marker-anchor-unreachable", + "rule": "RB-4/RB-5", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + "markerRead": {"status": "transport-error", "fixture": null}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "markerFetch"}, + "note": "Failure to fetch the advertised native anchor fails closed." + }, + { + "name": "well-known-index-hash-mismatch", + "rule": "RB-6", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "active", "integrity": "indexHash-mismatch", "binding": null}, + "markerRead": {"status": "not-attempted"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "surfaceIntegrity"}, + "note": "A stale or altered index cannot establish marker absence." + }, + { + "name": "fresh-active-record-establishes-absent", + "rule": "RB-6", + "expected": "pass", + "surface": {"kind": "well-known", "status": "active", "integrity": "current-indexHash", "binding": null}, + "markerRead": {"status": "not-applicable"}, + "want": {"revocationCheck": "absent", "session": "continue"}, + "note": "A successful integrity-consistent active record is the current-model marker-absent path." + }, + { + "name": "active-record-carrying-binding-is-inconsistent", + "rule": "RB-3/RB-5", + "expected": "indeterminate", + "surface": {"kind": "well-known", "status": "active", "integrity": "current-indexHash", "binding": "opaque-name"}, + "markerRead": {"status": "not-attempted"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "statusBindingConsistency"}, + "note": "An active record is forbidden from carrying a revocation binding." + }, + { + "name": "stale-catalog-record-cannot-establish-absent", + "rule": "RB-6", + "expected": "indeterminate", + "surface": {"kind": "catalog", "status": "active", "catalogAgeHours": 25, "binding": null}, + "markerRead": {"status": "not-attempted"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "catalogFreshness"}, + "note": "A catalog-only record older than the existing 24-hour freshness window cannot establish absence." + }, + { + "name": "valid-revocation-precedes-active-mirror", + "rule": "RB-6 precedence", + "expected": "fail", + "surfaces": [ + {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": "opaque-name"}, + {"kind": "catalog", "status": "active", "catalogAgeHours": 1, "binding": null} + ], + "markerRead": {"status": "ok", "fixture": "valid-v3"}, + "want": {"revocationCheck": "revoked", "session": "refuse"}, + "note": "One verified marker wins over another surface's active record." + }, + { + "name": "indeterminate-precedes-active-mirror", + "rule": "RB-6 precedence", + "expected": "indeterminate", + "surfaces": [ + {"kind": "well-known", "status": "revoked", "integrity": "current-indexHash", "binding": null}, + {"kind": "catalog", "status": "active", "catalogAgeHours": 1, "binding": null} + ], + "markerRead": {"status": "not-attempted"}, + "want": {"revocationCheck": "indeterminate", "session": "refuse", "failedAt": "requiredBinding"}, + "note": "A malformed revoked tombstone is not erased by another surface's active record." + } + ] +} diff --git a/reference-implementations/dacs-directory/test/fixtures/dacs-standard/signature-value-encoding-v0.1.json b/reference-implementations/dacs-directory/test/fixtures/dacs-standard/signature-value-encoding-v0.1.json new file mode 100644 index 0000000..ebe29ae --- /dev/null +++ b/reference-implementations/dacs-directory/test/fixtures/dacs-standard/signature-value-encoding-v0.1.json @@ -0,0 +1,122 @@ +{ + "set": "signature-value-encoding-v0.1", + "spec": "CORE §B.7 SIG-6", + "provenance": { + "sourceFixture": "dacs-v0.1-happy-path.json#artifacts[0].artifact.signature", + "validationScope": "signature-value wire encoding; cryptographic validity is independently pinned by the source fixture", + "algorithm": "ed25519" + }, + "signatureBytesHex": "436f7b1e403a964f7b23c121c47b16cbf1ce9a60fd1f4b709998991cb8cd682939cd2c3e94446a33839515e83d45a37a32f378fbc51a9b30ad084acac5cf8405", + "canonicalValue": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ", + "count": 10, + "hash": "5bacef9a3568cc1307d1be4838ed0f71886c4fe588d180b320076e880b2d82be", + "vectors": [ + { + "name": "canonical-base64url-unpadded", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ", + "expected": "accept", + "want": { + "failureStage": null, + "decodedLength": 64 + } + }, + { + "name": "standard-base64-same-bytes-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "Q297HkA6lk97I8EhxHsWy/HOmmD9H0twmZiZHLjNaCk5zSw+lERqM4OVFeg9RaN6MvN4+8UamzCtCErKxc+EBQ==", + "expected": "reject", + "want": { + "failureStage": "wire", + "reason": "standard-base64-alphabet-and-padding" + } + }, + { + "name": "padded-base64url-same-bytes-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ==", + "expected": "reject", + "want": { + "failureStage": "wire", + "reason": "padding-forbidden" + } + }, + { + "name": "embedded-whitespace-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "Q297HkA6lk97I8EhxHsW y_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ", + "expected": "reject", + "want": { + "failureStage": "wire", + "reason": "non-base64url-character" + } + }, + { + "name": "impossible-length-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "A", + "expected": "reject", + "want": { + "failureStage": "wire", + "reason": "impossible-base64url-length" + } + }, + { + "name": "non-zero-residual-bits-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "AB", + "expected": "reject", + "want": { + "failureStage": "wire", + "reason": "reencode-mismatch" + } + }, + { + "name": "canonical-wire-wrong-ed25519-length-rejected", + "mode": "conforming-verifier", + "algorithm": "ed25519", + "value": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-E", + "expected": "reject", + "want": { + "failureStage": "algorithm", + "decodedLength": 63 + } + }, + { + "name": "declared-standard-base64-legacy-import", + "mode": "legacy-import", + "declaredEncoding": "standard-base64-padded", + "value": "Q297HkA6lk97I8EhxHsWy/HOmmD9H0twmZiZHLjNaCk5zSw+lERqM4OVFeg9RaN6MvN4+8UamzCtCErKxc+EBQ==", + "expected": "accept", + "want": { + "canonicalValue": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ" + } + }, + { + "name": "declared-lowercase-hex-legacy-import", + "mode": "legacy-import", + "declaredEncoding": "lowercase-hex", + "value": "436f7b1e403a964f7b23c121c47b16cbf1ce9a60fd1f4b709998991cb8cd682939cd2c3e94446a33839515e83d45a37a32f378fbc51a9b30ad084acac5cf8405", + "expected": "accept", + "want": { + "canonicalValue": "Q297HkA6lk97I8EhxHsWy_HOmmD9H0twmZiZHLjNaCk5zSw-lERqM4OVFeg9RaN6MvN4-8UamzCtCErKxc-EBQ" + } + }, + { + "name": "undeclared-legacy-encoding-rejected", + "mode": "legacy-import", + "value": "Q297HkA6lk97I8EhxHsWy/HOmmD9H0twmZiZHLjNaCk5zSw+lERqM4OVFeg9RaN6MvN4+8UamzCtCErKxc+EBQ==", + "expected": "reject", + "want": { + "failureStage": "legacy-import", + "reason": "source-encoding-required" + } + } + ] +} diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index fa69174..03e942d 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -36,7 +36,7 @@ const PREFIXES: Record = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", bundle: "dacs-bundle:v1:", "verify-result": "dacs-verifyresult:v1:", }; -const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "hex") => +const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "base64url") => Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString(encoding); const signArtifact = async (raw: Obj, kind: ArtifactKind, signer: number, party = false) => { const value = await signature(artifactHash(raw, kind), PREFIXES[kind], signer); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 4dbdb25..9740637 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -36,7 +36,7 @@ const outsider = `did:demos:agent:${"d".repeat(64)}`; const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; const range = (from: number, count: number) => Array.from({ length: count }, (_, index) => locator(from + index)); const offline = async (): Promise => { throw new Error("identity unavailable"); }; -const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "hex") => +const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "base64url") => Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString(encoding); const listingScope = { diff --git a/reference-implementations/dacs-directory/test/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index be14e3a..948ebf0 100644 --- a/reference-implementations/dacs-directory/test/scan-robustness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-robustness.test.ts @@ -39,7 +39,7 @@ const PREFIXES: Record = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", bundle: "dacs-bundle:v1:", "verify-result": "dacs-verifyresult:v1:", }; -const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "hex") => +const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "base64url") => Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString(encoding); const signArtifact = async (raw: Obj, kind: ArtifactKind, signer: number, party = false) => { const value = await signature(artifactHash(raw, kind), PREFIXES[kind], signer); diff --git a/reference-implementations/dacs-directory/test/sig6.test.ts b/reference-implementations/dacs-directory/test/sig6.test.ts new file mode 100644 index 0000000..0c84bbf --- /dev/null +++ b/reference-implementations/dacs-directory/test/sig6.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { buildCurrentEvidenceGraph, verifyComponentSignature } from "../src/catalog/evidenceGraph.js"; +import { verifyListing, verifyListingRevocation } from "../src/catalog/listingVerification.js"; +import { + canonicalSignatureBytes, + canonicalSignatureValue, + importLegacySignatureValue, + type LegacySignatureEncoding, +} from "../src/catalog/signatureValue.js"; + +// CORE §B.7 SIG-6 against the pinned DACS-Standard vectors (SPEC_PIN), then on each conforming verifier. +const fixture = (name: string) => new URL(`./fixtures/dacs-standard/${name}`, import.meta.url); +const pinned = readFileSync(new URL("../SPEC_PIN", import.meta.url), "utf8"); +interface EncodingVector { + name: string; mode: "conforming-verifier" | "legacy-import"; value: string; expected: "accept" | "reject"; + declaredEncoding?: LegacySignatureEncoding; want: { decodedLength?: number; canonicalValue?: string }; +} +const encoding = JSON.parse(readFileSync(fixture("signature-value-encoding-v0.1.json"), "utf8")) as { + canonicalValue: string; signatureBytesHex: string; vectors: EncodingVector[]; +}; + +test("vendored DACS-Standard vectors are the byte-identical files SPEC_PIN records", () => { + for (const name of ["signature-value-encoding-v0.1.json", "revocation-binding-v0.3.json"]) { + const digest = createHash("sha256").update(readFileSync(fixture(name))).digest("hex"); + assert.match(pinned, new RegExp(`${name.replace(/[.]/g, "\\.")}\\s+${digest}`), name); + } +}); + +test("SIG-6 signature-value-encoding vectors: conforming decode and declared legacy import", () => { + assert.equal(encoding.vectors.length, 10); + for (const vector of encoding.vectors) { + if (vector.mode === "conforming-verifier") { + // Wire checks first; the algorithm-specific length is checked separately. + const bytes = canonicalSignatureBytes(vector.value, vector.want.decodedLength ?? 64); + assert.equal(bytes !== null && bytes.length === 64, vector.expected === "accept", vector.name); + if (vector.want.decodedLength === 63) { + assert.ok(canonicalSignatureBytes(vector.value, 63), `${vector.name}: canonical on the wire`); + assert.equal(canonicalSignatureBytes(vector.value), null, `${vector.name}: not an Ed25519 length`); + } + } else { + const imported = importLegacySignatureValue(vector.value, vector.declaredEncoding); + assert.equal(imported, vector.expected === "accept" ? vector.want.canonicalValue : null, vector.name); + } + } + const bytes = Uint8Array.from(Buffer.from(encoding.signatureBytesHex, "hex")); + assert.equal(canonicalSignatureValue(bytes), encoding.canonicalValue); + assert.deepEqual(canonicalSignatureBytes(encoding.canonicalValue), bytes); + // The wallet-hex import never guesses: uppercase is not the declared lowercase hex. + assert.equal(importLegacySignatureValue(encoding.signatureBytesHex.toUpperCase(), "lowercase-hex"), null); +}); + +const seed = Uint8Array.from(Buffer.alloc(32, 61)); +const did = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; +const signBytes = async (message: string) => Uint8Array.from(await ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seed))); +/** The seven non-canonical spellings of the same bytes a lenient decoder accepts. */ +function spellings(bytes: Uint8Array): Record { + const canonical = canonicalSignatureValue(bytes); + const last = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".indexOf(canonical.at(-1)!); + const hex = Buffer.from(bytes).toString("hex"); + return { + standardBase64: Buffer.from(bytes).toString("base64"), + paddedBase64url: `${canonical}==`, + embeddedWhitespace: `${canonical.slice(0, 20)} ${canonical.slice(20)}`, + nonZeroResidualBits: canonical.slice(0, -1) + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[last | 1], + lowercaseHex: hex, + prefixedHex: `0x${hex}`, + doublePrefixedHex: `0x0x${hex}`, + }; +} +const rejectedEverywhere = (verdicts: Record, accepted: unknown) => + assert.deepEqual(verdicts, Object.fromEntries(Object.keys(verdicts).map((key) => [key, accepted]))); + +test("SIG-6: a current listing signature and a revocation marker accept only the canonical value", async () => { + const identityScope = { bundleVersion: "1", presentedBy: did, presentedAt: 1, claims: [{ ref: did }] }; + // A per-claim presentation signature is a bare string, not a SIG-6 envelope value. + const presentation = Buffer.from(await signBytes(`dacs-bundle-presentation:v1:${contentHash(identityScope)}`)).toString("hex"); + const scope = { + dacsVersion: "1", listingVersion: 1, listingId: "sig6", requiredCapabilities: ["SR-2"], + seller: { identity: { ...identityScope, presentation: { kind: "per-claim", signatures: [{ ref: did, signature: presentation }] } }, displayName: "Agent" }, + offering: { title: "Service", description: "Description", category: "services.test", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, + pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, terms: {}, validity: { notBefore: 1 }, + }; + const listingBytes = await signBytes(`dacs-listing:v1:${contentHash(scope)}`); + const listingWith = (value: string) => verifyListing({ ...scope, signature: { algorithm: "ed25519", signer: did, value } }); + const listing = await listingWith(canonicalSignatureValue(listingBytes)); + assert.equal(listing?.profile, "dacs-v0.1"); + const listingVerdicts: Record = {}; + for (const [name, value] of Object.entries(spellings(listingBytes))) listingVerdicts[name] = (await listingWith(value)) !== null; + rejectedEverywhere(listingVerdicts, false); + + const markerScope = { listingId: "sig6", listingVersion: 1, listingContentHash: listing!.contentHash, revokedAt: 1 }; + const markerBytes = await signBytes(`dacs-revocation:v1:${contentHash(markerScope)}`); + const markerWith = (value: string) => verifyListingRevocation({ ...markerScope, signature: { algorithm: "ed25519", signer: did, value } }, listing!, 1); + assert.equal(await markerWith(canonicalSignatureValue(markerBytes)), true); + const markerVerdicts: Record = {}; + for (const [name, value] of Object.entries(spellings(markerBytes))) markerVerdicts[name] = await markerWith(value); + rejectedEverywhere(markerVerdicts, false); +}); + +test("SIG-6: evidence-graph component and bundle signatures accept only the canonical value", async () => { + const evidence = { evidenceVersion: "1", jobId: "sig6", phase: "pay-dem", outcome: "success", observedAt: 1 }; + const evidenceBytes = await signBytes(`dacs-evidence:v1:${contentHash(evidence)}`); + const componentWith = (value: string) => verifyComponentSignature(evidence, "evidence", { algorithm: "ed25519", signer: did, value }); + assert.equal(await componentWith(canonicalSignatureValue(evidenceBytes)), true); + const componentVerdicts: Record = {}; + for (const [name, value] of Object.entries(spellings(evidenceBytes))) componentVerdicts[name] = await componentWith(value); + rejectedEverywhere(componentVerdicts, false); + + // A single-signed abort needs no agreement, so the graph reaches its listing step only when the signature verifies. + const other = `did:demos:agent:${"1".repeat(64)}`; + const bundleScope = { + bundleVersion: "1", jobId: "sig6", outcome: "aborted-by-self", listingRef: { listingId: "sig6", version: 1, contentHash: "0".repeat(64) }, + parties: [{ role: "buyer", primaryClaim: did, bundleHash: "a".repeat(64) }, { role: "seller", primaryClaim: other, bundleHash: "b".repeat(64) }], + phaseSummary: [], vetRecords: [], settlementEvidence: [], recipeRegistryVersion: 1, railRegistryVersion: 1, finalisedAt: 1, + }; + const bundleBytes = await signBytes(`dacs-bundle:v1:${contentHash(bundleScope)}`); + const graphWith = async (value: string) => (await buildCurrentEvidenceGraph("stor-1", { + read: async () => ({ ...bundleScope, anchoredByRole: "buyer", signatures: [{ party: did, algorithm: "ed25519", value }] }), + resolveListing: async () => null, + })).signaturesVerified; + assert.equal(await graphWith(canonicalSignatureValue(bundleBytes)), true); + const bundleVerdicts: Record = {}; + for (const [name, value] of Object.entries(spellings(bundleBytes))) bundleVerdicts[name] = await graphWith(value); + rejectedEverywhere(bundleVerdicts, false); +}); + +test("SIG-6 does not reach the legacy-sdk-v0.1 reader: a draft SDK listing keeps its hex signature", async () => { + const scope = { + listingId: "legacy", listingVersion: 1, agentId: did, serviceId: "legacy", name: "Legacy", description: "Draft SDK listing", + claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], + }; + const hex = Buffer.from(await signBytes(`dacs-listing:v1:${contentHash(scope)}`)).toString("hex"); + assert.equal((await verifyListing({ ...scope, signature: { algorithm: "ed25519", signer: did, value: hex } }))?.profile, "legacy-sdk-v0.1"); + assert.equal((await verifyListing({ ...scope, signature: hex }))?.profile, "legacy-sdk-v0.1"); +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index fef7b72..d949e94 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -121,7 +121,7 @@ test("listing verification accepts a current structured listing and verifies its const value = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(currentScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const signed = { ...currentScope, signature: { algorithm: "ed25519", signer: did, value } }; const verified = await verifyListing(signed); assert.equal(verified?.profile, "dacs-v0.1"); @@ -131,7 +131,7 @@ test("listing verification accepts a current structured listing and verifies its const unsafeValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(unsafeScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); assert.equal(await verifyListing({ ...unsafeScope, signature: { algorithm: "ed25519", signer: did, value: unsafeValue } }), null); }); @@ -555,7 +555,7 @@ test("any valid revocation candidate wins and scanner candidates deduplicate", a revokedAt: Date.now(), }; const message = Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"); - const signature = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("hex"); + const signature = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("base64url"); const valid = { ...scope, signature: { algorithm: "ed25519", signer: did, value: signature } }; const bogus = { ...valid, revokedAt: scope.revokedAt + 1 }; const records = new Map>([["bogus", bogus], ["valid", valid]]); @@ -627,7 +627,7 @@ test("standalone revocation verifier rejects a signed revokedAt that is not a no if (sdkHash) assert.equal(lenientHash(scope), sdkHash); const hash = sdkHash ?? lenientHash(scope); const message = Buffer.from(`dacs-revocation:v1:${hash}`, "utf8"); - const value = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(markerSeed))).toString("hex"); + const value = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(markerSeed))).toString("base64url"); return { ...scope, signature: { algorithm: "ed25519", signer: markerDid, value } }; }; const outcome = async (revokedAt: unknown) => { @@ -677,7 +677,7 @@ test("a revocation candidate with no canonical form never stops the candidate lo revokedAt: Date.now(), }; const message = Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"); - const signature = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("hex"); + const signature = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("base64url"); const valid = { ...scope, signature: { algorithm: "ed25519", signer: did, value: signature } }; // Valid required fields; the extension member has no canonical (CF-1) number form. const infinite = { ...valid, futureField: JSON.parse("1e400") }; @@ -716,7 +716,7 @@ test("revocation verification work per call is bounded and reports what it exami assert.ok(verified); if (!verified) return; const scope = { listingId: listing.listingId, listingVersion: listing.listingVersion, listingContentHash: verified.contentHash, revokedAt: 1 }; - const value = Buffer.from(await ed25519Sign(Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"), privateKeyFromSeed(seed))).toString("hex"); + const value = Buffer.from(await ed25519Sign(Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"), privateKeyFromSeed(seed))).toString("base64url"); const valid = { ...scope, signature: { algorithm: "ed25519", signer: did, value } }; const refs = Array.from({ length: 20 }, (_, index) => `bogus-${index}`); const reads: string[] = []; @@ -850,7 +850,7 @@ test("listing verification is open-world: an unknown additive top-level field is // top-level allowlist would (wrongly) reject this. const acceptValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(forwardScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const accepted = await verifyListing({ ...forwardScope, signature: { algorithm: "ed25519", signer: did, value: acceptValue } }); assert.ok(accepted, "unknown additive top-level field must be tolerated (open-world)"); assert.equal(accepted?.profile, "dacs-v0.1"); @@ -860,7 +860,7 @@ test("listing verification is open-world: an unknown additive top-level field is // open-world relaxation did NOT weaken signature binding. const baseValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(baseScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const injected = { ...baseScope, futureListingField: { evil: true }, signature: { algorithm: "ed25519", signer: did, value: baseValue } }; assert.equal(await verifyListing(injected), null, "field injected after signing must fail the whole-scope hash"); }); From 74ebc0c9017f8b4ebdcdd4af70c728a51cc93943 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 02/42] fix(dacs-directory): validate listings against the full DACS-1 and PS-3 rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Publish a listing only once validity.notBefore has passed. Validation step 3 is notBefore ≤ now ≤ notAfter; only notAfter was checked. - Add the negotiate-sealed-envelope-procurement phase kind and apply the full PS-3 matrix. negotiate-fixed-price takes fixed, negotiable or metered pricing; rfq takes negotiable or metered; both sealed-envelope kinds take auction. Negotiable pricing with fixed-price negotiation and auction pricing with procurement were rejected before. - Measure the 16 KB LR-2 cap on the canonical form with the signature omitted, not on the raw JSON with its signature. - Accept only the DACS-4 auction selectionRule enum or a content-hash-bound rule-ref; any string was accepted before. - Bound a revocation marker's revokedAt by the CORE numeric profile. Negative values are numbers DACS-1 allows. Fractions are still refused, because the pinned SDK canonicaliser cannot form them. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/indexer.ts | 4 +- .../src/catalog/listingVerification.ts | 35 ++++--- .../dacs-directory/src/catalog/scan.ts | 4 +- .../test/listing-verification.test.ts | 97 ++++++++++++++++++- .../dacs-directory/test/verification.test.ts | 14 ++- 5 files changed, 132 insertions(+), 22 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 1b3a6c2..885c149 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -178,7 +178,9 @@ export async function indexRegistration( const version = typeof rawVersion === "number" && Number.isSafeInteger(rawVersion) && rawVersion > 0 ? rawVersion : 1; - const validity = scope.validity as { notAfter?: unknown } | undefined; + // DACS-1 validation step 3: notBefore ≤ now ≤ notAfter (if set). + const validity = scope.validity as { notBefore?: unknown; notAfter?: unknown } | undefined; + if (typeof validity?.notBefore === "number" && validity.notBefore > now) continue; if (typeof validity?.notAfter === "number" && validity.notAfter < now) continue; // Verification work is bounded per pass: markers already verified come first (even when // an older snapshot omitted them from the queue), then the next pending candidates, diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index 2eab316..b7a4a28 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,4 +1,4 @@ -import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; +import { canonicalize, contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; import { isListing, type Listing } from "@kynesyslabs/dacs/artifacts"; import { safePublicEndpoint } from "./publicEndpoint.js"; import { @@ -14,6 +14,8 @@ import { canonicalSignatureBytes, legacySignatureBytes } from "./signatureValue. import type { RevocationBinding } from "./types.js"; const SEPARATOR = "dacs-listing:v1:"; +/** DACS-1 LR-2: the canonical form (signature omitted) of a listing is at most 16 KB. */ +export const MAX_LISTING_CANONICAL_BYTES = 16_384; export interface VerifiedListing { listing: Listing | Record; @@ -27,10 +29,22 @@ export interface VerifiedListing { const record = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; const PHASES = new Set([ - "vet-credentials", "negotiate-fixed-price", "negotiate-rfq", "negotiate-sealed-envelope", "commit-agreement", "commit-payee-bound-agreement", + "vet-credentials", "negotiate-fixed-price", "negotiate-rfq", "negotiate-sealed-envelope", "negotiate-sealed-envelope-procurement", + "commit-agreement", "commit-payee-bound-agreement", "pay-evm-erc20", "pay-solana-spl", "pay-cross-chain-htlc", "pay-cross-chain-liquidity-tank", "pay-ap2", "pay-x402", "pay-dem", "deliver-storage-program", "deliver-entitlement", "deliver-attested-payload", "rate", ]); +/** DACS-3 PS-3: the pricing kinds each negotiation pattern accepts. */ +const NEGOTIATION_PRICING: Record> = { + "negotiate-fixed-price": new Set(["fixed", "negotiable", "metered"]), + "negotiate-rfq": new Set(["negotiable", "metered"]), + "negotiate-sealed-envelope": new Set(["auction"]), + "negotiate-sealed-envelope-procurement": new Set(["auction"]), +}; +/** DACS-4 PricingSpec auction `selectionRule`: a closed enum plus content-hash-bound rule refs. */ +const SELECTION_RULES = new Set(["lowest-price", "highest-price", "first-acceptable"]); +const validSelectionRule = (value: unknown): boolean => + typeof value === "string" && (SELECTION_RULES.has(value) || /^rule-ref:[0-9a-f]{64}:\S+$/.test(value)); // NOTE (open-world listing shape): the directory does NOT reject a listing that carries // unknown top-level fields. §11.1.2 (additivity) lets a later minor add top-level listing // fields, and the older-reads-newer contract (CORE.md) requires an older reader to consume a @@ -64,7 +78,7 @@ function currentListing(scope: Record): { const tags = Array.isArray(offering?.tags) ? offering.tags : []; const pricingOk = pricing?.kind === "fixed" ? validPriceTerm(pricing.price) : pricing?.kind === "negotiable" ? validPriceTerm(pricing.bandCenter) && typeof pricing.minPct === "number" && pricing.minPct >= 0 && pricing.minPct < 100 && typeof pricing.maxPct === "number" && pricing.maxPct >= 0 - : pricing?.kind === "auction" ? (!pricing.reservePrice || validPriceTerm(pricing.reservePrice)) && typeof pricing.selectionRule === "string" + : pricing?.kind === "auction" ? (!pricing.reservePrice || validPriceTerm(pricing.reservePrice)) && validSelectionRule(pricing.selectionRule) : pricing?.kind === "metered" ? validPriceTerm(pricing.unitPrice) && typeof pricing.unit === "string" && pricing.unit.length > 0 && pricing.unit.length <= 64 && (pricing.minTotal === undefined || ( @@ -81,13 +95,8 @@ function currentListing(scope: Record): { const commitmentIndexes = pipeline.flatMap((step, index) => step.kind === "commit-agreement" || step.kind === "commit-payee-bound-agreement" ? [index] : []); const commitmentOk = commitmentIndexes.length === 1 && commitmentIndexes[0] === negotiationIndex + 1; - const expectedNegotiation = pricing?.kind === "fixed" ? "negotiate-fixed-price" - : pricing?.kind === "negotiable" ? "negotiate-rfq" : pricing?.kind === "auction" ? "negotiate-sealed-envelope" : ""; - const negotiationOk = pricing?.kind === "metered" - ? negotiationKinds.length === 1 && ( - negotiationKinds[0] === "negotiate-fixed-price" || negotiationKinds[0] === "negotiate-rfq" - ) - : negotiationKinds.length === 1 && negotiationKinds[0] === expectedNegotiation; + const negotiationOk = negotiationKinds.length === 1 && typeof pricing?.kind === "string" && + Boolean(NEGOTIATION_PRICING[negotiationKinds[0] as string]?.has(pricing.kind)); const signer = typeof signature?.signer === "string" ? signature.signer : ""; const sellerClaim = typeof identity?.presentedBy === "string" ? identity.presentedBy : ""; const signerIdentity = canonicalSigningIdentity(signer); @@ -152,7 +161,7 @@ export async function verifyListing( if (current) { const scope = { ...raw }; delete scope.signature; - if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) return null; + if (Buffer.byteLength(canonicalize(scope), "utf8") > MAX_LISTING_CANONICAL_BYTES) return null; if (current.signature.algorithm !== "ed25519") return null; const hash = contentHash(scope); const verifiedSigner = await verifyEd25519( @@ -306,8 +315,8 @@ export async function verifyListingRevocation( scope.listingVersion !== expectedVersion || typeof scope.listingContentHash !== "string" || scope.listingContentHash.toLowerCase() !== listing.contentHash || - !Number.isSafeInteger(scope.revokedAt) || - Number(scope.revokedAt) < 0 + // DACS-1 types revokedAt as a number; CORE §B.2 bounds only its magnitude. + typeof scope.revokedAt !== "number" || !(Math.abs(scope.revokedAt) <= Number.MAX_SAFE_INTEGER) ) return false; const signature = raw.signature; if (!signature || typeof signature !== "object" || Array.isArray(signature)) return false; diff --git a/reference-implementations/dacs-directory/src/catalog/scan.ts b/reference-implementations/dacs-directory/src/catalog/scan.ts index 8369574..12b356c 100644 --- a/reference-implementations/dacs-directory/src/catalog/scan.ts +++ b/reference-implementations/dacs-directory/src/catalog/scan.ts @@ -197,8 +197,8 @@ export function isListingRevocationCandidate(value: unknown): boolean { Number(marker.listingVersion) > 0 && typeof marker.listingContentHash === "string" && /^[0-9a-fA-F]{64}$/.test(marker.listingContentHash) && - Number.isSafeInteger(marker.revokedAt) && - Number(marker.revokedAt) >= 0 && + typeof marker.revokedAt === "number" && + Math.abs(marker.revokedAt) <= Number.MAX_SAFE_INTEGER && (marker.reason === undefined || typeof marker.reason === "string") && (signed.algorithm === "ed25519" || signed.algorithm === "ecdsa-secp256k1" || diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 70a04e9..18196ad 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; -import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { canonicalize, contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; import { verifyListing } from "../src/catalog/listingVerification.js"; @@ -198,3 +201,95 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy acceptedRails: [{ railId: "ap2:stripe-paymentintents" }], })), null); }); + +test("PS-3: each negotiation pattern accepts exactly the pricing kinds DACS-3 allows", async () => { + const pricing: Record> = { + fixed: { kind: "fixed", price: { amount: "1", currency: "USDC" } }, + negotiable: { kind: "negotiable", bandCenter: { amount: "1", currency: "USDC" }, minPct: 10, maxPct: 10 }, + auction: { kind: "auction", reservePrice: { amount: "1", currency: "USDC" }, selectionRule: "lowest-price" }, + metered: { kind: "metered", unitPrice: { amount: "1", currency: "USDC" }, unit: "call" }, + }; + const allowed: Record = { + "negotiate-fixed-price": ["fixed", "negotiable", "metered"], + "negotiate-rfq": ["negotiable", "metered"], + "negotiate-sealed-envelope": ["auction"], + "negotiate-sealed-envelope-procurement": ["auction"], + }; + const verdicts: Record = {}; + const expected: Record = {}; + for (const [negotiation, kinds] of Object.entries(allowed)) { + for (const kind of Object.keys(pricing)) { + const listing = signedCurrentListing({ + pricing: pricing[kind], + pipeline: [{ kind: negotiation }, { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], + }); + verdicts[`${negotiation} ${kind}`] = (await verifyListing(listing)) !== null; + expected[`${negotiation} ${kind}`] = kinds.includes(kind); + } + } + assert.deepEqual(verdicts, expected); +}); + +test("auction pricing accepts only the DACS-4 selectionRule enum and content-hash-bound rule refs", async () => { + const auction = (selectionRule: unknown) => signedCurrentListing({ + pricing: { kind: "auction", selectionRule }, + pipeline: [{ kind: "negotiate-sealed-envelope" }, { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], + }); + for (const rule of ["lowest-price", "highest-price", "first-acceptable", `rule-ref:${"a".repeat(64)}:https://rules.example/r1`]) { + assert.ok(await verifyListing(auction(rule)), rule); + } + for (const rule of ["best-vibes", "", "rule-ref:not-a-hash:https://rules.example/r1", `rule-ref:${"a".repeat(64)}:`, 7]) { + assert.equal(await verifyListing(auction(rule)), null, String(rule)); + } +}); + +test("LR-2 caps the canonical form at 16,384 bytes, signature omitted, not the raw JSON", async () => { + const unsigned = (listing: Record) => { + const scope = { ...listing }; + delete scope.signature; + return Buffer.byteLength(canonicalize(scope), "utf8"); + }; + const sized = (bytes: number) => signedCurrentListing({ + padding: "x".repeat(bytes - unsigned(signedCurrentListing({ padding: "" }))), + }); + const atCap = sized(16_384); + assert.equal(unsigned(atCap), 16_384); + assert.ok(Buffer.byteLength(JSON.stringify(atCap), "utf8") > 16_384, "the raw JSON with its signature is larger"); + assert.ok(await verifyListing(atCap)); + assert.equal(await verifyListing(sized(16_385)), null); + // NFC composition shrinks the canonical form below the raw bytes; a composition exclusion grows it. + const composing = signedCurrentListing({ padding: "é".repeat(5_000) }); + assert.ok(Buffer.byteLength(JSON.stringify(composing), "utf8") > 16_384); + assert.ok(await verifyListing(composing)); + const expanding = signedCurrentListing({ padding: "क़".repeat(4_000) }); + assert.ok(Buffer.byteLength(JSON.stringify(expanding), "utf8") < 16_384); + assert.equal(await verifyListing(expanding), null); +}); + +test("the indexer publishes a current listing only once validity.notBefore has passed (DACS-1 validation step 3)", async () => { + const directory = mkdtempSync(join(tmpdir(), "dacs-directory-validity-")); + process.env.DACS_DIRECTORY_DATA = directory; + const { indexRegistration } = await import("../src/catalog/indexer.js"); + const anchors: Record> = { + [`stor-${"a1".padStart(40, "0")}`]: signedCurrentListing({ listingId: "future", validity: { notBefore: Date.now() + 3_600_000 } }), + [`stor-${"a2".padStart(40, "0")}`]: signedCurrentListing({ listingId: "current", validity: { notBefore: Date.now() - 1_000 } }), + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = anchors[String(input).split("/").pop() ?? ""]; + return data + ? Response.json({ success: true, owner: `0x${publicKeyHex}`, programName: "opaque", data }) + : new Response(null, { status: 404 }); + }; + try { + const record = await indexRegistration( + { primaryClaim: claim, displayName: "Seller", listingAnchors: Object.keys(anchors) }, + undefined, + async () => { throw new Error("identity unavailable"); }, + ); + assert.deepEqual(record.listings.map((listing) => listing.listingId), ["current"]); + } finally { + globalThis.fetch = originalFetch; + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index d949e94..9276924 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -589,7 +589,7 @@ test("any valid revocation candidate wins and scanner candidates deduplicate", a assert.equal(isListingRevocationCandidate({ ...valid, listingContentHash: "not-a-hash" }), false); }); -test("standalone revocation verifier rejects a signed revokedAt that is not a non-negative safe integer", async () => { +test("standalone revocation verifier bounds a signed revokedAt to the CORE numeric profile, not to non-negative integers", async () => { const markerSeed = Uint8Array.from(randomBytes(32)); const markerDid = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(markerSeed))).toString("hex")}`; const markerListing = { ...listing, agentId: markerDid }; @@ -639,21 +639,25 @@ test("standalone revocation verifier rejects a signed revokedAt that is not a no assert.deepEqual(await outcome(Date.now()), { candidate: true, verified: true }); assert.deepEqual(await outcome(0), { candidate: true, verified: true }); + // DACS-1 types revokedAt as a number and CORE §B.2 bounds only its magnitude. + assert.deepEqual(await outcome(-1), { candidate: true, verified: true }); + assert.deepEqual(await outcome(-Number.MAX_SAFE_INTEGER), { candidate: true, verified: true }); const rejected = { candidate: false, verified: false }; assert.deepEqual({ + // A valid marker, but the pinned SDK canonicalizer has no form for a fraction (SIG-3). fraction: await outcome(1791480684902.5), - negative: await outcome(-1), nan: await outcome(Number.NaN), infinity: await outcome(Number.POSITIVE_INFINITY), aboveMaxSafeInteger: await outcome(Number.MAX_SAFE_INTEGER + 1), - // Canonicalizable, so only the safe-integer check rejects it. + belowMinSafeInteger: await outcome(-Number.MAX_SAFE_INTEGER - 1), + // Canonicalizable, so only the type check rejects it. numericString: await outcome("1791480684902"), }, { - fraction: rejected, - negative: rejected, + fraction: { candidate: true, verified: false }, nan: rejected, infinity: rejected, aboveMaxSafeInteger: rejected, + belowMinSafeInteger: rejected, numericString: rejected, }); }); From 7ddc0b42e34b6dd8738420ccd0df66f93871bda5 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 03/42] fix(dacs-directory): never publish a listing as active while its revocation check is incomplete DACS-1 RB-5 and RB-6 say an incomplete RB-4 check or a transport failure is indeterminate, never absent. RB-3 says a revoked record keeps its binding. The indexer had only two outcomes: if a verified marker could not be read on a pass, or a valid marker sat behind unread or unexamined candidates, it republished the listing as active with a fresh catalogObservedAt. Readers of the catalog then accepted new sessions on a revoked listing. - Compute a three-state RevocationCheck per listing. revoked: a marker verifies this pass, or the previous record was revoked; a later pass never withdraws a verified revocation. indeterminate: a candidate was unread or is still pending. absent: every candidate was examined and rejected. - The catalog schema allows only active and revoked, so an indeterminate listing is withheld for that pass. It still resolves past sessions and identity. - Add readAnchorResult, which separates a definitive answer (404, 401, 403, 400, or non-object or oversized content) from a transport failure. Only a transport failure leaves a candidate unread. A deleted or hidden candidate is rejected, so it cannot keep a listing withheld for good. - Update the README's revocation paragraph, which still described a 16-candidate retention window that no longer exists. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 15 ++-- .../dacs-directory/src/catalog/chain.ts | 50 +++++++---- .../dacs-directory/src/catalog/indexer.ts | 36 ++++++-- .../dacs-directory/src/catalog/store.ts | 5 ++ .../dacs-directory/test/chain-storage.test.ts | 30 +++++++ .../dacs-directory/test/scan-fairness.test.ts | 86 ++++++++++++++++++- 6 files changed, 193 insertions(+), 29 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 454124b..216d409 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -189,13 +189,14 @@ DACS-5 absence evidence and cannot satisfy BB-8. 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. + Revocation markers are recognised by their signed content, never by program name. + Every candidate locator is queued once per listing hash; each pass fully verifies + at most 16 per listing — markers that already passed RB-4 first, then those + anchored by the listing owner, then arrival order. The queue itself has no cap. + While any candidate is unread or not yet examined the RB-4 check is incomplete + (`indeterminate`, RB-5), so the listing is withheld from the catalog rather than + published `active`. Once a marker verifies, the listing stays `revoked` with its + binding even if the marker later cannot be read (RB-3). BB-4-valid BundleBindings are also classified and accumulated under a deterministic per-job/role total-work ceiling; any overflow is sticky and makes that side `indeterminate`, never absent. diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index 181d851..8fc6e97 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -10,7 +10,7 @@ * path the StorageProgram API prescribes. The app stays free of the Demos * client's unrelated multichain dependency tree. */ -import { boundedJson, storageResponseJson } from "./artifactLimits.js"; +import { boundedJson, storageResponseJson, StorageResponseTooLarge } from "./artifactLimits.js"; import { sha256Hex } from "@kynesyslabs/dacs/canonical"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); @@ -41,26 +41,46 @@ export interface AnchorRecord { programName?: string; } -export async function readAnchorRecord(address: string): Promise { - if (!/^stor-[0-9a-f]{40}$/.test(address)) return null; +/** + * One storage read, classified. `absent` is a definitive answer that no + * readable artifact object is at the locator (missing, non-public, or content + * that cannot be one); `unavailable` is a transport or node failure, which + * callers must treat as indeterminate rather than absent (DACS-1 RB-6). + */ +export type AnchorRead = + | { status: "present"; record: AnchorRecord } + | { status: "absent" } + | { status: "unavailable" }; + +export async function readAnchorResult(address: string): Promise { + if (!/^stor-[0-9a-f]{40}$/.test(address)) return { status: "absent" }; + let res: Response; try { - const res = await fetch(`${RPC}/storage-program/${address}`, { + res = await fetch(`${RPC}/storage-program/${address}`, { cache: "no-store", signal: AbortSignal.timeout(15_000), }); - if (!res.ok) return null; - const json = (await storageResponseJson(res)) as { - success?: boolean; - data?: Record; - owner?: string; - programName?: string; - }; - return json?.success && json.data != null && typeof json.data === "object" && !Array.isArray(json.data) && boundedJson(json.data) !== null - ? { data: json.data, owner: json.owner, programName: json.programName } - : null; } catch { - return null; + return { status: "unavailable" }; } + if ([400, 401, 403, 404].includes(res.status)) return { status: "absent" }; + if (!res.ok) return { status: "unavailable" }; + let json: { success?: boolean; data?: unknown; owner?: string; programName?: string }; + try { + json = (await storageResponseJson(res)) as typeof json; + } catch (error) { + return { status: error instanceof StorageResponseTooLarge ? "absent" : "unavailable" }; + } + if (!json || typeof json !== "object" || !json.success) return { status: "unavailable" }; + const data = json.data; + return data != null && typeof data === "object" && !Array.isArray(data) && boundedJson(data) !== null + ? { status: "present", record: { data: data as Record, owner: json.owner, programName: json.programName } } + : { status: "absent" }; +} + +export async function readAnchorRecord(address: string): Promise { + const read = await readAnchorResult(address); + return read.status === "present" ? read.record : null; } export async function readAnchor(address: string): Promise | null> { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 885c149..aaa7437 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -28,7 +28,7 @@ import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleC // The SDK doesn't export sessionAnchorName from its public barrel // (dacs-sdk#178, follow-up to #14) — reach into the vendored build. import { sessionAnchorName } from "../../vendor/dacs-sdk/dist/agent/runSessionCore.js"; -import { readAnchor, readAnchorRecord } from "./chain.js"; +import { readAnchor, readAnchorRecord, readAnchorResult } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; import { findValidListingRevocation, @@ -44,6 +44,7 @@ import { verifyOwnerSignature } from "./registrationSig.js"; import { artifactAnchorTime, clearListingRejection, + hasPendingRevocationCandidates, nextRevocationCandidates, resolveProgramAddress, loadScanState, @@ -80,6 +81,7 @@ import type { ListingSummary, Registration, RegisteredDeal, + RevocationCheck, SellerRecord, } from "./types.js"; @@ -89,6 +91,17 @@ const verify = (b: Uint8Array, s: Uint8Array, p: Uint8Array): boolean => /** Resolve the raw GCR identity payload for a bare-hex Demos address. */ export type ResolveIdentities = (addressHex: string) => Promise; +/** + * Only a transport failure leaves a revocation candidate unread (RB-6). A + * locator the node answers for with no readable object holds no marker, so it + * is rejected; otherwise deleting or hiding a queued candidate would keep the + * listing's check incomplete for good. + */ +async function readRevocationCandidate(locator: string): Promise | null> { + const read = await readAnchorResult(locator); + return read.status === "present" ? read.record.data : read.status === "absent" ? {} : null; +} + export function listingBindingRejection( sellerClaim: string, anchorOwner: string | undefined, @@ -149,6 +162,7 @@ export async function indexRegistration( // ── Listings: read from chain, validate shape ───────────────────────────── const listings: ListingSummary[] = []; const listingArtifacts = new Map }>(); + const categoriesByListing = new Map(); let identityBundle: Record | undefined; const identityBundles: Record[] = []; const { verifiedRevocations } = loadScanState(); @@ -187,19 +201,25 @@ export async function indexRegistration( // those anchored by the listing's own owner first. Only one batch is ever selected. const limit = REVOCATION_VERIFICATIONS_PER_PASS; const progress: RevocationCandidateProgress = { unread: [], rejected: [] }; - const revocationBinding = await findValidListingRevocation( + const found = await findValidListingRevocation( [ ...(verifiedRevocations[verified.contentHash] ?? []).slice(0, limit), ...nextRevocationCandidates(verified.contentHash, anchored.owner ?? null, limit), ], verified, version, - readAnchor, + readRevocationCandidate, undefined, progress, limit, ); recordRevocationProgress(verified.contentHash, progress); + // RB-3: a revocation verified on an earlier pass is retained, whatever its marker reads now. + const revocationBinding = found ?? prior?.listings.find((listing) => listing.status === "revoked" && + listing.contentHash === verified.contentHash)?.revocationBinding ?? null; + // RB-5: an unread or unexamined candidate leaves the check incomplete, and incomplete is never absent. + const revocation: RevocationCheck = revocationBinding ? "revoked" + : progress.unread.length > 0 || hasPendingRevocationCandidates(verified.contentHash) ? "indeterminate" : "absent"; const presentation = listingPresentation(scope); const signedSeller = scope.seller && typeof scope.seller === "object" && !Array.isArray(scope.seller) ? scope.seller as Record : null; @@ -207,6 +227,12 @@ export async function indexRegistration( identityBundle = signedSeller.identity as Record; identityBundles.push(identityBundle); } + // Past sessions still verify against the listing whatever its revocation state. + listingArtifacts.set(`${listingId}\n${version}\n${verified.contentHash}`, { locator: anchor, raw: anchored.data }); + categoriesByListing.set(listingId, presentation.category); + // The catalog can publish neither "active" for an indeterminate check (a reader would take it + // as absent, RB-6) nor "revoked" without a binding (RB-3), so the listing waits for a later pass. + if (revocation === "indeterminate") continue; listings.push({ listingId, version, @@ -232,16 +258,14 @@ export async function indexRegistration( ? scope.buyerRequirement as Record : undefined, terms: scope.terms && typeof scope.terms === "object" ? scope.terms as Record : undefined, - status: revocationBinding ? "revoked" : "active", + status: revocation === "revoked" ? "revoked" : "active", ...(revocationBinding ? { revocationBinding } : {}), catalogObservedAt: now, }); - listingArtifacts.set(`${listingId}\n${version}\n${verified.contentHash}`, { locator: anchor, raw: anchored.data }); } // ── Deals: dereference + verify each bundle from chain ──────────────────── const dealCandidates: DealRecord[] = []; - const categoriesByListing = new Map(listings.map((l) => [l.listingId, l.offering.category])); const scanState = loadScanState(); const overflowBindings = new Set(scanState.bundleBindingOverflow ?? []); const relevantJobs = new Set((reg.deals ?? []).map((deal) => deal.jobId)); diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index ff1467d..950d5b7 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -295,6 +295,11 @@ export function nextRevocationCandidates(listingHash: string, listingOwner: stri const owned = listingOwner ? rows(pendingOwnerCandidates, listingHash, canonicalProgramOwner(listingOwner), limit) : []; return [...new Set([...owned, ...rows(pendingCandidates, listingHash, limit)])].slice(0, limit); } +const anyPendingCandidate = db.prepare(`SELECT 1 FROM revocation_candidates + WHERE listing_hash = ? AND state = 'pending' LIMIT 1`); +/** True while any candidate for a listing is unread or not yet examined, so its RB-4 check is incomplete. */ +export const hasPendingRevocationCandidates = (listingHash: string): boolean => + anyPendingCandidate.get(listingHash) !== undefined; const rejectCandidate = db.prepare("UPDATE revocation_candidates SET state='rejected' WHERE listing_hash = ? AND locator = ?"); const requeueCandidate = db.prepare(`UPDATE revocation_candidates SET seq = ? WHERE listing_hash = ? AND locator = ? AND state = 'pending'`); diff --git a/reference-implementations/dacs-directory/test/chain-storage.test.ts b/reference-implementations/dacs-directory/test/chain-storage.test.ts index 19e2d74..01a535a 100644 --- a/reference-implementations/dacs-directory/test/chain-storage.test.ts +++ b/reference-implementations/dacs-directory/test/chain-storage.test.ts @@ -5,6 +5,7 @@ import { deriveAnchorAddress, deriveStorageAddress, LIVE_STORAGE_SALT, + readAnchorResult, resolveOwnedAnchorByName, } from "../src/catalog/chain.js"; @@ -114,3 +115,32 @@ test("producer resume lookup fails closed when the bounded result page is full", globalThis.fetch = originalFetch; } }); + +test("a storage read separates a definitive answer from a transport failure (RB-6)", async () => { + const at = "stor-4444444444444444444444444444444444444444"; + const body = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); + const cases: Array<[string, () => Response | Promise, string]> = [ + ["object data", () => body({ success: true, owner, programName, data: { marker: 1 } }), "present"], + ["not found", () => new Response(null, { status: 404 }), "absent"], + ["not public", () => new Response(null, { status: 403 }), "absent"], + ["unauthenticated", () => new Response(null, { status: 401 }), "absent"], + ["invalid request", () => new Response(null, { status: 400 }), "absent"], + ["non-object data", () => body({ success: true, owner, programName, data: "text" }), "absent"], + ["oversized response", () => new Response("x".repeat(1024 * 1024 + 1)), "absent"], + ["node failure", () => new Response(null, { status: 503 }), "unavailable"], + ["rate limited", () => new Response(null, { status: 429 }), "unavailable"], + ["unsuccessful body", () => body({ success: false }), "unavailable"], + ["truncated JSON", () => new Response("{\"success\":tr"), "unavailable"], + ["network error", () => { throw new TypeError("fetch failed"); }, "unavailable"], + ]; + const originalFetch = globalThis.fetch; + try { + for (const [name, respond, expected] of cases) { + globalThis.fetch = async () => respond(); + assert.equal((await readAnchorResult(at)).status, expected, name); + } + assert.equal((await readAnchorResult("stor-../../admin")).status, "absent"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 9740637..8ffd5b8 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -61,13 +61,15 @@ async function agreementFor(jobId: string, buyerIndex: number, sellerIndex: numb return { ...scope, signatures: [await sign(buyerIndex), await sign(sellerIndex)] }; } -// Stubbed node. `unavailable` locators answer 503; `scanOnly` locators answer only their first read. +// Stubbed node. `unavailable` locators answer 503; `scanOnly` locators answer only their first read; +// `vanishing` locators answer 404 after their first read. interface StoredProgram { data: string; name?: string; owner?: string } let storage: Record = {}; let transactions: Obj[] = []; let reads = new Map(); const unavailable = new Set(); const scanOnly = new Set(); +const vanishing = new Set(); let nodeDown = false; globalThis.fetch = async (input, init) => { const path = new URL(String(input)).pathname; @@ -78,6 +80,7 @@ globalThis.fetch = async (input, init) => { const entry = storage[at]; if (!entry) return new Response(null, { status: 404 }); if (unavailable.has(at) || (scanOnly.has(at) && count > 1)) return new Response(null, { status: 503 }); + if (vanishing.has(at) && count > 1) return new Response(null, { status: 404 }); return new Response(`{"success":true,"owner":${JSON.stringify(entry.owner ?? ownerOf(seller))},` + `"programName":${JSON.stringify(entry.name ?? "opaque")},"data":${entry.data}}`, { headers: { "content-type": "application/json" } }); } @@ -93,6 +96,7 @@ function chain(programs: Record, written: string[], first reads = new Map(); unavailable.clear(); scanOnly.clear(); + vanishing.clear(); transactions = written.map((at, index) => memo(firstId + index, at)).reverse(); } const readsOf = (locators: string[]) => locators.reduce((total, at) => total + (reads.get(at) ?? 0), 0); @@ -261,6 +265,86 @@ test("a verified marker missing from the candidate queue is still checked first" assert.ok(readsOf(others) === 0, "the verified marker is checked before any other candidate"); }); +const publishedListing = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]; + +test("a verified revocation stays revoked while its marker cannot be read or no longer verifies (RB-3)", async () => { + resetIndex([sellerRegistration, later], 30); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(5)]: { data: JSON.stringify(marker), owner: ownerOf(otherBuyer) }, + }, [locator(1), locator(5)]); + await reindex(); + const binding = publishedListing()?.revocationBinding; + assert.equal(publishedListing()?.status, "revoked"); + assert.equal(binding?.markerAnchor.locator, locator(5)); + transactions = []; + // A transport failure is indeterminate, never absent: the record stays revoked with its binding. + unavailable.add(locator(5)); + await reindex(); + assert.equal(publishedListing()?.status, "revoked"); + assert.deepEqual(publishedListing()?.revocationBinding, binding); + // Later content at the marker's locator does not withdraw the revocation either. + unavailable.clear(); + storage[locator(5)] = { data: JSON.stringify(unsigned(1)), owner: ownerOf(otherBuyer) }; + await reindex(); + assert.equal(publishedListing()?.status, "revoked"); + assert.deepEqual(publishedListing()?.revocationBinding, binding); +}); + +test("an unread or unexamined revocation candidate withholds the listing rather than publishing it active (RB-5)", async () => { + // Unread: the third-party marker answers the scanner, then the node fails its verification read. + resetIndex([sellerRegistration, later], 31); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(6)]: { data: JSON.stringify(marker), owner: ownerOf(otherBuyer) }, + }, [locator(1), locator(6)]); + scanOnly.add(locator(6)); + await reindex(); + assert.equal(publishedListing(), undefined, "an incomplete check publishes no active record"); + scanOnly.clear(); + transactions = []; + await reindex(); + assert.equal(publishedListing()?.status, "revoked"); + + // Unexamined: the valid marker queues behind more bogus candidates than one pass verifies. + resetIndex([sellerRegistration, later], 32); + const bogus = range(500, 20); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(7)]: { data: JSON.stringify(marker), owner: ownerOf(otherBuyer) }, + ...Object.fromEntries(bogus.map((at, n) => [at, { data: JSON.stringify(unsigned(n)), owner: ownerOf(outsider) }])), + }, [locator(1), ...bogus, locator(7)]); + await reindex(); + assert.equal(publishedListing(), undefined, "16 of 21 candidates examined"); + transactions = []; + await reindex(); + assert.equal(publishedListing()?.status, "revoked"); + + // A candidate deleted after discovery holds no marker: it is rejected, so it cannot keep the check open. + resetIndex([sellerRegistration, later], 34); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(8)]: { data: JSON.stringify(unsigned(1)), owner: ownerOf(outsider) }, + }, [locator(1), locator(8)]); + vanishing.add(locator(8)); + await reindex(); + assert.equal(publishedListing()?.status, "active"); + assert.deepEqual(pending(listingHash), []); + + // Every candidate examined and rejected: absent, and an active record carries no binding. + resetIndex([sellerRegistration, later], 33); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + ...Object.fromEntries(bogus.map((at, n) => [at, { data: JSON.stringify(unsigned(n)), owner: ownerOf(outsider) }])), + }, [locator(1), ...bogus]); + await reindex(); + assert.equal(publishedListing(), undefined); + transactions = []; + await reindex(); + assert.equal(publishedListing()?.status, "active"); + assert.equal(Object.hasOwn(publishedListing()!, "revocationBinding"), false); +}); + for (const route of ["unsigned bundle-named program", "bundle and agreement signed by its own parties"]) { test(`deals are keyed by their owner: another owner's ${route} with the same jobId leaves the deal in place`, async () => { const jobId = "shared-job"; From 8ea4d4df6ef6866d5778586438fbbf6ed0a25f22 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 04/42] fix(dacs-directory): re-examine a rejected revocation marker whose stored value changes A rejected candidate went back to pending only when its content hash changed. The content hash omits the signature envelope, so a marker re-published in place with a corrected envelope was never examined again. That includes the canonical re-encoding SIG-6 asks producers to publish. The same marker at a fresh locator revoked the listing at once. Admit candidates with a hash of the whole stored value, envelope included. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/reindexCore.ts | 11 +++++++++-- .../dacs-directory/src/catalog/store.ts | 3 ++- .../dacs-directory/test/scan-fairness.test.ts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index d687442..7a5cd1b 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -6,6 +6,7 @@ */ import { discoveredDealKey, normalizeScanState, ownArray, SCAN_STATE_SCHEMA_VERSION } from "./scanState.js"; import { MAX_REGISTRATION_BUNDLE_BINDINGS, parseRegistration } from "./registration.js"; +import { boundedJson } from "./artifactLimits.js"; import { createHash } from "node:crypto"; import { indexRegistration, type ResolveIdentities } from "./indexer"; import { boundedBundleBindings, verifyBundleBinding } from "./bundleBinding"; @@ -200,11 +201,17 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise [observation.locator, observation])); + const storedValueHash = (data: Record | undefined) => { + const json = data ? boundedJson(data) : null; + return json === null ? null : createHash("sha256").update(json, "utf8").digest("hex"); + }; for (const [hash, addresses] of scan.revocations) { admitRevocationCandidates(hash, addresses.map((locator) => - ({ locator, owner: observed.get(locator)?.owner, contentHash: observed.get(locator)?.contentHash }))); + ({ locator, owner: observed.get(locator)?.owner, contentHash: storedValueHash(observed.get(locator)?.data) }))); } for (const [jobId, bindings] of scan.bundleBindings) { const bounded = boundedBundleBindings([...ownArray(state.bundleBindings, jobId), ...bindings]); diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index 950d5b7..7d4b5ec 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -238,7 +238,8 @@ if (getJson("sr2-anchor-schema-version", 0) < 2) db.transaction(() => { /** * Revocation candidates, per listing content hash. Each locator is admitted * once and stays pending until it is read and fails RB-4; a rejected locator - * is admitted again only when the chain shows it with different content. + * is admitted again only when the chain shows it with a different stored + * value (`content_hash` hashes the whole value, signature envelope included). * `seq` is arrival order; an unreadable candidate is given a new one, so it * moves behind the others and is retried later. */ diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 8ffd5b8..1483e76 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -250,6 +250,25 @@ test("a rejected candidate is verified again only when its content changes", asy assert.equal(listingStatus(), "revoked"); }); +test("a rejected marker re-published in place with a corrected signature envelope is examined again", async () => { + resetIndex([sellerRegistration, later], 35); + // The signed scope is the seller's, but the value is spelled in hex, which SIG-6 rejects. + const hexMarker = { ...marker, signature: { ...marker.signature, value: Buffer.from(marker.signature.value, "base64url").toString("hex") } }; + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(9)]: { data: JSON.stringify(hexMarker), owner: ownerOf(otherBuyer) }, + }, [locator(1), locator(9)]); + await reindex(); + assert.equal(publishedListing()?.status, "active"); + // Re-encoding the signature leaves the content hash unchanged, but not the stored value. + assert.equal(contentHash(hexMarker), contentHash(marker)); + storage[locator(9)] = { data: JSON.stringify(marker), owner: ownerOf(otherBuyer) }; + transactions = [memo(50, locator(9)), ...transactions]; + await reindex(); + assert.equal(publishedListing()?.status, "revoked"); + assert.equal(publishedListing()?.revocationBinding?.markerAnchor.locator, locator(9)); +}); + test("a verified marker missing from the candidate queue is still checked first", async () => { resetIndex([sellerRegistration, later], 15); const others = range(400, 20); From b5105ef07ad021f72d92dca205b3510e7f86ca97 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 05/42] fix(dacs-directory): attribute discovered deals through the bundle's signed agreementRef, not program names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DACS-5 §10.4.2 makes a StorageProgram name an opaque, colon-free write input: "A consumer MUST NOT treat the name as a resolution key." The scanner found a discovered deal's agreement under the raw name dacs3:agreement:. Conforming producers percent-encode that name (199 of the 209 agreement programs on node2 do), so their deals were never attributed to a seller and never counted. - A current-profile buyer bundle now resolves its agreement through the agreementRef it signs. The scanner reads that locator, checks the content hash, and requires a co-signed current agreement whose buyer is the bundle owner. The deal records the agreement locator. - Name-keyed duplicate invalidation in reindexCore applies only to deals attributed by name. Legacy SDK bundles keep resolving by their session anchor names on the labelled compatibility path. - Update the scanner header and the README, which described name-based classification for every profile. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 6 +- .../src/catalog/evidenceGraph.ts | 2 +- .../dacs-directory/src/catalog/reindexCore.ts | 10 +-- .../dacs-directory/src/catalog/scan.ts | 68 +++++++++++++------ .../test/malformed-artifacts.test.ts | 3 +- .../dacs-directory/test/scan-fairness.test.ts | 56 +++++++++++++-- 6 files changed, 111 insertions(+), 34 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 216d409..4fff031 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -185,8 +185,10 @@ DACS-5 absence evidence and cannot satisfy BB-8. registration. 2. **Chain scanning** (passive): the reindex pass walks the node's transaction history (`nodeCall getTransactions`, plain fetch), spots storage-program writes, classifies - anchored DACS artifacts by their self-describing program names, and attributes deals - to sellers via the buyer-anchored agreement. Agents nobody registered appear as + current-profile DACS artifacts by their signed content, and attributes a deal to its + seller through the agreement its buyer bundle references by locator and content hash. + Program names are never resolution keys, except on the legacy SDK compatibility path + (`dacs5:bundle:`, `dacs3: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 markers are recognised by their signed content, never by program name. diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index af5d439..4ad144a 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -119,7 +119,7 @@ export function signedScope(raw: Record, kind: ArtifactKind): R return scope; } export const artifactHash = (raw: Record, kind: ArtifactKind): string => contentHash(signedScope(raw, kind)); -const normalizedHash = (value: unknown): string => typeof value === "string" ? value.replace(/^sha256[:-]/i, "").toLowerCase() : ""; +export const normalizedHash = (value: unknown): string => typeof value === "string" ? value.replace(/^sha256[:-]/i, "").toLowerCase() : ""; export async function verifyComponentSignature( raw: Record, diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 7a5cd1b..c326e0f 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -14,6 +14,7 @@ import { readChainTip, scanChain, scanConsensusAnchorBackfill, + type DiscoveredDeal, } from "./scan"; import { chainResetRequired, chainResetThreshold } from "./chainContinuity"; import { crawlDomains } from "./wellknown"; @@ -189,13 +190,14 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise: → a listing (owner = the seller) * dacs5:bundle: → a buyer-anchored deal bundle * dacs5:bundle:seller: → the seller's counter-signed copy * - * Deal → seller attribution: the buyer-anchored agreement at - * `dacs3:agreement:` (owner-scoped to the bundle's owner) names the - * seller. So one scan discovers agents nobody registered, their listings, - * and their verifiable deal history — the catalog grows without submissions. + * Deal → seller attribution: a current buyer bundle's signed agreementRef + * (locator and content hash) names the co-signed agreement, whose parties + * name the seller; a legacy bundle's agreement is the owner-scoped + * `dacs3:agreement:` program. So one scan discovers agents nobody + * registered, their listings, and their verifiable deal history. * * Shape-defensive: the tx envelope is deep-walked for storage addresses * rather than assuming one schema (testnet payloads vary across versions). */ import { boundedJson, storageResponseJson, StorageResponseTooLarge, MAX_STORAGE_OWNER_LENGTH, MAX_STORAGE_NAME_LENGTH } from "./artifactLimits.js"; -import { verifiedDiscoveryAgreement } from "./evidenceGraph.js"; +import { artifactHash, isCurrentRef, normalizedHash, verifiedDiscoveryAgreement } from "./evidenceGraph.js"; import { isAgreementDocument } from "@kynesyslabs/dacs/artifacts"; import { verifyReferencedArtifactSignature } from "./bundlePolicy.js"; import { canonicalDemosAgentClaim } from "./claimRef.js"; @@ -39,7 +42,7 @@ export interface ScannedArtifacts { /** listing anchor address → owner address */ listings: Map; /** buyer owner + jobId (discoveredDealKey) → discovered deal (buyer-anchored bundle + owners) */ - deals: Map; + deals: Map; /** owner + programName → observed native address. */ programs: Map; /** listing content hash → every revocation candidate seen in this window, oldest first. */ @@ -60,6 +63,12 @@ export interface ScannedArtifacts { scanError?: string; } +/** + * A scanned deal. `agreementLocator` is set when a current-profile bundle's + * signed agreementRef attributed it, so program names played no part. + */ +export type DiscoveredDeal = RegisteredDeal & { sellerFromAgreement?: string; agreementLocator?: string }; + interface StorageRead { success?: boolean; owner?: string; @@ -486,7 +495,8 @@ export async function scanChain( const failures: ScannedArtifacts["failures"] = []; const namesByLocator = new Map(); let omittedBindings = 0; - const bundleOwners = new Map(); // owner + jobId → buyer bundle + // owner + jobId → buyer bundle; a current-profile bundle carries the agreementRef it signs. + const bundleOwners = new Map(); const sellerCopies = new Map>(); // preserve competing candidates const addSellerCopy = (jobId: string, address: string, owner: string) => { const copies = sellerCopies.get(jobId) ?? []; @@ -546,14 +556,14 @@ export async function scanChain( const jobId = data!.jobId as string; const role = data!.anchoredByRole; if (role === "seller") addSellerCopy(jobId, address, read.owner); - else bundleOwners.set(discoveredDealKey(read.owner, jobId), { jobId, address, owner: read.owner }); + else bundleOwners.set(discoveredDealKey(read.owner, jobId), { jobId, address, owner: read.owner, current: true, agreementRef: data!.agreementRef }); } else if (name.startsWith("dacs5:bundle:seller:")) { artifactKind = "bundle"; addSellerCopy(name.slice("dacs5:bundle:seller:".length), address, read.owner); } else if (name.startsWith("dacs5:bundle:")) { artifactKind = "bundle"; const jobId = name.slice("dacs5:bundle:".length); - bundleOwners.set(discoveredDealKey(read.owner, jobId), { jobId, address, owner: read.owner }); + bundleOwners.set(discoveredDealKey(read.owner, jobId), { jobId, address, owner: read.owner, current: false }); } observations.push({ locator: address, kind: artifactKind, profile, owner: read.owner, contentHash: dataHash, observedAt: Date.now(), data }); @@ -572,17 +582,34 @@ export async function scanChain( // retain the honest finalisedAt fallback and will be retried by backfill. } - // Attribute each discovered deal to its seller via the buyer-anchored agreement. - const deals = new Map(); - for (const [dealKey, { jobId, ...bundle }] of bundleOwners) { - const bundleName = namesByLocator.get(bundle.address); - if (bundleName && programs.get(programBindingKey(bundle.owner, bundleName)) === null) continue; - const agreementAddress = programs.get(programBindingKey(bundle.owner, `dacs3:agreement:${jobId}`)); + // A current-profile bundle names its agreement by the locator and content hash it signs, and a + // program name is never a resolution key for a conforming consumer (DACS-5 §10.4.2). + const signedAgreement = async (ref: unknown, jobId: string, buyer: string): Promise | undefined> => { + if (!isCurrentRef(ref)) return undefined; + const read = await readStorage(ref.anchor.locator); + const data = read.success ? read.data : undefined; + return data && (data.agreementVersion !== undefined || data.payeeBoundAgreementVersion !== undefined) && + storableContentHash(data) !== null && artifactHash(data, "agreement") === normalizedHash(ref.contentHash) && + await validAgreementForDiscovery(data, jobId, buyer) ? data : undefined; + }; + // The legacy SDK profile resolves its agreement by the owner-scoped session anchor name. + const namedLegacyAgreement = async (jobId: string, owner: string): Promise | undefined> => { + const agreementAddress = programs.get(programBindingKey(owner, `dacs3:agreement:${jobId}`)); const agreement = agreementAddress ? await readStorage(agreementAddress) : null; - const agreementData = agreement?.success && agreement.owner && agreement.programName === `dacs3:agreement:${jobId}` && - programBindingKey(agreement.owner, agreement.programName) === programBindingKey(bundle.owner, agreement.programName) && + return agreement?.success && agreement.owner && agreement.programName === `dacs3:agreement:${jobId}` && + programBindingKey(agreement.owner, agreement.programName) === programBindingKey(owner, agreement.programName) && agreement.data && storableContentHash(agreement.data) !== null && - await validAgreementForDiscovery(agreement.data, jobId, didOf(bundle.owner)) ? agreement.data : undefined; + await validAgreementForDiscovery(agreement.data, jobId, didOf(owner)) ? agreement.data : undefined; + }; + + // Attribute each discovered deal to its seller via the buyer-anchored agreement. + const deals = new Map(); + for (const [dealKey, { jobId, current, agreementRef, ...bundle }] of bundleOwners) { + const bundleName = namesByLocator.get(bundle.address); + if (!current && bundleName && programs.get(programBindingKey(bundle.owner, bundleName)) === null) continue; + const agreementData = current + ? await signedAgreement(agreementRef, jobId, didOf(bundle.owner)) + : await namedLegacyAgreement(jobId, bundle.owner); const agreementParties = Array.isArray(agreementData?.parties) ? agreementData.parties as Array> : []; const sellerFromAgreement = agreementData?.agreementVersion !== undefined || agreementData?.payeeBoundAgreementVersion !== undefined ? agreementParties.find((party) => party?.role === "seller" && typeof party.primaryClaim === "string")?.primaryClaim as string | undefined @@ -602,6 +629,7 @@ export async function scanChain( sellerBundleRef: sellerCopy?.address, owners: { buyer: didOf(bundle.owner), seller: seller ?? "" }, sellerFromAgreement: seller, + ...(current && seller && isCurrentRef(agreementRef) ? { agreementLocator: agreementRef.anchor.locator } : {}), }); } diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index 03e942d..6ea1bef 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -263,7 +263,8 @@ test("an unhashable program never shadows a valid agreement or supplies deal att const otherAgreement = locator(43); const unhashable = locator(44); const name = "dacs3:agreement:job-attribution"; - const buyerBundle = { data: JSON.stringify({ bundleVersion: "1", jobId: "job-attribution", parties: [], anchoredByRole: "buyer" }), owner: ownerOf(buyer) }; + // Only the legacy SDK profile resolves its agreement by owner and program name. + const buyerBundle = { data: '{"any":1}', name: "dacs5:bundle:job-attribution", owner: ownerOf(buyer) }; const scope = { agreementVersion: "1", jobId: "job-attribution", listingRef: { listingId: "svc", version: 1, contentHash: verifiedListing.contentHash }, parties: [{ role: "buyer", primaryClaim: buyer }, { role: "seller", primaryClaim: seller }], terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" } } }; diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 1483e76..9080ed8 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -368,12 +368,15 @@ for (const route of ["unsigned bundle-named program", "bundle and agreement sign test(`deals are keyed by their owner: another owner's ${route} with the same jobId leaves the deal in place`, async () => { const jobId = "shared-job"; const signedRoute = !route.startsWith("unsigned"); - // The signed route uses current-format bundles; the unsigned route uses name-only programs. - const currentBundle = JSON.stringify({ bundleVersion: "1", jobId, parties: [], anchoredByRole: "buyer" }); + // The signed route uses current-format bundles that sign their agreementRef; the unsigned route uses name-only programs. + const currentBundle = (at: string, agreement: Obj) => JSON.stringify({ bundleVersion: "1", jobId, parties: [], anchoredByRole: "buyer", + agreementRef: { anchor: { kind: "storage-program", locator: at }, contentHash: artifactHash(agreement, "agreement") } }); + const firstAgreement = await agreementFor(jobId, 0, 1); + const secondAgreement = await agreementFor(jobId, 2, 3); resetIndex([later], 7); chain({ - [locator(40)]: { name: `dacs5:bundle:${jobId}`, data: signedRoute ? currentBundle : '{"any":1}', owner: ownerOf(buyer) }, - [locator(41)]: { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(await agreementFor(jobId, 0, 1)), owner: ownerOf(buyer) }, + [locator(40)]: { name: `dacs5:bundle:${jobId}`, data: signedRoute ? currentBundle(locator(41), firstAgreement) : '{"any":1}', owner: ownerOf(buyer) }, + [locator(41)]: { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(firstAgreement), owner: ownerOf(buyer) }, }, [locator(40), locator(41)]); transactions.unshift(memo(10)); await reindex(); @@ -384,11 +387,11 @@ for (const route of ["unsigned bundle-named program", "bundle and agreement sign // The established writes are outside the next pass's replay overlap. const writer = signedRoute ? otherBuyer : outsider; storage[locator(42)] = signedRoute - ? { data: currentBundle, owner: ownerOf(writer) } + ? { data: currentBundle(locator(43), secondAgreement), owner: ownerOf(writer) } : { name: `dacs5:bundle:${jobId}`, data: '{"any":1}', owner: ownerOf(writer) }; const second = [locator(42)]; if (signedRoute) { - storage[locator(43)] = { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(await agreementFor(jobId, 2, 3)), owner: ownerOf(otherBuyer) }; + storage[locator(43)] = { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(secondAgreement), owner: ownerOf(otherBuyer) }; second.push(locator(43)); } transactions = [...second.map((at, index) => memo(20 + index, at)).reverse(), ...transactions]; @@ -412,6 +415,47 @@ for (const route of ["unsigned bundle-named program", "bundle and agreement sign }); } +test("a discovered current-profile deal is attributed through the agreementRef its bundle signs, never a program name (§10.4.2)", async () => { + const jobId = "01M4BD45XYXPF95E24JYPKNM2Y"; + const agreement = await agreementFor(jobId, 0, 1); + const agreementRef = (at: string) => ({ anchor: { kind: "storage-program", locator: at }, contentHash: artifactHash(agreement, "agreement") }); + const bundle = (ref: Obj) => JSON.stringify({ bundleVersion: "1", jobId, parties: [], anchoredByRole: "buyer", agreementRef: ref }); + const sellerCopy = JSON.stringify({ bundleVersion: "1", jobId, parties: [], anchoredByRole: "seller" }); + const scannedDeal = async () => [...(await scanChain(null, { maxTxs: 100, sinceTxId: 0 })).deals.values()].find((deal) => deal.jobId === jobId); + // Conforming producers percent-encode the colons, which is how node2 stores these names. + chain({ + [locator(980)]: { name: `dacs5%3Abundle%3A${jobId}`, data: bundle(agreementRef(locator(981))), owner: ownerOf(buyer) }, + [locator(981)]: { name: `dacs3%3Aagreement%3A${jobId}`, data: JSON.stringify(agreement), owner: ownerOf(buyer) }, + [locator(982)]: { name: "opaque-seller-copy", data: sellerCopy, owner: ownerOf(seller) }, + }, [locator(980), locator(981), locator(982)]); + let deal = await scannedDeal(); + assert.equal(deal?.owners.seller, seller); + assert.equal(deal?.sellerBundleRef, locator(982)); + assert.equal(deal?.agreementLocator, locator(981)); + + // A reference whose hash does not match attributes nothing, and a conventionally named + // agreement for the same job is not consulted in its place. + chain({ + [locator(980)]: { name: `dacs5:bundle:${jobId}`, data: bundle({ ...agreementRef(locator(981)), contentHash: "0".repeat(64) }), owner: ownerOf(buyer) }, + [locator(981)]: { name: "opaque", data: JSON.stringify(agreement), owner: ownerOf(buyer) }, + [locator(983)]: { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(agreement), owner: ownerOf(buyer) }, + }, [locator(980), locator(981), locator(983)]); + deal = await scannedDeal(); + assert.equal(deal?.owners.seller, ""); + + // Two programs sharing the conventional name do not void a deal its signed reference attributes. + resetIndex([later], 36); + chain({ + [locator(980)]: { name: `dacs5:bundle:${jobId}`, data: bundle(agreementRef(locator(981))), owner: ownerOf(buyer) }, + [locator(981)]: { name: `dacs3:agreement:${jobId}`, data: JSON.stringify(agreement), owner: ownerOf(buyer) }, + [locator(983)]: { name: `dacs3:agreement:${jobId}`, data: '{"any":1}', owner: ownerOf(buyer) }, + }, [locator(980), locator(981), locator(983)]); + transactions.unshift(memo(10)); + await reindex(); + assert.equal(store.loadScanState().programs[`${ownerOf(buyer)}\ndacs3:agreement:${jobId}`], null); + assert.equal(store.loadScanState().deals[`${ownerOf(buyer)}\n${jobId}`]?.owners.seller, seller); +}); + test("two buyers' attributed deals with the same jobId both reach the seller's record", async () => { const jobId = "two-buyers"; resetIndex([later], 19); From 41c616cc4ed7bec4d27de3299c249d1f973d2c33 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 06/42] =?UTF-8?q?fix(dacs-directory):=20reconcile=20a=20se?= =?UTF-8?q?ller's=20verified=20deals=20per=20jobId=20(=C2=A710.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DACS-5 §10.5.1 says the deriver "MUST collapse the input to one authoritative bundle per jobId before partitioning". Canonically equal copies collapse, a fully signed copy takes precedence over lesser-signed divergent ones, and equal standing voids the jobId (BB-6). #94 keyed the dedupe on (buyer, jobId), so two buyers' fully signed completed copies for one seller and jobId counted twice. A conforming deriver gives zero. - dedupeVerifiedDeals keeps one record per bundle ref and per (jobId, bundleContentHash). Among divergent copies of a jobId it keeps a lone fully signed one; any other verified copy stays listed with reputationEligible false. - DealRecord gains fullSignatureStanding, set from the BB-6 inspection on the current path and from the SDK signature verdicts on the legacy path. Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/bundlePolicy.ts | 35 ++++++++++---- .../dacs-directory/src/catalog/indexer.ts | 3 ++ .../dacs-directory/src/catalog/types.ts | 2 + .../test/current-indexer.test.ts | 1 + .../dacs-directory/test/scan-fairness.test.ts | 9 ++-- .../dacs-directory/test/verification.test.ts | 46 ++++++++++++------- 6 files changed, 68 insertions(+), 28 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index 40a48d3..c51aaf8 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -89,22 +89,41 @@ export function bundleMatchesRegisteredDeal( } /** - * Keep at most one verified reputation record per buyer and signed job, and per - * bundle ref. A jobId is unique only per producer, so two buyers' deals with - * the same jobId are distinct records. + * DACS-5 §10.5.1 per-jobId reconciliation over one seller's verified deals. + * One record is kept per bundle ref, and canonically equal copies of a jobId + * collapse to one. Among divergent copies a lone fully signed one takes + * precedence; equal standing voids the jobId (BB-6), whoever the buyers are. + * A superseded or voided record stays listed but is never reputation-eligible. */ export function dedupeVerifiedDeals(deals: DealRecord[]): DealRecord[] { - const jobs = new Set(); const refs = new Set(); - return deals.filter((deal) => { + const forms = new Set(); + const unique = deals.filter((deal) => { if (!deal.refsVerified) return true; - const job = `${demosSigningIdentity(deal.owners.buyer)}\n${deal.jobId}`; const ref = deal.buyerBundleRef.toLowerCase(); - if (jobs.has(job) || refs.has(ref)) return false; - jobs.add(job); + const form = `${deal.jobId}\n${deal.bundleContentHash ?? ref}`; + if (refs.has(ref) || forms.has(form)) return false; refs.add(ref); + forms.add(form); return true; }); + const byJob = new Map(); + for (const deal of unique) if (deal.refsVerified) byJob.set(deal.jobId, [...(byJob.get(deal.jobId) ?? []), deal]); + const authoritative = new Set(); + for (const copies of byJob.values()) { + const full = copies.filter((deal) => deal.fullSignatureStanding); + if (copies.length === 1) authoritative.add(copies[0]); + else if (full.length === 1) authoritative.add(full[0]); + } + return unique.map((deal) => !deal.refsVerified || authoritative.has(deal) ? deal : { ...deal, reputationEligible: false }); +} + +/** Every bundle party carries a valid signature in a legacy SDK verification. */ +export function legacyFullSignatureStanding(verification: BundleVerification | null | undefined): boolean { + const parties = verification?.bundle?.parties ?? []; + const valid = new Set((verification?.signatures ?? []).filter((signature) => signature.verdict === "valid") + .map((signature) => demosSigningIdentity(signature.party))); + return parties.length >= 2 && parties.every((party) => valid.has(demosSigningIdentity(party.primaryClaim))); } export interface ResolvedArtifact { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index aaa7437..123ea1c 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -69,6 +69,7 @@ import { bundleCategory, dedupeVerifiedDeals, hasRequiredBundleSignatures, + legacyFullSignatureStanding, refsPassStrictPolicy, verifiedListingTerms, type ResolvedArtifact, @@ -411,6 +412,7 @@ export async function indexRegistration( outcome: String(authoritative?.bundle.outcome ?? "") || undefined, sellerOutcome, anchoredByRole: authoritative?.bundle.anchoredByRole as DealRecord["anchoredByRole"], bundleContentHash: authoritative?.bundleContentHash, + fullSignatureStanding: (authoritative === sellerGraph ? sellerResolution : buyerResolution).inspected.fullSignatureStanding, reputationEligible: refsVerified, cancellationNeutral, finalisedAt: typeof authoritative?.bundle.finalisedAt === "number" ? authoritative.bundle.finalisedAt : undefined, @@ -497,6 +499,7 @@ export async function indexRegistration( ? bundle.anchoredByRole : undefined, // Only a bundle the verifier already hashed is hashed again; a rejected raw bundle gets no hash. bundleContentHash: bundle && signedBundleScope ? contentHash(signedBundleScope) : undefined, + fullSignatureStanding: legacyFullSignatureStanding(authoritative.verification), reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? ""), cancellationNeutral, finalisedAt: bundle?.finalisedAt, diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 70f3a42..0bac593 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -199,6 +199,8 @@ export interface DealRecord extends RegisteredDeal { sellerOutcome?: string; anchoredByRole?: "buyer" | "seller" | "orchestrator"; bundleContentHash?: string; + /** Every declared buyer/seller/orchestrator signed the authoritative copy (§10.4.1); BB-6 precedence. */ + fullSignatureStanding?: boolean; /** True only when the outcome belongs to the current DACS-5 closed set. */ reputationEligible?: boolean; /** Established ST-10 policy cancellation; neutral in fault denominators. */ diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index d796f0f..2c96fb7 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -803,6 +803,7 @@ test("indexer resolves current copies through BundleBindings rather than submitt bundleBindings, }, undefined, async () => { throw new Error("identity unavailable"); }); assert.equal(record.deals[0].refsVerified, true); + assert.equal(record.deals[0].fullSignatureStanding, true, "both parties signed the authoritative copy"); assert.equal(record.deals[0].buyerBundleRef, fixture.locators.buyer); assert.notEqual(record.deals[0].buyerBundleRef, submittedBuyerPointer); assert.equal(record.deals[0].sellerBundleRef, fixture.locators.seller); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 9080ed8..3e309a6 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -469,11 +469,12 @@ test("two buyers' attributed deals with the same jobId both reach the seller's r const deals = store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.deals ?? []; assert.deepEqual(deals.map((deal) => `${deal.owners.buyer} ${deal.buyerBundleRef}`).sort(), [`${buyer} ${locator(70)}`, `${otherBuyer} ${locator(72)}`].sort()); - // Verified-deal deduplication keeps one record per buyer and jobId, and per bundle ref. + // Both are listed, but §10.5.1 reconciles per jobId: equal-standing divergent copies count for neither. const verifiedDeal = (who: string, at: number) => ({ jobId, rail: "pay-dem", buyerBundleRef: locator(at), - owners: { buyer: who, seller }, signatureVerified: true, refsVerified: true, verifiedAt: 1 }); - assert.deepEqual(dedupeVerifiedDeals([verifiedDeal(buyer, 70), verifiedDeal(otherBuyer, 72), verifiedDeal(buyer, 74), verifiedDeal(outsider, 70)]) - .map((deal) => deal.buyerBundleRef), [locator(70), locator(72)]); + owners: { buyer: who, seller }, signatureVerified: true, refsVerified: true, reputationEligible: true, + bundleContentHash: at.toString(16).padStart(64, "0"), fullSignatureStanding: true, verifiedAt: 1 }); + assert.deepEqual(dedupeVerifiedDeals([verifiedDeal(buyer, 70), verifiedDeal(otherBuyer, 72), verifiedDeal(outsider, 70)]) + .map((deal) => [deal.buyerBundleRef, deal.reputationEligible]), [[locator(70), false], [locator(72), false]]); }); test("v9 scan state is completed by a full deal-history replay before it is marked migrated", async () => { diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 9276924..bf75ddc 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -15,6 +15,7 @@ import { bundleMatchesRegisteredDeal, dedupeVerifiedDeals, hasRequiredBundleSignatures, + legacyFullSignatureStanding, registeredAnchorRole, refsPassStrictPolicy, verifyReferencedArtifactSignature, @@ -309,24 +310,37 @@ test("unhashed anchor role must match the registered copy address", () => { assert.equal(registeredAnchorRole("stor-seller", "stor-buyer", undefined), null); }); -test("verified reputation deals are unique by signed job and bundle reference", () => { +test("verified reputation deals collapse to one authoritative copy per jobId, whoever the buyers are (§10.5.1, BB-6)", () => { + const ref = (character: string) => `stor-${character.repeat(40)}`; const base: DealRecord = { - jobId: "j", - rail: "pay-dem", - buyerBundleRef: `stor-${"a".repeat(40)}`, - owners: { buyer: "buyer", seller: "seller" }, - signatureVerified: true, - refsVerified: true, - verifiedAt: 1, + jobId: "j", rail: "pay-dem", buyerBundleRef: ref("a"), owners: { buyer: "buyer", seller: "seller" }, + signatureVerified: true, refsVerified: true, reputationEligible: true, sellerOutcome: "completed", + bundleContentHash: "1".repeat(64), fullSignatureStanding: true, finalisedAt: 10, verifiedAt: 1, }; - const records = dedupeVerifiedDeals([ - base, - { ...base }, - { ...base, buyerBundleRef: `stor-${"b".repeat(40)}` }, - { ...base, jobId: "other" }, - { ...base, jobId: "unverified", refsVerified: false }, - ]); - assert.deepEqual(records.map((deal) => deal.jobId), ["j", "unverified"]); + const listed = (deals: DealRecord[]) => dedupeVerifiedDeals(deals) + .map((deal) => `${deal.jobId} ${deal.buyerBundleRef.slice(-1)} ${deal.reputationEligible ? "eligible" : "excluded"}`); + // One record per bundle ref, and canonically equal copies of a jobId collapse to the first. + assert.deepEqual(listed([base, { ...base }, { ...base, jobId: "other" }, { ...base, buyerBundleRef: ref("b") }]), ["j a eligible"]); + // Divergent fully signed copies void the jobId even when their buyers differ. + const otherBuyer = { ...base, buyerBundleRef: ref("b"), owners: { buyer: "other-buyer", seller: "seller" }, bundleContentHash: "2".repeat(64) }; + assert.deepEqual(listed([base, otherBuyer]), ["j a excluded", "j b excluded"]); + // A lone fully signed copy takes precedence over a lesser-signed divergent one. + assert.deepEqual(listed([{ ...base, fullSignatureStanding: false }, otherBuyer]), ["j a excluded", "j b eligible"]); + assert.deepEqual(listed([{ ...base, fullSignatureStanding: false }, { ...otherBuyer, fullSignatureStanding: false }]), ["j a excluded", "j b excluded"]); + // Other jobIds and unverified records are untouched. + assert.deepEqual(listed([base, { ...base, jobId: "k", buyerBundleRef: ref("c") }, { ...base, jobId: "u", buyerBundleRef: ref("d"), refsVerified: false }]), + ["j a eligible", "k c eligible", "u d eligible"]); + const reputation = deriveSellerReputation(dedupeVerifiedDeals([base, otherBuyer]), 0, 20); + assert.equal(reputation.completed, 0); + assert.deepEqual(reputation.bundleRefs, []); +}); + +test("legacy full signature standing needs a valid signature from every bundle party", () => { + assert.equal(legacyFullSignatureStanding(result("completed", [{ party: "buyer", verdict: "valid" }, { party: "seller", verdict: "valid" }])), true); + assert.equal(legacyFullSignatureStanding(result("aborted-by-self", [{ party: "buyer", verdict: "valid" }])), false); + assert.equal(legacyFullSignatureStanding(result("completed", [{ party: "buyer", verdict: "valid" }, { party: "seller", verdict: "invalid" }])), false); + assert.equal(legacyFullSignatureStanding(result("completed", [{ party: "buyer", verdict: "valid" }, { party: "stranger", verdict: "valid" }])), false); + assert.equal(legacyFullSignatureStanding(null), false); }); test("DACS-5 scalar derivation uses seller perspective and neutral exclusions", () => { From cc1e3dc5550654ae7daadc4e0e7ba05568b2d4b9 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 07/42] fix(dacs-directory): check recognised listing members against their DACS-1, DACS-3 and DACS-4 types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DACS-1 validation step 1 is schema conformance, and LR-2 rejects a listing that fails any step. The verifier treated the deliverable, buyerRequirement, terms, rails and most phase parameters as any object, so a correctly signed listing with a malformed shape was published. - Check DeliverableSpec (all four kinds, including external), BundleRequirement and ClaimRequirement, ListingTerms, PaymentRailRef, requiredCapabilities, the optional offering members and validity.notAfter. - Check per-kind phase parameters: negotiate-rfq needs maxTurns >= 2 and a positive timeoutSec; both sealed-envelope kinds need commitDeadline, a revealWindow of at least 60 seconds, the auctionMode their kind requires, and a selectionRule equal to the auction pricing's (DACS-3 §8.4.3); pay-* steps need parameters; rate's `required` is a boolean. - Unknown members stay open-world, as before (SIG-5, §11.1.2). Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/listingVerification.ts | 104 +++++++++++++++++- .../test/listing-verification.test.ts | 75 ++++++++++++- 2 files changed, 171 insertions(+), 8 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index b7a4a28..4345901 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -45,6 +45,95 @@ const NEGOTIATION_PRICING: Record> = { const SELECTION_RULES = new Set(["lowest-price", "highest-price", "first-acceptable"]); const validSelectionRule = (value: unknown): boolean => typeof value === "string" && (SELECTION_RULES.has(value) || /^rule-ref:[0-9a-f]{64}:\S+$/.test(value)); + +// Schema conformance (DACS-1 validation step 1) for the recognised members. Members a later +// minor may add stay open-world, as below; a recognised member must have its defined type. +const optional = (value: unknown, valid: (value: unknown) => boolean): boolean => value === undefined || valid(value); +const isString = (value: unknown): boolean => typeof value === "string"; +const nonNegative = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value) && value >= 0; +const oneOf = (...values: string[]) => (value: unknown): boolean => typeof value === "string" && values.includes(value); +const CAPABILITIES = oneOf("SR-1", "SR-2", "SR-3", "SR-4", "SR-5"); + +/** DACS-4 DeliverableSpec. */ +function validDeliverable(value: unknown): boolean { + const deliverable = record(value); + const sizeOk = optional(deliverable?.expectedSizeBytes, (size) => Number.isSafeInteger(size) && Number(size) >= 0); + return deliverable?.kind === "storage-program" + ? optional(deliverable.schemaUrl, isString) && sizeOk && optional(deliverable.accessModel, oneOf("public", "buyer-only", "encrypt-to-buyer")) + : deliverable?.kind === "entitlement" ? nonNegative(deliverable.durationSec) && typeof deliverable.renewable === "boolean" + : deliverable?.kind === "attested-payload" + ? typeof deliverable.payloadFormat === "string" && deliverable.payloadFormat.length > 0 && sizeOk && + optional(deliverable.verificationMethod, (method) => typeof record(method)?.kind === "string") + : deliverable?.kind === "external" + ? typeof deliverable.description === "string" && + optional(deliverable.verificationMethod, (method) => typeof record(method)?.kind === "string") + : false; +} + +/** DACS-1 §6.3.3 BundleRequirement and ClaimRequirement. */ +function validClaimRequirement(value: unknown): boolean { + const requirement = record(value); + return Boolean(requirement && typeof requirement.scheme === "string" && requirement.scheme.length > 0 && + typeof requirement.verificationRequired === "boolean" && optional(requirement.maxAge, nonNegative) && + optional(requirement.recipeVersion, (version) => Number.isSafeInteger(version) && Number(version) >= 1) && + optional(requirement.parameters, (parameters) => record(parameters) !== null)); +} +function validBundleRequirement(value: unknown): boolean { + const requirement = record(value); + return Boolean(requirement && requirement.requirementVersion === "1" && + Array.isArray(requirement.required) && requirement.required.every(validClaimRequirement) && + optional(requirement.oneOf, (groups) => Array.isArray(groups) && groups.every((group) => + Array.isArray(group) && group.length > 0 && group.every(validClaimRequirement))) && + optional(requirement.preferredPresentation, oneOf("siwd", "sr1-root", "per-claim", "session-key", "any")) && + optional(requirement.primaryClaimSelector, isString)); +} + +/** DACS-1 ListingTerms. */ +function validTerms(value: unknown): boolean { + const terms = record(value); + return Boolean(terms && optional(terms.termsOfServiceUrl, isString) && optional(terms.termsOfServiceHash, isString) && + optional(terms.jurisdictions, (codes) => Array.isArray(codes) && codes.every((code) => typeof code === "string" && /^[A-Z]{2}$/.test(code))) && + optional(terms.conflictOfLawsRule, (rule) => oneOf("buyer-jurisdiction", "seller-jurisdiction")(rule) || + (typeof rule === "string" && /^rule-ref:\S+$/.test(rule))) && + optional(terms.deadlineSecAfterCommit, nonNegative) && optional(terms.acceptanceModel, oneOf("auto-accept")) && + optional(terms.cancellationPolicy, oneOf("none", "pre-commit", "with-fee")) && optional(terms.retentionYears, nonNegative) && + optional(terms.transcriptDisclosurePolicy, oneOf("none", "encrypted-anchored-recommended", "encrypted-anchored-required"))); +} + +/** DACS-4 PaymentRailRef. */ +const validRail = (value: unknown): boolean => { + const rail = record(value); + return Boolean(rail && typeof rail.railId === "string" && rail.railId.length > 0 && + optional(rail.railVersion, (version) => Number.isSafeInteger(version) && Number(version) >= 1) && + optional(rail.parameters, (parameters) => record(parameters) !== null)); +}; + +/** + * DACS-1 PhaseStep per-kind parameters (DACS-3 §8.4.2-§8.4.3). A sealed-envelope step's + * selectionRule is authoritative and the auction PricingSpec's must equal it. + */ +function validPhaseParameters(step: Record, pricing: Record | null): boolean { + if (step.parameters === undefined) return !["negotiate-rfq", "negotiate-sealed-envelope", "negotiate-sealed-envelope-procurement"].includes(String(step.kind)) && + !String(step.kind).startsWith("pay-"); + const parameters = record(step.parameters); + if (!parameters) return false; + if (step.kind === "negotiate-rfq") { + return Number.isSafeInteger(parameters.maxTurns) && Number(parameters.maxTurns) >= 2 && + typeof parameters.timeoutSec === "number" && parameters.timeoutSec > 0 && + optional(parameters.channelSubnet, isString) && optional(parameters.rfqInitiator, oneOf("buyer", "seller")) && + optional(parameters.fixedPriceFallback, (fallback) => typeof fallback === "boolean"); + } + if (step.kind === "negotiate-sealed-envelope" || step.kind === "negotiate-sealed-envelope-procurement") { + const procurement = step.kind === "negotiate-sealed-envelope-procurement"; + return typeof parameters.commitDeadline === "number" && Number.isSafeInteger(parameters.commitDeadline) && + typeof parameters.revealWindow === "number" && parameters.revealWindow >= 60 && + validSelectionRule(parameters.selectionRule) && parameters.selectionRule === pricing?.selectionRule && + (procurement ? parameters.auctionMode === "procurement" : optional(parameters.auctionMode, oneOf("demand"))) && + optional(parameters.channelSubnet, isString); + } + if (step.kind === "rate") return optional(parameters.required, (required) => typeof required === "boolean"); + return true; +} // NOTE (open-world listing shape): the directory does NOT reject a listing that carries // unknown top-level fields. §11.1.2 (additivity) lets a later minor add top-level listing // fields, and the older-reads-newer contract (CORE.md) requires an older reader to consume a @@ -87,6 +176,13 @@ function currentListing(scope: Record): { )) : false; const hasPayPhase = pipeline.some((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")); const rails = Array.isArray(scope.acceptedRails) ? scope.acceptedRails.map(record).filter(Boolean) : []; + const schemaOk = Array.isArray(scope.pipeline) && pipeline.length === scope.pipeline.length && + pipeline.every((step) => validPhaseParameters(step, pricing)) && + optional(scope.acceptedRails, (value) => Array.isArray(value) && value.every(validRail)) && + optional(scope.requiredCapabilities, (value) => Array.isArray(value) && value.every(CAPABILITIES)) && + optional(offering?.extendedDescriptionUrl, isString) && optional(offering?.extendedDescriptionHash, isString) && + validDeliverable(offering?.deliverable) && validBundleRequirement(scope.buyerRequirement) && validTerms(scope.terms) && + optional(validity?.notAfter, (notAfter) => typeof notAfter === "number"); const railIds = new Set(rails.map((rail) => rail?.railId).filter((rail): rail is string => typeof rail === "string")); const payBindingsOk = pipeline.filter((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")) .every((step) => { const parameters = record(step.parameters); return typeof parameters?.rail === "string" && railIds.has(parameters.rail); }); @@ -111,10 +207,10 @@ function currentListing(scope: Record): { !claims.some((claim) => typeof claim.ref === "string" && canonicalSigningIdentity(claim.ref) === signerIdentity) || typeof offering?.title !== "string" || offering.title.length > 200 || typeof offering.description !== "string" || offering.description.length > 2000 || - typeof offering.category !== "string" || !/^[a-z0-9.-]{1,64}$/.test(offering.category) || tags.length > 16 || tags.some((tag) => typeof tag !== "string" || tag.length > 32) || !record(offering.deliverable) || - !record(scope.buyerRequirement) || pipeline.length === 0 || pipeline.some((step) => typeof step.kind !== "string" || !PHASES.has(step.kind)) || - !pricingOk || !negotiationOk || !commitmentOk || - (hasPayPhase && (rails.length === 0 || !payBindingsOk)) || !record(scope.terms) || typeof validity?.notBefore !== "number" || + typeof offering.category !== "string" || !/^[a-z0-9.-]{1,64}$/.test(offering.category) || tags.length > 16 || tags.some((tag) => typeof tag !== "string" || tag.length > 32) || + pipeline.length === 0 || pipeline.some((step) => typeof step.kind !== "string" || !PHASES.has(step.kind)) || + !pricingOk || !negotiationOk || !commitmentOk || !schemaOk || + (hasPayPhase && (rails.length === 0 || !payBindingsOk)) || typeof validity?.notBefore !== "number" || (typeof validity.notAfter === "number" && validity.notAfter < validity.notBefore) || !signature ) return null; return { signer, sellerClaim, signature }; diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 18196ad..4f0eea1 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -48,7 +48,7 @@ function signedCurrentListing( tags: ["sig5"], deliverable: { kind: "storage-program" }, }, - buyerRequirement: { kind: "none" }, + buyerRequirement: { requirementVersion: "1", required: [] }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, @@ -182,7 +182,7 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy assert.ok(await verifyListing(listing)); assert.ok(await verifyListing(signedCurrentListing({ pricing: metered, - pipeline: [{ kind: "negotiate-rfq" }, ...pipeline.slice(1)], + pipeline: [{ kind: "negotiate-rfq", parameters: RFQ }, ...pipeline.slice(1)], acceptedRails: [{ railId: "ap2:stripe-paymentintents" }], }))); assert.equal(await verifyListing(signedCurrentListing({ @@ -202,6 +202,15 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy })), null); }); +const RFQ = { maxTurns: 6, timeoutSec: 300 }; +const sealed = (selectionRule: unknown, auctionMode?: string) => ({ commitDeadline: 1784016000000, revealWindow: 3600, selectionRule, ...(auctionMode ? { auctionMode } : {}) }); +const negotiationStep = (kind: string, selectionRule: unknown = "lowest-price") => ({ + kind, + ...(kind === "negotiate-rfq" ? { parameters: RFQ } + : kind === "negotiate-sealed-envelope" ? { parameters: sealed(selectionRule) } + : kind === "negotiate-sealed-envelope-procurement" ? { parameters: sealed(selectionRule, "procurement") } : {}), +}); + test("PS-3: each negotiation pattern accepts exactly the pricing kinds DACS-3 allows", async () => { const pricing: Record> = { fixed: { kind: "fixed", price: { amount: "1", currency: "USDC" } }, @@ -221,7 +230,7 @@ test("PS-3: each negotiation pattern accepts exactly the pricing kinds DACS-3 al for (const kind of Object.keys(pricing)) { const listing = signedCurrentListing({ pricing: pricing[kind], - pipeline: [{ kind: negotiation }, { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], + pipeline: [negotiationStep(negotiation), { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], }); verdicts[`${negotiation} ${kind}`] = (await verifyListing(listing)) !== null; expected[`${negotiation} ${kind}`] = kinds.includes(kind); @@ -233,7 +242,7 @@ test("PS-3: each negotiation pattern accepts exactly the pricing kinds DACS-3 al test("auction pricing accepts only the DACS-4 selectionRule enum and content-hash-bound rule refs", async () => { const auction = (selectionRule: unknown) => signedCurrentListing({ pricing: { kind: "auction", selectionRule }, - pipeline: [{ kind: "negotiate-sealed-envelope" }, { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], + pipeline: [negotiationStep("negotiate-sealed-envelope", selectionRule), { kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }], }); for (const rule of ["lowest-price", "highest-price", "first-acceptable", `rule-ref:${"a".repeat(64)}:https://rules.example/r1`]) { assert.ok(await verifyListing(auction(rule)), rule); @@ -243,6 +252,64 @@ test("auction pricing accepts only the DACS-4 selectionRule enum and content-has } }); +test("schema conformance: recognised listing members must have their DACS-1/3/4 types (validation step 1)", async () => { + const tail = [{ kind: "commit-agreement" }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }, { kind: "deliver-storage-program" }]; + const auction = { kind: "auction", selectionRule: "lowest-price" }; + const offering = (deliverable: unknown) => ({ title: "T", description: "D", category: "conformance.signature", tags: [], deliverable }); + const accepted: Record> = { + "full BundleRequirement": { buyerRequirement: { requirementVersion: "1", required: [{ scheme: "lei", verificationRequired: true, maxAge: 60, recipeVersion: 2, parameters: {} }], + oneOf: [[{ scheme: "domain", verificationRequired: false }]], preferredPresentation: "per-claim", primaryClaimSelector: "lei" } }, + "full ListingTerms": { terms: { termsOfServiceUrl: "https://t.example", termsOfServiceHash: "a".repeat(64), jurisdictions: ["GB", "US"], + conflictOfLawsRule: "rule-ref:https://law.example", deadlineSecAfterCommit: 3600, acceptanceModel: "auto-accept", cancellationPolicy: "pre-commit", + retentionYears: 7, transcriptDisclosurePolicy: "none", futureTerm: true } }, + "entitlement deliverable": { offering: offering({ kind: "entitlement", durationSec: 86_400, renewable: true }) }, + "attested-payload deliverable": { offering: offering({ kind: "attested-payload", payloadFormat: "application/json", verificationMethod: { kind: "self-signed" }, expectedSizeBytes: 10 }) }, + "storage-program deliverable": { offering: offering({ kind: "storage-program", schemaUrl: "https://s.example", accessModel: "buyer-only" }) }, + "external deliverable": { offering: offering({ kind: "external", description: "Signed courier receipt", verificationMethod: { kind: "tlsnotary", endpoint: "https://n.example" } }) }, + "rate phase": { pipeline: [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "rate", parameters: { required: true } }] }, + "rail with version": { acceptedRails: [{ railId: "pay-x402", railVersion: 2, parameters: { network: "base" } }] }, + "sealed-envelope demand": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope", parameters: { ...sealed("lowest-price"), auctionMode: "demand" } }, ...tail] }, + "sealed-envelope procurement": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope-procurement", parameters: sealed("lowest-price", "procurement") }, ...tail] }, + "rfq with fallback": { pricing: { kind: "negotiable", bandCenter: { amount: "1", currency: "USDC" }, minPct: 10, maxPct: 10 }, + pipeline: [{ kind: "negotiate-rfq", parameters: { ...RFQ, rfqInitiator: "seller", fixedPriceFallback: true } }, ...tail] }, + "notAfter after notBefore": { validity: { notBefore: 1, notAfter: 2 } }, + "required capabilities": { requiredCapabilities: ["SR-2", "SR-4"] }, + }; + const rejected: Record> = { + "BundleRequirement without its version": { buyerRequirement: { required: [] } }, + "BundleRequirement without required": { buyerRequirement: { requirementVersion: "1" } }, + "ClaimRequirement without verificationRequired": { buyerRequirement: { requirementVersion: "1", required: [{ scheme: "lei" }] } }, + "empty oneOf group": { buyerRequirement: { requirementVersion: "1", required: [], oneOf: [[]] } }, + "unknown preferred presentation": { buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "telepathy" } }, + "unknown cancellation policy": { terms: { cancellationPolicy: "non-refundable" } }, + "lowercase jurisdiction": { terms: { jurisdictions: ["gb"] } }, + "negative deadline": { terms: { deadlineSecAfterCommit: -1 } }, + "terms not an object": { terms: [] }, + "unknown deliverable kind": { offering: offering({ kind: "telegram" }) }, + "entitlement without renewable": { offering: offering({ kind: "entitlement", durationSec: 60 }) }, + "attested payload without format": { offering: offering({ kind: "attested-payload" }) }, + "external deliverable without description": { offering: offering({ kind: "external" }) }, + "unknown access model": { offering: offering({ kind: "storage-program", accessModel: "friends" }) }, + "rfq without parameters": { pricing: { kind: "negotiable", bandCenter: { amount: "1", currency: "USDC" }, minPct: 10, maxPct: 10 }, pipeline: [{ kind: "negotiate-rfq" }, ...tail] }, + "rfq with one turn": { pricing: { kind: "negotiable", bandCenter: { amount: "1", currency: "USDC" }, minPct: 10, maxPct: 10 }, pipeline: [{ kind: "negotiate-rfq", parameters: { ...RFQ, maxTurns: 1 } }, ...tail] }, + "sealed-envelope without parameters": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope" }, ...tail] }, + "sealed-envelope rules disagree": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope", parameters: sealed("highest-price") }, ...tail] }, + "sealed-envelope short reveal window": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope", parameters: { ...sealed("lowest-price"), revealWindow: 59 } }, ...tail] }, + "demand phase in procurement mode": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope", parameters: sealed("lowest-price", "procurement") }, ...tail] }, + "procurement without its mode": { pricing: auction, pipeline: [{ kind: "negotiate-sealed-envelope-procurement", parameters: sealed("lowest-price") }, ...tail] }, + "pipeline entry not an object": { pipeline: [{ kind: "negotiate-fixed-price" }, ...tail, "rate"] }, + "phase parameters not an object": { pipeline: [{ kind: "negotiate-fixed-price", parameters: [] }, ...tail] }, + "rate required not a boolean": { pipeline: [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "rate", parameters: { required: "yes" } }] }, + "rail entry not an object": { acceptedRails: [{ railId: "pay-x402" }, "pay-dem"] }, + "rail without an id": { acceptedRails: [{ railId: "pay-x402" }, { railVersion: 1 }] }, + "notAfter not a number": { validity: { notBefore: 1, notAfter: "2999-01-01" } }, + "unknown capability": { requiredCapabilities: ["SR-9"] }, + "extended description hash not a string": { offering: { ...offering({ kind: "storage-program" }), extendedDescriptionHash: 7 } }, + }; + for (const [name, extra] of Object.entries(accepted)) assert.ok(await verifyListing(signedCurrentListing(extra)), name); + for (const [name, extra] of Object.entries(rejected)) assert.equal(await verifyListing(signedCurrentListing(extra)), null, name); +}); + test("LR-2 caps the canonical form at 16,384 bytes, signature omitted, not the raw JSON", async () => { const unsigned = (listing: Record) => { const scope = { ...listing }; From 3936c74582cf58739595f9c0916ab60286c0e9bd Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 08/42] =?UTF-8?q?fix(dacs-directory):=20hold=20agreements?= =?UTF-8?q?=20and=20vet=20records=20in=20the=20evidence=20graph=20to=20DAC?= =?UTF-8?q?S-3=20=C2=A78.5.2=20and=20DACS-2=20=C2=A77.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evidence graph accepted a co-signed agreement once its commitment artifact matched the listing pipeline. It did not check the currency, price, rail, deliverable, pattern or role direction against the pinned listing, so a correctly signed agreement on other terms could feed a completed deal. Vet records missed required members, their VerifyResultRef recipeVersion was never compared, and a record that did not pass could back a successful vet phase. - Add agreementListingConformance: §8.5.2 checks 1-4 and 7-9 in order, with CD-1 decimal arithmetic, the inclusive half-up band, the metered MTR-1..5 total, the fixed-price fallback of an RFQ step that declares fixedPriceFallback, and SE-8 role direction for procurement. Checks 5 and 6 need the CommitmentRecord's committedAt, which a bundle does not reference, so they are not evaluated. - Require VerifyResult's recipeVersion, method, reason and fetchedAt, and the composite record's bundleHash, requirementHash and generatedAt. Bind evaluatedParty and bundleHash to a session party, requirementHash to the listing's buyerRequirement, and each VerifyResultRef's recipeVersion to the resolved result. - A vet phase reported ok must rest on a passing record (VPC-4), and a session that reached its agreement needs a passing record for each party it vetted. - Admit graph artifacts up to the 128 KB DACS-5 storage soft cap; 64 KiB rejected valid bundles. Correct the storage-limit comments: the node and demosdk 4.0.13 accept 1 MiB. Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/artifactLimits.ts | 11 +- .../src/catalog/evidenceGraph.ts | 153 ++++++++++-- .../test/current-indexer.test.ts | 217 +++++++++++++++++- .../test/malformed-artifacts.test.ts | 6 +- 4 files changed, 359 insertions(+), 28 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/artifactLimits.ts b/reference-implementations/dacs-directory/src/catalog/artifactLimits.ts index cb4efd8..962c82b 100644 --- a/reference-implementations/dacs-directory/src/catalog/artifactLimits.ts +++ b/reference-implementations/dacs-directory/src/catalog/artifactLimits.ts @@ -1,5 +1,14 @@ -/** Demos SR-2 is capped at 128 KiB; leave room for JSON encoding and metadata. */ +/** + * A DACS artifact must fit the substrate's 128 KB storage soft cap (DACS-5 §10.4.2, DEMOS-MAPPING + * SR-2); a larger session uses the extended-pointer pattern, which this indexer does not follow. + */ +export const MAX_DACS_ARTIFACT_BYTES = 128 * 1024; +/** Ceiling on a stored payload's JSON, with room above the DACS cap for encoding and metadata. */ export const MAX_ARTIFACT_BYTES = 256 * 1024; +/** + * The Demos node accepts programs up to 1 MiB (STORAGE_PROGRAM_MAX_SIZE_BYTES; MAX_SIZE_BYTES in + * demosdk 4.0.13), so one storage response is bounded there. + */ export const MAX_STORAGE_RESPONSE_BYTES = 1024 * 1024; export const MAX_ARTIFACT_DEPTH = 128; export const MAX_STORAGE_OWNER_LENGTH = 512; diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 4ad144a..8398c2f 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -1,4 +1,5 @@ import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { MAX_DACS_ARTIFACT_BYTES } from "./artifactLimits.js"; import { bundleSignerPolicy } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; import { canonicalSignatureBytes } from "./signatureValue.js"; @@ -42,7 +43,7 @@ const OUTCOMES = new Set(["completed", "failed-perm", "failed-counterparty", "fa const PHASE_OUTCOMES = new Set(["ok", "fail"]); const ERROR_CLASSES = new Set(["permanent", "transient", "counterparty", "substrate", "settlement-atomicity"]); const MAX_GRAPH_ARTIFACTS = 64; -const withinArtifactLimit = (raw: Record): boolean => Buffer.byteLength(JSON.stringify(raw), "utf8") <= 65_536; +const withinArtifactLimit = (raw: Record): boolean => Buffer.byteLength(JSON.stringify(raw), "utf8") <= MAX_DACS_ARTIFACT_BYTES; const priceTermOk = (value: unknown): boolean => { const price = rec(value); const amount = String(price?.amount ?? ""); return Boolean(price && /^(?:0|[1-9]\d*)(?:\.\d*[1-9])?$/.test(amount) && /[1-9]/.test(amount) && @@ -108,6 +109,96 @@ function agreementMatchesListing(agreement: Record, listing: Re expected.every((key) => actual.includes(key)); } +/** A CD-1 decimal as an integer count of 10^-scale units. */ +interface Decimal { units: bigint; scale: number } +const decimal = (amount: unknown): Decimal => { + const [whole, fraction = ""] = String(amount).split("."); + return { units: BigInt(whole + fraction), scale: fraction.length }; +}; +const compareDecimals = (left: Decimal, right: Decimal): number => { + const scale = Math.max(left.scale, right.scale); + const a = left.units * 10n ** BigInt(scale - left.scale); + const b = right.units * 10n ** BigInt(scale - right.scale); + return a < b ? -1 : a > b ? 1 : 0; +}; +/** bandCenter × (100 ± pct) / 100, rounded half-up to the centre's fractional digits (§8.5.2 check 2). */ +function bandBound(center: unknown, pct: unknown, direction: 1n | -1n): Decimal | null { + if (typeof pct !== "number" || !/^\d+(?:\.\d+)?$/.test(String(pct))) return null; + const centre = decimal(center); + const percent = decimal(pct); + const denominator = 100n * 10n ** BigInt(percent.scale); + const numerator = centre.units * (denominator + direction * percent.units); + return numerator < 0n ? null : { units: (2n * numerator + denominator) / (2n * denominator), scale: centre.scale }; +} +const AGREEMENT_PATTERNS: Record = { + "negotiate-fixed-price": "fixed-price", + "negotiate-rfq": "rfq", + "negotiate-sealed-envelope": "sealed-envelope", + "negotiate-sealed-envelope-procurement": "sealed-envelope", +}; + +/** + * DACS-3 §8.5.2 listing conformance, checks 1-4 and 7-9 in order; returns the first failed check. + * Checks 5 (deadline) and 6 (not expired) compare against the CommitmentRecord's anchored + * committedAt, which a bundle does not reference, so they are not evaluated here. + */ +export function agreementListingConformance( + agreement: Record, + listing: Record, + publisher: string, +): string | null { + const terms = rec(agreement.terms); + const pricing = rec(listing.pricing); + const price = rec(terms?.price); + const pipeline = arr(listing.pipeline); + if (!terms || !pricing || !price || !priceTermOk(price)) return "terms"; + const listed = pricing.kind === "fixed" ? rec(pricing.price) : pricing.kind === "negotiable" ? rec(pricing.bandCenter) + : pricing.kind === "metered" ? rec(pricing.unitPrice) : pricing.kind === "auction" ? rec(pricing.reservePrice) : null; + // 1. Currency, before any amount comparison. + if (listed && price.currency !== listed.currency) return "currency"; + // 2. Price, by the recognised pricing kind (MTR-5 rejects any other). + const amount = decimal(price.amount); + if (pricing.kind === "fixed" || (pricing.kind === "negotiable" && agreement.derivedFromPattern === "fixed-price")) { + if (!listed || compareDecimals(amount, decimal(listed.amount)) !== 0) return "price"; + } else if (pricing.kind === "negotiable") { + const lower = listed ? bandBound(listed.amount, pricing.minPct, -1n) : null; + const upper = listed ? bandBound(listed.amount, pricing.maxPct, 1n) : null; + if (!lower || !upper || lower.units <= 0n || compareDecimals(amount, lower) < 0 || compareDecimals(amount, upper) > 0) return "price"; + } else if (pricing.kind === "metered") { + const metered = rec(terms.meteredQuantity); + if (!listed || !metered || metered.unit !== pricing.unit || + typeof metered.quantity !== "string" || !/^(?:0|[1-9]\d*)$/.test(metered.quantity)) return "price"; + const unitPrice = decimal(listed.amount); + const total = { units: unitPrice.units * BigInt(metered.quantity), scale: unitPrice.scale }; + const minimum = rec(pricing.minTotal) ? decimal(rec(pricing.minTotal)!.amount) : null; + if (compareDecimals(amount, minimum && compareDecimals(minimum, total) > 0 ? minimum : total) !== 0) return "price"; + } else if (pricing.kind !== "auction") return "unrecognized-pricing-kind"; + // 3. Rail: present exactly when the pipeline pays, and one the listing accepts. + const rail = rec(terms.rail); + const pays = pipeline.some((phase) => typeof phase.kind === "string" && phase.kind.startsWith("pay-")); + if (pays ? !rail : terms.rail !== undefined) return "rail"; + if (rail && !arr(listing.acceptedRails).some((accepted) => accepted.railId === rail.railId && + (accepted.railVersion === undefined || rail.railVersion === undefined || accepted.railVersion === rail.railVersion))) return "rail"; + // 4. Deliverable: the canonical DeliverableRef of the listed DeliverableSpec. + const deliverable = rec(terms.deliverable); + const offered = rec(rec(listing.offering)?.deliverable); + if (!deliverable || !offered || deliverable.deliverableType !== offered.kind || + normalizedHash(deliverable.hash) !== contentHash(offered) || deliverable.schemaUrl !== offered.schemaUrl) return "deliverable"; + // 7. Pattern. An RFQ step declaring fixedPriceFallback also admits the fixed-price fallback, + // whose price check 2 already held to the band centre. + const negotiation = pipeline.find((phase) => typeof phase.kind === "string" && phase.kind.startsWith("negotiate-")); + const fallback = negotiation?.kind === "negotiate-rfq" && rec(negotiation.parameters)?.fixedPriceFallback === true && + agreement.derivedFromPattern === "fixed-price"; + if (!negotiation || (!fallback && agreement.derivedFromPattern !== AGREEMENT_PATTERNS[String(negotiation.kind)])) return "pattern"; + // 8. Role direction: the publisher is the agreement seller, or the buyer of a procurement (SE-8). + const publisherRole = negotiation.kind === "negotiate-sealed-envelope-procurement" ? "buyer" : "seller"; + if (!arr(agreement.parties).some((party) => party.role === publisherRole && sameSigningIdentity(party.primaryClaim, publisher))) { + return "role-direction"; + } + // 9. Artifact type and payout bindings match the commitment phase. + return agreementMatchesListing(agreement, listing) ? null : "commitment-artifact"; +} + export function signedScope(raw: Record, kind: ArtifactKind): Record { if (kind === "attestation") return { ...raw }; const scope = { ...raw }; @@ -198,10 +289,11 @@ function shapeOk(raw: Record, kind: ArtifactKind): boolean { : Array.isArray(terms.payoutBindings) && terms.payoutBindings.every(payoutBindingShapeOk))); } if (kind === "evidence") return raw.evidenceVersion === "1" && typeof raw.jobId === "string" && typeof raw.phase === "string" && (raw.outcome === "success" || raw.outcome === "failure") && typeof raw.observedAt === "number" && (raw.amendmentRefs === undefined || Array.isArray(raw.amendmentRefs)) && (raw.attestationRef === undefined || isCurrentRef(raw.attestationRef)); - if (kind === "verify-result") return raw.resultVersion === "1" && typeof raw.scheme === "string" && typeof raw.identifier === "string" && typeof raw.verifiedAt === "number" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.decision)) && isCurrentRef(raw.attestation); + // DACS-2 §7.5 VerifyResult and §7.7 CompositeVerificationRecord required members. + if (kind === "verify-result") return raw.resultVersion === "1" && typeof raw.scheme === "string" && typeof raw.identifier === "string" && Number.isSafeInteger(raw.recipeVersion) && typeof raw.method === "string" && typeof raw.reason === "string" && typeof raw.fetchedAt === "number" && typeof raw.verifiedAt === "number" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.decision)) && isCurrentRef(raw.attestation); if (kind === "composite") { const supplementary = arr(raw.supplementary); - return raw.recordVersion === "1" && typeof raw.jobId === "string" && typeof raw.evaluatedParty === "string" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.overallDecision)) && Array.isArray(raw.freshness) && Array.isArray(raw.dealSpecific) && Array.isArray(raw.supplementary) && supplementary.length === raw.supplementary.length && supplementary.every((signal) => typeof signal.source === "string" && typeof signal.signalType === "string" && (typeof signal.value === "number" || typeof signal.value === "string") && typeof signal.observedAt === "number" && (signal.attestation === undefined ? signal.source !== "external" : isCurrentRef(signal.attestation))); + return raw.recordVersion === "1" && typeof raw.jobId === "string" && typeof raw.evaluatedParty === "string" && /^[0-9a-f]{64}$/.test(normalizedHash(raw.bundleHash)) && /^[0-9a-f]{64}$/.test(normalizedHash(raw.requirementHash)) && typeof raw.generatedAt === "number" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.overallDecision)) && Array.isArray(raw.freshness) && Array.isArray(raw.dealSpecific) && Array.isArray(raw.supplementary) && supplementary.length === raw.supplementary.length && supplementary.every((signal) => typeof signal.source === "string" && typeof signal.signalType === "string" && (typeof signal.value === "number" || typeof signal.value === "string") && typeof signal.observedAt === "number" && (signal.attestation === undefined ? signal.source !== "external" : isCurrentRef(signal.attestation))); } if (kind === "rating") return raw.ratingVersion === "1" && typeof raw.jobId === "string" && typeof raw.rater === "string" && typeof raw.target === "string" && (raw.targetRole === "buyer" || raw.targetRole === "seller") && Number.isInteger(raw.value) && Number(raw.value) >= 1 && Number(raw.value) <= 5 && typeof raw.ratedAt === "number" && (typeof raw.freeText !== "string" || raw.freeText.length <= 1000); return true; @@ -342,27 +434,36 @@ async function verifyCurrentEvidenceGraph( sameSigningIdentity(party.primaryClaim, rec(resolved.raw.signature)?.signer))) { return { ...fail("evidence signer is not a session party"), signaturesVerified }; } + if (kind === "composite") { + // bundleHash and evaluatedParty bind the vet to one session party's IdentityBundle (DACS-2 §7.7). + const evaluated = parties.find((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.evaluatedParty)); + if (!evaluated || normalizedHash(evaluated.bundleHash) !== normalizedHash(resolved.raw.bundleHash)) { + return { ...fail("composite record is not bound to a session party's bundle"), signaturesVerified }; + } + } artifacts.push(resolved); } } // Resolve the transitive evidence graph, not only the bundle's first-level // pointers. Cycles and repeated refs collapse only when kind, locator, and // content hash all agree. - const seen = new Map(artifacts.map((artifact) => [ - `${artifact.kind}\n${artifact.locator}`, artifact.contentHash, - ])); + const seen = new Map(artifacts.map((artifact) => [`${artifact.kind}\n${artifact.locator}`, artifact])); let graphLimitExceeded = false; const addNested = async (kind: ArtifactKind, value: unknown): Promise => { if (!isCurrentRef(value)) return false; + // A VerifyResultRef's recipeVersion is load-bearing (CORE §B.3) and must equal the resolved result's. + const recipeOk = (artifact: ResolvedEvidence) => kind !== "verify-result" || + (Number.isSafeInteger(value.recipeVersion) && artifact.raw.recipeVersion === value.recipeVersion); const key = `${kind}\n${value.anchor.locator}`; const hash = normalizedHash(value.contentHash); - if (seen.has(key)) return seen.get(key) === hash; + const known = seen.get(key); + if (known) return known.contentHash === hash && recipeOk(known); if (artifacts.length >= MAX_GRAPH_ARTIFACTS) { graphLimitExceeded = true; return false; } - const nested = await resolveRef(kind, value, deps); if (!nested) return false; + const nested = await resolveRef(kind, value, deps); if (!nested || !recipeOk(nested)) return false; if ((kind === "evidence" || kind === "composite") && nested.raw.jobId !== raw.jobId) return false; if (kind === "evidence" && !parties.some((party) => sameSigningIdentity(party.primaryClaim, rec(nested.raw.signature)?.signer))) return false; - seen.set(key, nested.contentHash); artifacts.push(nested); return true; + seen.set(key, nested); artifacts.push(nested); return true; }; for (let index = 0; index < artifacts.length; index++) { const artifact = artifacts[index]; @@ -392,19 +493,44 @@ async function verifyCurrentEvidenceGraph( const verifiedPhaseRefs = new Set(artifacts.filter((artifact) => artifact.ref && (artifact.kind === "evidence" || artifact.kind === "verify-result" || artifact.kind === "composite")).map((artifact) => `${artifact.ref!.anchor.locator}\n${normalizedHash(artifact.ref!.contentHash)}`)); + const composites = artifacts.filter((artifact) => artifact.kind === "composite"); + const compositeDecisions = new Map(composites.map((artifact) => [ + `${artifact.ref!.anchor.locator}\n${normalizedHash(artifact.ref!.contentHash)}`, artifact.raw.overallDecision, + ])); for (const phase of arr(raw.phaseSummary)) { if (phase.attestationRef === undefined) continue; if (!isCurrentRef(phase.attestationRef) || !verifiedPhaseRefs.has(`${phase.attestationRef.anchor.locator}\n${normalizedHash(phase.attestationRef.contentHash)}`)) { return { ...fail("phase attestation reference is outside the verified graph"), signaturesVerified }; } + // DACS-2 VPC-4: a vet phase whose decision is not pass fails, so it cannot have succeeded. + const decision = compositeDecisions.get(`${phase.attestationRef.anchor.locator}\n${normalizedHash(phase.attestationRef.contentHash)}`); + if (phase.outcome === "ok" && decision !== undefined && decision !== "pass") { + return { ...fail("a successful vet phase rests on a composite record that did not pass"), signaturesVerified }; + } + } + // A session that reached its agreement passed vet for every party it vetted (VPC-2, VPC-4); + // a failed attempt may sit beside the passing one. + const passed = new Set(composites.filter((artifact) => artifact.raw.overallDecision === "pass") + .map((artifact) => canonicalSigningIdentity(String(artifact.raw.evaluatedParty)))); + if (agreement && composites.some((artifact) => !passed.has(canonicalSigningIdentity(String(artifact.raw.evaluatedParty))))) { + return { ...fail("a committed session has a vetted party with no passing composite record"), signaturesVerified }; } if (artifacts.length >= MAX_GRAPH_ARTIFACTS) return { ...fail("evidence graph exceeds size limit"), signaturesVerified }; const listingRef = rec(raw.listingRef)!; const listed = await deps.resolveListing(listingRef); const listingHash = normalizedHash(listingRef.contentHash); - if (!listed || artifactHash(listed.raw, "listing") !== listingHash || - !(await verifyListing(listed.raw, resolveKey))) return { ...fail("listing reference failed"), signaturesVerified }; + const verifiedListing = listed && artifactHash(listed.raw, "listing") === listingHash + ? await verifyListing(listed.raw, resolveKey) : null; + if (!listed || !verifiedListing) return { ...fail("listing reference failed"), signaturesVerified }; + // DACS-2 §7.7: requirementHash binds a vet to the listing's BundleRequirement, which gates the + // publisher's counterparty. The publisher itself is vetted against requirements the listing omits. + const requirement = rec(listed.raw.buyerRequirement); + if (requirement && composites.some((artifact) => + !sameSigningIdentity(artifact.raw.evaluatedParty, verifiedListing.sellerClaim) && + normalizedHash(artifact.raw.requirementHash) !== contentHash(requirement))) { + return { ...fail("composite record is not bound to the listing's requirement"), signaturesVerified }; + } if (agreement && JSON.stringify(agreement.listingRef) !== JSON.stringify(raw.listingRef)) return { ...fail("agreement listing binding failed"), signaturesVerified }; if (agreement) { const agreementParties = arr(agreement.parties); @@ -413,9 +539,8 @@ async function verifyCurrentEvidenceGraph( party.role === role && sameSigningIdentity(party.primaryClaim, candidate.primaryClaim))))) { return { ...fail("agreement party binding failed"), signaturesVerified }; } - if (!agreementMatchesListing(agreement, listed.raw)) { - return { ...fail("agreement artifact does not match the pinned listing pipeline"), signaturesVerified }; - } + const nonconforming = agreementListingConformance(agreement, listed.raw, verifiedListing.sellerClaim); + if (nonconforming) return { ...fail(`agreement fails listing conformance check: ${nonconforming}`), signaturesVerified }; } artifacts.push({ kind: "listing", locator: listed.locator, raw: listed.raw, contentHash: listingHash }); return { profile: "dacs-v0.1", ok: true, bundle: raw, bundleContentHash: artifactHash(raw, "bundle"), signaturesVerified, refsVerified: true, artifacts, agreement, listing: listed.raw, ratings: artifacts.filter((artifact) => artifact.kind === "rating").map((artifact) => artifact.raw) }; diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 2c96fb7..3483379 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { artifactHash, buildCurrentEvidenceGraph, signedScope } from "../src/catalog/evidenceGraph.js"; +import { agreementListingConformance, artifactHash, buildCurrentEvidenceGraph, signedScope } from "../src/catalog/evidenceGraph.js"; import { agreementRail } from "../src/catalog/agreementMetadata.js"; import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "../src/catalog/currentReconciliation.js"; import { deriveIdentityTier, type RecipePolicy } from "../src/catalog/identityVerification.js"; @@ -29,6 +29,8 @@ const sign = async (raw: Obj, kind: "listing" | "agreement" | "evidence" | "rati const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString("base64url"); return party ? { party: dids[signer], algorithm: "ed25519", value } : { signer: dids[signer], algorithm: "ed25519", value }; }; +// §8.5.2 check 4: the agreement names the canonical DeliverableRef of the listed DeliverableSpec. +const deliverableRef = { deliverableType: "attested-payload", hash: contentHash({ kind: "attested-payload", payloadFormat: "application/json" }) }; const ref = (at: string, raw: Obj, kind: Parameters[1], extra: Obj = {}) => ({ anchor: { kind: "storage-program", locator: at }, contentHash: artifactHash(raw, kind), ...extra, }); @@ -51,11 +53,12 @@ async function vector(jobId = "job-1", offset = 0) { (identity.presentation as Obj).signatures = [{ ref: dids[1], signature: identitySig }]; const listing: Obj = { ...listingScope, signature: await sign(listingScope, "listing", 1) }; maps.set(at(1), listing); const listingRef = { listingId: "svc", version: 1, contentHash: artifactHash(listing, "listing") }; - const agreementScope: Obj = { agreementVersion: "1", jobId, listingRef, + const agreementScope: Obj = { agreementVersion: "1", jobId, listingRef, derivedFromPattern: "fixed-price", parties: [{ role: "buyer", primaryClaim: dids[0] }, { role: "seller", primaryClaim: dids[1] }], terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" }, + deliverable: deliverableRef, } }; const agreement: Obj = { ...agreementScope, signatures: [await sign(agreementScope, "agreement", 0, true), await sign(agreementScope, "agreement", 1, true)] }; maps.set(at(2), agreement); const evidenceScope: Obj = { evidenceVersion: "1", jobId, phase: "pay-dem", phaseIndex: 2, outcome: "success", paymentTxRefs: [`tx-${jobId}`], observedAt: 100 }; @@ -145,10 +148,12 @@ async function payeeBoundVector(jobId = "payee-bound-job", offset = 0) { payeeBoundAgreementVersion: "1", jobId, listingRef, + derivedFromPattern: "fixed-price", parties: [{ role: "buyer", primaryClaim: dids[0] }, { role: "seller", primaryClaim: dids[1] }], terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" }, + deliverable: deliverableRef, payoutBindings: [{ railId: "pay-dem", phaseIndex: 2, payeeAddress: dids[1] }], }, }; @@ -557,16 +562,126 @@ test("payee-bound agreements fail closed on discriminator, domain and payout cov assert.equal(mismatchedGraph.ok, false, "agreement artifact type must match the listing commitment phase"); }); +test("DACS-3 §8.5.2: an agreement conforms to its listing in currency, price, rail, deliverable, pattern and role direction", () => { + const offered = { kind: "attested-payload", payloadFormat: "application/json" }; + const pays = [{ kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }]; + const listing = (pricing: Obj, negotiation: Obj = { kind: "negotiate-fixed-price" }, pipeline: Obj[] = pays): Obj => ({ + offering: { deliverable: offered }, pricing, pipeline: [negotiation, ...pipeline], acceptedRails: [{ railId: "pay-dem", railVersion: 1 }], + }); + const agreement = (terms: Obj = {}, extra: Obj = {}): Obj => ({ + agreementVersion: "1", derivedFromPattern: "fixed-price", + parties: [{ role: "buyer", primaryClaim: dids[0] }, { role: "seller", primaryClaim: dids[1] }], + terms: { price: { amount: "10", currency: "DEM" }, rail: { railId: "pay-dem" }, deliverable: deliverableRef, ...terms }, ...extra, + }); + const check = (deal: Obj, offer: Obj) => agreementListingConformance(deal, offer, dids[1]); + const price = (amount: string, currency = "DEM") => ({ price: { amount, currency } }); + const fixed = listing({ kind: "fixed", price: { amount: "10", currency: "DEM" } }); + assert.equal(check(agreement(), fixed), null); + // 1. Currency, before any amount. + assert.equal(check(agreement(price("10", "USD")), fixed), "currency"); + // 2. Fixed: equal to the listed price. + assert.equal(check(agreement(price("10.5")), fixed), "price"); + // 2. Negotiable: the inclusive band, bounds rounded half-up to the centre's digits. + const rfq = { kind: "negotiate-rfq", parameters: { maxTurns: 6, timeoutSec: 60 } }; + const band = (amount: string, minPct: number, maxPct: number) => + listing({ kind: "negotiable", bandCenter: { amount, currency: "DEM" }, minPct, maxPct }, rfq); + const quoted = (amount: string) => agreement(price(amount), { derivedFromPattern: "rfq" }); + for (const [amount, ok] of [["190", true], ["210", true], ["200", true], ["189.99", false], ["210.01", false]] as const) { + assert.equal(check(quoted(amount), band("200", 5, 5)), ok ? null : "price", amount); + } + assert.equal(check(quoted("1.35"), band("1.5", 10, 0)), "price", "1.35 rounds half-up to a 1.4 lower bound"); + assert.equal(check(quoted("1.4"), band("1.5", 10, 0)), null); + assert.equal(check(quoted("0.6"), band("1", 50, 0)), "price", "0.5 rounds half-up to 1"); + assert.equal(check(quoted("1"), band("1", 60, 0)), "price", "a lower bound that rounds to 0 rejects the listing"); + // 2. Fixed-price over negotiable pricing takes the band centre exactly. + const fixedOverBand = listing({ kind: "negotiable", bandCenter: { amount: "200", currency: "DEM" }, minPct: 5, maxPct: 5 }); + assert.equal(check(agreement(price("200")), fixedOverBand), null); + assert.equal(check(agreement(price("195")), fixedOverBand), "price"); + // 2. Metered: max(minTotal, unitPrice × quantity) for the declared unit and a canonical quantity. + const metered = listing({ kind: "metered", unitPrice: { amount: "0.02", currency: "DEM" }, unit: "call", minTotal: { amount: "1", currency: "DEM" } }); + const usage = (amount: string, quantity: unknown, unit = "call") => agreement({ ...price(amount), meteredQuantity: { quantity, unit } }); + assert.equal(check(usage("2", "100"), metered), null); + assert.equal(check(usage("1", "10"), metered), null, "the minimum total applies"); + for (const [deal, name] of [[usage("0.2", "10"), "below the minimum"], [usage("2", "100", "token"), "another unit"], + [usage("2", "0100"), "non-canonical quantity"], [usage("2", 100), "numeric quantity"], [agreement(price("2")), "no quantity"]] as const) { + assert.equal(check(deal, metered), "price", name); + } + assert.equal(check(agreement(), listing({ kind: "subscription", price: { amount: "10", currency: "DEM" } })), "unrecognized-pricing-kind"); + // 3. Rail: present exactly when the pipeline pays, and accepted by the listing. + assert.equal(check(agreement({ rail: undefined }), fixed), "rail"); + assert.equal(check(agreement({ rail: "pay-dem" }), fixed), "rail"); + assert.equal(check(agreement({ rail: { railId: "pay-x402" } }), fixed), "rail"); + assert.equal(check(agreement({ rail: { railId: "pay-dem", railVersion: 2 } }), fixed), "rail"); + assert.equal(check(agreement({ rail: { railId: "pay-dem", railVersion: 1 } }), fixed), null); + const intakeOnly = listing({ kind: "fixed", price: { amount: "10", currency: "DEM" } }, undefined, [{ kind: "commit-agreement" }, { kind: "deliver-attested-payload" }]); + assert.equal(check(agreement({ rail: undefined }), intakeOnly), null); + assert.equal(check(agreement(), intakeOnly), "rail"); + // 4. Deliverable. + for (const deliverable of [undefined, { ...deliverableRef, hash: "0".repeat(64) }, { ...deliverableRef, deliverableType: "storage-program" }, + { ...deliverableRef, schemaUrl: "https://schema.example" }]) { + assert.equal(check(agreement({ deliverable }), fixed), "deliverable", JSON.stringify(deliverable)); + } + // 7. Pattern. + assert.equal(check(agreement({}, { derivedFromPattern: "rfq" }), fixed), "pattern"); + assert.equal(check(agreement({}, { derivedFromPattern: undefined }), fixed), "pattern"); + // An RFQ step declaring fixedPriceFallback also admits a fixed-price agreement at the band centre (DACS-3 §8.5.2, PS-3). + const fallback = (fixedPriceFallback: unknown) => listing({ kind: "negotiable", bandCenter: { amount: "200", currency: "DEM" }, minPct: 5, maxPct: 5 }, + { kind: "negotiate-rfq", parameters: { maxTurns: 6, timeoutSec: 60, fixedPriceFallback } }); + assert.equal(check(agreement(price("200")), fallback(true)), null); + assert.equal(check(agreement(price("205")), fallback(true)), "price", "the fallback takes the band centre exactly"); + assert.equal(check(agreement(price("200")), fallback(false)), "pattern"); + assert.equal(check(agreement(price("200")), fallback("true")), "pattern"); + assert.equal(check(agreement(price("205"), { derivedFromPattern: "rfq" }), fallback(true)), null); + // 8. Role direction: the publisher sells, except in a procurement, where it buys (SE-8). + const swapped = { parties: [{ role: "buyer", primaryClaim: dids[1] }, { role: "seller", primaryClaim: dids[0] }] }; + assert.equal(check(agreement({}, swapped), fixed), "role-direction"); + const procurement = listing({ kind: "auction", selectionRule: "lowest-price" }, { kind: "negotiate-sealed-envelope-procurement" }); + assert.equal(check(agreement({}, { derivedFromPattern: "sealed-envelope" }), procurement), "role-direction"); + assert.equal(check(agreement({}, { derivedFromPattern: "sealed-envelope", ...swapped }), procurement), null); + // 9. Artifact type matches the commitment phase. + const payeeBound = listing({ kind: "fixed", price: { amount: "10", currency: "DEM" } }, undefined, [{ kind: "commit-payee-bound-agreement" }, ...pays.slice(1)]); + assert.equal(check(agreement(), payeeBound), "commitment-artifact"); +}); + +test("a bundle within the 128 KB DACS-5 storage soft cap verifies, and one above it does not", async () => { + const { listing, buyerBundle } = await vector("sized-job", 720); + const sized = async (bytes: number) => { + const scope = signedScope(buyerBundle, "bundle"); + scope.producerNote = ""; + const base = Buffer.byteLength(JSON.stringify(await resignBundle(scope)), "utf8"); + scope.producerNote = "x".repeat(bytes - base); + const bundle = await resignBundle(scope); + assert.equal(Buffer.byteLength(JSON.stringify(bundle), "utf8"), bytes); + maps.set(locator(725), bundle); + return graphAt(locator(725), listing, locator(721)); + }; + const within = await sized(128 * 1024); + assert.equal(within.ok, true, within.reason); + assert.equal((await sized(100_000)).ok, true); + assert.equal((await sized(128 * 1024 + 1)).reason, "invalid current DACS-5 bundle shape"); +}); + +test("the evidence graph rejects an agreement that does not conform to its listing", async () => { + const fixture = await vector("nonconforming-job", 700); + const scope = signedScope(fixture.agreement, "agreement"); + await installAgreement(fixture, { ...scope, terms: { ...(scope.terms as Obj), price: { amount: "1.3", currency: "DEM" } } }); + const graph = await graphAt(fixture.locators.buyer, fixture.listing, fixture.locators.listing); + assert.equal(graph.reason, "agreement fails listing conformance check: price"); +}); + test("current evidence graph accepts a verified vet record as its phase attestation", async () => { const { listing, buyerBundle: bundle } = await vector(); const compositeScope: Obj = { recordVersion: "1", jobId: "job-1", evaluatedParty: dids[1], + bundleHash: "b".repeat(64), + requirementHash: "c".repeat(64), freshness: [], supplementary: [], dealSpecific: [], overallDecision: "pass", + generatedAt: 30, }; const composite = { ...compositeScope, @@ -589,6 +704,57 @@ test("current evidence graph accepts a verified vet record as its phase attestat assert.equal(graph.ok, true, graph.reason); }); +test("a vet record binds the listing's requirement and must pass for a vet phase or agreement to rest on it (DACS-2 §7.7, VPC-4)", async () => { + const { listing, buyerBundle: bundle } = await vector(); + const requirementHash = contentHash((listing as Obj).buyerRequirement as Obj); + const record = (party: number, extra: Obj = {}): Obj => ({ + recordVersion: "1", jobId: "job-1", evaluatedParty: dids[party], bundleHash: (party === 0 ? "a" : "b").repeat(64), + requirementHash: party === 0 ? requirementHash : "c".repeat(64), + freshness: [], supplementary: [], dealSpecific: [], overallDecision: "pass", generatedAt: 30, ...extra, + }); + const graphWith = async (records: Obj[], vetPhase: number | null) => { + const refs = []; + for (const [index, scope] of records.entries()) { + const signed = { ...scope, signature: await sign(scope, "composite", 1) }; + maps.set(locator(20 + index), signed); + refs.push(ref(locator(20 + index), signed, "composite")); + } + const scope = signedScope(bundle, "bundle"); + scope.vetRecords = refs; + if (vetPhase !== null) scope.phaseSummary = [{ index: 0, kind: "vet-credentials", outcome: "ok", attestationRef: refs[vetPhase] }]; + maps.set(locator(5), await resignBundle(scope)); + return buildCurrentEvidenceGraph(locator(5), { read: async (at) => maps.get(at) ?? null, + resolveListing: async () => ({ locator: locator(1), raw: listing }) }); + }; + // The buyer is vetted against the listing's buyerRequirement; the publisher against requirements the listing omits. + assert.equal((await graphWith([record(0), record(1)], 0)).ok, true); + assert.equal((await graphWith([record(0, { requirementHash: "c".repeat(64) })], null)).reason, + "composite record is not bound to the listing's requirement"); + for (const overallDecision of ["fail", "indeterminate", "error"]) { + assert.equal((await graphWith([record(0, { overallDecision })], 0)).reason, + "a successful vet phase rests on a composite record that did not pass", overallDecision); + assert.equal((await graphWith([record(0, { overallDecision })], null)).reason, + "a committed session has a vetted party with no passing composite record", overallDecision); + } + // A failed attempt beside the passing record for the same party is consistent with the session. + assert.equal((await graphWith([record(0, { overallDecision: "fail", generatedAt: 20 }), record(0)], 1)).ok, true); + // A failed vet on a session that never reached its agreement is ordinary evidence of that failure. + const aborted = signedScope(bundle, "bundle"); + delete aborted.agreementRef; + aborted.outcome = "aborted-by-self"; + const failing = { ...record(0, { overallDecision: "fail" }) }; + const signedFailing = { ...failing, signature: await sign(failing, "composite", 1) }; + maps.set(locator(20), signedFailing); + aborted.vetRecords = [ref(locator(20), signedFailing, "composite")]; + aborted.phaseSummary = [{ index: 0, kind: "vet-credentials", outcome: "fail", errorClass: "counterparty", attestationRef: (aborted.vetRecords as unknown[])[0] }]; + aborted.settlementEvidence = []; + aborted.ratingRefs = []; + maps.set(locator(5), await resignBundle(aborted)); + const abortedGraph = await buildCurrentEvidenceGraph(locator(5), { read: async (at) => maps.get(at) ?? null, + resolveListing: async () => ({ locator: locator(1), raw: listing }) }); + assert.equal(abortedGraph.ok, true, abortedGraph.reason); +}); + test("current evidence graph resolves VerifyResult authority attestations", async () => { const { listing, buyerBundle: bundle } = await vector(); const attestation = { authority: "registry", status: "active" }; @@ -614,25 +780,54 @@ test("current evidence graph resolves VerifyResult authority attestations", asyn recordVersion: "1", jobId: "job-1", evaluatedParty: dids[1], + bundleHash: "b".repeat(64), + requirementHash: "c".repeat(64), freshness: [ref(locator(8), verifyResult, "verify-result", { recipeVersion: 1 })], supplementary: [], dealSpecific: [], overallDecision: "pass", + generatedAt: 30, }; - const composite = { - ...compositeScope, - signature: await sign(compositeScope, "composite", 0), + const graphWith = async (composite: Obj) => { + const signed = { ...composite, signature: await sign(composite, "composite", 0) }; + maps.set(locator(6), signed); + const scope = signedScope(bundle, "bundle"); + scope.vetRecords = [ref(locator(6), signed, "composite")]; + maps.set(locator(5), await resignBundle(scope)); + return buildCurrentEvidenceGraph(locator(5), { read: async (at) => maps.get(at) ?? null, + resolveListing: async () => ({ locator: locator(1), raw: listing }) }); }; - maps.set(locator(6), composite); - const scope = signedScope(bundle, "bundle"); - scope.vetRecords = [ref(locator(6), composite, "composite")]; - maps.set(locator(5), await resignBundle(scope)); - const graph = await buildCurrentEvidenceGraph(locator(5), { read: async (at) => maps.get(at) ?? null, - resolveListing: async () => ({ locator: locator(1), raw: listing }) }); + const graph = await graphWith(compositeScope); assert.equal(graph.ok, true, graph.reason); assert.equal(graph.artifacts.some((artifact) => artifact.kind === "attestation"), true); + // DACS-2 §7.7: the record's required members, and its binding to one session party's IdentityBundle. + for (const member of ["bundleHash", "requirementHash", "generatedAt"]) { + const missing = { ...compositeScope }; + delete missing[member]; + assert.equal((await graphWith(missing)).reason, "composite reference failed", member); + } + assert.equal((await graphWith({ ...compositeScope, evaluatedParty: `did:demos:agent:${"9".repeat(64)}` })).reason, + "composite record is not bound to a session party's bundle"); + assert.equal((await graphWith({ ...compositeScope, bundleHash: "a".repeat(64) })).reason, + "composite record is not bound to a session party's bundle", "the seller was vetted against the buyer's bundle hash"); + // A VerifyResultRef's recipeVersion must equal the resolved result's; it is load-bearing (CORE §B.3). + for (const recipeVersion of [2, undefined]) { + const freshness = [ref(locator(8), verifyResult, "verify-result", recipeVersion === undefined ? {} : { recipeVersion })]; + assert.equal((await graphWith({ ...compositeScope, freshness })).reason, "nested VerifyResult reference failed", String(recipeVersion)); + } + // DACS-2 §7.5: a VerifyResult without its method, reason or fetchedAt is not one. + for (const member of ["method", "reason", "fetchedAt", "recipeVersion"]) { + const missing = { ...verifyResultScope }; + delete missing[member]; + const partial = { ...missing, signature: await sign(missing, "verify-result", 0) }; + maps.set(locator(9), partial); + const freshness = [ref(locator(9), partial, "verify-result", { recipeVersion: 1 })]; + assert.equal((await graphWith({ ...compositeScope, freshness })).reason, "nested VerifyResult reference failed", member); + } + await graphWith(compositeScope); + maps.set(locator(7), { ...attestation, status: "tampered" }); const tampered = await buildCurrentEvidenceGraph(locator(5), { read: async (at) => maps.get(at) ?? null, resolveListing: async () => ({ locator: locator(1), raw: listing }) }); diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index 6ea1bef..8167368 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -346,8 +346,10 @@ async function currentDeal(jobId: string, offset: number, paymentTxRefs: unknown const currentListing = { ...listingRaw, signature: await signArtifact(listingRaw, "listing", 1) }; const listingRef = { listingId: "svc-current", version: 1, contentHash: artifactHash(currentListing, "listing") }; const agreementScope: Obj = { - agreementVersion: "1", jobId, listingRef, parties: [{ role: "buyer", primaryClaim: buyer }, { role: "seller", primaryClaim: seller }], - terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" } }, + agreementVersion: "1", jobId, listingRef, derivedFromPattern: "fixed-price", + parties: [{ role: "buyer", primaryClaim: buyer }, { role: "seller", primaryClaim: seller }], + terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" }, + deliverable: { deliverableType: "attested-payload", hash: contentHash((listingRaw.offering as Obj).deliverable as Obj) } }, }; const agreement = { ...agreementScope, signatures: [await signArtifact(agreementScope, "agreement", 0, true), await signArtifact(agreementScope, "agreement", 1, true)] }; const evidenceScope: Obj = { evidenceVersion: "1", jobId, phase: "pay-dem", phaseIndex: 2, outcome: "success", paymentTxRefs, observedAt: 100 }; From d0aa844730fd755f9c2648577070913511f8d250 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 09/42] test(dacs-directory): pin the browser deal verdict and the artifact proxy's locator guard The in-browser strict-pass, limited and fail verdict lived inside the VerifyDeal component, and the artifact proxy's only path guard (chain.ts, /^stor-[0-9a-f]{40}$/) had no test, so either could regress silently. - Move the verdict into bundlePolicy.browserDealVerdict, unchanged, and test each input: expected and registered parties, signatures, strict refs, the registered copy role, and a missing registration. - Test that the artifact route forwards only a native storage locator to the node: traversal, query, uppercase, short, long and padded refs never reach fetch. Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/bundlePolicy.ts | 49 +++++++++++++++++++ .../src/components/VerifyDeal.tsx | 43 ++-------------- .../test/artifact-route.test.ts | 38 ++++++++++++++ .../dacs-directory/test/verification.test.ts | 27 ++++++++++ 4 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/artifact-route.test.ts diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index c51aaf8..31630aa 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -68,6 +68,55 @@ export function bundleMatchesRegisteredAnchor( return expectedRole !== null && bundle?.anchoredByRole === expectedRole; } +export type BrowserDealVerdict = "strict-pass" | "limited" | "fail"; + +/** + * The in-browser verdict on a legacy deal copy. Strict needs the expected parties, + * the required signatures, strict referenced artifacts, and an anchoredByRole that + * matches the address the directory registered for this copy. Without a registered + * address-role binding, internally consistent evidence is only "limited". + */ +export function browserDealVerdict(input: { + verification: BundleVerification; + bundleRef: string; + buyerOwner: string; + expectedSeller?: string; + registered: { owners: unknown; buyerBundleRef: unknown; sellerBundleRef: unknown } | null; + signaturesOk: boolean; + refsOk: boolean; +}): { verdict: BrowserDealVerdict; reason?: string } { + const { verification, bundleRef, registered } = input; + const buyer = verification.bundle?.parties.find((p) => p.role === "buyer")?.primaryClaim; + const seller = verification.bundle?.parties.find((p) => p.role === "seller")?.primaryClaim; + const anchorBindingAvailable = registeredAnchorRole(bundleRef, registered?.buyerBundleRef, registered?.sellerBundleRef) !== null; + const owners = registered?.owners && typeof registered.owners === "object" && + !Array.isArray(registered.owners) ? registered.owners as Record : null; + const registeredPartiesMatch = typeof owners?.buyer === "string" && typeof owners.seller === "string" && + buyer === owners.buyer && seller === owners.seller; + const expectedPartiesMatch = buyer === input.buyerOwner && (!input.expectedSeller || seller === input.expectedSeller) && + (!anchorBindingAvailable || registeredPartiesMatch); + const anchorRoleMatches = bundleMatchesRegisteredAnchor( + verification.bundle, bundleRef, registered?.buyerBundleRef, registered?.sellerBundleRef, + ); + const internallyConsistent = verification.ok && expectedPartiesMatch && input.signaturesOk && input.refsOk; + const reason = !expectedPartiesMatch + ? "bundle parties do not match the expected buyer/seller" + : !input.signaturesOk + ? "required buyer/seller signatures are missing or invalid" + : !input.refsOk + ? "one or more referenced artifact signatures are missing or invalid" + : !anchorBindingAvailable + ? "the directory has no registered address-role binding for this bundle, so anchoredByRole cannot be verified" + : !anchorRoleMatches + ? "bundle anchoredByRole does not match its registered buyer/seller address" + : verification.reason; + return { + verdict: internallyConsistent && anchorRoleMatches ? "strict-pass" + : internallyConsistent && !anchorBindingAvailable ? "limited" : "fail", + reason, + }; +} + /** * Bind a catalog-submitted deal pointer to the identities and job id covered by * the verified bundle signatures. Registration fields are routing hints only; diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index 17ffcb4..f6c6ca9 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -21,9 +21,8 @@ import { type BundleVerification, } from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; import { - bundleMatchesRegisteredAnchor, + browserDealVerdict, hasRequiredBundleSignatures, - registeredAnchorRole, refsPassStrictPolicy, type ResolvedArtifact, } from "@/src/catalog/bundlePolicy"; @@ -126,50 +125,16 @@ export default function VerifyDeal({ resolvePublicKey: async (did) => keyFromDid(did), verify: async (b, s, p) => ed25519Verify(b, s, publicKeyFromRaw(p)), }); - const buyer = verification.bundle?.parties.find((p) => p.role === "buyer")?.primaryClaim; - const seller = verification.bundle?.parties.find((p) => p.role === "seller")?.primaryClaim; const registered = verification.bundle?.jobId ? await registeredBundleRefs(verification.bundle.jobId, bundleRef) : null; - const anchorBindingAvailable = registeredAnchorRole( - bundleRef, - registered?.buyerBundleRef, - registered?.sellerBundleRef, - ) !== null; - const owners = registered?.owners && typeof registered.owners === "object" && - !Array.isArray(registered.owners) ? registered.owners as Record : null; - const registeredPartiesMatch = typeof owners?.buyer === "string" && typeof owners.seller === "string" && - buyer === owners.buyer && seller === owners.seller; - const expectedPartiesMatch = buyer === buyerOwner && (!expectedSeller || seller === expectedSeller) && - (!anchorBindingAvailable || registeredPartiesMatch); - const anchorRoleMatches = bundleMatchesRegisteredAnchor( - verification.bundle, - bundleRef, - registered?.buyerBundleRef, - registered?.sellerBundleRef, - ); const signaturesOk = hasRequiredBundleSignatures( verification, rawBundle, ); const refsOk = signaturesOk ? await refsPassStrictPolicy(verification, resolvedArtifacts) : false; - const internallyConsistent = verification.ok && expectedPartiesMatch && signaturesOk && refsOk; - const strict: BundleVerification = { - ...verification, - ok: internallyConsistent && anchorRoleMatches, - reason: !expectedPartiesMatch - ? "bundle parties do not match the expected buyer/seller" - : !signaturesOk - ? "required buyer/seller signatures are missing or invalid" - : !refsOk - ? "one or more referenced artifact signatures are missing or invalid" - : !anchorBindingAvailable - ? "the directory has no registered address-role binding for this bundle, so anchoredByRole cannot be verified" - : !anchorRoleMatches - ? "bundle anchoredByRole does not match its registered buyer/seller address" - : verification.reason, - }; - setResult(strict); - setVerdict(strict.ok ? "strict-pass" : internallyConsistent && !anchorBindingAvailable ? "limited" : "fail"); + const outcome = browserDealVerdict({ verification, bundleRef, buyerOwner, expectedSeller, registered, signaturesOk, refsOk }); + setResult({ ...verification, ok: outcome.verdict === "strict-pass", reason: outcome.reason }); + setVerdict(outcome.verdict); setState("done"); } catch (cause) { const message = cause instanceof TypeError diff --git a/reference-implementations/dacs-directory/test/artifact-route.test.ts b/reference-implementations/dacs-directory/test/artifact-route.test.ts new file mode 100644 index 0000000..edaa52a --- /dev/null +++ b/reference-implementations/dacs-directory/test/artifact-route.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; + +// The artifact proxy forwards `ref` to the node's storage-program path; only a native locator may reach it. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-artifact-route-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const { GET } = await import("../app/api/dacs/artifact/route.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +const native = `stor-${"ab".repeat(20)}`; + +test("the artifact proxy reads only a native storage locator and never forwards any other ref upstream", async () => { + const requested: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + requested.push(String(input)); + return Response.json({ success: true, owner: `0x${"1".repeat(64)}`, programName: "opaque", data: { ok: true } }); + }; + try { + const read = async (ref: string) => (await GET(new NextRequest(`http://localhost/api/dacs/artifact?ref=${encodeURIComponent(ref)}`))).json(); + for (const ref of [ + "../admin", "stor-../../admin", `${native}/../../admin`, `${native}?raw=1`, native.toUpperCase(), + `stor-${"ab".repeat(19)}`, `${native}a`, ` ${native}`, `stor-${"ab".repeat(32)}`, "%2e%2e%2fadmin", + ]) { + assert.deepEqual(await read(ref), { ref, value: null }, ref); + } + assert.deepEqual(requested, [], "no invalid ref reaches the node"); + assert.deepEqual(await read(native), { ref: native, value: { ok: true } }); + assert.equal(requested.length, 1); + assert.equal(new URL(requested[0]).pathname.replace(/^.*\/storage-program\//, ""), native); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index bf75ddc..9ed5655 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -11,6 +11,7 @@ import { } from "@kynesyslabs/dacs/crypto"; import { + browserDealVerdict, bundleMatchesRegisteredAnchor, bundleMatchesRegisteredDeal, dedupeVerifiedDeals, @@ -310,6 +311,32 @@ test("unhashed anchor role must match the registered copy address", () => { assert.equal(registeredAnchorRole("stor-seller", "stor-buyer", undefined), null); }); +test("the browser deal verdict is strict only with matching parties, signatures, refs and a registered copy role", () => { + const verification = result("completed", [{ party: "buyer", verdict: "valid" }, { party: "seller", verdict: "valid" }]); + const registered = { owners: { buyer: "buyer", seller: "seller" }, buyerBundleRef: "stor-buyer", sellerBundleRef: "stor-seller" }; + type Input = Parameters[0]; + const base: Input = { verification, bundleRef: "stor-buyer", buyerOwner: "buyer", expectedSeller: "seller", registered, signaturesOk: true, refsOk: true }; + const verdict = (change: Partial) => browserDealVerdict({ ...base, ...change }); + assert.deepEqual(verdict({}), { verdict: "strict-pass", reason: undefined }); + const cases: Array<[string, Partial, string, string | undefined]> = [ + ["another buyer expected", { buyerOwner: "someone" }, "fail", "bundle parties do not match the expected buyer/seller"], + ["another seller expected", { expectedSeller: "someone" }, "fail", "bundle parties do not match the expected buyer/seller"], + ["registered owners differ", { registered: { ...registered, owners: { buyer: "buyer", seller: "someone" } } }, "fail", + "bundle parties do not match the expected buyer/seller"], + ["registered owners missing", { registered: { ...registered, owners: null } }, "fail", "bundle parties do not match the expected buyer/seller"], + ["signatures missing", { signaturesOk: false }, "fail", "required buyer/seller signatures are missing or invalid"], + ["refs not strict", { refsOk: false }, "fail", "one or more referenced artifact signatures are missing or invalid"], + ["SDK verification failed", { verification: { ...verification, ok: false, reason: "sdk" } }, "fail", "sdk"], + ["no registered copy address", { registered: null }, "limited", + "the directory has no registered address-role binding for this bundle, so anchoredByRole cannot be verified"], + ["copy registered for the other role", { bundleRef: "stor-seller" }, "fail", "bundle anchoredByRole does not match its registered buyer/seller address"], + ]; + for (const [name, change, expected, reason] of cases) assert.deepEqual(verdict(change), { verdict: expected, reason }, name); + // Without a registered binding only internal consistency is reported, and a failed check still fails. + assert.equal(verdict({ registered: null, signaturesOk: false }).verdict, "fail"); + assert.equal(verdict({ registered: null, expectedSeller: undefined }).verdict, "limited"); +}); + test("verified reputation deals collapse to one authoritative copy per jobId, whoever the buyers are (§10.5.1, BB-6)", () => { const ref = (character: string) => `stor-${character.repeat(40)}`; const base: DealRecord = { From 190e2d519012103ec2e7bcc60f530131bee3213b Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 10/42] fix(dacs-directory): break SB-2 settlement ties on the lower evidence hash DACS-4 SB-2 assigns a settlement transaction cited by two jobs to the earliest evidence observation, "ties broken by lower evidence hash". The deriver broke ties by jobId. Each settlement id now carries the content hash of the evidence that cites it, ties go to the lower hash, and jobId only breaks a tie between equal hashes. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/indexer.ts | 2 +- .../dacs-directory/src/catalog/reputation.ts | 10 +++++++--- .../dacs-directory/src/catalog/types.ts | 4 ++-- .../dacs-directory/test/current-indexer.test.ts | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 123ea1c..61f78ac 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -399,7 +399,7 @@ export async function indexRegistration( const refs = Array.isArray(artifact.raw.paymentTxRefs) ? artifact.raw.paymentTxRefs : []; const phaseIndex = Number(artifact.raw.phaseIndex ?? 0); return refs.map((ref) => typeof ref === "string" ? ref : String((ref as Record)?.txId ?? (ref as Record)?.id ?? "")) - .filter(Boolean).map((id) => ({ id, observedAt: Number(artifact.raw.observedAt), phaseIndex })); + .filter(Boolean).map((id) => ({ id, observedAt: Number(artifact.raw.observedAt), phaseIndex, evidenceHash: artifact.contentHash })); }); const cancellation = [sellerOk ? sellerGraph?.bundle.cancellation : undefined, buyerOk ? buyerGraph.bundle.cancellation : undefined] .find((value) => value && typeof value === "object" && !Array.isArray(value)) as Record | undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/reputation.ts b/reference-implementations/dacs-directory/src/catalog/reputation.ts index c1e9f55..aca0555 100644 --- a/reference-implementations/dacs-directory/src/catalog/reputation.ts +++ b/reference-implementations/dacs-directory/src/catalog/reputation.ts @@ -67,12 +67,16 @@ export function deriveSellerReputation( return typeof timestamp === "number" && timestamp >= windowStart && timestamp <= windowEnd; }); // SB-2: a settlement transaction reused across jobs contributes once, at - // its earliest evidence observation. Deterministic jobId breaks exact ties. + // its earliest evidence observation; the lower evidence hash breaks a tie, + // then the jobId, so the choice is deterministic. const settlementOwner = new Map(); for (const deal of scoped) for (const tx of deal.settlementTxIds ?? []) { const prior = settlementOwner.get(tx.id); - const priorTime = prior?.settlementTxIds?.find((candidate) => candidate.id === tx.id)?.observedAt ?? Infinity; - if (!prior || tx.observedAt < priorTime || (tx.observedAt === priorTime && deal.jobId < prior.jobId)) settlementOwner.set(tx.id, deal); + const priorTx = prior?.settlementTxIds?.find((candidate) => candidate.id === tx.id); + const priorTime = priorTx?.observedAt ?? Infinity; + const order = byteOrder(tx.evidenceHash ?? "", priorTx?.evidenceHash ?? ""); + if (!prior || tx.observedAt < priorTime || + (tx.observedAt === priorTime && (order < 0 || (order === 0 && deal.jobId < prior.jobId)))) settlementOwner.set(tx.id, deal); } scoped = scoped.filter((deal) => !(deal.settlementTxIds ?? []).some((tx) => settlementOwner.get(tx.id) !== deal)); const completed = scoped.filter((deal) => deal.sellerOutcome === "completed").length; diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 0bac593..4111733 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -211,8 +211,8 @@ export interface DealRecord extends RegisteredDeal { agreementPrice?: { amount: string; currency: string }; /** Signature/hash/session-bound DACS-5 ratings. */ ratings?: VerifiedRating[]; - /** Settlement ids used for the SB-2 cross-job uniqueness gate. */ - settlementTxIds?: Array<{ id: string; observedAt: number; phaseIndex: number }>; + /** Settlement ids used for the SB-2 cross-job uniqueness gate, with the content hash of the evidence citing each. */ + settlementTxIds?: Array<{ id: string; observedAt: number; phaseIndex: number; evidenceHash?: string }>; } export interface SellerRecord { diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 3483379..d84037e 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -999,6 +999,8 @@ test("indexer resolves current copies through BundleBindings rather than submitt }, undefined, async () => { throw new Error("identity unavailable"); }); assert.equal(record.deals[0].refsVerified, true); assert.equal(record.deals[0].fullSignatureStanding, true, "both parties signed the authoritative copy"); + assert.deepEqual(record.deals[0].settlementTxIds, [{ id: `tx-${deal.jobId}`, observedAt: 100, phaseIndex: 2, + evidenceHash: artifactHash(fixture.evidence, "evidence") }], "SB-2 carries the citing evidence hash"); assert.equal(record.deals[0].buyerBundleRef, fixture.locators.buyer); assert.notEqual(record.deals[0].buyerBundleRef, submittedBuyerPointer); assert.equal(record.deals[0].sellerBundleRef, fixture.locators.seller); @@ -1186,6 +1188,21 @@ test("DACS-5 derives exact volume, ratings, SR-2 receipt window and transaction assert.equal(out.averageSellerRating, 5); assert.equal(out.bundleCount, 1); assert.equal(out.windowingBasis, "sr2-anchor-timestamp"); }); +test("SB-2: a settlement id cited at the same time by two jobs counts for the lower evidence hash", () => { + const deal = (jobId: string, evidenceHash: string, amount: string): DealRecord => ({ + jobId, rail: "pay-dem", buyerBundleRef: locator(jobId === "a" ? 160 : 161), owners: { buyer: dids[0], seller: dids[1] }, + signatureVerified: true, refsVerified: true, reputationEligible: true, sellerOutcome: "completed", finalisedAt: 1, anchorTimestamp: 100, + verifiedAt: 1, bundleContentHash: jobId.repeat(64), agreementPrice: { amount, currency: "DEM" }, + settlementTxIds: [{ id: "demos:tx", observedAt: 10, phaseIndex: 2, evidenceHash }], + }); + const volume = (deals: DealRecord[]) => deriveSellerReputation(deals, 0, 200).observedTransactionalVolume; + // Job "z" sorts after "a", but its evidence hash is lower, so it keeps the settlement. + assert.deepEqual(volume([deal("a", "f".repeat(64), "1"), deal("z", "0".repeat(64), "2")]), [{ currency: "DEM", amount: "2" }]); + assert.deepEqual(volume([deal("z", "0".repeat(64), "2"), deal("a", "f".repeat(64), "1")]), [{ currency: "DEM", amount: "2" }]); + // Equal evidence hashes fall back to the jobId. + assert.deepEqual(volume([deal("z", "0".repeat(64), "2"), deal("a", "0".repeat(64), "1")]), [{ currency: "DEM", amount: "1" }]); +}); + test("DACS-5 uses one window basis and omits malformed currency aggregates", () => { const record = (jobId: string, values: Partial): DealRecord => ({ jobId, rail: "pay-dem", buyerBundleRef: locator(150 + Number(jobId)), From bf6d935980a04870e8a68e02eba7c65f56487df6 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 11/42] fix(dacs-directory): treat the node's 400 INVALID_REQUEST storage answer as terminal The node answers 400 INVALID_REQUEST for an address no program can occupy (kynesyslabs/node routes.ts). The scanner classed it as the transient STORAGE_RPC_UNAVAILABLE and retried it. Class it with STORAGE_NOT_FOUND; an unexplained 400 stays retryable. Co-Authored-By: Claude Opus 5.5 --- reference-implementations/dacs-directory/src/catalog/scan.ts | 2 ++ .../dacs-directory/test/scan-addresses.test.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/reference-implementations/dacs-directory/src/catalog/scan.ts b/reference-implementations/dacs-directory/src/catalog/scan.ts index 25de30f..45004a1 100644 --- a/reference-implementations/dacs-directory/src/catalog/scan.ts +++ b/reference-implementations/dacs-directory/src/catalog/scan.ts @@ -101,6 +101,8 @@ export function storageReadFailureCode(status: number, errorCode?: string): Stor return "STORAGE_NOT_FOUND"; } if (normalized === "PERMISSION_DENIED" || normalized === "UNAUTHORIZED") return "STORAGE_NOT_PUBLIC"; + // The node answers 400 INVALID_REQUEST for an address no program can occupy; retrying cannot help. + if (status === 400 && normalized === "INVALID_REQUEST") return "STORAGE_NOT_FOUND"; return status >= 200 && status < 300 ? "STORAGE_INVALID_RESPONSE" : "STORAGE_RPC_UNAVAILABLE"; } diff --git a/reference-implementations/dacs-directory/test/scan-addresses.test.ts b/reference-implementations/dacs-directory/test/scan-addresses.test.ts index bba2b7e..afa912e 100644 --- a/reference-implementations/dacs-directory/test/scan-addresses.test.ts +++ b/reference-implementations/dacs-directory/test/scan-addresses.test.ts @@ -137,6 +137,9 @@ test("storage failures are classified into public-safe operational causes", () = assert.equal(storageReadFailureCode(503), "STORAGE_RPC_UNAVAILABLE"); assert.equal(storageReadFailureCode(503, "NOT_FOUND"), "STORAGE_RPC_UNAVAILABLE"); assert.equal(storageReadFailureCode(200), "STORAGE_INVALID_RESPONSE"); + // The node's 400 INVALID_REQUEST is terminal; an unexplained 400 stays retryable. + assert.equal(storageReadFailureCode(400, "INVALID_REQUEST"), "STORAGE_NOT_FOUND"); + assert.equal(storageReadFailureCode(400), "STORAGE_RPC_UNAVAILABLE"); }); test("terminal storage failures skip retries while transient failures remain bounded", async () => { From f08877c8cf03ea997f11796925348d26c1a29bab Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:41 +0200 Subject: [PATCH 12/42] fix(dacs-directory): show only the seller's own delivery attestation on the deal page The deal page read the delivery attestation at the seller's derived address and said "The seller signed this delivery attestation" before any check ran. The node does not check that an address matches its derivation, so another writer's record there was shown as the seller's. Show the card only when the record's storage owner is the expected seller, and say its signature still has to be checked. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/app/deal/[ref]/page.tsx | 15 ++++-- .../dacs-directory/test/deal-page.test.ts | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/deal-page.test.ts diff --git a/reference-implementations/dacs-directory/app/deal/[ref]/page.tsx b/reference-implementations/dacs-directory/app/deal/[ref]/page.tsx index ce6f7e3..d56fce1 100644 --- a/reference-implementations/dacs-directory/app/deal/[ref]/page.tsx +++ b/reference-implementations/dacs-directory/app/deal/[ref]/page.tsx @@ -4,8 +4,8 @@ import type { Metadata } from "next"; import CopyText from "@/src/components/CopyText"; import VerifyAttestation from "@/src/components/VerifyAttestation"; import VerifyDeal from "@/src/components/VerifyDeal"; -import { readAnchor } from "@/src/catalog/chain"; -import { resolveProgramAddress } from "@/src/catalog/store"; +import { readAnchor, readAnchorRecord } from "@/src/catalog/chain"; +import { canonicalProgramOwner, resolveProgramAddress } from "@/src/catalog/store"; const EXPLORER = "https://explorer.demos.sh"; @@ -47,7 +47,11 @@ export default async function Deal({ const attestationAddress = sellerClaim && jobId ? resolveProgramAddress(sellerClaim, deliveryName) : null; - const attestation = attestationAddress ? await readAnchor(attestationAddress) : null; + // The node does not check that an address matches its derivation, so only a record the + // expected seller owns is shown; its signature is still unverified until the check runs. + const attestationRecord = attestationAddress ? await readAnchorRecord(attestationAddress) : null; + const attestation = attestationRecord?.owner && sellerClaim && + canonicalProgramOwner(attestationRecord.owner) === canonicalProgramOwner(sellerClaim) ? attestationRecord.data : null; const att = attestation as { repo?: string; pullNumber?: number; reviewId?: number; ghAuthor?: string; deliveredAt?: string; ghStateHash?: string; @@ -101,9 +105,10 @@ export default async function Deal({ )}

- The seller signed this delivery attestation (DACS-X) over the GitHub state at + The seller's storage holds this delivery attestation (DACS-X) over the GitHub state at delivery time — state hash {att.ghStateHash?.slice(0, 16)}…. - The attestation rides beside the bundle until the SDK can reference it in-bundle. + Check its signature to confirm the seller signed it. The attestation rides beside the + bundle until the SDK can reference it in-bundle.

)} diff --git a/reference-implementations/dacs-directory/test/deal-page.test.ts b/reference-implementations/dacs-directory/test/deal-page.test.ts new file mode 100644 index 0000000..44ef1c0 --- /dev/null +++ b/reference-implementations/dacs-directory/test/deal-page.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import * as React from "react"; +import { isValidElement, type ReactNode } from "react"; + +// The deal page shows a delivery attestation read from the seller's derived address. +// tsconfig preserves JSX for Next.js, so tsx compiles the page with the classic runtime. +(globalThis as { React?: typeof React }).React = React; +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-deal-page-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const { default: Deal } = await import("../app/deal/[ref]/page.js"); +const { resolveProgramAddress } = await import("../src/catalog/store.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +const sellerHex = "5".repeat(64); +const seller = `did:demos:agent:${sellerHex}`; +const bundleRef = `stor-${"12".repeat(20)}`; +const attestationAt = resolveProgramAddress(seller, "dacsx:delivery:job-delivered")!; +const attestation = { repo: "org/repo", pullNumber: 7, reviewId: 9, ghAuthor: "reviewer", ghStateHash: "f".repeat(64) }; + +/** Every text node in an unrendered element tree; components are not called. */ +function texts(node: ReactNode): string[] { + if (typeof node === "string" || typeof node === "number") return [String(node)]; + if (Array.isArray(node)) return node.flatMap(texts); + return isValidElement<{ children?: ReactNode }>(node) ? texts(node.props.children) : []; +} + +test("the deal page shows a delivery attestation only from the seller's own storage, and never claims it is signed unchecked", async () => { + const originalFetch = globalThis.fetch; + const render = async (attestationOwner: string) => { + globalThis.fetch = async (input) => { + const at = String(input).replace(/^.*\/storage-program\//, ""); + const [owner, data] = at === bundleRef ? [`0x${"1".repeat(64)}`, { jobId: "job-delivered", outcome: "completed" }] + : at === attestationAt ? [attestationOwner, attestation] : [null, null]; + return data ? Response.json({ success: true, owner, programName: "opaque", data }) : new Response(null, { status: 404 }); + }; + return texts(await Deal({ params: Promise.resolve({ ref: bundleRef }), searchParams: Promise.resolve({ seller }) })).join(""); + }; + try { + const owned = await render(`0x${sellerHex}`); + assert.match(owned, /What was delivered/); + assert.doesNotMatch(owned, /The seller signed this delivery attestation/); + assert.match(owned, /Check its signature to confirm the seller signed it/); + // Another writer's record at the derived address is not the seller's attestation. + assert.doesNotMatch(await render(`0x${"6".repeat(64)}`), /What was delivered/); + } finally { + globalThis.fetch = originalFetch; + } +}); From c89dc6ccaa8831032f2163d8ddaff9c7ab42e05e Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:42 +0200 Subject: [PATCH 13/42] docs(dacs-directory): record the pinned upstreams and correct stale notes - README: name the Standard, SDK and node revisions the verdicts depend on and the node's storage-read contract; list ARTIFACT_REJECTED and its retry class; describe the binding ceiling as it is since #94 (32 per signer of each job and role, no total across signers). - SPEC_PIN: the did:demos:agent profile is Ed25519, so listings signed with ecdsa-secp256k1 or sr1-aggregate are not verified. - The SDK repository is public; the token is only for a private mirror (README, setup-sdk.sh, CI). - The sessionAnchorName import comments: upstream exported it after the pinned SDK_REV. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dacs-directory-ci.yml | 8 ++--- .../dacs-directory/README.md | 32 ++++++++++++++++--- .../dacs-directory/SPEC_PIN | 4 ++- .../dacs-directory/scripts/setup-sdk.sh | 6 ++-- .../dacs-directory/src/catalog/indexer.ts | 5 +-- .../src/components/VerifyDeal.tsx | 3 +- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/.github/workflows/dacs-directory-ci.yml b/.github/workflows/dacs-directory-ci.yml index 844cbff..29512db 100644 --- a/.github/workflows/dacs-directory-ci.yml +++ b/.github/workflows/dacs-directory-ci.yml @@ -42,11 +42,9 @@ jobs: node-version: "22" - name: Vendor + build the pinned dacs-sdk, install app deps - # The vendored dacs-sdk is a private repo; setup-sdk.sh authenticates - # the nested clone with DACS_SDK_GITHUB_TOKEN when present. Add a - # read-only token (repo secret) with access to DACS-Agent-commerce/dacs-sdk - # or this step fails at the clone. The default GITHUB_TOKEN cannot read - # sibling private repos. + # DACS-Agent-commerce/dacs-sdk is public, so the clone needs no token. + # setup-sdk.sh still authenticates the nested clone with + # DACS_SDK_GITHUB_TOKEN when present, for builds from a private mirror. env: DACS_SDK_GITHUB_TOKEN: ${{ secrets.DACS_SDK_GITHUB_TOKEN }} run: npm run setup diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 4fff031..3c0a2a7 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -114,7 +114,7 @@ vendor directory. | `DEMOS_RPC` | No | Demos RPC base URL; defaults to the public testnet endpoint | | `NEXT_PUBLIC_DEMOS_RPC` | No | Credential-free public HTTPS Demos RPC advertised to machine clients; defaults to the public testnet endpoint and deliberately does not inherit `DEMOS_RPC` | | `DACS_ADMIN_TOKEN` | Production | Bearer token for the operational reindex endpoints | -| `DACS_SDK_GITHUB_TOKEN` | GitHub deploy | Fine-grained, read-only token for cloning the pinned private SDK during the build; not needed after the SDK is published | +| `DACS_SDK_GITHUB_TOKEN` | No | Fine-grained, read-only token for cloning the pinned SDK from a private mirror during the build; the upstream SDK repository is public | | `DACS_DIRECTORY_DATA` | No | Writable directory for the SQLite repository and legacy JSON migration inputs | | `DACS_SCAN_MAX_TXS` | No | Maximum transactions scanned per pass; defaults to `100000` and fails closed if insufficient | | `DACS_SCAN_FINALITY_DEPTH` | No | Newest transaction count held back before indexing; defaults to `2` | @@ -171,9 +171,13 @@ DACS artifact; it does not attribute a publishing failure to an agent. Raw excep payloads, internal URLs and stack traces are never returned. Storage-read diagnostics distinguish `STORAGE_NOT_FOUND`, `STORAGE_NOT_PUBLIC`, -`STORAGE_RPC_UNAVAILABLE`, and `STORAGE_INVALID_RESPONSE`. Missing and non-public +`STORAGE_RPC_UNAVAILABLE`, `STORAGE_INVALID_RESPONSE`, and `ARTIFACT_REJECTED`. Missing and non-public locators are terminal until a later chain replay observes them again; transient node -and response failures retain bounded retries. `STORAGE_NOT_FOUND` is operational +and response failures retain bounded retries. `ARTIFACT_REJECTED` means the content was +read but fails the artifact policy (oversized, a DACS-named program without object data, +or no canonical JSON form). It has its own retry class: it is never dead-lettered, it is +retried with backoff while at most 1,000 rejected locators are active, and the +longest-waiting one takes the next free slot. `STORAGE_NOT_FOUND` is operational diagnostic evidence only: under the current Demos mapping it is never authoritative DACS-5 absence evidence and cannot satisfy BB-8. @@ -200,8 +204,10 @@ DACS-5 absence evidence and cannot satisfy BB-8. published `active`. Once a marker verifies, the listing stays `revoked` with its binding even if the marker later cannot be read (RB-3). BB-4-valid BundleBindings are also classified and accumulated under a deterministic - per-job/role total-work ceiling; any overflow is sticky and makes that side - `indeterminate`, never absent. + ceiling of 32 per signer of each job and role; another signer's bindings never use + that budget (BB-6). Overflow is sticky and makes that signer's side `indeterminate`, + never absent. There is no total across signers, so stored bindings, and the work to + re-verify them each pass, grow with the number of signers for one job. 3. **Evidence graph and federation**: current bundle copies are reached only after deriving the role-specific logical address and resolving a signed BundleBinding. The optional DACS-1 well-known bundle-binding index is hash-bound and SSRF-bounded. @@ -258,6 +264,22 @@ unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, vo settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog computations remain advisory and independently reproducible from their refs. +**Pinned upstreams.** Verdicts depend on three upstreams, each pinned: + +- **DACS Standard**: `SPEC_PIN` records the revision this Directory implements (v0.4, + `4bb9e48`) and the conformance vector files vendored under `test/fixtures/dacs-standard/`, + whose hashes `test/sig6.test.ts` checks. The `did:demos:agent` primary-claim profile is + the one deliberate addition. +- **dacs-sdk**: `SDK_REV` in `scripts/setup-sdk.sh`. It supplies canonical hashing, + Ed25519, CCI parsing and the verifier for the labelled legacy SDK path. +- **Demos node**: the storage-read contract was checked against `kynesyslabs/node` + `ac8f45f`. `GET /storage-program/
` answers 400 `INVALID_REQUEST` for an + address without the `stor-` prefix, 404 `NOT_FOUND`, 403 `PERMISSION_DENIED`, or + `{success, owner, programName, data, ...}`; a program holds at most 1 MiB + (`STORAGE_PROGRAM_MAX_SIZE_BYTES`). `test/chain-storage.test.ts` pins how the indexer + classifies each answer. Only a transport or server failure leaves a revocation check + incomplete. + ## License MIT. This in-tree submission is covered by the Community repository's root diff --git a/reference-implementations/dacs-directory/SPEC_PIN b/reference-implementations/dacs-directory/SPEC_PIN index c141c2d..8fd5ace 100644 --- a/reference-implementations/dacs-directory/SPEC_PIN +++ b/reference-implementations/dacs-directory/SPEC_PIN @@ -2,7 +2,9 @@ # github.com/DACS-Agent-commerce/DACS-Standard, v0.4 (annotated tag 0be9e80b342d0f3e269ae9ab45c2bbc372f161d4) 4bb9e48a1095ab32c06c25b7c0b52018d3ce4091 # Profile notes: primary claims use the did:demos:agent ClaimReference profile -# (src/catalog/claimRef.ts), which the pinned revision does not yet define. +# (src/catalog/claimRef.ts), which the pinned revision does not yet define. Its keys +# are Ed25519, so artifacts signed with ecdsa-secp256k1 or sr1-aggregate cannot be +# verified under this profile and are not published or counted. # Vendored conformance vectors (byte-identical copies; sha256 checked by test/sig6.test.ts): # conformance/vectors/security/signature-value-encoding-v0.1.json bf47cd0a42b0e0e30f2ff032327395d5a1b820758e6188c51212eb9129ba8312 # conformance/vectors/security/revocation-binding-v0.3.json 3e83dfe5cb603e3dce98a4280e2456bb30bcae9979f4a0db1ae322982bdaf1ce diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index ef9bdeb..adc09b2 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -4,9 +4,9 @@ set -euo pipefail cd "$(dirname "$0")/.." SDK_REV="44d8ff2a07df8c951b94619d20b957b4bb5ce140" -# Railway's GitHub integration can check out this repository, but it does not -# pass its credentials through to nested private-repository clones. Supply a -# narrowly scoped, read-only token as DACS_SDK_GITHUB_TOKEN for those builds. +# The upstream SDK repository is public. Railway's GitHub integration does not +# pass its credentials through to nested clones, so a build from a private +# mirror needs a narrowly scoped, read-only token as DACS_SDK_GITHUB_TOKEN. # The credential is passed through Git's environment-based configuration so it # is never written to the checkout or embedded in the remote URL. git_with_sdk_auth() { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 61f78ac..ac5e58a 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -25,8 +25,9 @@ import { parseCciRecord } from "@kynesyslabs/dacs/identity"; // import) and breaks the production webpack build. Reach into the leaf // module directly to keep this file free of the substrate client. import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; -// The SDK doesn't export sessionAnchorName from its public barrel -// (dacs-sdk#178, follow-up to #14) — reach into the vendored build. +// The SDK at SDK_REV (2026-07-04) doesn't export sessionAnchorName from its public +// barrel; upstream fixed that after the pin (dacs-sdk#178, follow-up to #14), so +// reach into the vendored build until the pin moves. import { sessionAnchorName } from "../../vendor/dacs-sdk/dist/agent/runSessionCore.js"; import { readAnchor, readAnchorRecord, readAnchorResult } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index f6c6ca9..4059eef 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -32,7 +32,8 @@ const keyFromDid = (did: string): Uint8Array | null => { return hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; }; -// Mirrors the SDK's sessionAnchorName (not exported publicly — dacs-sdk#14). +// Mirrors the SDK's sessionAnchorName, which the pinned SDK_REV does not export +// publicly (fixed upstream after the pin: dacs-sdk#14). const anchorName: Record string> = { "dacs-3-agreement": (j) => `dacs3:agreement:${j}`, "dacs-4-evidence": (j) => `dacs4:evidence:${j}`, From a40203be580b7c792cea9a95b0dded61c14eef87 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:05 +0200 Subject: [PATCH 14/42] test(dacs-directory): pin the listing gate and RB-4 rules the mutation sweep left untested A mutation sweep of the listing verifier and indexer showed most listing acceptance and revocation rules could be removed without a failing test. - listing-rules: every DACS-1 identity, offering, pricing, pipeline and validity rule and every schema member at its boundary, the per-claim presentation, the legacy-sdk-v0.1 envelope, the indexer's publication gate (readable, owner-bound, hash-pinned, in its window), and RB-3 retention scoped to the same listing version. - revocation-vectors: run the vendored revocation-binding-v0.3 vectors through the Directory's verifier with the vector's key, and pin RB-4's tuple and envelope checks with markers that are validly signed, so only the rule under test can reject them. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/test/listing-rules.test.ts | 396 ++++++++++++++++++ .../test/revocation-vectors.test.ts | 135 ++++++ 2 files changed, 531 insertions(+) create mode 100644 reference-implementations/dacs-directory/test/listing-rules.test.ts create mode 100644 reference-implementations/dacs-directory/test/revocation-vectors.test.ts diff --git a/reference-implementations/dacs-directory/test/listing-rules.test.ts b/reference-implementations/dacs-directory/test/listing-rules.test.ts new file mode 100644 index 0000000..8cc13eb --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-rules.test.ts @@ -0,0 +1,396 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; + +// DACS-1 listing validation (§6.3.4 steps 1-3, LR-2) and the indexer's publication gate, one case per +// rule. Every case is signed again, so only the rule under test can reject it. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-listing-rules-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const { verifyListing } = await import("../src/catalog/listingVerification.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +type Obj = Record; +const seeds = [61, 62].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const keys = seeds.map((seed) => Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")); +const dids = keys.map((key) => `did:demos:agent:${key}`); +const sign = (message: string, signer = 0, encoding: BufferEncoding = "base64url") => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[signer]))).toString(encoding); +const tail = [{ kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }]; +const RFQ = { maxTurns: 6, timeoutSec: 300 }; +const SEALED = { commitDeadline: 1784016000000, revealWindow: 3600, selectionRule: "lowest-price" }; +const negotiable = { kind: "negotiable", bandCenter: { amount: "1", currency: "DEM" }, minPct: 10, maxPct: 10 }; +const auction = { kind: "auction", selectionRule: "lowest-price" }; + +function identityFor(claim: string, presentation: (identity: Obj) => Obj | undefined) { + const identity: Obj = { bundleVersion: "1", presentedBy: claim, presentedAt: 1, claims: [{ ref: claim }] }; + const value = presentation(identity); + return value === undefined ? identity : { ...identity, presentation: value }; +} +const perClaim = (identity: Obj, signer = 0, encoding: BufferEncoding = "hex") => + ({ kind: "per-claim", signatures: [{ ref: identity.presentedBy, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`, signer, encoding) }] }); + +/** A valid current listing, edited by `change` before it is signed by `signer`. */ +function listing(change: (scope: Obj) => void = () => {}, options: { signer?: number; envelope?: Obj | null; presentation?: (identity: Obj) => Obj | undefined } = {}): Obj { + const scope: Obj = { + dacsVersion: "1", listingVersion: 1, listingId: "rules-listing", requiredCapabilities: ["SR-2"], + seller: { displayName: "Rules desk", identity: identityFor(dids[0], options.presentation ?? ((identity) => perClaim(identity))) }, + offering: { title: "Rules", description: "Listing rules", category: "rules.test", tags: ["rules"], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + buyerRequirement: { requirementVersion: "1", required: [] }, + pipeline: [{ kind: "negotiate-fixed-price" }, ...tail], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "pay-dem" }], terms: {}, validity: { notBefore: 1 }, + }; + change(scope); + if (options.envelope === null) return scope; + const signer = options.signer ?? 0; + return { ...scope, signature: { algorithm: "ed25519", signer: dids[signer], value: sign(`dacs-listing:v1:${contentHash(scope)}`, signer), ...options.envelope } }; +} +const at = (scope: Obj, path: string) => path.split(".").reduce((node, key) => node[key] as Obj, scope); +const set = (path: string, value: unknown) => (scope: Obj) => { + const keys = path.split("."); const last = keys.pop()!; + const node = keys.length ? at(scope, keys.join(".")) : scope; + if (value === undefined) delete node[last]; else node[last] = value; +}; +const pricedWith = (pricing: Obj, negotiation: Obj) => (scope: Obj) => { scope.pricing = pricing; scope.pipeline = [negotiation, ...tail]; }; +const step = (step: Obj, pricing: Obj = scope0Pricing) => (scope: Obj) => { scope.pricing = pricing; scope.pipeline = [step, ...tail]; }; +const scope0Pricing = { kind: "fixed", price: { amount: "1", currency: "DEM" } }; + +async function verdicts(cases: Record void>): Promise> { + const out: Record = {}; + for (const [name, change] of Object.entries(cases)) out[name] = (await verifyListing(listing(change))) !== null; + return out; +} +const all = (names: Record, value: boolean) => Object.fromEntries(Object.keys(names).map((name) => [name, value])); + +test("DACS-1 §6.3.2: identity, offering, pricing, pipeline and validity rules accept their boundaries", async () => { + const accepted: Record void> = { + "the base listing": () => {}, + "a 128-character listingId of every allowed character": set("listingId", "aZ09._~-".repeat(16)), + "a 200-character displayName": set("seller.displayName", "d".repeat(200)), + "a public HTTPS endpoint": set("seller.publicEndpoint", "https://agent.example/a2a"), + "a 200-character title": set("offering.title", "t".repeat(200)), + "a 2,000-character description": set("offering.description", "d".repeat(2000)), + "a 64-character category": set("offering.category", "c".repeat(64)), + "16 tags of 32 characters": set("offering.tags", Array.from({ length: 16 }, () => "t".repeat(32))), + "negotiable pricing at 0% and 99%": pricedWith({ ...negotiable, minPct: 99, maxPct: 0 }, { kind: "negotiate-rfq", parameters: RFQ }), + "an amount below one": set("pricing.price", { amount: "0.5", currency: "DEM" }), + "a 64-character currency and unit": set("pricing.price", { amount: "1", currency: "c".repeat(64), unit: "u".repeat(64) }), + "metered pricing with a minimum total in its currency": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: "u".repeat(64), minTotal: { amount: "2", currency: "DEM" } }, { kind: "negotiate-fixed-price" }), + "an auction with a reserve price": pricedWith({ ...auction, reservePrice: { amount: "2", currency: "DEM" } }, { kind: "negotiate-sealed-envelope", parameters: SEALED }), + "an intake-only pipeline without rails": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "deliver-attested-payload" }]; delete scope.acceptedRails; }, + "a vet phase before negotiation": (scope) => { scope.pipeline = [{ kind: "vet-credentials", parameters: {} }, { kind: "negotiate-fixed-price" }, ...tail]; }, + "notAfter equal to notBefore": set("validity", { notBefore: 5, notAfter: 5 }), + }; + const rejected: Record void> = { + "dacsVersion 2": set("dacsVersion", "2"), + "listingVersion 0": set("listingVersion", 0), + "listingVersion as a string": set("listingVersion", "1"), + "an empty listingId": set("listingId", ""), + "a 129-character listingId": set("listingId", "a".repeat(129)), + "a listingId with a space": set("listingId", "rules listing"), + "a listingId that is not a string": set("listingId", 7), + "a 201-character displayName": set("seller.displayName", "d".repeat(201)), + "no displayName": set("seller.displayName", undefined), + "a loopback endpoint": set("seller.publicEndpoint", "http://localhost/a2a"), + "no presentedBy": set("seller.identity.presentedBy", undefined), + "no claims": set("seller.identity.claims", []), + "claims without the publisher": set("seller.identity.claims", [{ ref: dids[1] }]), + "a claim ref that is not a string": set("seller.identity.claims", [{ ref: 7 }]), + "a 201-character title": set("offering.title", "t".repeat(201)), + "no title": set("offering.title", undefined), + "a 2,001-character description": set("offering.description", "d".repeat(2001)), + "no description": set("offering.description", undefined), + "an uppercase category": set("offering.category", "Rules.Test"), + "a 65-character category": set("offering.category", "c".repeat(65)), + "no category": set("offering.category", undefined), + "17 tags": set("offering.tags", Array.from({ length: 17 }, (_, n) => `t${n}`)), + "a 33-character tag": set("offering.tags", ["t".repeat(33)]), + "a tag that is not a string": set("offering.tags", [7]), + "an empty pipeline": set("pipeline", []), + "an unknown phase kind": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "deliver-by-post" }]; }, + "a phase kind that is not a string": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, { kind: 7 }]; }, + "two negotiation steps": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "negotiate-fixed-price" }, ...tail]; }, + "a commitment that does not follow negotiation": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "commit-agreement" }]; }, + "two commitment steps": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "commit-payee-bound-agreement" }]; }, + "no negotiation step": (scope) => { scope.pipeline = [...tail]; }, + "a negative minPct": pricedWith({ ...negotiable, minPct: -1 }, { kind: "negotiate-rfq", parameters: RFQ }), + "a minPct of 100": pricedWith({ ...negotiable, minPct: 100 }, { kind: "negotiate-rfq", parameters: RFQ }), + "minPct as a string": pricedWith({ ...negotiable, minPct: "10" }, { kind: "negotiate-rfq", parameters: RFQ }), + "a negative maxPct": pricedWith({ ...negotiable, maxPct: -1 }, { kind: "negotiate-rfq", parameters: RFQ }), + "no maxPct": pricedWith({ ...negotiable, maxPct: undefined }, { kind: "negotiate-rfq", parameters: RFQ }), + "a band centre of zero": pricedWith({ ...negotiable, bandCenter: { amount: "0", currency: "DEM" } }, { kind: "negotiate-rfq", parameters: RFQ }), + "an unknown pricing kind": set("pricing", { kind: "subscription", price: { amount: "1", currency: "DEM" } }), + "an auction reserve price of zero": pricedWith({ ...auction, reservePrice: { amount: "0", currency: "DEM" } }, { kind: "negotiate-sealed-envelope", parameters: SEALED }), + "a metered unit that is empty": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: "" }, { kind: "negotiate-fixed-price" }), + "a 65-character metered unit": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: "u".repeat(65) }, { kind: "negotiate-fixed-price" }), + "a metered unit that is not a string": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: 7 }, { kind: "negotiate-fixed-price" }), + "a metered unit price of zero": pricedWith({ kind: "metered", unitPrice: { amount: "0", currency: "DEM" }, unit: "call" }, { kind: "negotiate-fixed-price" }), + "a malformed minimum total": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: "call", minTotal: { amount: "two", currency: "DEM" } }, { kind: "negotiate-fixed-price" }), + "a minimum total in another currency": pricedWith({ kind: "metered", unitPrice: { amount: "1", currency: "DEM" }, unit: "call", minTotal: { amount: "2", currency: "USD" } }, { kind: "negotiate-fixed-price" }), + "an amount with a leading zero": set("pricing.price", { amount: "01", currency: "DEM" }), + "an amount with a trailing zero": set("pricing.price", { amount: "1.50", currency: "DEM" }), + "an amount of zero": set("pricing.price", { amount: "0", currency: "DEM" }), + "an amount with an exponent": set("pricing.price", { amount: "1e3", currency: "DEM" }), + "an amount that is a number": set("pricing.price", { amount: 1, currency: "DEM" }), + "an empty currency": set("pricing.price", { amount: "1", currency: "" }), + "a 65-character currency": set("pricing.price", { amount: "1", currency: "c".repeat(65) }), + "an empty price unit": set("pricing.price", { amount: "1", currency: "DEM", unit: "" }), + "a 65-character price unit": set("pricing.price", { amount: "1", currency: "DEM", unit: "u".repeat(65) }), + "a price unit that is not a string": set("pricing.price", { amount: "1", currency: "DEM", unit: 7 }), + "a pay phase without accepted rails": set("acceptedRails", []), + "a pay phase on a rail the listing does not accept": set("acceptedRails", [{ railId: "pay-x402" }]), + "a pay phase whose rail is not a string": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: 7 } }]; }, + "no notBefore": set("validity", {}), + "notBefore as a string": set("validity", { notBefore: "1" }), + "notAfter before notBefore": set("validity", { notBefore: 5, notAfter: 4 }), + }; + assert.deepEqual(await verdicts(accepted), all(accepted, true)); + assert.deepEqual(await verdicts(rejected), all(rejected, false)); + // The envelope: no signature, another algorithm, or a signer outside the publisher's claims. + assert.equal(await verifyListing(listing(undefined, { envelope: null })), null); + assert.equal(await verifyListing(listing(undefined, { envelope: { algorithm: "ecdsa-secp256k1" } })), null); + assert.equal(await verifyListing(listing(undefined, { signer: 1 })), null); + assert.equal(await verifyListing({ ...listing(), signatures: [] }), null); +}); + +test("DACS-1 validation step 1: schema boundaries of the recognised members", async () => { + const rfq = (parameters: Obj) => step({ kind: "negotiate-rfq", parameters: { ...RFQ, ...parameters } }, negotiable); + const sealed = (parameters: Obj, kind = "negotiate-sealed-envelope") => step({ kind, parameters: { ...SEALED, ...parameters } }, auction); + const requirement = (change: Obj) => set("buyerRequirement", { requirementVersion: "1", required: [], ...change }); + const claim = (change: Obj) => requirement({ required: [{ scheme: "lei", verificationRequired: true, ...change }] }); + const deliverable = (value: Obj) => set("offering.deliverable", value); + const accepted: Record void> = { + "an expected size of 0": deliverable({ kind: "storage-program", expectedSizeBytes: 0 }), + "an entitlement of 0 seconds": deliverable({ kind: "entitlement", durationSec: 0, renewable: false }), + "a claim pinned to recipe version 1 with maxAge 0": claim({ recipeVersion: 1, maxAge: 0, parameters: { tier: "1" } }), + "a rail at version 1": set("acceptedRails", [{ railId: "pay-dem", railVersion: 1 }]), + "an RFQ of two turns and a one-second timeout": rfq({ maxTurns: 2, timeoutSec: 1, channelSubnet: "subnet", rfqInitiator: "buyer" }), + "a reveal window of 60 seconds": sealed({ revealWindow: 60, channelSubnet: "subnet" }), + "terms at their zero values": set("terms", { deadlineSecAfterCommit: 0, retentionYears: 0, conflictOfLawsRule: "seller-jurisdiction" }), + "a rate phase without parameters": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "rate" }]; }, + "extended description members": set("offering.extendedDescriptionUrl", "https://desk.example/more"), + }; + const rejected: Record void> = { + "a negative expected size": deliverable({ kind: "storage-program", expectedSizeBytes: -1 }), + "an expected size as a string": deliverable({ kind: "attested-payload", payloadFormat: "application/json", expectedSizeBytes: "10" }), + "a schemaUrl that is not a string": deliverable({ kind: "storage-program", schemaUrl: 7 }), + "a negative entitlement": deliverable({ kind: "entitlement", durationSec: -1, renewable: false }), + "an empty payload format": deliverable({ kind: "attested-payload", payloadFormat: "" }), + "a verification method without a kind": deliverable({ kind: "attested-payload", payloadFormat: "application/json", verificationMethod: {} }), + "an external verification method that is a string": deliverable({ kind: "external", description: "receipt", verificationMethod: "tlsnotary" }), + "no deliverable": deliverable(undefined as unknown as Obj), + "an empty claim scheme": claim({ scheme: "" }), + "a claim scheme that is not a string": claim({ scheme: 7 }), + "a negative maxAge": claim({ maxAge: -1 }), + "maxAge as a string": claim({ maxAge: "60" }), + "recipe version 0": claim({ recipeVersion: 0 }), + "a recipe version as a string": claim({ recipeVersion: "1" }), + "claim parameters as a list": claim({ parameters: [] }), + "a required entry that is not an object": requirement({ required: ["lei"] }), + "required not a list": requirement({ required: {} }), + "oneOf not a list": requirement({ oneOf: {} }), + "a oneOf group that is not a list": requirement({ oneOf: [{ scheme: "lei", verificationRequired: true }] }), + "a oneOf group with an incomplete claim": requirement({ oneOf: [[{ scheme: "lei" }]] }), + "a primaryClaimSelector that is not a string": requirement({ primaryClaimSelector: 7 }), + "no buyerRequirement": set("buyerRequirement", undefined), + "a terms URL that is not a string": set("terms", { termsOfServiceUrl: 7 }), + "a terms hash that is not a string": set("terms", { termsOfServiceHash: 7 }), + "jurisdictions not a list": set("terms", { jurisdictions: "GB" }), + "a jurisdiction that is not a string": set("terms", { jurisdictions: [7] }), + "a rule-ref without a URI": set("terms", { conflictOfLawsRule: "rule-ref:" }), + "an unknown conflict-of-laws rule": set("terms", { conflictOfLawsRule: "nearest-court" }), + "a conflict-of-laws rule that is not a string": set("terms", { conflictOfLawsRule: 7 }), + "an unknown acceptance model": set("terms", { acceptanceModel: "manual" }), + "a negative retention": set("terms", { retentionYears: -1 }), + "an unknown disclosure policy": set("terms", { transcriptDisclosurePolicy: "public" }), + "a deadline as a string": set("terms", { deadlineSecAfterCommit: "3600" }), + "no terms": set("terms", undefined), + "an empty rail id": set("acceptedRails", [{ railId: "pay-dem" }, { railId: "" }]), + "a rail id that is not a string": set("acceptedRails", [{ railId: "pay-dem" }, { railId: 7 }]), + "rail version 0": set("acceptedRails", [{ railId: "pay-dem", railVersion: 0 }]), + "a rail version as a string": set("acceptedRails", [{ railId: "pay-dem", railVersion: "1" }]), + "rail parameters as a list": set("acceptedRails", [{ railId: "pay-dem", parameters: [] }]), + "acceptedRails not a list": set("acceptedRails", { railId: "pay-dem" }), + "requiredCapabilities not a list": set("requiredCapabilities", "SR-2"), + "a capability that is not a string": set("requiredCapabilities", [7]), + "an extended description URL that is not a string": set("offering.extendedDescriptionUrl", 7), + "maxTurns as a string": rfq({ maxTurns: "6" }), + "a timeout of 0": rfq({ timeoutSec: 0 }), + "a timeout as a string": rfq({ timeoutSec: "300" }), + "an RFQ subnet that is not a string": rfq({ channelSubnet: 7 }), + "an unknown RFQ initiator": rfq({ rfqInitiator: "both" }), + "a commit deadline as a string": sealed({ commitDeadline: "soon" }), + "a reveal window as a string": sealed({ revealWindow: "3600" }), + "an unknown selection rule": sealed({ selectionRule: "best" }), + "a sealed-envelope subnet that is not a string": sealed({ channelSubnet: 7 }), + "a procurement phase in demand mode": sealed({ auctionMode: "demand" }, "negotiate-sealed-envelope-procurement"), + "a pay phase without parameters": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem" }]; }, + "rate parameters as a list": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "rate", parameters: [] }]; }, + "a pipeline entry that is not an object": (scope) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, null]; }, + }; + assert.deepEqual(await verdicts(accepted), all(accepted, true)); + assert.deepEqual(await verdicts(rejected), all(rejected, false)); +}); + +test("DACS-1 §6.3.1: the listing's IdentityBundle presentation is the publisher's own per-claim signature", async () => { + const presented = async (presentation: (identity: Obj) => Obj | undefined) => (await verifyListing(listing(undefined, { presentation }))) !== null; + assert.deepEqual({ + "a hex presentation signature": await presented((identity) => perClaim(identity)), + "a base64url presentation signature": await presented((identity) => perClaim(identity, 0, "base64url")), + "a signature entry beside another claim's": await presented((identity) => ({ kind: "per-claim", signatures: [{ ref: dids[1], signature: "00" }, ...perClaim(identity).signatures] })), + "an SR-1 root presentation": await presented((identity) => ({ ...perClaim(identity), kind: "sr1-root" })), + "no presentation": await presented(() => undefined), + "signatures not a list": await presented((identity) => ({ kind: "per-claim", signatures: perClaim(identity).signatures[0] })), + "only another claim's signature": await presented((identity) => ({ kind: "per-claim", signatures: [{ ...perClaim(identity).signatures[0], ref: dids[1] }] })), + "a signature that is not a string": await presented(() => ({ kind: "per-claim", signatures: [{ ref: dids[0], signature: 7 }] })), + "a signature by another key": await presented((identity) => perClaim(identity, 1)), + }, { + "a hex presentation signature": true, "a base64url presentation signature": true, "a signature entry beside another claim's": true, + "an SR-1 root presentation": false, "no presentation": false, "signatures not a list": false, + "only another claim's signature": false, "a signature that is not a string": false, "a signature by another key": false, + }); +}); + +const legacyScope = { + listingId: "legacy", listingVersion: 1, agentId: dids[0], serviceId: "legacy", name: "Legacy", description: "Draft SDK listing", + claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], +}; +const legacyValue = (signer = 0, scope: Obj = legacyScope) => sign(`dacs-listing:v1:${contentHash(scope)}`, signer, "hex"); + +test("legacy-sdk-v0.1 listings: an Ed25519 envelope by the agentId's own key", async () => { + const envelope = (change: Obj = {}) => ({ algorithm: "ed25519", signer: dids[0], value: legacyValue(), ...change }); + const verdict = async (raw: Obj) => (await verifyListing(raw))?.profile ?? null; + assert.deepEqual({ + "an envelope": await verdict({ ...legacyScope, signature: envelope() }), + "a compact value": await verdict({ ...legacyScope, signature: legacyValue() }), + "a 0x value": await verdict({ ...legacyScope, signature: envelope({ value: `0x${legacyValue()}` }) }), + "another algorithm": await verdict({ ...legacyScope, signature: envelope({ algorithm: "ecdsa-secp256k1" }) }), + "a signer that is not a string": await verdict({ ...legacyScope, signature: envelope({ signer: 7 }) }), + "a value that is not a string": await verdict({ ...legacyScope, signature: envelope({ value: 7 }) }), + "a value that is not a signature": await verdict({ ...legacyScope, signature: envelope({ value: "zz" }) }), + "an envelope list": await verdict({ ...legacyScope, signature: [envelope()] }), + "no signature": await verdict({ ...legacyScope }), + "another signer's envelope": await verdict({ ...legacyScope, signature: envelope({ signer: dids[1], value: legacyValue(1) }) }), + "another agent's listing": await verdict({ ...legacyScope, agentId: dids[1], signature: envelope({ value: legacyValue(0, { ...legacyScope, agentId: dids[1] }) }) }), + "not a listing": await verdict({ listingId: "legacy", signature: envelope() }), + }, { + "an envelope": "legacy-sdk-v0.1", "a compact value": "legacy-sdk-v0.1", "a 0x value": "legacy-sdk-v0.1", + "another algorithm": null, "a signer that is not a string": null, "a value that is not a string": null, + "a value that is not a signature": null, "an envelope list": null, "no signature": null, + "another signer's envelope": null, "another agent's listing": null, "not a listing": null, + }); +}); + +test("the indexer publishes a verified listing only when it is readable, bound to its owner, hash-pinned and in its validity window", async () => { + const now = Date.now(); + const stored: Record = {}; + const anchor = (n: number, data: Obj, owner = keys[0]) => { + const at = `stor-${(0xb000 + n).toString(16).padStart(40, "0")}`; + stored[at] = { data, owner: `0x${owner}` }; + return at; + }; + const named = (listingId: string, change: (scope: Obj) => void = () => {}) => listing((scope) => { scope.listingId = listingId; change(scope); }); + const pinned = named("pinned"); + const anchors = { + published: anchor(1, named("published", (scope) => { scope.terms = { cancellationPolicy: "pre-commit" }; })), + pinned: anchor(2, pinned), + mispinned: anchor(3, named("mispinned")), + expired: anchor(4, named("expired", set("validity", { notBefore: 1, notAfter: now - 60_000 }))), + future: anchor(5, named("future", set("validity", { notBefore: now + 3_600_000 }))), + foreign: anchor(6, named("foreign"), keys[1]), + unsigned: anchor(7, named("unsigned", set("offering.title", 7))), + legacy: anchor(8, { ...legacyScope, listingVersion: 3, signature: legacyValue(0, { ...legacyScope, listingVersion: 3 }) }), + missing: `stor-${"f".repeat(40)}`, + }; + // A draft SDK listing named only by serviceId and without a version is version 1. + const { listingVersion: _, ...unversioned } = legacyScope; + const unversionedAt = anchor(9, { ...unversioned, serviceId: "unversioned", listingId: undefined, signature: legacyValue(0, { ...unversioned, serviceId: "unversioned", listingId: undefined }) }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const entry = stored[String(input).split("/").pop() ?? ""]; + return entry ? Response.json({ success: true, owner: entry.owner, programName: "opaque", data: entry.data }) : new Response(null, { status: 404 }); + }; + try { + const record = await indexRegistration({ + primaryClaim: dids[0], displayName: "Rules desk", listingAnchors: [...Object.values(anchors), unversionedAt], + listingContentHashes: { [anchors.pinned]: `sha256-${contentHash({ ...pinned, signature: undefined })}`, [anchors.mispinned]: "0".repeat(64) }, + }, undefined, async () => { throw new Error("identity unavailable"); }); + const published = Object.fromEntries(record.listings.map((entry) => [entry.listingId, entry])); + assert.deepEqual(Object.keys(published).sort(), ["legacy", "pinned", "published", "unversioned"]); + assert.equal(published.legacy.version, 3); + assert.equal(published.unversioned.version, 1); + assert.equal(published.legacy.artifactProfile, "legacy-sdk-v0.1"); + assert.equal(published.published.artifactProfile, "dacs-v0.1"); + assert.deepEqual(published.published.terms, { cancellationPolicy: "pre-commit" }); + assert.deepEqual(published.published.buyerRequirement, { requirementVersion: "1", required: [] }); + assert.equal(published.published.status, "active"); + assert.equal(Object.hasOwn(published.published, "revocationBinding"), false); + // The current listing's IdentityBundle is carried for the identity tier; a legacy listing has none. + assert.equal((record.identityBundle as Obj | undefined)?.presentedBy, dids[0]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("further listing rules: band floor, step order, claim binding and every-member checks", async () => { + // The publisher (presentedBy) must be among the claims even when the signer is, and its presentation verifies. + const otherPublisher = (scope: Obj) => { + const identity: Obj = { bundleVersion: "1", presentedBy: dids[1], presentedAt: 1, claims: [{ ref: dids[0] }] }; + (scope.seller as Obj).identity = { ...identity, presentation: perClaim(identity, 1) }; + }; + const twoPays = (scope: Obj) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, + { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "pay-x402", parameters: { rail: "pay-x402" } }]; }; + const claim = { scheme: "lei", verificationRequired: true }; + const accepted = { + "a minPct of 0": pricedWith({ ...negotiable, minPct: 0 }, { kind: "negotiate-rfq", parameters: RFQ }), + "two pay phases on accepted rails": (scope: Obj) => { twoPays(scope); scope.acceptedRails = [{ railId: "pay-dem" }, { railId: "pay-x402" }]; }, + "every oneOf group complete": set("buyerRequirement", { requirementVersion: "1", required: [], oneOf: [[claim], [claim, { ...claim, scheme: "domain" }]] }), + }; + const rejected = { + "a second negotiation after the commitment": (scope: Obj) => { scope.pipeline = [{ kind: "negotiate-fixed-price" }, ...tail, { kind: "negotiate-rfq", parameters: RFQ }]; }, + "a publisher outside the claims": otherPublisher, + "a pay phase on a rail the listing does not accept, beside one it does": twoPays, + "one unknown capability among known ones": set("requiredCapabilities", ["SR-2", "SR-9"]), + "a fixedPriceFallback that is not a boolean": step({ kind: "negotiate-rfq", parameters: { ...RFQ, fixedPriceFallback: "yes" } }, negotiable), + "one lowercase jurisdiction among valid ones": set("terms", { jurisdictions: ["GB", "gb"] }), + "one jurisdiction that is not a string": set("terms", { jurisdictions: ["GB", 7] }), + "a conflict-of-laws rule-ref that is not a string": set("terms", { conflictOfLawsRule: ["rule-ref:https://law.example"] }), + "one incomplete oneOf group": set("buyerRequirement", { requirementVersion: "1", required: [], oneOf: [[claim], [{ scheme: "domain" }]] }), + "one incomplete claim within a group": set("buyerRequirement", { requirementVersion: "1", required: [], oneOf: [[claim, { scheme: "domain" }]] }), + "one oneOf group that is not a list": set("buyerRequirement", { requirementVersion: "1", required: [], oneOf: [[claim], claim] }), + }; + assert.deepEqual(await verdicts(accepted), all(accepted, true)); + assert.deepEqual(await verdicts(rejected), all(rejected, false)); +}); + +test("RB-3 retention carries forward only this listing version's own revoked record and binding", async () => { + const at = `stor-${"c1".padStart(40, "0")}`; + const current = listing((scope) => { scope.listingId = "retained"; }); + const hash = contentHash({ ...current, signature: undefined }); + const binding = { sellerPrimaryClaim: dids[0], listingId: "retained", listingVersion: 1, listingContentHash: hash, + logicalAddress: `dacs1-revoked:${dids[0].replace(/:/g, "%3A")}:retained:v1`, markerAnchor: { kind: "storage-program", locator: `stor-${"d1".padStart(40, "0")}` }, markerContentHash: "e".repeat(64) }; + const prior = (listings: Obj[]) => ({ primaryClaim: dids[0], displayName: "Rules desk", identityTier: "self-declared", cci: [], listings, deals: [] } as never); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => String(input).endsWith(at) + ? Response.json({ success: true, owner: `0x${keys[0]}`, programName: "opaque", data: current }) : new Response(null, { status: 404 }); + try { + const statusWith = async (listings: Obj[]) => { + const record = await indexRegistration({ primaryClaim: dids[0], displayName: "Rules desk", listingAnchors: [at] }, prior(listings), + async () => { throw new Error("identity unavailable"); }); + const published = record.listings[0] as unknown as Obj; + return `${published.status} ${Object.hasOwn(published, "revocationBinding") ? "with binding" : "without binding"}`; + }; + assert.equal(await statusWith([{ listingId: "retained", contentHash: hash, status: "revoked", revocationBinding: binding }]), "revoked with binding"); + assert.equal(await statusWith([{ listingId: "retained", contentHash: "f".repeat(64), status: "revoked", revocationBinding: { ...binding, listingContentHash: "f".repeat(64) } }]), + "active without binding", "another version's revocation"); + assert.equal(await statusWith([{ listingId: "retained", contentHash: hash, status: "active", revocationBinding: binding }]), + "active without binding", "an active record's stray binding"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/revocation-vectors.test.ts b/reference-implementations/dacs-directory/test/revocation-vectors.test.ts new file mode 100644 index 0000000..9694d12 --- /dev/null +++ b/reference-implementations/dacs-directory/test/revocation-vectors.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { + findValidListingRevocation, + verifyListingRevocation, + type RevocationCandidateProgress, + type VerifiedListing, +} from "../src/catalog/listingVerification.js"; +import type { ResolvePrimaryClaimKey } from "../src/catalog/primaryClaimKey.js"; + +// DACS-1 RB-1..RB-6 against the pinned revocation-binding vectors (SPEC_PIN), then RB-4's tuple +// checks against markers that are validly signed, so only the tuple rule can reject them. +type Obj = Record; +interface Vector { + name: string; expected: string; markerRead: { status: string; fixture?: string | null; signatureOverride?: string }; + surface?: { binding?: string | null }; want: { revocationCheck: string; failedAt?: string }; +} +const vectors = JSON.parse(readFileSync(new URL("./fixtures/dacs-standard/revocation-binding-v0.3.json", import.meta.url), "utf8")) as { + publicKeys: Record; vectors: Vector[]; + fixtures: { listing: Obj & { listingId: string; listingVersion: number; listingContentHash: string; signer: string; sellerPrimaryClaim: string }; + markers: Record; bindings: Record }; +}; +const { fixtures } = vectors; +const vectorKeys: ResolvePrimaryClaimKey = async (claim, algorithm) => vectors.publicKeys[claim] && algorithm === "ed25519" + ? { canonicalClaim: claim, algorithm, publicKey: Uint8Array.from(Buffer.from(vectors.publicKeys[claim], "base64url")) } : null; +const vectorListing: VerifiedListing = { + listing: { listingId: fixtures.listing.listingId }, scope: { listingId: fixtures.listing.listingId }, + contentHash: fixtures.listing.listingContentHash, signer: fixtures.listing.signer, sellerClaim: fixtures.listing.sellerPrimaryClaim, profile: "dacs-v0.1", +}; +/** Reader-surface checks (well-known index integrity, catalog freshness, a received tombstone) that a producer never consumes. */ +const SURFACE_ONLY = new Set(["requiredBinding", "surfaceIntegrity", "statusBindingConsistency", "catalogFreshness"]); + +test("revocation-binding vectors: the marker reads a catalog producer performs give the vector's verdict and binding", async () => { + const applied: string[] = []; + for (const vector of vectors.vectors) { + if (vector.markerRead.status === "not-applicable" || SURFACE_ONLY.has(vector.want.failedAt ?? "")) continue; + const fixtureBinding = fixtures.bindings[vector.surface?.binding ?? "opaque-name"]; + const at = fixtureBinding.markerAnchor.locator; + const fixture = vector.markerRead.fixture ? fixtures.markers[vector.markerRead.fixture] : null; + const marker = fixture && vector.markerRead.signatureOverride + ? { ...fixture, signature: { ...fixture.signature, value: vector.markerRead.signatureOverride } } : fixture; + const progress: RevocationCandidateProgress = { unread: [], rejected: [] }; + const binding = await findValidListingRevocation([at], vectorListing, fixtures.listing.listingVersion, + async () => vector.markerRead.status === "ok" ? marker : null, vectorKeys, progress); + const verdict = binding ? "revoked" : progress.unread.length > 0 ? "indeterminate (unread)" : "indeterminate (rejected)"; + const { producerWriteInputs: _, ...published } = fixtureBinding; + const overrides = (vector as { bindingOverrides?: Obj }).bindingOverrides ?? {}; + if (vector.want.revocationCheck === "revoked") { + assert.deepEqual(binding, published, vector.name); + } else if (vector.want.failedAt === "logicalAddress" || vector.want.failedAt === "markerContentHash") { + // The override corrupts a received binding. A producer derives its binding from the marker it + // verified, so it publishes the vector's correct values and never the overridden one. + assert.deepEqual(binding, published, vector.name); + assert.notEqual(binding?.[vector.want.failedAt], overrides[vector.want.failedAt], vector.name); + } else { + assert.equal(verdict, vector.want.failedAt === "markerFetch" ? "indeterminate (unread)" : "indeterminate (rejected)", vector.name); + } + applied.push(vector.name); + } + assert.deepEqual(applied.sort(), [ + "binding-logical-address-mismatch", "binding-marker-content-hash-mismatch", "convention-name-binding-resolves-revoked", + "marker-anchor-unreachable", "marker-signature-invalid", "opaque-name-binding-resolves-revoked", + "valid-revocation-precedes-active-mirror", "validly-signed-marker-wrong-listing-version", + ]); + assert.equal(vectors.vectors.length, 14, "every other vector is a reader-surface check"); +}); + +const seed = Uint8Array.from(Buffer.alloc(32, 83)); +const otherSeed = Uint8Array.from(Buffer.alloc(32, 84)); +const didOf = (from: Uint8Array) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(from))).toString("hex")}`; +const listingHash = "c".repeat(64); +const listing: VerifiedListing = { + listing: { listingId: "svc" }, scope: { listingId: "svc" }, contentHash: listingHash, signer: didOf(seed), sellerClaim: didOf(seed), profile: "dacs-v0.1", +}; +const signed = async (scope: Obj, key = seed, envelope: Obj = {}) => ({ + ...scope, + signature: { algorithm: "ed25519", signer: didOf(seed), ...envelope, + value: Buffer.from(await ed25519Sign(Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"), privateKeyFromSeed(key))).toString("base64url") }, +}); + +test("RB-4: a validly signed marker revokes only the exact listing tuple, signed by the listing's own signer", async () => { + const tuple = { listingId: "svc", listingVersion: 2, listingContentHash: listingHash, revokedAt: 1 }; + const verdict = async (marker: Obj, expectedVersion = 2, against = listing) => verifyListingRevocation(marker, against, expectedVersion); + assert.equal(await verdict(await signed(tuple)), true); + assert.equal(await verdict(await signed({ ...tuple, reason: "withdrawn" })), true, "an optional reason is part of the signed scope"); + const rejected: Record = { + "another listingId": await verdict(await signed({ ...tuple, listingId: "svc-2" })), + "another listingVersion": await verdict(await signed({ ...tuple, listingVersion: 3 })), + "the listingVersion as a string": await verdict(await signed({ ...tuple, listingVersion: "2" })), + "a version the caller does not expect": await verdict(await signed(tuple), 3), + "another listingContentHash": await verdict(await signed({ ...tuple, listingContentHash: "d".repeat(64) })), + "a listingContentHash that is not a string": await verdict(await signed({ ...tuple, listingContentHash: 7 })), + "a revokedAt that is not a number": await verdict(await signed({ ...tuple, revokedAt: "1" })), + "another key's signature": await verdict(await signed(tuple, otherSeed)), + "an envelope naming another signer": await verdict(await signed(tuple, seed, { signer: didOf(otherSeed) })), + "a non-Ed25519 algorithm": await verdict(await signed(tuple, seed, { algorithm: "ecdsa-secp256k1" })), + "a listing signed by someone else": await verdict(await signed(tuple), 2, { ...listing, signer: didOf(otherSeed) }), + "a revokedAt beyond the safe-integer range": await verdict({ ...(await signed(tuple)), revokedAt: Number.MAX_SAFE_INTEGER + 2 }), + "no signature": await verdict({ ...tuple }), + "a signature list": await verdict({ ...tuple, signature: [(await signed(tuple)).signature] }), + "a bare signature string": await verdict({ ...tuple, signature: (await signed(tuple)).signature.value }), + "an envelope signer that is not a string": await verdict(await signed(tuple, seed, { signer: 7 })), + "an envelope value that is not a string": await verdict({ ...tuple, signature: { ...(await signed(tuple)).signature, value: 7 } }), + "a hex envelope value": await verdict({ ...tuple, signature: { ...(await signed(tuple)).signature, + value: Buffer.from((await signed(tuple)).signature.value as string, "base64url").toString("hex") } }), + "an envelope signer with no resolvable key": await verdict(await signed(tuple, seed, { signer: `cci:${"8".repeat(64)}` })), + }; + assert.deepEqual(rejected, Object.fromEntries(Object.keys(rejected).map((name) => [name, false]))); + // A legacy listing names its id as serviceId. + const legacy: VerifiedListing = { ...listing, scope: {}, listing: { serviceId: "svc" }, profile: "legacy-sdk-v0.1" }; + assert.equal(await verdict(await signed(tuple), 2, legacy), true); + assert.equal(await verdict(await signed({ ...tuple, listingId: "other" }), 2, legacy), false); +}); + +test("RB-4: the first valid marker in the batch wins and the binding names it; unread and rejected candidates are reported", async () => { + const tuple = { listingId: "svc", listingVersion: 2, listingContentHash: listingHash, revokedAt: 1 }; + const valid = await signed(tuple); + const stored: Record = { "stor-bad": await signed({ ...tuple, listingVersion: 3 }), "stor-gone": null, "stor-good": valid }; + const progress: RevocationCandidateProgress = { unread: [], rejected: [] }; + const binding = await findValidListingRevocation(["stor-bad", "stor-gone", "stor-good", "stor-bad"], listing, 2, + async (at) => stored[at], undefined, progress); + assert.deepEqual(binding, { + sellerPrimaryClaim: listing.sellerClaim, listingId: "svc", listingVersion: 2, listingContentHash: listingHash, + logicalAddress: `dacs1-revoked:${listing.sellerClaim.replace(/:/g, "%3A")}:svc:v2`, + markerAnchor: { kind: "storage-program", locator: "stor-good" }, markerContentHash: contentHash(tuple), + }); + assert.deepEqual(progress, { unread: ["stor-gone"], rejected: ["stor-bad"] }); + // The batch limit is honoured: a valid marker past it is not read. + const reads: string[] = []; + assert.equal(await findValidListingRevocation(["stor-bad", "stor-good"], listing, 2, async (at) => { reads.push(at); return stored[at]; }, undefined, undefined, 1), null); + assert.deepEqual(reads, ["stor-bad"]); +}); From 759a638c2906c60d3588d06bf0a8edfcf08c904b Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:05 +0200 Subject: [PATCH 15/42] test(dacs-directory): pin the evidence graph and BundleBinding rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current-profile evidence graph and the BB-4..BB-7 binding path had hundreds of surviving mutants on shape, reference, signature and party checks. - evidence-graph-rules: one case per DACS-5 bundle shape, party, signature and fault-attribution rule, per top-level and nested reference rule, per DACS-3/DACS-5 artifact shape and signature rule, DACS-2 vet records, CA-5/CA-7 bindings, payee-bound payout coverage and the §8.5.2 inputs. Each mutated artifact is signed again so only the rule under test can reject it. - bundle-binding-rules: BB-4 members and envelope, the per-signer stored ceiling and its overflow, and BB-5..BB-7 side resolution. - indexer-binding-rules: the indexer resolves each side only through its role holder's bindings, records the deal unverified otherwise, and reports full signature standing. Co-Authored-By: Claude Opus 5.5 --- .../test/bundle-binding-rules.test.ts | 146 ++++ .../test/evidence-graph-rules.test.ts | 628 ++++++++++++++++++ .../test/indexer-binding-rules.test.ts | 167 +++++ 3 files changed, 941 insertions(+) create mode 100644 reference-implementations/dacs-directory/test/bundle-binding-rules.test.ts create mode 100644 reference-implementations/dacs-directory/test/evidence-graph-rules.test.ts create mode 100644 reference-implementations/dacs-directory/test/indexer-binding-rules.test.ts diff --git a/reference-implementations/dacs-directory/test/bundle-binding-rules.test.ts b/reference-implementations/dacs-directory/test/bundle-binding-rules.test.ts new file mode 100644 index 0000000..33d2877 --- /dev/null +++ b/reference-implementations/dacs-directory/test/bundle-binding-rules.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { + boundedBundleBindings, + logicalBundleAddress, + MAX_BUNDLE_BINDING_CANDIDATES_PER_SIGNER, + MAX_BUNDLE_BINDINGS_PER_JOB_ROLE, + resolveBundleSide, + verifyBundleBinding, +} from "../src/catalog/bundleBinding.js"; +import type { BundleBinding } from "../src/catalog/types.js"; + +// DACS-5 BB-4..BB-7, one case per rule. Each malformed binding is signed again, so only the rule +// under test can reject it. +type Obj = Record; +const seeds = [71, 72].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const native = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; +const JOB = "binding-rules"; + +function scopeFor(change: Obj = {}): Obj { + return { bindingVersion: "1", jobId: JOB, role: "buyer", logicalAddress: logicalBundleAddress(JOB, "buyer"), nativeAddress: native(1), + bundleContentHash: "a".repeat(64), signer: dids[0], ...change }; +} +const signed = (scope: Obj, signer = 0, envelope: Obj = {}): Obj => ({ + ...scope, + signature: { algorithm: "ed25519", signer: dids[signer], + value: Buffer.from(ed25519Sign(Buffer.from(`dacs-bundle-binding:v1:${contentHash(scope)}`, "utf8"), privateKeyFromSeed(seeds[signer]))).toString("base64url"), ...envelope }, +}); +const binding = (change: Obj = {}, signer = 0) => signed(scopeFor(change), signer) as unknown as BundleBinding; + +test("BB-4: a binding's members, envelope and signature", async () => { + const verdict = async (value: unknown) => (await verifyBundleBinding(value)) !== null; + const accepted: Record = { + "the base binding": binding(), + "a 160-character jobId": signed(scopeFor({ jobId: "j".repeat(160), logicalAddress: logicalBundleAddress("j".repeat(160), "buyer") })), + "each role": signed(scopeFor({ role: "orchestrator" })), + "a 256-character anchorTx": signed(scopeFor({ anchorTx: "t".repeat(256) })), + "an unknown member": signed(scopeFor({ futureMember: { kept: true } })), + }; + const rejected: Record = { + "not an object": "binding", + "a list": [binding()], + "bindingVersion 2": signed(scopeFor({ bindingVersion: "2" })), + "a jobId that is not a string": signed(scopeFor({ jobId: 7 })), + "an empty jobId": signed(scopeFor({ jobId: "" })), + "a 161-character jobId": signed(scopeFor({ jobId: "j".repeat(161) })), + "a role that is not a string": signed(scopeFor({ role: 7 })), + "an unknown role": signed(scopeFor({ role: "auditor" })), + "a logical address that is not a string": signed(scopeFor({ logicalAddress: 7 })), + "a native-form logical address": signed(scopeFor({ logicalAddress: native(2) })), + "a native address that is not a string": signed(scopeFor({ nativeAddress: 7 })), + "a logical-form native address": signed(scopeFor({ nativeAddress: logicalBundleAddress(JOB, "buyer") })), + "a content hash that is not a string": signed(scopeFor({ bundleContentHash: 7 })), + "an uppercase content hash": signed(scopeFor({ bundleContentHash: "A".repeat(64) })), + "an anchorTx that is not a string": signed(scopeFor({ anchorTx: 7 })), + "a 257-character anchorTx": signed(scopeFor({ anchorTx: "t".repeat(257) })), + "a signer that is not a string": signed(scopeFor({ signer: 7 })), + "no signature": scopeFor(), + "another algorithm": signed(scopeFor(), 0, { algorithm: "ecdsa-secp256k1" }), + "an envelope signer that is not a string": signed(scopeFor(), 0, { signer: 7 }), + "an envelope value that is not a string": signed(scopeFor(), 0, { value: 7 }), + "a signer with no resolvable key": signed(scopeFor({ signer: `cci:${"8".repeat(64)}` })), + "an envelope naming another signer": signed(scopeFor(), 1), + "a padded envelope value": { ...binding(), signature: { ...binding().signature, value: `${binding().signature.value}==` } }, + "another key's signature for this signer": { ...binding(), signature: { ...binding({}, 1).signature, signer: dids[0] } }, + "over 16 KiB serialized": signed(scopeFor({ padding: "p".repeat(16_384) })), + }; + assert.deepEqual(Object.fromEntries(await Promise.all(Object.entries(accepted).map(async ([name, value]) => [name, await verdict(value)]))), + Object.fromEntries(Object.keys(accepted).map((name) => [name, true]))); + assert.deepEqual(Object.fromEntries(await Promise.all(Object.entries(rejected).map(async ([name, value]) => [name, await verdict(value)]))), + Object.fromEntries(Object.keys(rejected).map((name) => [name, false]))); + // The verified binding keeps unknown members and only the three envelope members. + const kept = await verifyBundleBinding({ ...signed(scopeFor({ futureMember: 1 })), signature: { ...signed(scopeFor({ futureMember: 1 })).signature as Obj } }); + assert.equal((kept as unknown as Obj).futureMember, 1); + assert.deepEqual(Object.keys(kept!.signature).sort(), ["algorithm", "signer", "value"]); +}); + +test("BB-6: the stored ceiling is per signer of each job and role, and its overflow is reported", () => { + const many = (count: number, signer = 0, role = "buyer") => Array.from({ length: count }, (_, n) => + binding({ nativeAddress: native(100 + n), role, logicalAddress: logicalBundleAddress(JOB, role as BundleBinding["role"]), signer: dids[signer] }, signer)); + const exact = boundedBundleBindings(many(MAX_BUNDLE_BINDINGS_PER_JOB_ROLE)); + assert.equal(exact.bindings.length, MAX_BUNDLE_BINDINGS_PER_JOB_ROLE); + assert.deepEqual(exact.overflowKeys, []); + const over = boundedBundleBindings(many(MAX_BUNDLE_BINDINGS_PER_JOB_ROLE + 1)); + assert.equal(over.bindings.length, MAX_BUNDLE_BINDINGS_PER_JOB_ROLE); + assert.deepEqual(over.overflowKeys, [`${JOB}\nbuyer\n${dids[0]}`]); + // Another signer's or another role's bindings never share the budget, and duplicates count once. + const mixed = boundedBundleBindings([...many(MAX_BUNDLE_BINDINGS_PER_JOB_ROLE), ...many(3, 1), ...many(2, 0, "seller"), ...many(4)]); + assert.equal(mixed.bindings.length, MAX_BUNDLE_BINDINGS_PER_JOB_ROLE + 5); + assert.deepEqual(mixed.overflowKeys, []); + // The retained window is deterministic: the same input in any order keeps the same bindings. + const shuffled = boundedBundleBindings([...many(MAX_BUNDLE_BINDINGS_PER_JOB_ROLE + 6)].reverse()); + assert.deepEqual(shuffled.bindings.map((value) => value.nativeAddress), boundedBundleBindings(many(MAX_BUNDLE_BINDINGS_PER_JOB_ROLE + 6)).bindings.map((value) => value.nativeAddress)); +}); + +test("BB-5..BB-7: a side resolves only from its role holder's bindings, within budget, to one authoritative copy", async () => { + const inspectAll = async (value: BundleBinding) => ({ value: value.nativeAddress, bundleContentHash: value.bundleContentHash, fullSignatureStanding: true }); + const resolve = (bindings: BundleBinding[], options: Partial>[0]> = {}) => + resolveBundleSide({ jobId: JOB, role: "buyer", expectedSigner: dids[0], bindings, inspect: inspectAll, ...options }); + const base = binding(); + assert.deepEqual(await resolve([base]), { disposition: "present", binding: base, inspected: { value: native(1), bundleContentHash: "a".repeat(64), fullSignatureStanding: true } }); + const reason = async (bindings: BundleBinding[], options: Partial>[0]> = {}) => { + const side = await resolve(bindings, options); + return side.disposition === "present" ? `present ${side.binding.nativeAddress}` : side.reason; + }; + const sellerBinding = binding({ role: "seller", logicalAddress: logicalBundleAddress(JOB, "seller") }); + const NONE = "no verified BundleBinding for role"; + assert.deepEqual({ + "an expected signer that is not a Demos agent claim": await reason([base], { expectedSigner: "cci:someone" }), + "a sticky overflow": await reason([base], { overflow: true }), + "a binding for another job": await reason([binding({ jobId: "other", logicalAddress: logicalBundleAddress("other", "buyer") })]), + "a binding for another role": await reason([sellerBinding]), + "a logical address for another role": await reason([binding({ logicalAddress: logicalBundleAddress(JOB, "seller") })]), + "another signer's binding": await reason([binding({ signer: dids[1] }, 1)]), + "exactly the budget of distinct copies": await reason(Array.from({ length: MAX_BUNDLE_BINDING_CANDIDATES_PER_SIGNER }, (_, n) => binding({ nativeAddress: native(10 + n) }))), + "one copy over the budget": await reason(Array.from({ length: MAX_BUNDLE_BINDING_CANDIDATES_PER_SIGNER + 1 }, (_, n) => binding({ nativeAddress: native(10 + n) }))), + "every copy failing its post-fetch check": await reason([base], { inspect: async () => null }), + "a copy whose content hash differs": await reason([base], { inspect: async (value) => ({ value: value.nativeAddress, bundleContentHash: "b".repeat(64), fullSignatureStanding: true }) }), + "two equal-standing divergent copies": await reason([base, binding({ nativeAddress: native(2), bundleContentHash: "b".repeat(64) })]), + "a lone fully signed copy beside a lesser one": await reason([base, binding({ nativeAddress: native(2), bundleContentHash: "b".repeat(64) })], { + inspect: async (value) => ({ value: value.nativeAddress, bundleContentHash: value.bundleContentHash, fullSignatureStanding: value.nativeAddress === native(2) }), + }), + }, { + "an expected signer that is not a Demos agent claim": "role holder is not a supported canonical claim", + "a sticky overflow": "bundle-binding discovery cap exhausted", + "a binding for another job": NONE, "a binding for another role": NONE, "a logical address for another role": NONE, + "another signer's binding": NONE, + "exactly the budget of distinct copies": "present stor-000000000000000000000000000000000000000a", + "one copy over the budget": "BB-6 per-signer fetch budget exhausted", + "every copy failing its post-fetch check": "every BundleBinding failed BB-5 post-fetch checks", + "a copy whose content hash differs": "every BundleBinding failed BB-5 post-fetch checks", + "two equal-standing divergent copies": "authorized equal-standing bundle copies diverge", + "a lone fully signed copy beside a lesser one": `present ${native(2)}`, + }); +}); + +test("BB-4 and BB-6 boundaries: a one-character jobId, and a lone copy without full standing still resolves", async () => { + assert.notEqual(await verifyBundleBinding(signed(scopeFor({ jobId: "j", logicalAddress: logicalBundleAddress("j", "buyer") }))), null); + const lone = binding(); + const side = await resolveBundleSide({ jobId: JOB, role: "buyer", expectedSigner: dids[0], bindings: [lone], + inspect: async (value) => ({ value: value.nativeAddress, bundleContentHash: value.bundleContentHash, fullSignatureStanding: false }) }); + assert.equal(side.disposition, "present"); +}); diff --git a/reference-implementations/dacs-directory/test/evidence-graph-rules.test.ts b/reference-implementations/dacs-directory/test/evidence-graph-rules.test.ts new file mode 100644 index 0000000..ff43687 --- /dev/null +++ b/reference-implementations/dacs-directory/test/evidence-graph-rules.test.ts @@ -0,0 +1,628 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { + agreementListingConformance, + agreementPrice, + artifactHash, + buildCurrentEvidenceGraph, + isCurrentRef, + verifiedDiscoveryAgreement, + type ArtifactKind, +} from "../src/catalog/evidenceGraph.js"; + +// Each DACS rule the current-profile evidence graph enforces, one case per rule. Every mutated +// artifact is signed again, so only the rule under test can reject it. +type Obj = Record; +const seeds = [51, 52, 53].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const [BUYER, SELLER, OUTSIDER] = [0, 1, 2]; +const at = (n: number) => `stor-${(0x9000 + n).toString(16).padStart(40, "0")}`; +const JOB = "rules-job"; +const PREFIX: Record = { + listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", rating: "dacs-rating:v1:", + "verify-result": "dacs-verifyresult:v1:", composite: "dacs-composite:v1:", bundle: "dacs-bundle:v1:", +}; +const prefixFor = (raw: Obj, kind: ArtifactKind) => kind === "agreement" && raw.payeeBoundAgreementVersion !== undefined + ? "dacs-payee-bound-agreement:v1:" : kind === "bundle" && raw.faultBundleVersion !== undefined ? "dacs-fault-bundle:v1:" : PREFIX[kind]; +const signatureValue = async (message: string, signer: number) => + Buffer.from(await ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[signer]))).toString("base64url"); +const envelope = async (raw: Obj, kind: ArtifactKind, signer: number, as = signer, field: "signer" | "party" = "signer") => + ({ [field]: dids[as], algorithm: "ed25519", value: await signatureValue(prefixFor(raw, kind) + artifactHash(raw, kind), signer) }); +const refTo = (locator: string, raw: Obj, kind: ArtifactKind): Obj => + ({ anchor: { kind: "storage-program", locator }, contentHash: artifactHash(raw, kind) }); +const clone = (value: T): T => structuredClone(value); +const offered = { kind: "attested-payload", payloadFormat: "application/json" }; + +async function listingFor(commitment = "commit-agreement"): Promise { + const identity: Obj = { bundleVersion: "1", presentedBy: dids[SELLER], presentedAt: 1, claims: [{ ref: dids[SELLER] }] }; + const presentation = Buffer.from(await ed25519Sign(Buffer.from(`dacs-bundle-presentation:v1:${contentHash(identity)}`), + privateKeyFromSeed(seeds[SELLER]))).toString("hex"); + const scope: Obj = { + dacsVersion: "1", listingVersion: 1, listingId: "rules", requiredCapabilities: ["SR-2"], + seller: { identity: { ...identity, presentation: { kind: "per-claim", signatures: [{ ref: dids[SELLER], signature: presentation }] } }, displayName: "seller" }, + offering: { title: "rules", description: "rules", category: "services.test", tags: [], deliverable: offered }, + buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, + pipeline: [{ kind: "negotiate-fixed-price" }, { kind: commitment }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }], + pricing: { kind: "fixed", price: { amount: "1.25", currency: "DEM" } }, acceptedRails: [{ railId: "pay-dem" }], terms: {}, validity: { notBefore: 1 }, + }; + return { ...scope, signature: await envelope(scope, "listing", SELLER) }; +} +const listing = await listingFor(); +const payeeBoundListing = await listingFor("commit-payee-bound-agreement"); +const listingRefOf = (raw: Obj) => ({ listingId: "rules", version: 1, contentHash: artifactHash(raw, "listing") }); + +/** The scopes of one valid completed session; a case edits them before anything is signed. */ +function baseScopes(forListing = listing) { + const listingRef = listingRefOf(forListing); + return { + agreement: { + agreementVersion: "1", jobId: JOB, listingRef, derivedFromPattern: "fixed-price", + parties: [{ role: "buyer", primaryClaim: dids[BUYER] }, { role: "seller", primaryClaim: dids[SELLER] }], + terms: { price: { amount: "1.25", currency: "DEM" }, rail: { railId: "pay-dem" }, deliverable: { deliverableType: "attested-payload", hash: contentHash(offered) } }, + } as Obj, + evidence: { evidenceVersion: "1", jobId: JOB, phase: "pay-dem", phaseIndex: 2, outcome: "success", paymentTxRefs: ["tx"], observedAt: 100 } as Obj, + rating: { ratingVersion: "1", jobId: JOB, rater: dids[BUYER], target: dids[SELLER], targetRole: "seller", value: 5, ratedAt: 110 } as Obj, + bundle: { + bundleVersion: "1", jobId: JOB, outcome: "completed", listingRef, + parties: [{ role: "buyer", bundleHash: "a".repeat(64), primaryClaim: dids[BUYER] }, { role: "seller", bundleHash: "b".repeat(64), primaryClaim: dids[SELLER] }], + phaseSummary: [{ index: 2, kind: "pay-dem", outcome: "ok" }], vetRecords: [], + recipeRegistryVersion: 1, railRegistryVersion: 1, finalisedAt: 120, + } as Obj, + }; +} +type Scopes = ReturnType; +interface Session { store: Map; scopes: Scopes; stored: Record; refs: Record } +interface Case { + listing?: Obj; + scopes?: (scopes: Scopes) => void; + /** Signs and stores the artifacts; the defaults are the parties who produce each one. */ + agreementSigners?: number[]; evidenceSigner?: number; ratingSigner?: number; bundleSigners?: number[]; + artifacts?: (session: Session) => Promise | void; + bundle?: (bundle: Obj, session: Session) => Promise | void; + anchoredByRole?: unknown; + stored?: (bundle: Obj, session: Session) => void; +} + +async function evaluate(c: Case = {}) { + const forListing = c.listing ?? listing; + const scopes = baseScopes(forListing); + c.scopes?.(scopes); + const store = new Map([[at(1), forListing]]); + const session: Session = { store, scopes, stored: {}, refs: {} }; + const agreement = { ...scopes.agreement, signatures: await Promise.all((c.agreementSigners ?? [BUYER, SELLER]) + .map((signer) => envelope(scopes.agreement, "agreement", signer, signer, "party"))) }; + const evidence = { ...scopes.evidence, signature: await envelope(scopes.evidence, "evidence", c.evidenceSigner ?? SELLER) }; + const rating = { ...scopes.rating, signature: await envelope(scopes.rating, "rating", c.ratingSigner ?? BUYER) }; + Object.assign(session.stored, { agreement, evidence, rating }); + session.refs = { agreement: refTo(at(2), agreement, "agreement"), evidence: refTo(at(3), evidence, "evidence"), rating: refTo(at(4), rating, "rating") }; + store.set(at(2), agreement).set(at(3), evidence).set(at(4), rating); + await c.artifacts?.(session); + const bundle: Obj = { ...scopes.bundle, agreementRef: session.refs.agreement, settlementEvidence: [session.refs.evidence], ratingRefs: [session.refs.rating] }; + await c.bundle?.(bundle, session); + const signed: Obj = { ...bundle, signatures: await Promise.all((c.bundleSigners ?? [BUYER, SELLER]) + .map((signer) => envelope(bundle, "bundle", signer, signer, "party"))), anchoredByRole: c.anchoredByRole ?? "buyer" }; + c.stored?.(signed, session); + store.set(at(5), signed); + return buildCurrentEvidenceGraph(at(5), { + read: async (locator) => store.get(locator) ?? null, + resolveListing: async () => ({ locator: at(1), raw: forListing }), + }); +} + +/** Store a signed artifact at a fresh locator and return its reference. */ +async function publish(session: Session, n: number, scope: Obj, kind: ArtifactKind, signer: number): Promise { + const raw = { ...scope, signature: await envelope(scope, kind, signer) }; + session.store.set(at(n), raw); + return refTo(at(n), raw, kind); +} +const SHAPE = "invalid current DACS-5 bundle shape"; + +async function verdicts(cases: Record): Promise> { + const out: Record = {}; + for (const [name, c] of Object.entries(cases)) { + const graph = await evaluate(c); + out[name] = graph.ok ? "ok" : graph.reason ?? "failed without a reason"; + } + return out; +} + +test("the valid fixture session verifies, with every artifact in the graph", async () => { + const graph = await evaluate(); + assert.equal(graph.ok, true, graph.reason); + assert.equal(graph.refsVerified, true); + assert.deepEqual(graph.artifacts.map((artifact) => artifact.kind).sort(), ["agreement", "evidence", "listing", "rating"]); + assert.deepEqual(agreementPrice(graph.agreement), { amount: "1.25", currency: "DEM" }); +}); + +test("DACS-5 §10.4.1: the bundle's own shape, parties and signatures", async () => { + const phase = (change: Obj) => (bundle: Obj) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", ...change }]; }; + assert.deepEqual(await verdicts({ + "a singular signature member": { stored: (bundle) => { bundle.signature = bundle.signatures; } }, + "both version discriminators": { bundle: (bundle) => { bundle.faultBundleVersion = "1"; } }, + "another bundleVersion": { bundle: (bundle) => { bundle.bundleVersion = "2"; } }, + "a legacy bundle naming a faulted party": { bundle: (bundle) => { bundle.faultedParty = "none"; } }, + "a jobId that is not a string": { bundle: (bundle) => { bundle.jobId = 7; } }, + "an unknown outcome": { bundle: (bundle) => { bundle.outcome = "won"; } }, + "an unknown anchoredByRole": { anchoredByRole: "auditor" }, + "no listingRef": { bundle: (bundle) => { delete bundle.listingRef; } }, + "one party": { bundle: (bundle) => { bundle.parties = (bundle.parties as Obj[]).slice(0, 1); } }, + "phaseSummary not an array": { bundle: (bundle) => { bundle.phaseSummary = {}; } }, + "a phase entry that is not an object": { bundle: (bundle) => { bundle.phaseSummary = ["pay-dem"]; } }, + "a negative phase index": { bundle: phase({ index: -1 }) }, + "a phase index that is not an integer": { bundle: phase({ index: "2" }) }, + "a phase kind that is not a string": { bundle: phase({ kind: 7 }) }, + "an unknown phase outcome": { bundle: phase({ outcome: "maybe" }) }, + "an unknown errorClass": { bundle: phase({ outcome: "fail", errorClass: "cosmic" }) }, + "two phases with one index": { bundle: (bundle) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok" }, { index: 2, kind: "rate", outcome: "ok" }]; } }, + "vetRecords not an array": { bundle: (bundle) => { bundle.vetRecords = {}; } }, + "settlementEvidence not an array": { bundle: (bundle) => { bundle.settlementEvidence = {}; } }, + "ratingRefs not an array": { bundle: (bundle) => { bundle.ratingRefs = "none"; } }, + "a nonempty amendments list": { bundle: (bundle, session) => { bundle.amendments = [session.refs.evidence]; } }, + "a recipe registry version of 0": { bundle: (bundle) => { bundle.recipeRegistryVersion = 0; } }, + "a rail registry version as a string": { bundle: (bundle) => { bundle.railRegistryVersion = "1"; } }, + "finalisedAt as a string": { bundle: (bundle) => { bundle.finalisedAt = "120"; } }, + "two buyers": { bundle: (bundle) => { bundle.parties = [...(bundle.parties as Obj[]), { role: "buyer", bundleHash: "c".repeat(64), primaryClaim: dids[OUTSIDER] }]; } }, + "no seller": { bundle: (bundle) => { bundle.parties = [(bundle.parties as Obj[])[0], { role: "orchestrator", bundleHash: "c".repeat(64), primaryClaim: dids[SELLER] }]; } }, + "a party with an unknown role": { bundle: (bundle) => { bundle.parties = [...(bundle.parties as Obj[]), { role: "auditor", bundleHash: "c".repeat(64), primaryClaim: dids[OUTSIDER] }]; } }, + "a party without a primary claim": { bundle: (bundle) => { (bundle.parties as Obj[])[1] = { role: "seller", bundleHash: "b".repeat(64) }; } }, + "a party bundleHash that is not a hash": { bundle: (bundle) => { (bundle.parties as Obj[])[1] = { ...(bundle.parties as Obj[])[1], bundleHash: "b" }; } }, + "signatures not an array": { stored: (bundle) => { bundle.signatures = (bundle.signatures as Obj[])[0]; } }, + "a signature entry that is not an object": { stored: (bundle) => { bundle.signatures = [...(bundle.signatures as Obj[]), null]; } }, + "a seller signature made with another key": { stored: (bundle) => { (bundle.signatures as Obj[])[1] = { ...(bundle.signatures as Obj[])[1], value: (bundle.signatures as Obj[])[0].value }; } }, + "a signature with another algorithm": { stored: (bundle) => { (bundle.signatures as Obj[])[1] = { ...(bundle.signatures as Obj[])[1], algorithm: "ecdsa-secp256k1" }; } }, + "a signature without a party": { stored: (bundle) => { const { party: _, ...rest } = (bundle.signatures as Obj[])[1]; (bundle.signatures as Obj[])[1] = rest; } }, + "a completed bundle signed by the buyer alone": { bundleSigners: [BUYER] }, + "no agreementRef on a completed session": { bundle: (bundle) => { delete bundle.agreementRef; } }, + }), { + "a singular signature member": SHAPE, "both version discriminators": SHAPE, "another bundleVersion": SHAPE, + "a legacy bundle naming a faulted party": SHAPE, "a jobId that is not a string": SHAPE, "an unknown outcome": SHAPE, + "an unknown anchoredByRole": SHAPE, "no listingRef": SHAPE, "one party": SHAPE, "phaseSummary not an array": SHAPE, + "a phase entry that is not an object": SHAPE, "a negative phase index": SHAPE, "a phase index that is not an integer": SHAPE, + "a phase kind that is not a string": SHAPE, "an unknown phase outcome": SHAPE, "an unknown errorClass": SHAPE, + "two phases with one index": SHAPE, "vetRecords not an array": SHAPE, "settlementEvidence not an array": SHAPE, + "ratingRefs not an array": SHAPE, "a nonempty amendments list": SHAPE, "a recipe registry version of 0": SHAPE, + "a rail registry version as a string": SHAPE, "finalisedAt as a string": SHAPE, + "two buyers": "invalid or duplicate bundle parties", "no seller": "invalid or duplicate bundle parties", + "a party with an unknown role": "invalid or duplicate bundle parties", "a party without a primary claim": "invalid or duplicate bundle parties", + "a party bundleHash that is not a hash": "invalid or duplicate bundle parties", + "signatures not an array": "bundle signatures contain malformed entries", + "a signature entry that is not an object": "bundle signatures contain malformed entries", + "a seller signature made with another key": "required current bundle signatures are missing or invalid", + "a signature with another algorithm": "required current bundle signatures are missing or invalid", + "a signature without a party": "required current bundle signatures are missing or invalid", + "a completed bundle signed by the buyer alone": "required current bundle signatures are missing or invalid", + "no agreementRef on a completed session": "non-abort bundle has no agreement", + }); + // An empty amendments list and a self-aborted session signed by its anchor alone are valid. + assert.equal((await evaluate({ bundle: (bundle) => { bundle.amendments = []; } })).ok, true); + assert.equal((await evaluate({ bundleSigners: [BUYER], bundle: (bundle) => { bundle.outcome = "aborted-by-self"; delete bundle.agreementRef; } })).ok, true); +}); + +test("DACS-5 FaultAttributionBundle: faultedParty agrees with the outcome and the anchoring role", async () => { + const fault = (outcome: string, faultedParty: unknown, anchoredByRole = "buyer"): Case => ({ + anchoredByRole, + bundle: (bundle) => { + delete bundle.bundleVersion; + Object.assign(bundle, { faultBundleVersion: "1", outcome, faultedParty }); + if (outcome.startsWith("aborted")) delete bundle.agreementRef; + }, + }); + const ok = "ok"; const bad = "invalid fault attribution"; + assert.deepEqual(await verdicts({ + "completed, none": fault("completed", "none"), + "completed, seller": fault("completed", "seller"), + "failed-substrate, none": fault("failed-substrate", "none"), + "failed-substrate, buyer": fault("failed-substrate", "buyer"), + "failed-perm, the anchor": fault("failed-perm", "buyer"), + "failed-perm, the other party": fault("failed-perm", "seller"), + "aborted-by-self, the anchor": fault("aborted-by-self", "seller", "seller"), + "aborted-by-self, none": fault("aborted-by-self", "none", "seller"), + "failed-counterparty, the other party": fault("failed-counterparty", "seller"), + "failed-counterparty, the anchor": fault("failed-counterparty", "buyer"), + "failed-counterparty, a role not in the session": fault("failed-counterparty", "orchestrator"), + "failed-counterparty, not a string": fault("failed-counterparty", 7), + "aborted-by-other, the other party": fault("aborted-by-other", "buyer", "seller"), + "aborted-by-other, the anchor": fault("aborted-by-other", "seller", "seller"), + }), { + "completed, none": ok, "completed, seller": bad, "failed-substrate, none": ok, "failed-substrate, buyer": bad, + "failed-perm, the anchor": ok, "failed-perm, the other party": bad, "aborted-by-self, the anchor": ok, "aborted-by-self, none": bad, + "failed-counterparty, the other party": ok, "failed-counterparty, the anchor": bad, + "failed-counterparty, a role not in the session": bad, "failed-counterparty, not a string": bad, + "aborted-by-other, the other party": ok, "aborted-by-other, the anchor": bad, + }); +}); + +test("DACS-5 §10.4.3: top-level references are current, unique, hash-bound and from this session", async () => { + const other = (n: number, scope: Obj, kind: ArtifactKind, signer: number) => async (session: Session) => { + const ref = await publish(session, n, scope, kind, signer); + session.refs[kind === "evidence" ? "evidence" : kind === "rating" ? "rating" : "agreement"] = ref; + }; + assert.deepEqual(await verdicts({ + "an agreementRef with another content hash": { bundle: (bundle) => { bundle.agreementRef = { ...(bundle.agreementRef as Obj), contentHash: "0".repeat(64) }; } }, + "an agreementRef on another anchor kind": { bundle: (bundle) => { bundle.agreementRef = { ...(bundle.agreementRef as Obj), anchor: { kind: "ipfs", locator: at(2) } }; } }, + "an agreementRef to a non-native locator": { bundle: (bundle) => { bundle.agreementRef = { ...(bundle.agreementRef as Obj), anchor: { kind: "storage-program", locator: at(2).toUpperCase() } }; } }, + "an evidence ref with another content hash": { bundle: (bundle) => { bundle.settlementEvidence = [{ ...(bundle.settlementEvidence as Obj[])[0], contentHash: "0".repeat(64) }]; } }, + "one evidence listed twice": { bundle: (bundle, session) => { bundle.settlementEvidence = [session.refs.evidence, session.refs.evidence]; } }, + "evidence at the agreement's locator": { bundle: (bundle, session) => { bundle.settlementEvidence = [{ ...session.refs.evidence, anchor: (session.refs.agreement as Obj).anchor }]; } }, + "evidence reusing the agreement's hash": { bundle: (bundle, session) => { bundle.settlementEvidence = [{ ...session.refs.evidence, contentHash: (session.refs.agreement as Obj).contentHash }]; } }, + "an agreement for another job": { scopes: (scopes) => { scopes.agreement.jobId = "other-job"; } }, + "evidence for another job": { scopes: (scopes) => { scopes.evidence.jobId = "other-job"; } }, + "a rating for another job": { scopes: (scopes) => { scopes.rating.jobId = "other-job"; } }, + "evidence signed by an outsider": { evidenceSigner: OUTSIDER }, + "a rating by an outsider": { ratingSigner: OUTSIDER, scopes: (scopes) => { scopes.rating.rater = dids[OUTSIDER]; } }, + "a rating of an outsider": { scopes: (scopes) => { scopes.rating.target = dids[OUTSIDER]; } }, + "a rating naming the wrong target role": { scopes: (scopes) => { scopes.rating.targetRole = "buyer"; } }, + "a second evidence at a fresh locator": { artifacts: other(6, { ...baseScopes().evidence, phaseIndex: 3, paymentTxRefs: ["tx-2"] }, "evidence", SELLER) }, + "the agreement's sha256-prefixed hash": { bundle: (bundle) => { bundle.agreementRef = { ...(bundle.agreementRef as Obj), contentHash: `sha256:${(bundle.agreementRef as Obj).contentHash}` }; } }, + }), { + "an agreementRef with another content hash": "agreement reference failed", + "an agreementRef on another anchor kind": "duplicate or invalid top-level reference", + "an agreementRef to a non-native locator": "duplicate or invalid top-level reference", + "an evidence ref with another content hash": "evidence reference failed", + "one evidence listed twice": "duplicate or invalid top-level reference", + "evidence at the agreement's locator": "duplicate or invalid top-level reference", + "evidence reusing the agreement's hash": "duplicate or invalid top-level reference", + "an agreement for another job": "agreement belongs to another job", + "evidence for another job": "evidence belongs to another job", + "a rating for another job": "rating belongs to another job", + "evidence signed by an outsider": "evidence signer is not a session party", + "a rating by an outsider": "rating party binding failed", + "a rating of an outsider": "rating party binding failed", + "a rating naming the wrong target role": "rating party binding failed", + "a second evidence at a fresh locator": "ok", + "the agreement's sha256-prefixed hash": "ok", + }); + // A rating listed twice under one hash collapses; the same locator under another hash does not. + assert.equal((await evaluate({ bundle: (bundle, session) => { bundle.ratingRefs = [session.refs.rating, session.refs.rating]; } })).ok, true); + assert.equal((await evaluate({ bundle: (bundle, session) => { bundle.ratingRefs = [session.refs.rating, { ...session.refs.rating, contentHash: "0".repeat(64) }]; } })).reason, + "duplicate or invalid top-level reference"); +}); + +test("DACS-3 and DACS-5 artifact shapes and signatures: a malformed or wrongly signed artifact fails its reference", async () => { + const agreement = (change: (scope: Obj) => void): Case => ({ scopes: (scopes) => change(scopes.agreement) }); + const evidence = (change: (scope: Obj) => void): Case => ({ scopes: (scopes) => change(scopes.evidence) }); + const rating = (change: (scope: Obj) => void): Case => ({ scopes: (scopes) => change(scopes.rating) }); + const restore = (kind: "agreement" | "evidence" | "rating", change: (raw: Obj) => void) => (session: Session) => { + const raw = clone(session.stored[kind]); change(raw); + session.store.set((session.refs[kind].anchor as Obj).locator as string, raw); + }; + const AGREEMENT = "agreement reference failed"; const EVIDENCE = "evidence reference failed"; const RATING = "rating reference failed"; + assert.deepEqual(await verdicts({ + "an agreement signed by the buyer alone": { agreementSigners: [BUYER] }, + "an agreement with a forged seller signature": { artifacts: restore("agreement", (raw) => { (raw.signatures as Obj[])[1] = { ...(raw.signatures as Obj[])[1], value: (raw.signatures as Obj[])[0].value }; }) }, + "an agreement carrying a singular signature too": { artifacts: restore("agreement", (raw) => { raw.signature = (raw.signatures as Obj[])[0]; }) }, + "agreement signatures not an array": { artifacts: restore("agreement", (raw) => { raw.signatures = (raw.signatures as Obj[])[0]; }) }, + "an agreement signature entry that is not an object": { artifacts: restore("agreement", (raw) => { raw.signatures = [...(raw.signatures as Obj[]), "sig"]; }) }, + "an agreement with three role holders": agreement((scope) => { scope.parties = [...(scope.parties as Obj[]), { role: "buyer", primaryClaim: dids[OUTSIDER] }]; }), + "an agreement with one party": agreement((scope) => { scope.parties = (scope.parties as Obj[]).slice(0, 1); }), + "an agreement without a listingRef": agreement((scope) => { delete scope.listingRef; }), + "an agreement price with a leading zero": agreement((scope) => { (scope.terms as Obj).price = { amount: "01.25", currency: "DEM" }; }), + "an agreement price of zero": agreement((scope) => { (scope.terms as Obj).price = { amount: "0", currency: "DEM" }; }), + "an agreement price without a currency": agreement((scope) => { (scope.terms as Obj).price = { amount: "1.25", currency: "" }; }), + "an agreement with both discriminators": agreement((scope) => { scope.payeeBoundAgreementVersion = "1"; }), + "agreementVersion 2": agreement((scope) => { scope.agreementVersion = "2"; }), + "a plain agreement carrying payout bindings": agreement((scope) => { (scope.terms as Obj).payoutBindings = []; }), + "evidenceVersion 2": evidence((scope) => { scope.evidenceVersion = "2"; }), + "an evidence phase that is not a string": evidence((scope) => { scope.phase = 7; }), + "an unknown evidence outcome": evidence((scope) => { scope.outcome = "maybe"; }), + "evidence observedAt as a string": evidence((scope) => { scope.observedAt = "100"; }), + "evidence amendmentRefs not an array": evidence((scope) => { scope.amendmentRefs = {}; }), + "an evidence attestationRef that is not a reference": evidence((scope) => { scope.attestationRef = { anchor: { kind: "storage-program", locator: at(9) } }; }), + "evidence with a signatures list": { artifacts: restore("evidence", (raw) => { raw.signatures = [raw.signature]; }) }, + "evidence signed with another algorithm": { artifacts: restore("evidence", (raw) => { raw.signature = { ...(raw.signature as Obj), algorithm: "ecdsa-secp256k1" }; }) }, + "evidence whose signer is not a string": { artifacts: restore("evidence", (raw) => { raw.signature = { ...(raw.signature as Obj), signer: 7 }; }) }, + "evidence without a signature": { artifacts: restore("evidence", (raw) => { delete raw.signature; }) }, + "ratingVersion 2": rating((scope) => { scope.ratingVersion = "2"; }), + "a rating of 0": rating((scope) => { scope.value = 0; }), + "a rating of 6": rating((scope) => { scope.value = 6; }), + "a rating that is not an integer": rating((scope) => { scope.value = "5"; }), + "a rating of an orchestrator": rating((scope) => { scope.targetRole = "orchestrator"; }), + "ratedAt as a string": rating((scope) => { scope.ratedAt = "110"; }), + "free text over 1,000 characters": rating((scope) => { scope.freeText = "x".repeat(1001); }), + "a rating signed by the seller for the buyer": { ratingSigner: SELLER }, + "a rating whose rater is not a string": { scopes: (scopes) => { scopes.rating.rater = 7; } }, + }), { + "an agreement signed by the buyer alone": AGREEMENT, "an agreement with a forged seller signature": AGREEMENT, + "an agreement carrying a singular signature too": AGREEMENT, "agreement signatures not an array": AGREEMENT, + "an agreement signature entry that is not an object": AGREEMENT, "an agreement with three role holders": AGREEMENT, + "an agreement with one party": AGREEMENT, "an agreement without a listingRef": AGREEMENT, + "an agreement price with a leading zero": AGREEMENT, "an agreement price of zero": AGREEMENT, + "an agreement price without a currency": AGREEMENT, "an agreement with both discriminators": AGREEMENT, + "agreementVersion 2": AGREEMENT, "a plain agreement carrying payout bindings": AGREEMENT, + "evidenceVersion 2": EVIDENCE, "an evidence phase that is not a string": EVIDENCE, "an unknown evidence outcome": EVIDENCE, + "evidence observedAt as a string": EVIDENCE, "evidence amendmentRefs not an array": EVIDENCE, + "an evidence attestationRef that is not a reference": EVIDENCE, "evidence with a signatures list": EVIDENCE, + "evidence signed with another algorithm": EVIDENCE, "evidence whose signer is not a string": EVIDENCE, + "evidence without a signature": EVIDENCE, + "ratingVersion 2": RATING, "a rating of 0": RATING, "a rating of 6": RATING, "a rating that is not an integer": RATING, + "a rating of an orchestrator": RATING, "ratedAt as a string": RATING, "free text over 1,000 characters": RATING, + "a rating signed by the seller for the buyer": RATING, "a rating whose rater is not a string": RATING, + }); + // Boundaries that stay valid: failure evidence, ratings of 1 and 5, and exactly 1,000 characters of free text. + for (const c of [evidence((scope) => { scope.outcome = "failure"; }), rating((scope) => { scope.value = 1; }), + rating((scope) => { scope.freeText = "x".repeat(1000); })]) { + const graph = await evaluate(c); + assert.equal(graph.ok, true, graph.reason); + } +}); + +test("DACS-2 vet records and DACS-4 evidence chains are dereferenced and checked transitively", async () => { + const requirementHash = contentHash((listing.buyerRequirement as Obj)); + const attestationRaw = { authority: "registry", body: "ok" }; + const attest = (session: Session, n = 20) => { + session.store.set(at(n), attestationRaw); + return { anchor: { kind: "storage-program", locator: at(n) }, contentHash: contentHash(attestationRaw) }; + }; + const verifyResult = (session: Session, change: Obj = {}) => ({ + resultVersion: "1", scheme: "lei", identifier: "5493001KJTIIGC8Y1R12", recipeVersion: 1, method: "verifiable-credential", decision: "pass", + reason: "registry match", attestation: attest(session), fetchedAt: 10, verifiedAt: 20, ...change, + }); + const composite = (change: Obj = {}) => ({ + recordVersion: "1", jobId: JOB, evaluatedParty: dids[BUYER], bundleHash: "a".repeat(64), requirementHash, + freshness: [], supplementary: [], dealSpecific: [], overallDecision: "pass", generatedAt: 30, ...change, + }); + const vetted = (build: (session: Session) => Promise): Case => ({ + bundle: async (bundle, session) => { bundle.vetRecords = [await publish(session, 30, await build(session), "composite", SELLER)]; }, + }); + const withResult = (change: Obj = {}, refChange: (ref: Obj) => Obj = (ref) => ref) => vetted(async (session) => { + const ref = await publish(session, 31, verifyResult(session, change), "verify-result", OUTSIDER); + return composite({ freshness: [refChange({ ...ref, recipeVersion: 1 })] }); + }); + const signal = (change: Obj) => vetted(async () => composite({ supplementary: [{ source: "dacs-5", signalType: "completion-rate", value: 90, observedAt: 5, ...change }] })); + const amended = (scope: (base: Obj) => Obj, signer = SELLER, key = "amendmentRefs"): Case => ({ + artifacts: async (session) => { + const second = await publish(session, 40, scope({ ...baseScopes().evidence, paymentTxRefs: ["tx-2"] }), "evidence", signer); + const first = { ...session.scopes.evidence, [key]: key === "amendmentRefs" ? [second] : second }; + session.refs.evidence = await publish(session, 41, first, "evidence", SELLER); + }, + }); + const COMPOSITE = "composite reference failed"; const NESTED = "nested VerifyResult reference failed"; + assert.deepEqual(await verdicts({ + "a verified result and its attestation": withResult(), + "a result whose decision is not in the enum": withResult({ decision: "maybe" }), + "a result whose attestation is not a reference": withResult({ attestation: { anchor: { kind: "storage-program", locator: at(20) } } }), + "a result reference with another content hash": withResult({}, (ref) => ({ ...ref, contentHash: "0".repeat(64) })), + "a result whose attestation hash does not match": vetted(async (session) => { + const result = verifyResult(session, { attestation: { ...attest(session), contentHash: "0".repeat(64) } }); + return composite({ dealSpecific: [{ ...(await publish(session, 31, result, "verify-result", OUTSIDER)), recipeVersion: 1 }] }); + }), + "a supplementary signal from dacs-5": signal({}), + "an external signal without an attestation": signal({ source: "external" }), + "an external signal with its attestation": vetted(async (session) => composite({ supplementary: [{ source: "external", signalType: "rating-avg", value: "4.5", observedAt: 5, attestation: attest(session) }] })), + "a signal value that is a boolean": signal({ value: true }), + "a signal source that is not a string": signal({ source: 7 }), + "a signal without a type": signal({ signalType: undefined }), + "a signal without observedAt": signal({ observedAt: undefined }), + "a signal that is not an object": vetted(async () => composite({ supplementary: ["signal"] })), + "a record for another job": vetted(async () => composite({ jobId: "other-job" })), + "a record with an unknown decision": vetted(async () => composite({ overallDecision: "maybe" })), + "freshness not an array": vetted(async () => composite({ freshness: {} })), + "an amendment from the seller": amended((scope) => scope), + "an amendment for another job": amended((scope) => ({ ...scope, jobId: "other-job" })), + "an amendment signed by an outsider": amended((scope) => scope, OUTSIDER), + "a superseded evidence reference": amended((scope) => scope, SELLER, "supersedesEvidenceRef"), + "a superseded reference with another hash": { artifacts: async (session) => { + const first = { ...session.scopes.evidence, supersedesEvidenceRef: { ...session.refs.evidence, contentHash: "0".repeat(64) } }; + session.refs.evidence = await publish(session, 41, first, "evidence", SELLER); + } }, + "a delivery attestation that matches": { artifacts: async (session) => { + session.refs.evidence = await publish(session, 41, { ...session.scopes.evidence, attestationRef: attest(session) }, "evidence", SELLER); + } }, + "a delivery attestation with another hash": { artifacts: async (session) => { + session.refs.evidence = await publish(session, 41, { ...session.scopes.evidence, attestationRef: { ...attest(session), contentHash: "0".repeat(64) } }, "evidence", SELLER); + } }, + "a phase attestationRef to the verified evidence": { bundle: (bundle, session) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", attestationRef: session.refs.evidence }]; } }, + "a phase attestationRef outside the graph": { bundle: (bundle, session) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", attestationRef: { ...session.refs.evidence, contentHash: "0".repeat(64) } }]; } }, + "a phase attestationRef that is not a reference": { bundle: (bundle) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", attestationRef: "evidence" }]; } }, + }), { + "a verified result and its attestation": "ok", "a result whose decision is not in the enum": NESTED, + "a result whose attestation is not a reference": NESTED, "a result reference with another content hash": NESTED, + "a result whose attestation hash does not match": "VerifyResult attestation reference failed", + "a supplementary signal from dacs-5": "ok", "an external signal without an attestation": COMPOSITE, + "an external signal with its attestation": "ok", "a signal value that is a boolean": COMPOSITE, + "a signal source that is not a string": COMPOSITE, "a signal without a type": COMPOSITE, "a signal without observedAt": COMPOSITE, + "a signal that is not an object": COMPOSITE, "a record for another job": "composite belongs to another job", + "a record with an unknown decision": COMPOSITE, "freshness not an array": COMPOSITE, + "an amendment from the seller": "ok", "an amendment for another job": "settlement amendment reference failed", + "an amendment signed by an outsider": "settlement amendment reference failed", "a superseded evidence reference": "ok", + "a superseded reference with another hash": "settlement evidence chain failed", + "a delivery attestation that matches": "ok", "a delivery attestation with another hash": "delivery attestation reference failed", + "a phase attestationRef to the verified evidence": "ok", + "a phase attestationRef outside the graph": "phase attestation reference is outside the verified graph", + "a phase attestationRef that is not a reference": "phase attestation reference is outside the verified graph", + }); +}); + +test("DACS-3 §8.5 and CA-7: the agreement binds the session's listing and parties, and payee-bound payouts cover the pipeline", async () => { + const payee = (bindings: unknown): Case => ({ + listing: payeeBoundListing, + scopes: (scopes) => { + delete scopes.agreement.agreementVersion; + scopes.agreement.payeeBoundAgreementVersion = "1"; + (scopes.agreement.terms as Obj).payoutBindings = bindings; + }, + }); + const binding = (change: Obj = {}) => ({ railId: "pay-dem", phaseIndex: 2, payeeAddress: dids[SELLER], ...change }); + const COVERAGE = "agreement fails listing conformance check: commitment-artifact"; + assert.deepEqual(await verdicts({ + "the bundle pins another listing hash": { bundle: (bundle) => { bundle.listingRef = { ...(bundle.listingRef as Obj), contentHash: "0".repeat(64) }; } }, + "the agreement pins another listing version": { scopes: (scopes) => { scopes.agreement.listingRef = { ...(scopes.agreement.listingRef as Obj), version: 2 }; } }, + "the agreement's seller is not the bundle's": { agreementSigners: [BUYER, OUTSIDER], scopes: (scopes) => { + scopes.agreement.parties = [{ role: "buyer", primaryClaim: dids[BUYER] }, { role: "seller", primaryClaim: dids[OUTSIDER] }]; + } }, + "a plain agreement on a payee-bound listing": { listing: payeeBoundListing }, + "exact payout coverage": payee([binding()]), + "no payout bindings": payee([]), + "an extra payout binding": payee([binding(), binding({ phaseIndex: 3 })]), + "a duplicated payout binding": payee([binding(), binding()]), + "a payout binding on another rail": payee([binding({ railId: "pay-x402" })]), + "a payout binding for another phase": payee([binding({ phaseIndex: 1 })]), + "a negative phase index": payee([binding({ phaseIndex: -1 })]), + "an empty payee address": payee([binding({ payeeAddress: "" })]), + "an empty rail id": payee([binding({ railId: "" })]), + "payout bindings not an array": payee(binding()), + "a payout binding that is not an object": payee(["pay-dem"]), + }), { + "the bundle pins another listing hash": "listing reference failed", + "the agreement pins another listing version": "agreement listing binding failed", + "the agreement's seller is not the bundle's": "agreement party binding failed", + "a plain agreement on a payee-bound listing": COVERAGE, + "exact payout coverage": "ok", "no payout bindings": COVERAGE, "an extra payout binding": COVERAGE, + "a duplicated payout binding": COVERAGE, "a payout binding on another rail": COVERAGE, "a payout binding for another phase": COVERAGE, + "a negative phase index": "agreement reference failed", "an empty payee address": "agreement reference failed", + "an empty rail id": "agreement reference failed", "payout bindings not an array": "agreement reference failed", + "a payout binding that is not an object": "agreement reference failed", + }); +}); + +test("DACS-3 §8.5.2: terms, pricing kinds and the band's inputs are checked before any amount", () => { + const base = baseScopes().agreement; + const withListing = (pricing: unknown, pipeline: unknown = listing.pipeline, extra: Obj = {}) => ({ ...listing, pricing, pipeline, ...extra }); + const check = (agreement: Obj, offer: Obj) => agreementListingConformance(agreement, offer, dids[SELLER]); + const price = (amount: string, currency = "DEM") => ({ ...base, terms: { ...(base.terms as Obj), price: { amount, currency } } }); + assert.equal(check(base, listing), null); + assert.equal(check({ ...base, terms: undefined }, listing), "terms"); + assert.equal(check(base, withListing(undefined)), "terms"); + assert.equal(check({ ...base, terms: { ...(base.terms as Obj), price: undefined } }, listing), "terms"); + assert.equal(check(price("0"), listing), "terms"); + // Auction pricing: the reserve price's currency binds the agreement; no reserve leaves only the other checks. + const sealed = [{ kind: "negotiate-sealed-envelope" }, ...(listing.pipeline as Obj[]).slice(1)]; + const auction = (reservePrice?: Obj) => withListing({ kind: "auction", selectionRule: "lowest-price", ...(reservePrice ? { reservePrice } : {}) }, sealed); + const won = { ...base, derivedFromPattern: "sealed-envelope" }; + assert.equal(check(won, auction()), null); + assert.equal(check(won, auction({ amount: "1", currency: "DEM" })), null); + assert.equal(check(won, auction({ amount: "1", currency: "USD" })), "currency"); + // Metered pricing without a minimum total is unitPrice × quantity exactly. + const metered = withListing({ kind: "metered", unitPrice: { amount: "0.25", currency: "DEM" }, unit: "call" }); + const usage = (amount: string, quantity: string) => ({ ...price(amount), terms: { ...(price(amount).terms as Obj), meteredQuantity: { quantity, unit: "call" } } }); + assert.equal(check(usage("1.25", "5"), metered), null); + assert.equal(check(usage("1.5", "5"), metered), "price"); + assert.equal(check(usage("0", "0"), metered), "terms", "a zero total is not a positive price"); + // A minimum total equal to the computed total is not "greater"; the total stands. + const floored = withListing({ kind: "metered", unitPrice: { amount: "0.25", currency: "DEM" }, unit: "call", minTotal: { amount: "1.25", currency: "DEM" } }); + assert.equal(check(usage("1.25", "5"), floored), null); + // A band whose percentages are not plain non-negative numbers has no bounds. + const rfq = [{ kind: "negotiate-rfq", parameters: { maxTurns: 6, timeoutSec: 60 } }, ...(listing.pipeline as Obj[]).slice(1)]; + const quoted = { ...price("1.25"), derivedFromPattern: "rfq" }; + const band = (minPct: unknown, maxPct: unknown) => withListing({ kind: "negotiable", bandCenter: { amount: "1.25", currency: "DEM" }, minPct, maxPct }, rfq); + assert.equal(check(quoted, band(10, 10)), null); + for (const [minPct, maxPct] of [["10", 10], [10, "10"], [-1, 10], [10, 1e-7], [10, Number.NaN]] as const) { + assert.equal(check(quoted, band(minPct, maxPct)), "price", `${String(minPct)} ${String(maxPct)}`); + } + // Rail versions bind only when both sides state one; another rail id never matches. + const railed = (rail: Obj, accepted: Obj[]) => check({ ...base, terms: { ...(base.terms as Obj), rail } }, withListing(listing.pricing, listing.pipeline, { acceptedRails: accepted })); + assert.equal(railed({ railId: "pay-dem", railVersion: 2 }, [{ railId: "pay-dem" }]), null); + assert.equal(railed({ railId: "pay-dem" }, [{ railId: "pay-dem", railVersion: 2 }]), null); + assert.equal(railed({ railId: "pay-dem" }, [{ railId: "pay-x402" }, { railId: "pay-dem" }]), null); + assert.equal(railed({ railId: "pay-dem" }, [{ railId: "pay-x402" }]), "rail"); + // A listing whose offering has no deliverable, or whose pipeline has no negotiation step, cannot conform. + assert.equal(check(base, { ...listing, offering: {} }), "deliverable"); + assert.equal(check(base, withListing(listing.pricing, (listing.pipeline as Obj[]).slice(1))), "pattern"); +}); + +test("a current reference names a native storage locator and a sha256 content hash", () => { + const hash = "a".repeat(64); + assert.equal(isCurrentRef({ anchor: { kind: "storage-program", locator: at(1) }, contentHash: hash }), true); + assert.equal(isCurrentRef({ anchor: { kind: "storage-program", locator: at(1) }, contentHash: `sha256:${hash}` }), true); + for (const value of [ + null, "ref", { contentHash: hash }, { anchor: { kind: "ipfs", locator: at(1) }, contentHash: hash }, + { anchor: { kind: "storage-program", locator: 7 }, contentHash: hash }, { anchor: { kind: "storage-program", locator: "stor-12" }, contentHash: hash }, + { anchor: { kind: "storage-program", locator: at(1) }, contentHash: "a".repeat(63) }, { anchor: { kind: "storage-program", locator: at(1) } }, + ]) assert.equal(isCurrentRef(value), false, JSON.stringify(value)); +}); + +test("further graph rules: discriminators, profiles, bounds, external verifiers and phase back-pointers", async () => { + // CA-5: an agreement carrying both discriminators is rejected under either signing domain. + const bothDiscriminators = (prefix: string): Case => ({ artifacts: async (session) => { + const scope = { ...session.scopes.agreement, payeeBoundAgreementVersion: "1" }; + const raw = { ...scope, signatures: await Promise.all([BUYER, SELLER].map(async (signer) => + ({ party: dids[signer], algorithm: "ed25519", value: await signatureValue(prefix + artifactHash(scope, "agreement"), signer) }))) }; + session.store.set(at(2), raw); + session.refs.agreement = refTo(at(2), raw, "agreement"); + } }); + const COVERAGE = "agreement fails listing conformance check: commitment-artifact"; + const twoPays = await (async () => { + const scope = { ...signedScopeOf(payeeBoundListing), pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-payee-bound-agreement" }, + { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }] }; + return { ...scope, signature: await envelope(scope, "listing", SELLER) }; + })(); + const payee = (forListing: Obj, bindings: unknown): Case => ({ + listing: forListing, + scopes: (scopes) => { delete scopes.agreement.agreementVersion; scopes.agreement.payeeBoundAgreementVersion = "1"; (scopes.agreement.terms as Obj).payoutBindings = bindings; }, + }); + const payout = (phaseIndex: number) => ({ railId: "pay-dem", phaseIndex, payeeAddress: dids[SELLER] }); + const attestation = { authority: "registry" }; + const big = "x".repeat(128 * 1024); + assert.deepEqual(await verdicts({ + "both discriminators under the plain domain": bothDiscriminators("dacs-agreement:v1:"), + "both discriminators under the payee-bound domain": bothDiscriminators("dacs-payee-bound-agreement:v1:"), + "payeeBoundAgreementVersion 2": { listing: payeeBoundListing, scopes: (scopes) => { + payee(payeeBoundListing, [payout(2)]).scopes!(scopes); + scopes.agreement.payeeBoundAgreementVersion = "2"; + } }, + "faultBundleVersion 2": { bundle: (bundle) => { delete bundle.bundleVersion; Object.assign(bundle, { faultBundleVersion: "2", faultedParty: "none" }); } }, + "amendments as an object": { bundle: (bundle) => { bundle.amendments = {}; } }, + "a rail registry version of 0": { bundle: (bundle) => { bundle.railRegistryVersion = 0; } }, + "no buyer": { bundle: (bundle) => { bundle.parties = [{ role: "orchestrator", bundleHash: "c".repeat(64), primaryClaim: dids[BUYER] }, (bundle.parties as Obj[])[1]]; } }, + "evidence over the 128 KB cap": { scopes: (scopes) => { scopes.evidence.note = big; } }, + "a vet record signed by an external verifier": { bundle: async (bundle, session) => { + bundle.vetRecords = [await publish(session, 30, { recordVersion: "1", jobId: JOB, evaluatedParty: dids[BUYER], bundleHash: "a".repeat(64), + requirementHash: contentHash(listing.buyerRequirement as Obj), freshness: [], supplementary: [], dealSpecific: [], overallDecision: "pass", generatedAt: 30 }, "composite", OUTSIDER)]; + } }, + "an external signal whose attestation hash differs": { bundle: async (bundle, session) => { + session.store.set(at(20), attestation); + bundle.vetRecords = [await publish(session, 30, { recordVersion: "1", jobId: JOB, evaluatedParty: dids[BUYER], bundleHash: "a".repeat(64), + requirementHash: contentHash(listing.buyerRequirement as Obj), freshness: [], dealSpecific: [], overallDecision: "pass", generatedAt: 30, + supplementary: [{ source: "external", signalType: "rating-avg", value: 4, observedAt: 5, attestation: { anchor: { kind: "storage-program", locator: at(20) }, contentHash: "0".repeat(64) } }] }, "composite", SELLER)]; + } }, + "a phase attestationRef to the agreement": { bundle: (bundle, session) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", attestationRef: session.refs.agreement }]; } }, + "a phase attestationRef to a rating": { bundle: (bundle, session) => { bundle.phaseSummary = [{ index: 2, kind: "pay-dem", outcome: "ok", attestationRef: session.refs.rating }]; } }, + "agreement roles swapped against the bundle": { scopes: (scopes) => { scopes.agreement.parties = [{ role: "buyer", primaryClaim: dids[SELLER] }, { role: "seller", primaryClaim: dids[BUYER] }]; } }, + "payouts for both pay phases": payee(twoPays, [payout(2), payout(3)]), + "a payout for one pay phase twice": payee(twoPays, [payout(2), { ...payout(2), payeeAddress: dids[BUYER] }]), + }), { + "both discriminators under the plain domain": "agreement reference failed", + "both discriminators under the payee-bound domain": "agreement reference failed", + "payeeBoundAgreementVersion 2": "agreement reference failed", + "faultBundleVersion 2": SHAPE, "amendments as an object": SHAPE, "a rail registry version of 0": SHAPE, + "no buyer": "invalid or duplicate bundle parties", + "evidence over the 128 KB cap": "evidence reference failed", + "a vet record signed by an external verifier": "ok", + "an external signal whose attestation hash differs": "supplementary attestation reference failed", + "a phase attestationRef to the agreement": "phase attestation reference is outside the verified graph", + "a phase attestationRef to a rating": "phase attestation reference is outside the verified graph", + "agreement roles swapped against the bundle": "agreement party binding failed", + "payouts for both pay phases": "ok", + "a payout for one pay phase twice": COVERAGE, + }); + // A graph that fails before its signatures verify never reports them verified. + const shapeFailure = await evaluate({ bundle: (bundle) => { bundle.outcome = "won"; } }); + assert.equal(shapeFailure.signaturesVerified, false); + const refFailure = await evaluate({ scopes: (scopes) => { scopes.evidence.jobId = "other-job"; } }); + assert.equal(refFailure.signaturesVerified, true); +}); + +test("discovery accepts an agreement only with its shape and both parties' signatures", async () => { + const scopes = baseScopes(); + const signedBy = async (scope: Obj, signers: number[]) => ({ ...scope, signatures: await Promise.all(signers.map((signer) => envelope(scope, "agreement", signer, signer, "party"))) }); + assert.equal(await verifiedDiscoveryAgreement(await signedBy(scopes.agreement, [BUYER, SELLER])), true); + assert.equal(await verifiedDiscoveryAgreement(await signedBy(scopes.agreement, [BUYER])), false); + assert.equal(await verifiedDiscoveryAgreement(await signedBy({ ...scopes.agreement, parties: [] }, [BUYER, SELLER])), false); + assert.equal(await verifiedDiscoveryAgreement({ ...(await signedBy(scopes.agreement, [BUYER, SELLER])), extra: JSON.parse("1e400") }), false); +}); + +test("§8.5.2 pattern: the first negotiation step names the pattern, and the RFQ fallback admits only fixed-price", () => { + const base = baseScopes().agreement; + const check = (agreement: Obj, pipeline: Obj[], pricing: Obj = listing.pricing as Obj) => + agreementListingConformance(agreement, { ...listing, pipeline, pricing }, dids[SELLER]); + const tail = (listing.pipeline as Obj[]).slice(1); + assert.equal(check(base, [{ kind: "vet-credentials" }, { kind: "negotiate-fixed-price" }, ...tail]), null); + const band = { kind: "negotiable", bandCenter: { amount: "1.25", currency: "DEM" }, minPct: 10, maxPct: 10 }; + const rfq = [{ kind: "negotiate-rfq", parameters: { maxTurns: 6, timeoutSec: 60, fixedPriceFallback: true } }, ...tail]; + assert.equal(check(base, rfq, band), null); + assert.equal(check({ ...base, derivedFromPattern: "sealed-envelope" }, rfq, band), "pattern"); +}); + +function signedScopeOf(raw: Obj): Obj { const { signature: _, ...scope } = raw; return scope; } diff --git a/reference-implementations/dacs-directory/test/indexer-binding-rules.test.ts b/reference-implementations/dacs-directory/test/indexer-binding-rules.test.ts new file mode 100644 index 0000000..f4e87dc --- /dev/null +++ b/reference-implementations/dacs-directory/test/indexer-binding-rules.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; + +// The indexer resolves each side of a current-profile deal only through BundleBindings its own +// role holder signed (DACS-5 BB-5..BB-7), and reports the deal unverified otherwise. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-indexer-binding-rules-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { artifactHash, signedScope } = await import("../src/catalog/evidenceGraph.js"); +const { bundleBindingOverflowKey, logicalBundleAddress } = await import("../src/catalog/bundleBinding.js"); +const { loadScanState, saveScanState } = await import("../src/catalog/store.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +type Obj = Record; +const seeds = [101, 102, 103].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const [BUYER, SELLER, OUTSIDER] = [0, 1, 2]; +const at = (n: number) => `stor-${(0xe000 + n).toString(16).padStart(40, "0")}`; +const JOB = "indexer-binding-job"; +const value = (message: string, signer: number) => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[signer]))).toString("base64url"); +const PREFIX: Record = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", rating: "dacs-rating:v1:", bundle: "dacs-bundle:v1:" }; +const signedWith = (raw: Obj, kind: "listing" | "agreement" | "evidence" | "rating" | "bundle", signer: number, field = "signer") => + ({ [field]: dids[signer], algorithm: "ed25519", value: value(PREFIX[kind] + artifactHash(raw, kind), signer) }); +const refTo = (locator: string, raw: Obj, kind: "agreement" | "evidence" | "rating") => ({ anchor: { kind: "storage-program", locator }, contentHash: artifactHash(raw, kind) }); + +const store = new Map(); +const identity: Obj = { bundleVersion: "1", presentedBy: dids[SELLER], presentedAt: 1, claims: [{ ref: dids[SELLER] }] }; +const listingScope: Obj = { + dacsVersion: "1", listingVersion: 1, listingId: "binding-rules", requiredCapabilities: ["SR-2"], + seller: { identity: { ...identity, presentation: { kind: "per-claim", signatures: [{ ref: dids[SELLER], + signature: Buffer.from(ed25519Sign(Buffer.from(`dacs-bundle-presentation:v1:${contentHash(identity)}`), privateKeyFromSeed(seeds[SELLER]))).toString("hex") }] } }, displayName: "seller" }, + offering: { title: "rules", description: "rules", category: "rules.binding", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + buyerRequirement: { requirementVersion: "1", required: [] }, + pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }], + pricing: { kind: "fixed", price: { amount: "2", currency: "DEM" } }, acceptedRails: [{ railId: "pay-dem" }], terms: {}, validity: { notBefore: 1 }, +}; +const listing = { ...listingScope, signature: signedWith(listingScope, "listing", SELLER) }; +const listingRef = { listingId: "binding-rules", version: 1, contentHash: artifactHash(listing, "listing") }; +const agreementScope: Obj = { agreementVersion: "1", jobId: JOB, listingRef, derivedFromPattern: "fixed-price", + parties: [{ role: "buyer", primaryClaim: dids[BUYER] }, { role: "seller", primaryClaim: dids[SELLER] }], + terms: { price: { amount: "2", currency: "DEM" }, rail: { railId: "pay-dem" }, deliverable: { deliverableType: "attested-payload", hash: contentHash({ kind: "attested-payload", payloadFormat: "application/json" }) } } }; +const agreement = { ...agreementScope, signatures: [signedWith(agreementScope, "agreement", BUYER, "party"), signedWith(agreementScope, "agreement", SELLER, "party")] }; +const evidenceScope: Obj = { evidenceVersion: "1", jobId: JOB, phase: "pay-dem", phaseIndex: 2, outcome: "success", paymentTxRefs: [{ txId: "tx-binding" }], observedAt: 100 }; +const evidence = { ...evidenceScope, signature: signedWith(evidenceScope, "evidence", SELLER) }; +const ratingScope: Obj = { ratingVersion: "1", jobId: JOB, rater: dids[BUYER], target: dids[SELLER], targetRole: "seller", value: 4, ratedAt: 110 }; +const rating = { ...ratingScope, signature: signedWith(ratingScope, "rating", BUYER) }; +store.set(at(1), listing).set(at(2), agreement).set(at(3), evidence).set(at(4), rating); +const bundleScope = (change: Obj = {}): Obj => ({ + bundleVersion: "1", jobId: JOB, outcome: "completed", listingRef, agreementRef: refTo(at(2), agreement, "agreement"), + parties: [{ role: "buyer", bundleHash: "a".repeat(64), primaryClaim: dids[BUYER] }, { role: "seller", bundleHash: "b".repeat(64), primaryClaim: dids[SELLER] }], + phaseSummary: [{ index: 2, kind: "pay-dem", outcome: "ok" }], vetRecords: [], settlementEvidence: [refTo(at(3), evidence, "evidence")], + ratingRefs: [refTo(at(4), rating, "rating")], recipeRegistryVersion: 1, railRegistryVersion: 1, finalisedAt: 120, ...change, +}); +const copy = (anchoredByRole: string, scope = bundleScope(), signers = [BUYER, SELLER]) => + ({ ...scope, signatures: signers.map((signer) => signedWith(scope, "bundle", signer, "party")), anchoredByRole }); +store.set(at(5), copy("buyer")).set(at(6), copy("seller")); +store.set(at(7), copy("seller", bundleScope({ jobId: "another-job" }))); +store.set(at(8), copy("seller", bundleScope(), [SELLER])); + +function binding(role: "buyer" | "seller", nativeAddress: string, signer: number, change: Obj = {}): Obj { + const scope: Obj = { bindingVersion: "1", jobId: JOB, role, logicalAddress: logicalBundleAddress(JOB, role), nativeAddress, + bundleContentHash: artifactHash(store.get(nativeAddress) ?? {}, "bundle"), signer: dids[signer], ...change }; + return { ...scope, signature: { algorithm: "ed25519", signer: dids[signer], value: value(`dacs-bundle-binding:v1:${contentHash(scope)}`, signer) } }; +} + +async function index(bindings: Obj[], overflow: string[] = []) { + const state = loadScanState(); + state.bundleBindingOverflow = overflow; + saveScanState(state); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = store.get(String(input).split("/").pop() ?? ""); + return data ? Response.json({ success: true, owner: `0x${dids[SELLER].slice(-64)}`, programName: "opaque", data }) : new Response(null, { status: 404 }); + }; + try { + const record = await indexRegistration({ primaryClaim: dids[SELLER], displayName: "seller", listingAnchors: [at(1)], + deals: [{ jobId: JOB, rail: "pay-dem", buyerBundleRef: at(90), sellerBundleRef: at(91), owners: { buyer: dids[BUYER], seller: dids[SELLER] } }], + bundleBindings: bindings as never }, undefined, async () => { throw new Error("identity unavailable"); }); + return record.deals[0]; + } finally { + globalThis.fetch = originalFetch; + } +} +const holders = () => [binding("buyer", at(5), BUYER), binding("seller", at(6), SELLER)]; + +test("a deal resolved through its holders' bindings carries the authoritative copy's verdicts", async () => { + const deal = await index(holders()); + assert.equal(deal.refsVerified, true); + assert.equal(deal.signatureVerified, true); + assert.equal(deal.reputationEligible, true); + assert.equal(deal.buyerBundleRef, at(5)); + assert.equal(deal.sellerBundleRef, at(6)); + assert.equal(deal.anchoredByRole, "seller"); + assert.equal(deal.sellerOutcome, "completed"); + assert.equal(deal.fullSignatureStanding, true); + assert.equal(deal.rail, "pay-dem"); + assert.equal(deal.category, "rules.binding"); + assert.equal(deal.finalisedAt, 120); + assert.deepEqual(deal.agreementPrice, { amount: "2", currency: "DEM" }); + assert.deepEqual(deal.settlementTxIds, [{ id: "tx-binding", observedAt: 100, phaseIndex: 2, evidenceHash: artifactHash(evidence, "evidence") }]); + assert.deepEqual(deal.ratings, [{ rater: dids[BUYER], target: dids[SELLER], targetRole: "seller", value: 4, ratedAt: 110, contentHash: artifactHash(rating, "rating") }]); + assert.equal(deal.cancellationNeutral, false); +}); + +test("a side resolves only from a valid binding its role holder signed for this job, role and copy", async () => { + const verdict = async (bindings: Obj[], overflow: string[] = []) => { + const deal = await index(bindings, overflow); + return `${deal.refsVerified ? "verified" : "unverified"} ${deal.sellerBundleRef}`; + }; + const [buyerSide, sellerSide] = holders(); + assert.deepEqual({ + "an outsider's binding beside the holders'": await verdict([...holders(), binding("seller", at(7), OUTSIDER)]), + "no seller binding": await verdict([buyerSide]), + "a seller binding signed by the buyer": await verdict([buyerSide, binding("seller", at(6), BUYER)]), + "a seller binding signed by an outsider": await verdict([buyerSide, binding("seller", at(6), OUTSIDER)]), + "a seller binding to the buyer's copy": await verdict([buyerSide, binding("seller", at(5), SELLER)]), + "a seller binding to another job's copy": await verdict([buyerSide, binding("seller", at(7), SELLER)]), + "a seller binding with another content hash": await verdict([buyerSide, binding("seller", at(6), SELLER, { bundleContentHash: "0".repeat(64) })]), + "a seller binding for another job": await verdict([buyerSide, binding("seller", at(6), SELLER, { jobId: "another-job", logicalAddress: logicalBundleAddress("another-job", "seller") })]), + "a seller binding whose signature fails": await verdict([buyerSide, { ...sellerSide, nativeAddress: at(8) }]), + "the seller's sticky overflow": await verdict(holders(), [bundleBindingOverflowKey(JOB, "seller", dids[SELLER])]), + "another signer's overflow": await verdict(holders(), [bundleBindingOverflowKey(JOB, "seller", dids[OUTSIDER])]), + "a seller copy signed by the seller alone": await verdict([buyerSide, binding("seller", at(8), SELLER)]), + }, { + "an outsider's binding beside the holders'": `verified ${at(6)}`, + "no seller binding": `unverified ${at(91)}`, + "a seller binding signed by the buyer": `unverified ${at(91)}`, + "a seller binding signed by an outsider": `unverified ${at(91)}`, + "a seller binding to the buyer's copy": `unverified ${at(91)}`, + "a seller binding to another job's copy": `unverified ${at(91)}`, + "a seller binding with another content hash": `unverified ${at(91)}`, + "a seller binding for another job": `unverified ${at(91)}`, + "a seller binding whose signature fails": `unverified ${at(91)}`, + "the seller's sticky overflow": `unverified ${at(91)}`, + "another signer's overflow": `verified ${at(6)}`, + "a seller copy signed by the seller alone": `unverified ${at(91)}`, + }); + // Copies that contradict on outcome are no authoritative pair: each side resolves, the deal does not count. + store.set(at(9), copy("seller", bundleScope({ outcome: "failed-perm" }))); + const divergent = await index([buyerSide, binding("seller", at(9), SELLER)]); + assert.equal(divergent.refsVerified, false); + assert.equal(divergent.reputationEligible, false); + // An advisory difference is not a contradiction. + store.set(at(10), copy("seller", bundleScope({ finalisedAt: 121 }))); + assert.equal((await index([buyerSide, binding("seller", at(10), SELLER)])).refsVerified, true); + // The registration's own stale pointers never stand in for a resolved copy. + assert.equal((await index(holders())).buyerBundleRef, at(5)); + assert.equal(signedScope(copy("buyer"), "bundle").anchoredByRole, undefined); +}); + +test("a session aborted before commitment verifies with each copy signed by its anchor alone, without full standing", async () => { + const aborted = (outcome: string) => { const scope = bundleScope({ outcome }); delete scope.agreementRef; return scope; }; + store.set(at(11), copy("buyer", aborted("aborted-by-self"), [BUYER])); + store.set(at(12), copy("seller", aborted("aborted-by-other"), [SELLER])); + const deal = await index([binding("buyer", at(11), BUYER), binding("seller", at(12), SELLER)]); + assert.equal(deal.refsVerified, true); + assert.equal(deal.fullSignatureStanding, false); + assert.equal(deal.sellerOutcome, "aborted-by-other"); + assert.equal(deal.outcome, "aborted-by-other"); + assert.equal(deal.anchoredByRole, "seller"); +}); From a0d82d09c6d1fd3605abe66d2bcffc6d86c1dde4 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:05 +0200 Subject: [PATCH 16/42] test(dacs-directory): give the legacy deal path a positive end-to-end test and pin its strict policy The legacy-sdk-v0.1 deal path had no positive test, so any of its guards could be removed silently. The browser verdict uses the same policy. - An SDK-profile deal verifies end to end through the indexer, and each copy must be strict: both copies, the registered roles, both signatures, resolvable evidence and no outsider signature. - The strict ref policy, referenced-artifact signatures, the bundle's signature entries, registered deal and copy-role binding, the browser verdict's registered owners and the pinned cancellation terms, one case per rule. - A copy carrying the current bundleVersion "1" goes to the current verifier and is never retried as legacy. Co-Authored-By: Claude Opus 5.5 --- .../test/legacy-deal-rules.test.ts | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 reference-implementations/dacs-directory/test/legacy-deal-rules.test.ts diff --git a/reference-implementations/dacs-directory/test/legacy-deal-rules.test.ts b/reference-implementations/dacs-directory/test/legacy-deal-rules.test.ts new file mode 100644 index 0000000..38f6066 --- /dev/null +++ b/reference-implementations/dacs-directory/test/legacy-deal-rules.test.ts @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import type { BundleVerification } from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; + +// The labelled legacy-sdk-v0.1 deal path: the strict policy the browser verdict and the indexer +// layer on the pinned SDK's verifyBundleCore, and one end-to-end deal through the indexer. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-legacy-rules-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const policy = await import("../src/catalog/bundlePolicy.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { resolveProgramAddress } = await import("../src/catalog/store.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +type Obj = Record; +const seeds = [91, 92, 93].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const [BUYER, SELLER, OUTSIDER] = [0, 1, 2]; +const sign = (message: string, signer: number, encoding: BufferEncoding = "hex") => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[signer]))).toString(encoding); +const at = (n: number) => `stor-${(0xd000 + n).toString(16).padStart(40, "0")}`; +const JOB = "legacy-job"; + +const listingScope: Obj = { + listingId: "legacy-svc", listingVersion: 1, agentId: dids[SELLER], serviceId: "legacy-svc", name: "Legacy", description: "Draft SDK service", + category: "legacy.test", claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], +}; +const listing = { ...listingScope, signature: { algorithm: "ed25519", signer: dids[SELLER], value: sign(`dacs-listing:v1:${contentHash(listingScope)}`, SELLER) } }; +const agreementScope: Obj = { + jobId: JOB, pattern: "negotiate-fixed-price", buyer: dids[BUYER], seller: dids[SELLER], listingRef: at(1), + price: { amount: "5", asset: "DEM", decimals: 0, rail: "pay-dem" }, delivery: { phase: "deliver-attested-payload", format: "application/json" }, expiresAt: "2027-01-01T00:00:00Z", +}; +const agreementSignature = (signer: number, as = signer, field = "signer") => + ({ algorithm: "ed25519", [field]: dids[as], value: sign(`dacs-agreement:v1:${contentHash(agreementScope)}`, signer) }); +const agreement = { ...agreementScope, signatures: [agreementSignature(BUYER), agreementSignature(SELLER)] }; +const evidenceScope: Obj = { + evidenceVersion: "1", jobId: JOB, phase: "pay-dem", phaseIndex: 0, outcome: "success", + paymentTxRefs: [{ rail: "demos", txHash: "0xabc", kind: "payment" }], paymentAmount: { amount: "5", currency: "DEM" }, + settlementFinality: { model: "bft-final", finalityBlocks: 1, finalityObservedAt: 100 }, observedAt: 100, +}; +const evidence = { ...evidenceScope, signature: { algorithm: "ed25519", signer: dids[SELLER], value: sign(`dacs-evidence:v1:${contentHash(evidenceScope)}`, SELLER) } }; + +function verification(change: (bundle: NonNullable) => void = () => {}): BundleVerification { + const bundle = { + bundleVersion: "0.1", jobId: JOB, outcome: "completed", anchoredByRole: "buyer", + listingRef: { listingId: "legacy-svc", version: 1, contentHash: contentHash(listingScope) }, + agreementRef: { kind: "dacs-3-agreement", id: "agreement-legacy", contentHash: contentHash(agreementScope) }, + parties: [{ role: "buyer", primaryClaim: dids[BUYER], bundleHash: "b" }, { role: "seller", primaryClaim: dids[SELLER], bundleHash: "s" }], + phaseSummary: [], vetRecords: [], settlementEvidence: [{ kind: "dacs-4-evidence", id: "evidence-legacy", contentHash: contentHash(evidenceScope) }], + recipeRegistryVersion: 1, railRegistryVersion: 1, finalisedAt: 120, + } as NonNullable; + change(bundle); + return { + ok: true, fullyVerified: true, bundle, + signatures: [{ party: dids[BUYER], verdict: "valid" }, { party: dids[SELLER], verdict: "valid" }], + refs: [{ kind: "dacs-3-agreement", id: "agreement-legacy", verdict: "ok" }, { kind: "dacs-4-evidence", id: "evidence-legacy", verdict: "ok" }, { kind: "dacs-1-listing", id: "legacy-svc", verdict: "ok" }], + }; +} +const artifacts = (): Array<{ kind: string; raw: Obj }> => [{ kind: "dacs-3-agreement", raw: structuredClone(agreement) }, { kind: "dacs-4-evidence", raw: structuredClone(evidence) }, { kind: "dacs-1-listing", raw: structuredClone(listing) }]; + +test("strict refs: the SDK's ref verdicts, the positional kinds and ids, the parties and the hashes all bind", async () => { + const strict = (value: BundleVerification, list = artifacts()) => policy.refsPassStrictPolicy(value, list); + assert.equal(await strict(verification()), true); + const cases: Record?]> = { + "an SDK verification that failed": [{ ...verification(), ok: false }], + "a ref the SDK did not resolve": [{ ...verification(), refs: verification().refs.map((ref, n) => n === 1 ? { ...ref, verdict: "missing" as const } : ref) }], + "one artifact fewer than expected": [verification(), artifacts().slice(0, 2)], + "one ref verdict fewer than expected": [{ ...verification(), refs: verification().refs.slice(0, 2) }], + "an agreementRef of another kind": [verification((bundle) => { bundle.agreementRef = { ...bundle.agreementRef, kind: "dacs-2-verifyresult" }; })], + "evidence of another kind": [verification((bundle) => { bundle.settlementEvidence = [{ ...bundle.settlementEvidence[0], kind: "dacs-3-agreement" }]; })], + "a vet record of another kind": [verification((bundle) => { bundle.vetRecords = [{ kind: "dacs-4-evidence", id: "vet", contentHash: "f".repeat(64) }]; })], + "a ref id that differs from the bundle's": [{ ...verification(), refs: verification().refs.map((ref, n) => n === 0 ? { ...ref, id: "agreement-other" } : ref) }], + "a ref kind that differs from the bundle's": [{ ...verification(), refs: verification().refs.map((ref, n) => n === 1 ? { ...ref, kind: "dacs-2-verifyresult" } : ref) }], + "an artifact of another kind in the agreement's place": [verification(), [{ kind: "dacs-4-evidence", raw: structuredClone(evidence) }, ...artifacts().slice(1)]], + "two buyers": [verification((bundle) => { bundle.parties = [...bundle.parties, { role: "buyer", primaryClaim: dids[OUTSIDER], bundleHash: "o" }]; })], + "no seller": [verification((bundle) => { bundle.parties = bundle.parties.slice(0, 1); })], + "an agreement for another job": [verification(), [{ kind: "dacs-3-agreement", raw: { ...agreement, jobId: "other" } }, ...artifacts().slice(1)]], + "evidence for another job": [verification(), [artifacts()[0], { kind: "dacs-4-evidence", raw: { ...evidence, jobId: "other" } }, artifacts()[2]]], + "an agreement whose seller is not the bundle's": [verification(), [{ kind: "dacs-3-agreement", raw: { ...agreement, seller: dids[OUTSIDER] } }, ...artifacts().slice(1)]], + "an agreement whose buyer is not the bundle's": [verification(), [{ kind: "dacs-3-agreement", raw: { ...agreement, buyer: dids[OUTSIDER] } }, ...artifacts().slice(1)]], + "evidence that no longer hashes to its ref": [verification(), [artifacts()[0], { kind: "dacs-4-evidence", raw: { ...evidence, observedAt: 101 } }, artifacts()[2]]], + "a listing that no longer hashes to its ref": [verification(), [...artifacts().slice(0, 2), { kind: "dacs-1-listing", raw: { ...listing, name: "Renamed" } }]], + "a listing with no canonical form": [verification(), [...artifacts().slice(0, 2), { kind: "dacs-1-listing", raw: { ...listing, extra: JSON.parse("1e400") } }]], + "an unsigned evidence": [verification(), [artifacts()[0], { kind: "dacs-4-evidence", raw: evidenceScope }, artifacts()[2]]], + }; + const results: Record = {}; + for (const [name, [value, list]] of Object.entries(cases)) results[name] = await strict(value, list); + assert.deepEqual(results, Object.fromEntries(Object.keys(cases).map((name) => [name, false]))); +}); + +test("referenced legacy artifacts carry exactly one signature form, each entry an Ed25519 envelope that verifies", async () => { + const parties = new Set([dids[BUYER], dids[SELLER]]); + const signed = (kind: string, raw: Obj, claims = parties) => policy.verifyReferencedArtifactSignature({ kind, raw }, claims); + assert.equal(await signed("dacs-3-agreement", agreement), true); + assert.equal(await signed("dacs-3-agreement", { ...agreementScope, signatures: [agreementSignature(BUYER, BUYER, "party"), agreementSignature(SELLER, SELLER, "party")] }), true, "party names the signer too"); + assert.equal(await signed("dacs-4-evidence", evidence), true); + assert.equal(await signed("dacs-1-listing", listing), true); + const vet = { recordVersion: "1", jobId: JOB }; + assert.equal(await signed("dacs-2-verifyresult", { ...vet, signature: { algorithm: "ed25519", signer: dids[OUTSIDER], value: sign(`dacs-composite:v1:${contentHash(vet)}`, OUTSIDER) } }), true, + "a vet record may be signed by an external verifier"); + const rejected: Record> = { + "an unknown artifact kind": signed("dacs-9-other", evidence), + "both signature forms": signed("dacs-4-evidence", { ...evidence, signatures: [evidence.signature] }), + "neither signature form": signed("dacs-4-evidence", evidenceScope), + "an empty signatures list": signed("dacs-4-evidence", { ...evidenceScope, signatures: [] }), + "a signatures member that is not a list": signed("dacs-4-evidence", { ...evidenceScope, signatures: evidence.signature }), + "a singular signature that is a list": signed("dacs-4-evidence", { ...evidenceScope, signature: [evidence.signature] }), + "a singular signature that is a string": signed("dacs-4-evidence", { ...evidenceScope, signature: evidence.signature.value }), + "an entry that is not an object": signed("dacs-3-agreement", { ...agreementScope, signatures: [...agreement.signatures, "sig"] }), + "an entry with another algorithm": signed("dacs-4-evidence", { ...evidenceScope, signature: { ...evidence.signature, algorithm: "ecdsa-secp256k1" } }), + "an entry without a signer": signed("dacs-4-evidence", { ...evidenceScope, signature: { algorithm: "ed25519", value: evidence.signature.value } }), + "an entry whose value is not a string": signed("dacs-4-evidence", { ...evidenceScope, signature: { ...evidence.signature, value: 7 } }), + "an entry whose value is not a signature": signed("dacs-4-evidence", { ...evidenceScope, signature: { ...evidence.signature, value: "zz" } }), + "an entry that does not verify": signed("dacs-4-evidence", { ...evidenceScope, observedAt: 1, signature: evidence.signature }), + "an agreement signed by the buyer alone": signed("dacs-3-agreement", { ...agreementScope, signatures: [agreementSignature(BUYER)] }), + "an agreement whose buyer is not a string": signed("dacs-3-agreement", { ...agreementScope, buyer: 7, signatures: agreement.signatures }), + "an agreement whose seller is not a string": signed("dacs-3-agreement", { ...agreementScope, seller: 7, signatures: agreement.signatures }), + "evidence signed only by an outsider": signed("dacs-4-evidence", { ...evidenceScope, signature: { algorithm: "ed25519", signer: dids[OUTSIDER], value: sign(`dacs-evidence:v1:${contentHash(evidenceScope)}`, OUTSIDER) } }), + "evidence with no parties to bind": signed("dacs-4-evidence", evidence, new Set()), + "an unsigned listing": signed("dacs-1-listing", listingScope), + }; + const results: Record = {}; + for (const [name, verdict] of Object.entries(rejected)) results[name] = await verdict; + assert.deepEqual(results, Object.fromEntries(Object.keys(rejected).map((name) => [name, false]))); +}); + +test("the bundle's own signature entries must mirror the SDK's verdicts, every one valid", () => { + const value = verification(); + const raw = (change: (entries: Obj[]) => unknown = (entries) => entries) => + ({ signatures: change([{ algorithm: "ed25519", party: dids[BUYER], value: "x" }, { algorithm: "ed25519", party: dids[SELLER], value: "y" }]) }); + assert.equal(policy.hasRequiredBundleSignatures(value, raw()), true); + const rejected: Record = { + "no SDK bundle": policy.hasRequiredBundleSignatures({ ...value, bundle: undefined }, raw()), + "no raw bundle": policy.hasRequiredBundleSignatures(value, null), + "a raw bundle that is a list": policy.hasRequiredBundleSignatures(value, [raw()]), + "a raw bundle that is a string": policy.hasRequiredBundleSignatures(value, "bundle"), + "a singular signature too": policy.hasRequiredBundleSignatures(value, { ...raw(), signature: {} }), + "signatures not a list": policy.hasRequiredBundleSignatures(value, raw((entries) => entries[0])), + "one entry fewer": policy.hasRequiredBundleSignatures(value, raw((entries) => entries.slice(0, 1))), + "an entry that is not an object": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[0], "y"])), + "an entry that is a list": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[0], [entries[1]]])), + "an entry with another algorithm": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[0], { ...entries[1], algorithm: "sr1-aggregate" }])), + "entries in another order": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[1], entries[0]])), + "an entry whose value is not a string": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[0], { ...entries[1], value: 7 }])), + "an entry whose value is empty": policy.hasRequiredBundleSignatures(value, raw((entries) => [entries[0], { ...entries[1], value: "" }])), + "an SDK verdict that is not valid": policy.hasRequiredBundleSignatures({ ...value, signatures: [value.signatures[0], { ...value.signatures[1], verdict: "unverified" }] }, raw()), + "no signatures at all": policy.hasRequiredBundleSignatures({ ...value, signatures: [] }, raw((entries) => entries.slice(0, 0))), + }; + assert.deepEqual(rejected, Object.fromEntries(Object.keys(rejected).map((name) => [name, false]))); +}); + +test("a registered deal binds the bundle's single buyer and seller, and the registered copy role", () => { + const deal = { jobId: JOB, rail: "pay-dem", buyerBundleRef: at(2), owners: { buyer: dids[BUYER], seller: dids[SELLER] } }; + const matches = (change: (bundle: NonNullable) => void, catalogSeller = dids[SELLER]) => + policy.bundleMatchesRegisteredDeal(verification(change).bundle, deal, catalogSeller); + assert.equal(matches(() => {}), true); + assert.equal(policy.bundleMatchesRegisteredDeal(undefined, deal, dids[SELLER]), false); + assert.equal(matches((bundle) => { bundle.parties = [...bundle.parties, { role: "buyer", primaryClaim: dids[OUTSIDER], bundleHash: "o" }]; }), false); + assert.equal(matches((bundle) => { bundle.parties = [...bundle.parties, { role: "seller", primaryClaim: dids[OUTSIDER], bundleHash: "o" }]; }), false); + assert.equal(matches((bundle) => { bundle.parties[1] = { ...bundle.parties[1], primaryClaim: dids[OUTSIDER] }; }, dids[OUTSIDER]), false, "the registered seller differs"); + assert.equal(matches(() => {}, dids[OUTSIDER]), false, "another catalog seller"); + // The unhashed copy role must match a registered address; an unknown address never matches. + const bundle = { anchoredByRole: "buyer" }; + assert.equal(policy.bundleMatchesRegisteredAnchor(bundle, at(2).toUpperCase(), at(2), at(3)), true); + assert.equal(policy.bundleMatchesRegisteredAnchor({ anchoredByRole: undefined }, at(9), at(2), undefined), false); + assert.equal(policy.bundleMatchesRegisteredAnchor(undefined, at(2), at(2), at(3)), false); + assert.equal(policy.registeredAnchorRole(at(3), 7, at(3)), "seller"); +}); + +test("browser verdict: registered owners must be an object naming both parties", () => { + const value = verification(); + const registered = { owners: { buyer: dids[BUYER], seller: dids[SELLER] }, buyerBundleRef: at(2), sellerBundleRef: at(3) }; + const verdict = (owners: unknown) => policy.browserDealVerdict({ verification: value, bundleRef: at(2), buyerOwner: dids[BUYER], expectedSeller: dids[SELLER], + registered: { ...registered, owners }, signaturesOk: true, refsOk: true }).verdict; + assert.equal(verdict(registered.owners), "strict-pass"); + for (const owners of ["owners", [registered.owners], { buyer: dids[BUYER] }, { buyer: dids[BUYER], seller: 7 }, { buyer: dids[OUTSIDER], seller: dids[SELLER] }]) { + assert.equal(verdict(owners), "fail", JSON.stringify(owners)); + } +}); + +test("cancellation terms come only from the strict-verified listing the bundle pins by id, version and hash", () => { + const pinned = (scope: Obj, change: (bundle: NonNullable) => void = () => {}) => policy.verifiedListingTerms( + verification((bundle) => { bundle.listingRef = { listingId: "legacy-svc", version: 1, contentHash: contentHash(scope) }; change(bundle); }), + [{ kind: "dacs-1-listing", raw: { ...scope, signature: "verified-by-strict-policy" } }], true); + const terms = { cancellationPolicy: "pre-commit" }; + assert.deepEqual(pinned({ ...listingScope, terms }), terms); + const { listingId: _, ...byServiceId } = listingScope; + assert.deepEqual(pinned({ ...byServiceId, terms }), terms, "a draft listing named by serviceId"); + const { listingVersion: __, ...unversioned } = listingScope; + assert.deepEqual(pinned({ ...unversioned, version: 1, terms }), terms, "the draft version member"); + assert.deepEqual(pinned({ ...unversioned, terms }), terms, "an unversioned draft listing is version 1"); + for (const [name, scope] of Object.entries({ + "a version of 0": { ...listingScope, listingVersion: 0, terms }, "a version as a string": { ...listingScope, listingVersion: "1", terms }, + "terms as a list": { ...listingScope, terms: [terms] }, "no terms": { ...listingScope }, "a listing for another id": { ...listingScope, listingId: "other", terms }, + })) assert.equal(pinned(scope), undefined, name); + assert.equal(pinned({ ...listingScope, terms }, (bundle) => { bundle.listingRef = { ...bundle.listingRef, version: 2 }; }), undefined, "another pinned version"); + assert.equal(policy.verifiedListingTerms(verification(), [], true), undefined, "no listing artifact"); + assert.equal(policy.verifiedListingTerms(undefined, [], true), undefined); + assert.equal(policy.bundleCategory(undefined, new Map([["legacy-svc", "legacy.test"]])), undefined); +}); + +test("§10.5.1 reconciliation leaves unverified records and lone verified copies untouched", () => { + const record = (change: Obj = {}) => ({ jobId: JOB, rail: "pay-dem", buyerBundleRef: at(2), owners: { buyer: dids[BUYER], seller: dids[SELLER] }, + signatureVerified: true, refsVerified: true, reputationEligible: true, bundleContentHash: "1".repeat(64), verifiedAt: 1, ...change }); + const out = policy.dedupeVerifiedDeals([record({ refsVerified: false, reputationEligible: false }), record({ refsVerified: false, reputationEligible: false }), record({ buyerBundleRef: at(3) })]); + assert.deepEqual(out.map((deal) => [deal.buyerBundleRef, deal.refsVerified, deal.reputationEligible]), + [[at(2), false, false], [at(2), false, false], [at(3), true, true]]); +}); + +test("an SDK-profile deal verifies end to end on the labelled legacy path, and each copy must be strict", async () => { + // The legacy path is chosen only when neither copy carries the current bundleVersion "1" discriminator. + const bundleScope: Obj = { ...verification().bundle } as Obj; + delete bundleScope.anchoredByRole; + const bundleSignature = (signer: number) => ({ party: dids[signer], algorithm: "ed25519", value: sign(`dacs-bundle:v1:${contentHash(bundleScope)}`, signer, "base64url") }); + const copy = (anchoredByRole: string, change: Obj = {}) => ({ ...bundleScope, signatures: [bundleSignature(BUYER), bundleSignature(SELLER)], anchoredByRole, ...change }); + const owner = `0x${dids[SELLER].slice(-64)}`; + const run = async (stored: Record) => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = stored[String(input).split("/").pop() ?? ""]; + return data ? Response.json({ success: true, owner, programName: "opaque", data }) : new Response(null, { status: 404 }); + }; + try { + return await indexRegistration({ primaryClaim: dids[SELLER], displayName: "Legacy seller", listingAnchors: [at(1)], + deals: [{ jobId: JOB, rail: "pay-dem", buyerBundleRef: at(2), sellerBundleRef: at(3), owners: { buyer: dids[BUYER], seller: dids[SELLER] } }] }, + undefined, async () => { throw new Error("identity unavailable"); }); + } finally { + globalThis.fetch = originalFetch; + } + }; + const base = { + [at(1)]: listing, [at(2)]: copy("buyer"), [at(3)]: copy("seller"), + [resolveProgramAddress(dids[BUYER], `dacs3:agreement:${JOB}`)!]: agreement, + [resolveProgramAddress(dids[BUYER], `dacs4:evidence:${JOB}`)!]: evidence, + }; + const record = await run(base); + assert.equal(record.deals.length, 1); + assert.deepEqual({ ...record.deals[0], verifiedAt: 0, anchorTimestamp: undefined }, { + jobId: JOB, rail: "pay-dem", buyerBundleRef: at(2), sellerBundleRef: at(3), owners: { buyer: dids[BUYER], seller: dids[SELLER] }, + signatureVerified: true, refsVerified: true, outcome: "completed", sellerOutcome: "completed", anchoredByRole: "seller", + bundleContentHash: contentHash(bundleScope), fullSignatureStanding: true, reputationEligible: true, cancellationNeutral: false, + finalisedAt: 120, anchorTimestamp: undefined, category: "legacy.test", verifiedAt: 0, + }); + assert.equal(record.reputation.completed, 1); + const verdict = async (stored: Record) => { + const deal = (await run(stored)).deals[0]; + return `${deal.refsVerified ? "verified" : "unverified"} ${deal.reputationEligible ? "eligible" : "ineligible"} ${deal.sellerOutcome}`; + }; + const evidenceAt = resolveProgramAddress(dids[BUYER], `dacs4:evidence:${JOB}`)!; + assert.deepEqual({ + "no seller copy": await verdict({ ...base, [at(3)]: undefined as unknown as Obj }), + "a seller copy anchored as the buyer": await verdict({ ...base, [at(3)]: copy("buyer") }), + "a buyer copy anchored as the seller": await verdict({ ...base, [at(2)]: copy("seller") }), + "a seller copy signed by the buyer alone": await verdict({ ...base, [at(3)]: { ...copy("seller"), signatures: [bundleSignature(BUYER)] } }), + "missing evidence": await verdict({ ...base, [evidenceAt]: undefined as unknown as Obj }), + "a seller copy also signed by an outsider": await verdict({ ...base, [at(3)]: { ...copy("seller"), signatures: [bundleSignature(BUYER), bundleSignature(SELLER), bundleSignature(OUTSIDER)] } }), + }, { + "no seller copy": "unverified ineligible completed", + "a seller copy anchored as the buyer": "unverified ineligible completed", + "a buyer copy anchored as the seller": "unverified ineligible completed", + "a seller copy signed by the buyer alone": "unverified ineligible completed", + "missing evidence": "unverified ineligible completed", + "a seller copy also signed by an outsider": "unverified ineligible completed", + }); + // Signature order is not significant. + assert.equal(await verdict({ ...base, [at(3)]: { ...copy("seller"), signatures: [bundleSignature(SELLER), bundleSignature(BUYER)] } }), "verified eligible completed"); + // A copy carrying the current discriminator is verified as a current bundle, never retried as legacy. + const current = await verdict({ ...base, [at(2)]: copy("buyer", { bundleVersion: "1" }), [at(3)]: copy("seller", { bundleVersion: "1" }) }); + assert.equal(current, "unverified ineligible undefined"); +}); + +test("further legacy rules: an invalid presented signature, empty optional lists, unique ids, and the pinned listing content", async () => { + const value = verification(); + const outsiderEntry = { algorithm: "ed25519", party: dids[OUTSIDER], value: "z" }; + const entries = [{ algorithm: "ed25519", party: dids[BUYER], value: "x" }, { algorithm: "ed25519", party: dids[SELLER], value: "y" }]; + assert.equal(policy.hasRequiredBundleSignatures({ ...value, signatures: [...value.signatures, { party: dids[OUTSIDER], verdict: "invalid" }] }, + { signatures: [...entries, outsiderEntry] }), false, "an invalid presented signature fails the copy"); + const extended = (change: Obj) => verification((bundle) => Object.assign(bundle, change)); + assert.equal(await policy.refsPassStrictPolicy(extended({ ratingRefs: [], amendments: [] }), artifacts()), true); + assert.equal(await policy.refsPassStrictPolicy(extended({ amendments: "none" }), artifacts()), false); + // Two evidence refs with one id are ambiguous even when their hashes differ. + const duplicated = verification((bundle) => { bundle.settlementEvidence = [bundle.settlementEvidence[0], { kind: "dacs-4-evidence", id: "evidence-legacy", contentHash: "e".repeat(64) }]; }); + duplicated.refs = [duplicated.refs[0], duplicated.refs[1], { kind: "dacs-4-evidence", id: "evidence-legacy", verdict: "ok" }, duplicated.refs[2]]; + assert.equal(await policy.refsPassStrictPolicy(duplicated, [artifacts()[0], artifacts()[1], artifacts()[1], artifacts()[2]]), false); + // Cancellation terms bind to the pinned content, not only to its id and version. + const terms = { cancellationPolicy: "pre-commit" }; + const pinnedTo = (pinned: Obj, served: Obj) => policy.verifiedListingTerms( + verification((bundle) => { bundle.listingRef = { listingId: "legacy-svc", version: Number(pinned.listingVersion), contentHash: contentHash(pinned) }; }), + [{ kind: "dacs-1-listing", raw: { ...served, signature: "verified-by-strict-policy" } }], true); + assert.equal(pinnedTo({ ...listingScope, terms: { cancellationPolicy: "none" } }, { ...listingScope, terms }), undefined); + assert.equal(pinnedTo({ ...listingScope, listingVersion: 0, terms }, { ...listingScope, listingVersion: 0, terms }), undefined, "version 0"); +}); From aca78b898c60fa80d6439aae9cfeea8d3e5acc6b Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:05 +0200 Subject: [PATCH 17/42] test(dacs-directory): pin the identity tier, reputation derivation and SIG-6 decoder rules - identity-rules: a claim counts toward the reported tier only through a hash-bound, signed, passing VerifyResult from a trusted signer under a live, version-pinned recipe, inside its validity window, with its authority attestation; each rule withholds the tier when it fails. - reputation-rules: eligibility, the windowing basis, SB-2, neutral outcomes and rates, bundle references, seller ratings, observed volume and ST-10 neutrality. - signature-value-rules: SIG-6 encoding and decoding at every length, the declared legacy import and the legacy-sdk-v0.1 reader. Co-Authored-By: Claude Opus 5.5 --- .../test/identity-rules.test.ts | 155 ++++++++++++++++++ .../test/reputation-rules.test.ts | 150 +++++++++++++++++ .../test/signature-value-rules.test.ts | 68 ++++++++ 3 files changed, 373 insertions(+) create mode 100644 reference-implementations/dacs-directory/test/identity-rules.test.ts create mode 100644 reference-implementations/dacs-directory/test/reputation-rules.test.ts create mode 100644 reference-implementations/dacs-directory/test/signature-value-rules.test.ts diff --git a/reference-implementations/dacs-directory/test/identity-rules.test.ts b/reference-implementations/dacs-directory/test/identity-rules.test.ts new file mode 100644 index 0000000..cf5ce66 --- /dev/null +++ b/reference-implementations/dacs-directory/test/identity-rules.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { artifactHash } from "../src/catalog/evidenceGraph.js"; +import { deriveIdentityTier, resolveConfiguredRecipe, type RecipePolicy } from "../src/catalog/identityVerification.js"; + +// The identity tier the Directory reports: a claim counts only through a hash-bound, signed, +// passing VerifyResult from a trusted signer under a live, version-pinned recipe (DACS-2 §7.4-§7.5). +type Obj = Record; +const seeds = [81, 82].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const [TRUSTED, OTHER] = [0, 1]; +const at = (n: number) => `stor-${(0xc000 + n).toString(16).padStart(40, "0")}`; +const LEI = "5493001KJTIIGC8Y1R12"; +const VERIFIED_AT = 1_000_000; +const NOW = VERIFIED_AT + 1_000; +const attested = { authority: "gleif", record: LEI }; + +const policy = (change: Partial = {}): RecipePolicy => ({ + scheme: "lei", recipeVersion: 2, methods: ["verifiable-credential"], defaultMaxAgeSec: 3600, availability: "live", trustedResultSigners: [dids[TRUSTED]], ...change, +}); +interface Case { + result?: Obj; signer?: number; envelope?: Obj; claim?: Obj; ref?: Obj; recipe?: RecipePolicy | null; + now?: number; store?: (store: Map) => void; scheme?: string; +} + +async function tier(c: Case = {}) { + const scheme = c.scheme ?? "lei"; + const store = new Map([[at(2), attested]]); + const result: Obj = { + resultVersion: "1", scheme, identifier: LEI, recipeVersion: 2, method: "verifiable-credential", decision: "pass", reason: "registry match", + attestation: { anchor: { kind: "storage-program", locator: at(2) }, contentHash: contentHash(attested) }, fetchedAt: VERIFIED_AT - 10, verifiedAt: VERIFIED_AT, + ...c.result, + }; + for (const key of Object.keys(result)) if (result[key] === undefined) delete result[key]; + const signer = c.signer ?? TRUSTED; + const value = Buffer.from(ed25519Sign(Buffer.from(`dacs-verifyresult:v1:${artifactHash(result, "verify-result")}`, "utf8"), privateKeyFromSeed(seeds[signer]))).toString("base64url"); + const signed = { ...result, signature: { algorithm: "ed25519", signer: dids[signer], value, ...c.envelope } }; + store.set(at(1), signed); + c.store?.(store); + const claim = { + ref: `${scheme}:${LEI}`, + verifiedBy: { anchor: { kind: "storage-program", locator: at(1) }, contentHash: artifactHash(signed, "verify-result"), recipeVersion: 2, ...c.ref }, + ...c.claim, + }; + const recipe = c.recipe === undefined ? policy({ scheme }) : c.recipe; + return deriveIdentityTier({ claims: [claim] }, async (wantScheme, version) => + recipe && recipe.scheme === wantScheme && recipe.recipeVersion === version ? recipe : null, + c.now ?? NOW, async (locator) => store.get(locator) ?? null); +} + +test("an institutional scheme verified under a live recipe gives the institutional tier, another scheme gives verified", async () => { + assert.equal(await tier(), "institutional"); + assert.equal(await tier({ scheme: "domain" }), "verified"); + assert.equal(await tier({ claim: { ref: { scheme: "lei", identifier: LEI } } }), "institutional", "a structured claim reference"); + assert.equal(await tier({ recipe: policy({ availability: "operator_gated" }) }), "institutional"); + assert.equal(await deriveIdentityTier(undefined), "self-declared"); + assert.equal(await deriveIdentityTier({ claims: "lei" }), "self-declared"); + assert.equal(await deriveIdentityTier({ claims: [null, "lei"] }), "self-declared"); +}); + +test("every VerifyResult rule withholds the tier when it fails", async () => { + const withheld: Record = { + "a claim ref without a scheme": { claim: { ref: LEI } }, + "a claim ref starting with a colon": { claim: { ref: `:${LEI}` } }, + "a claim ref that is not a string or object": { claim: { ref: 7 } }, + "a structured claim ref without an identifier": { claim: { ref: { scheme: "lei" } } }, + "a structured claim ref with a numeric scheme": { claim: { ref: { scheme: 7, identifier: LEI } } }, + "no verifiedBy": { claim: { verifiedBy: undefined } }, + "a verifiedBy that is not a reference": { ref: { anchor: { kind: "ipfs", locator: at(1) } } }, + "a verifiedBy recipe version as a string": { ref: { recipeVersion: "2" } }, + "an unreadable result": { store: (store) => { store.delete(at(1)); } }, + "a result with another content hash": { ref: { contentHash: "0".repeat(64) } }, + "resultVersion 2": { result: { resultVersion: "2" } }, + "a result for another scheme": { result: { scheme: "domain" } }, + "a result for another identifier": { result: { identifier: "OTHER" } }, + "a failed result": { result: { decision: "fail" } }, + "an indeterminate result": { result: { decision: "indeterminate" } }, + "a result under another recipe version": { result: { recipeVersion: 3 } }, + "a result without verifiedAt": { result: { verifiedAt: undefined } }, + "an unsigned result": { store: (store) => { const { signature: _, ...unsigned } = store.get(at(1))!; store.set(at(1), unsigned); } }, + "a forged signature": { envelope: { value: Buffer.from(ed25519Sign(Buffer.from("other"), privateKeyFromSeed(seeds[TRUSTED]))).toString("base64url") } }, + "a signer with no resolvable key": { envelope: { signer: `cci:${"8".repeat(64)}` } }, + "an untrusted signer": { signer: OTHER }, + "no recipe": { recipe: null }, + "a method the recipe does not allow": { result: { method: "tlsnotary" } }, + "a mocked recipe": { recipe: policy({ availability: "mocked" }) }, + "a disabled recipe": { recipe: policy({ availability: "disabled" }) }, + "a failed recipe": { recipe: policy({ availability: "failed" }) }, + "a recipe trusting another signer": { recipe: policy({ trustedResultSigners: [dids[OTHER]] }) }, + "a recipe trusting an unresolvable signer": { recipe: policy({ trustedResultSigners: ["cci:unknown"] }) }, + "validUntil before verifiedAt": { result: { validUntil: VERIFIED_AT - 1 } }, + "a past validUntil": { result: { validUntil: NOW - 1 } }, + "a result older than the recipe's maximum age": { now: VERIFIED_AT + 3_600_000 + 1 }, + "an expired claim wrapper": { claim: { expiresAt: NOW - 1 } }, + "no authority attestation": { result: { attestation: undefined } }, + "an attestation that is not a reference": { result: { attestation: { anchor: { kind: "storage-program", locator: at(2) } } } }, + "an unreadable attestation": { store: (store) => { store.delete(at(2)); } }, + "an attestation with another hash": { store: (store) => { store.set(at(2), { ...attested, record: "OTHER" }); } }, + }; + const results: Record = {}; + for (const [name, c] of Object.entries(withheld)) results[name] = await tier(c); + assert.deepEqual(results, Object.fromEntries(Object.keys(withheld).map((name) => [name, "self-declared"]))); + // Boundaries that still count: expiry exactly now, by validUntil, by the recipe's age, or by the claim wrapper. + assert.equal(await tier({ result: { validUntil: NOW } }), "institutional"); + assert.equal(await tier({ now: VERIFIED_AT + 3_600_000 }), "institutional"); + assert.equal(await tier({ claim: { expiresAt: NOW } }), "institutional"); + assert.equal(await tier({ result: { validUntil: VERIFIED_AT } , now: VERIFIED_AT }), "institutional"); +}); + +test("one verified claim is enough, and an institutional scheme outranks the rest", async () => { + const claims = async (schemes: string[]) => { + const store = new Map([[at(2), attested]]); + const claimsList: Obj[] = []; + for (const [index, scheme] of schemes.entries()) { + const result: Obj = { resultVersion: "1", scheme, identifier: LEI, recipeVersion: 2, method: "verifiable-credential", decision: "pass", reason: "ok", + attestation: { anchor: { kind: "storage-program", locator: at(2) }, contentHash: contentHash(attested) }, fetchedAt: 1, verifiedAt: VERIFIED_AT }; + const value = Buffer.from(ed25519Sign(Buffer.from(`dacs-verifyresult:v1:${artifactHash(result, "verify-result")}`), privateKeyFromSeed(seeds[TRUSTED]))).toString("base64url"); + const signed = { ...result, signature: { algorithm: "ed25519", signer: dids[TRUSTED], value } }; + store.set(at(10 + index), signed); + claimsList.push({ ref: `${scheme}:${LEI}`, verifiedBy: { anchor: { kind: "storage-program", locator: at(10 + index) }, contentHash: artifactHash(signed, "verify-result"), recipeVersion: 2 } }); + } + claimsList.push({ ref: "github:unverified" }); + return deriveIdentityTier({ claims: claimsList }, async (scheme) => policy({ scheme }), NOW, async (locator) => store.get(locator) ?? null); + }; + assert.equal(await claims(["domain", "lei"]), "institutional"); + assert.equal(await claims(["domain", "github"]), "verified"); + for (const scheme of ["finra-crd", "sam-uei", "fedramp", "cmmc", "naics"]) assert.equal(await claims([scheme]), "institutional", scheme); + assert.equal(await claims([]), "self-declared"); +}); + +test("recipe policy comes only from the configured, version-pinned list", async () => { + const original = process.env.DACS_RECIPE_POLICIES; + try { + process.env.DACS_RECIPE_POLICIES = JSON.stringify([policy(), policy({ scheme: "domain", recipeVersion: 1 })]); + assert.deepEqual(await resolveConfiguredRecipe("lei", 2), policy()); + assert.equal((await resolveConfiguredRecipe("domain", 1))?.scheme, "domain"); + assert.equal(await resolveConfiguredRecipe("lei", 1), null); + assert.equal(await resolveConfiguredRecipe("domain", 2), null); + process.env.DACS_RECIPE_POLICIES = "{not json"; + assert.equal(await resolveConfiguredRecipe("lei", 2), null); + delete process.env.DACS_RECIPE_POLICIES; + assert.equal(await resolveConfiguredRecipe("lei", 2), null); + } finally { + if (original === undefined) delete process.env.DACS_RECIPE_POLICIES; else process.env.DACS_RECIPE_POLICIES = original; + } +}); + +test("further identity rules: any listed trusted signer counts, and an inverted window never does", async () => { + assert.equal(await tier({ recipe: policy({ trustedResultSigners: [dids[OTHER], dids[TRUSTED]] }) }), "institutional"); + assert.equal(await tier({ recipe: policy({ trustedResultSigners: ["cci:unknown", dids[TRUSTED]] }) }), "institutional"); + // validUntil before verifiedAt is never valid, even before validUntil passes. + assert.equal(await tier({ result: { validUntil: VERIFIED_AT - 10 }, now: VERIFIED_AT - 20 }), "self-declared"); +}); diff --git a/reference-implementations/dacs-directory/test/reputation-rules.test.ts b/reference-implementations/dacs-directory/test/reputation-rules.test.ts new file mode 100644 index 0000000..14e8046 --- /dev/null +++ b/reference-implementations/dacs-directory/test/reputation-rules.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { deriveSellerReputation, flipOutcome, isNeutralCancellation } from "../src/catalog/reputation.js"; +import type { DealRecord } from "../src/catalog/types.js"; + +// DACS-5 §10.5 derivation: which deals count, on which time basis, and what the reported +// rates, ratings, volume and bundle references are. +const SELLER = "did:demos:agent:seller"; +let next = 0; +function deal(change: Partial = {}): DealRecord { + const n = ++next; + return { + jobId: `job-${n}`, rail: "pay-dem", buyerBundleRef: `stor-b${n}`, sellerBundleRef: `stor-s${n}`, owners: { buyer: "did:demos:agent:buyer", seller: SELLER }, + signatureVerified: true, refsVerified: true, reputationEligible: true, sellerOutcome: "completed", finalisedAt: 10, verifiedAt: 10, + bundleContentHash: n.toString(16).padStart(64, "0"), anchoredByRole: "seller", ...change, + }; +} + +test("only verified, eligible deals with a current outcome are scored", () => { + const scored = (deals: DealRecord[]) => deriveSellerReputation(deals, 0, 100).bundleCount; + assert.equal(scored([deal()]), 1); + assert.equal(scored([deal({ refsVerified: false })]), 0); + assert.equal(scored([deal({ reputationEligible: false })]), 0); + assert.equal(scored([deal({ sellerOutcome: "won" })]), 0); + assert.equal(scored([deal({ sellerOutcome: undefined })]), 0); + for (const outcome of ["completed", "failed-perm", "failed-counterparty", "failed-substrate", "aborted-by-self", "aborted-by-other"]) { + assert.equal(scored([deal({ sellerOutcome: outcome })]), 1, outcome); + } +}); + +test("the window uses SR-2 anchor time only when every scored deal has one, with inclusive bounds", () => { + const anchored = deriveSellerReputation([deal({ anchorTimestamp: 50, finalisedAt: 500 }), deal({ anchorTimestamp: 60, finalisedAt: 1 })], 50, 60); + assert.equal(anchored.windowingBasis, "sr2-anchor-timestamp"); + assert.equal(anchored.bundleCount, 2); + const mixed = deriveSellerReputation([deal({ anchorTimestamp: 50, finalisedAt: 5 }), deal({ finalisedAt: 6 })], 5, 6); + assert.equal(mixed.windowingBasis, "finalisedAt"); + assert.equal(mixed.bundleCount, 2); + assert.equal(deriveSellerReputation([deal({ finalisedAt: 4 }), deal({ finalisedAt: 7 })], 5, 6).bundleCount, 0); + assert.equal(deriveSellerReputation([deal({ finalisedAt: undefined })], 0, 100).bundleCount, 0); + // A scored deal outside the window does not decide the basis for the others. + assert.equal(deriveSellerReputation([deal({ refsVerified: false, finalisedAt: 1 }), deal({ anchorTimestamp: 9 })], 0, 100).windowingBasis, "sr2-anchor-timestamp"); +}); + +test("SB-2: a reused settlement id counts once, for the earliest observation, and its other deals drop out", () => { + const tx = (id: string, observedAt: number, evidenceHash = "a".repeat(64)) => ({ id, observedAt, phaseIndex: 2, evidenceHash }); + const later = deal({ jobId: "a-later", settlementTxIds: [tx("t", 20)] }); + const earlier = deal({ jobId: "z-earlier", settlementTxIds: [tx("t", 10)] }); + const kept = (deals: DealRecord[]) => (deriveSellerReputation(deals, 0, 100).bundleRefs ?? []).map((ref) => ref.id).sort(); + assert.deepEqual(kept([later, earlier]), ["z-earlier"]); + assert.deepEqual(kept([earlier, later]), ["z-earlier"]); + // A deal citing a second, unshared settlement still drops out when one of its ids belongs elsewhere. + assert.deepEqual(kept([earlier, deal({ jobId: "two-txs", settlementTxIds: [tx("u", 5), tx("t", 30)] })]), ["z-earlier"]); + assert.deepEqual(kept([deal({ jobId: "own", settlementTxIds: [tx("v", 1)] }), earlier]), ["own", "z-earlier"]); +}); + +test("rates: substrate failures and policy cancellations are neutral, and counterparty faults leave the blame denominator", () => { + const reputation = deriveSellerReputation([ + deal(), deal(), deal({ sellerOutcome: "failed-perm" }), deal({ sellerOutcome: "failed-counterparty" }), deal({ sellerOutcome: "aborted-by-other" }), + deal({ sellerOutcome: "failed-substrate" }), deal({ sellerOutcome: "aborted-by-self", cancellationNeutral: true }), + deal({ sellerOutcome: "aborted-by-other", cancellationNeutral: true }), + ], 0, 100); + assert.equal(reputation.completed, 2); + assert.equal(reputation.totalAgreements, 8); + assert.equal(reputation.completionRate, 2 / 5); + assert.equal(reputation.counterpartyFaultRate, 2 / 5); + assert.equal(reputation.counterpartyAdjustedCompletionRate, 2 / 3); + const neutralOnly = deriveSellerReputation([deal({ sellerOutcome: "failed-substrate" })], 0, 100); + assert.deepEqual([neutralOnly.completionRate, neutralOnly.counterpartyFaultRate, neutralOnly.counterpartyAdjustedCompletionRate], [null, null, null]); + const blameless = deriveSellerReputation([deal({ sellerOutcome: "failed-counterparty" })], 0, 100); + assert.deepEqual([blameless.completionRate, blameless.counterpartyAdjustedCompletionRate], [0, null]); + assert.equal(deriveSellerReputation([], 0, 100).averageBuyerRating, null); +}); + +test("bundle references name the authoritative copy and are ordered by content hash bytes", () => { + const refs = deriveSellerReputation([ + deal({ jobId: "seller-copy", bundleContentHash: "b".repeat(64), anchoredByRole: "seller", sellerBundleRef: "stor-seller", buyerBundleRef: "stor-buyer" }), + deal({ jobId: "buyer-copy", bundleContentHash: "a".repeat(64), anchoredByRole: "buyer", sellerBundleRef: "stor-seller-2", buyerBundleRef: "stor-buyer-2" }), + deal({ jobId: "no-seller-copy", bundleContentHash: "c".repeat(64), anchoredByRole: "seller", sellerBundleRef: undefined, buyerBundleRef: "stor-buyer-3" }), + deal({ jobId: "unhashed", bundleContentHash: undefined }), + ], 0, 100).bundleRefs ?? []; + assert.deepEqual(refs.map((ref) => [ref.id, ref.anchor.locator]), [["buyer-copy", "stor-buyer-2"], ["seller-copy", "stor-seller"], ["no-seller-copy", "stor-buyer-3"]]); + assert.ok(refs.every((ref) => ref.kind === "dacs-5-bundle" && ref.anchor.kind === "storage-program")); +}); + +test("seller ratings: one per rater, job and role, the latest wins, and self-ratings and others' targets are ignored", () => { + const rating = (rater: string, value: number, ratedAt: number, change: Record = {}) => + ({ rater, target: SELLER, targetRole: "seller" as const, value, ratedAt, contentHash: "1".repeat(64), ...change }); + const average = (ratings: NonNullable) => deriveSellerReputation([deal({ ratings })], 0, 100).averageSellerRating; + assert.equal(average([rating("buyer", 4, 1)]), 4); + assert.equal(average([rating("buyer", 1, 1), rating("BUYER", 5, 2)]), 5, "the later rating by the same rater replaces the earlier"); + assert.equal(average([rating("buyer", 5, 2), rating("buyer", 1, 1)]), 5); + assert.equal(average([rating("buyer", 1, 2, { contentHash: "1".repeat(64) }), rating("buyer", 3, 2, { contentHash: "2".repeat(64) })]), 3, "a tie keeps the higher content hash"); + assert.equal(average([rating("buyer", 2, 1), rating("other", 4, 1)]), 3); + assert.equal(average([rating(SELLER, 5, 1)]), null, "a self-rating"); + assert.equal(average([rating("buyer", 5, 1, { target: "did:demos:agent:else" })]), null, "a rating of someone else"); + assert.equal(average([rating("buyer", 5, 1, { targetRole: "buyer" })]), null, "a buyer-role rating"); + assert.equal(average([rating("buyer", 5, 1, { target: SELLER.toUpperCase() })]), 5, "claims compare case-insensitively"); +}); + +test("observed volume sums completed deals' agreed prices exactly, per currency in byte order", () => { + const priced = (amount: string, currency: string, sellerOutcome = "completed") => deal({ agreementPrice: { amount, currency }, sellerOutcome }); + const reputation = deriveSellerReputation([ + priced("0.1", "USDC"), priced("0.2", "USDC"), priced("2", "DEM"), priced("3", "DEM"), priced("9", "DEM", "failed-perm"), + priced("1.25", "eur"), deal({ agreementPrice: undefined }), + ], 0, 100); + assert.deepEqual(reputation.observedTransactionalVolume, [{ currency: "DEM", amount: "5" }, { currency: "USDC", amount: "0.3" }, { currency: "eur", amount: "1.25" }]); + assert.deepEqual(reputation.transactionCountByCurrency, [{ currency: "DEM", count: 2 }, { currency: "USDC", count: 2 }, { currency: "eur", count: 1 }]); + assert.deepEqual(deriveSellerReputation([priced("0.5", "DEM"), priced("0.5", "DEM")], 0, 100).observedTransactionalVolume, [{ currency: "DEM", amount: "1" }]); + assert.deepEqual(deriveSellerReputation([priced("0.05", "DEM"), priced("0.05", "DEM")], 0, 100).observedTransactionalVolume, [{ currency: "DEM", amount: "0.1" }]); + assert.deepEqual(deriveSellerReputation([priced("0", "DEM")], 0, 100).observedTransactionalVolume, [{ currency: "DEM", amount: "0" }]); + assert.deepEqual(deriveSellerReputation([priced("0.0", "DEM")], 0, 100).observedTransactionalVolume, [{ currency: "DEM", amount: "0" }]); + // A malformed amount omits its currency rather than inventing a total. + assert.deepEqual(deriveSellerReputation([priced("1", "DEM"), priced("1e3", "DEM"), priced("2", "USD")], 0, 100).observedTransactionalVolume, [{ currency: "USD", amount: "2" }]); +}); + +test("ST-10: a cancellation is neutral only for a pre-commit abort under a signed pre-commit policy", () => { + const ok = { claimedPolicy: "pre-commit" }; const terms = { cancellationPolicy: "pre-commit" }; + assert.equal(isNeutralCancellation("aborted-by-self", ok, terms, []), true); + assert.equal(isNeutralCancellation("aborted-by-other", ok, terms, [{ kind: "negotiate-fixed-price", outcome: "ok" }]), true); + assert.equal(isNeutralCancellation("aborted-by-self", ok, terms, [{ kind: "commit-agreement", outcome: "fail" }]), true); + for (const [name, args] of Object.entries({ + "a completed session": ["completed", ok, terms, []], + "a failed session": ["failed-perm", ok, terms, []], + "no marker": ["aborted-by-self", undefined, terms, []], + "a marker list": ["aborted-by-self", [ok], terms, []], + "a with-fee claim": ["aborted-by-self", { claimedPolicy: "with-fee" }, terms, []], + "no listing terms": ["aborted-by-self", ok, undefined, []], + "terms as a list": ["aborted-by-self", ok, [terms], []], + "a none policy": ["aborted-by-self", ok, { cancellationPolicy: "none" }, []], + "after commit-agreement": ["aborted-by-self", ok, terms, [{ kind: "commit-agreement", outcome: "ok" }]], + "after commit-payee-bound-agreement": ["aborted-by-other", ok, terms, [null, { kind: "commit-payee-bound-agreement", outcome: "ok" }]], + } as Record)) assert.equal(isNeutralCancellation(...args), false, name); + assert.deepEqual(["aborted-by-self", "aborted-by-other", "failed-perm", "failed-counterparty", "completed", undefined].map(flipOutcome), + ["aborted-by-other", "aborted-by-self", "failed-counterparty", "failed-perm", "completed", undefined]); +}); + +test("further derivation rules: the empty basis, SB-2 ties on equal hashes, and rating recency before hash", () => { + assert.equal(deriveSellerReputation([], 0, 100).windowingBasis, "finalisedAt"); + const tx = (observedAt: number, evidenceHash: string) => [{ id: "shared", observedAt, phaseIndex: 2, evidenceHash }]; + const kept = (deals: DealRecord[]) => (deriveSellerReputation(deals, 0, 100).bundleRefs ?? []).map((ref) => ref.id); + const a = deal({ jobId: "a", settlementTxIds: tx(10, "5".repeat(64)) }); + const z = deal({ jobId: "z", settlementTxIds: tx(10, "5".repeat(64)) }); + assert.deepEqual(kept([a, z]), ["a"]); + assert.deepEqual(kept([z, a]), ["a"]); + const rating = (value: number, ratedAt: number, contentHash: string) => ({ rater: "buyer", target: SELLER, targetRole: "seller" as const, value, ratedAt, contentHash }); + const average = (ratings: NonNullable) => deriveSellerReputation([deal({ ratings })], 0, 100).averageSellerRating; + assert.equal(average([rating(2, 5, "9".repeat(64)), rating(4, 6, "1".repeat(64))]), 4, "the newer rating wins over a higher hash"); + assert.equal(average([rating(4, 6, "1".repeat(64)), rating(2, 5, "9".repeat(64))]), 4); + assert.equal(average([rating(3, 6, "9".repeat(64)), rating(5, 6, "1".repeat(64))]), 3, "an equal time keeps the higher hash in either order"); +}); diff --git a/reference-implementations/dacs-directory/test/signature-value-rules.test.ts b/reference-implementations/dacs-directory/test/signature-value-rules.test.ts new file mode 100644 index 0000000..961f765 --- /dev/null +++ b/reference-implementations/dacs-directory/test/signature-value-rules.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + canonicalSignatureBytes, + canonicalSignatureValue, + importLegacySignatureValue, + legacySignatureBytes, +} from "../src/catalog/signatureValue.js"; + +// CORE §B.7 SIG-6 at every length and boundary, beyond the 64-byte vectors in sig6.test.ts. +const bytes = (length: number, fill = (n: number) => (n * 37 + 11) & 255) => Uint8Array.from({ length }, (_, n) => fill(n)); + +test("canonical encoding is unpadded Base64URL at every remainder length, and decoding inverts it exactly", () => { + for (let length = 1; length <= 66; length++) { + const value = bytes(length); + const encoded = canonicalSignatureValue(value); + assert.equal(encoded, Buffer.from(value).toString("base64url"), `length ${length}`); + assert.deepEqual(canonicalSignatureBytes(encoded, length), value, `length ${length}`); + // The algorithm-specific length is checked separately from the wire form. + assert.equal(canonicalSignatureBytes(encoded, length + 1), null); + assert.equal(canonicalSignatureBytes(encoded, length - 1), null); + } + assert.equal(canonicalSignatureValue(new Uint8Array(0)), ""); + assert.equal(canonicalSignatureValue(Uint8Array.from([255, 255, 255])), "____"); + assert.deepEqual(canonicalSignatureBytes("____", 3), Uint8Array.from([255, 255, 255])); +}); + +test("a conforming decoder rejects every non-canonical spelling before any verification", () => { + const signature = canonicalSignatureValue(bytes(64)); + for (const [name, value] of Object.entries({ + "not a string": 7, "empty": "", "an impossible length": "AAAAA", "a single character": "A", + "an invalid character": `${signature.slice(0, -1)}.`, "a standard-Base64 plus": `+${signature.slice(1)}`, "a standard-Base64 slash": `/${signature.slice(1)}`, + "padding": `${signature}==`, "a leading space": ` ${signature}`, "non-zero residual bits": "AB", + })) assert.equal(canonicalSignatureBytes(value, name === "non-zero residual bits" ? 1 : 64), null, name); + assert.deepEqual(canonicalSignatureBytes("AA", 1), Uint8Array.from([0])); + assert.deepEqual(canonicalSignatureBytes("AAA", 2), Uint8Array.from([0, 0])); +}); + +test("the declared legacy import decodes only its declared encoding, strictly, and emits the canonical value", () => { + const raw = bytes(64); + const canonical = canonicalSignatureValue(raw); + const hex = Buffer.from(raw).toString("hex"); + const padded = Buffer.from(raw).toString("base64"); + assert.equal(importLegacySignatureValue(hex, "lowercase-hex"), canonical); + assert.equal(importLegacySignatureValue(padded, "standard-base64-padded"), canonical); + for (const [name, value, declared] of [ + ["uppercase hex", hex.toUpperCase(), "lowercase-hex"], ["short hex", hex.slice(2), "lowercase-hex"], ["long hex", `${hex}00`, "lowercase-hex"], + ["0x hex", `0x${hex}`, "lowercase-hex"], ["hex with a non-hex character", `${hex.slice(0, -1)}g`, "lowercase-hex"], + ["unpadded standard Base64", padded.replace(/=+$/, ""), "standard-base64-padded"], ["URL-safe characters", Buffer.from(raw).toString("base64url") + "==", "standard-base64-padded"], + ["extra padding", `${padded.replace(/=+$/, "")}===`, "standard-base64-padded"], ["the wrong length", Buffer.from(bytes(63)).toString("base64"), "standard-base64-padded"], + ["non-zero residual bits", `${padded.slice(0, -3)}B==`, "standard-base64-padded"], ["padding inside", `${padded.slice(0, 10)}=${padded.slice(11)}`, "standard-base64-padded"], + ["hex as Base64", hex, "standard-base64-padded"], ["no declared encoding", hex, undefined], + ] as Array<[string, string, Parameters[1]]>) assert.equal(importLegacySignatureValue(value, declared), null, name); + // Other algorithm lengths are declared by the caller. + assert.equal(importLegacySignatureValue(Buffer.from(bytes(65)).toString("hex"), "lowercase-hex", 65), canonicalSignatureValue(bytes(65))); + assert.equal(importLegacySignatureValue(Buffer.from(bytes(65)).toString("base64"), "standard-base64-padded", 65), canonicalSignatureValue(bytes(65))); +}); + +test("the legacy-sdk-v0.1 reader accepts the draft SDK spellings of exactly 64 bytes", () => { + const raw = bytes(64); + const hex = Buffer.from(raw).toString("hex"); + for (const value of [hex, hex.toUpperCase(), `0x${hex}`, `0x0x${hex}`, Buffer.from(raw).toString("base64url"), Buffer.from(raw).toString("base64")]) { + assert.deepEqual(legacySignatureBytes(value), raw, value); + } + for (const value of [Buffer.from(bytes(63)).toString("hex"), Buffer.from(bytes(63)).toString("base64url"), Buffer.from(bytes(65)).toString("base64url"), ""]) { + assert.equal(legacySignatureBytes(value), null, value); + } +}); From fd851fe33a958598b2cff1ba4f79c2e9744501b2 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:24:13 +0000 Subject: [PATCH 18/42] fix(dacs-directory): hold evidence, settlement, reputation, catalog and storage reads to the pinned Standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settlement evidence (DACS-4 §9.7, DACS-5 §10.4.3): - A success-outcome payment record must state paymentAmount, a known settlementFinality model and at least one paymentTxRef; delivery and failed-payment records carry no finality, and evidence names a pay-* or deliver-* phase. - settlementEvidence holds exactly one record per executed settle-phase entry, paired with it (by attestationRef, or as the only entry of its kind) with agreeing outcomes. A completed payment phase with no evidence no longer verifies. SB-1/SB-2 (DACS-4 §9.5.8): - settlementKey.ts derives the canonical settlement-tx-id from each ChainTxRef variant (demos:{hash}, evm:{chainId}:{txHash}:{logIndex}, solana:{cluster}:{signature}:{instructionIndex}) and collapses 0x and upper-case spellings. A reference it cannot key is `error`: the deal is listed but not reputation-eligible. - phaseIndex comes from the bundle's phase entry, never the evidence. - Reputation admits claims through one (id, jobId, phaseIndex) ledger, so a cross-phase reuse is rejected as well as a cross-job one. The legacy SDK path emits its claims too. Agreement and listing (DACS-3 §8.5.2, DACS-1 LR-1, DACS-4 PricingSpec): - Auction reserves are an inclusive ceiling for lowest-price and an inclusive floor for the other selection rules. - Checks 5 and 6 are evaluated against a supplied committedAt. A bundle references no CommitmentRecord, so a session whose listing sets deadlineSecAfterCommit or validity.notAfter is indeterminate and not counted. - Fixed-price over negotiable pricing still rejects a band whose lower bound is <= 0, and the graph checks the whole listing pin. Amendments (DACS-4 §9.7.1): amendments and amendmentRefs resolve as SettlementAmendment under dacs-amendment:v1:. AMEND-1..3 violations are flagged (AMEND-4) and never treated as an unwind. Standing and identity: bundle standing comes from the verified signer set, and a distinct orchestrator is required for full standing (bundleSignerPolicy.fullSignatureStanding). The seller identity tier is the highest of its listings' tiers (sellerIdentityTier). Reputation: ST-10 neutrality requires no agreementRef and a session that never left Vet/Negotiate. Two-copy reconciliation is version-aware (fault-bundle pairs by faultedParty and outcome class, legacy pairs by the perspective flip, mixed pairs by the implied-fault set, with the fault bundle authoritative). Listing reputationHints go through the same derivation, scoped to the listing's category. Catalog (DACS-1 §6.3.6): the BundleBinding catalog keeps and serves every BB-4-valid record, paged; the 32-per-signer cap bounds only the Directory's own BB-6 work as a sticky overflow. Storage and node reads: a read is a definitive absence only for the node's 404 NOT_FOUND or 403 PERMISSION_DENIED; any other 4xx leaves a revocation check incomplete and a scan read retryable. The producer resume lookup falls back to the pinned node revision's gcr_routine search only on that revision's literal "Unknown message" answer to the nodeCall. `npm run check:node` is a read-only contract probe. In-browser verifier: VerifyDeal's checks, verdict text and rows move to verifyDealModel.ts; the component only renders them. Tests: fixtures use spec-shaped ChainTxRefs, evidence and node 404 answers. New cases pin each rule above, the anonymous read endpoints, the well-known absence rule, more than 32 bindings from one signer, the discovery attribution guards, and the in-browser verdicts. They kill the mutation-sweep survivors on the evidence graph, the legacy path, the attribution guards, standing and the identity tier. Co-Authored-By: Claude Opus 5.5 --- .../app/api/dacs/bundles/[jobId]/route.ts | 21 +- .../dacs-directory/package.json | 1 + .../scripts/check-node-contract.ts | 15 + .../src/catalog/bundleBinding.ts | 44 ++- .../src/catalog/bundleSignerPolicy.ts | 16 ++ .../dacs-directory/src/catalog/chain.ts | 105 ++++--- .../dacs-directory/src/catalog/contracts.ts | 13 +- .../src/catalog/currentReconciliation.ts | 56 +++- .../src/catalog/evidenceGraph.ts | 242 +++++++++++++--- .../dacs-directory/src/catalog/gcr.ts | 5 +- .../src/catalog/identityVerification.ts | 5 + .../dacs-directory/src/catalog/indexer.ts | 114 ++++---- .../src/catalog/nodeContract.ts | 66 +++++ .../dacs-directory/src/catalog/reindexCore.ts | 10 +- .../dacs-directory/src/catalog/reputation.ts | 67 +++-- .../dacs-directory/src/catalog/scan.ts | 16 +- .../src/catalog/settlementKey.ts | 86 ++++++ .../src/components/VerifyDeal.tsx | 183 ++++-------- .../src/components/verifyDealModel.ts | 149 ++++++++++ .../dacs-directory/test/build-listing.test.ts | 2 +- .../test/catalog-routes.test.ts | 162 +++++++++++ .../dacs-directory/test/chain-storage.test.ts | 80 +++++- .../test/current-indexer.test.ts | 60 ++-- .../dacs-directory/test/deal-page.test.ts | 2 +- .../test/evidence-graph-rules.test.ts | 261 ++++++++++++++++-- .../test/identity-rules.test.ts | 9 +- .../test/indexer-binding-rules.test.ts | 30 +- .../test/legacy-deal-rules.test.ts | 77 +++++- .../dacs-directory/test/listing-rules.test.ts | 9 +- .../test/listing-verification.test.ts | 2 +- .../test/malformed-artifacts.test.ts | 21 +- .../test/reputation-rules.test.ts | 62 ++++- .../test/revocation-vectors.test.ts | 12 +- .../test/scan-addresses.test.ts | 25 +- .../dacs-directory/test/scan-fairness.test.ts | 50 +++- .../test/scan-robustness.test.ts | 2 +- .../dacs-directory/test/verification.test.ts | 2 +- 37 files changed, 1660 insertions(+), 422 deletions(-) create mode 100644 reference-implementations/dacs-directory/scripts/check-node-contract.ts create mode 100644 reference-implementations/dacs-directory/src/catalog/nodeContract.ts create mode 100644 reference-implementations/dacs-directory/src/catalog/settlementKey.ts create mode 100644 reference-implementations/dacs-directory/src/components/verifyDealModel.ts create mode 100644 reference-implementations/dacs-directory/test/catalog-routes.test.ts diff --git a/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts index d1a7497..98e0ea3 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/bundles/[jobId]/route.ts @@ -1,17 +1,28 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { verifyBundleBinding } from "@/src/catalog/bundleBinding"; +import { parsePagination } from "@/src/catalog/pagination"; import { loadScanState } from "@/src/catalog/store"; +/** + * DACS-1 §6.3.6: an unauthenticated read that serves every BB-4-valid BundleBinding this catalog + * holds for the jobId, whoever signed it, in pages. The BB-6 per-signer budget applies only when + * a consumer resolves a side, never to what the catalog serves. + */ export async function GET( - _request: Request, + request: NextRequest, { params }: { params: Promise<{ jobId: string }> }, ) { const { jobId } = await params; if (jobId.length < 1 || jobId.length > 160) { return NextResponse.json({ error: "jobId must be 1-160 characters" }, { status: 400 }); } - const candidates = loadScanState().bundleBindings?.[jobId] ?? []; - const bindings = (await Promise.all(candidates.map((binding) => verifyBundleBinding(binding)))) + const page = parsePagination(request.nextUrl.searchParams.get("limit"), request.nextUrl.searchParams.get("cursor")); + if (!page.ok) return NextResponse.json({ error: page.error }, { status: 400 }); + const stored = loadScanState().bundleBindings; + const candidates = stored && Object.prototype.hasOwnProperty.call(stored, jobId) ? stored[jobId] ?? [] : []; + const slice = candidates.slice(page.cursor, page.cursor + page.limit); + const bindings = (await Promise.all(slice.map((binding) => verifyBundleBinding(binding)))) .filter((binding) => binding !== null); - return NextResponse.json({ bindings }); + const next = page.cursor + page.limit; + return NextResponse.json({ bindings, total: candidates.length, cursor: next < candidates.length ? String(next) : undefined }); } diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..a557389 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -12,6 +12,7 @@ "start:railway": "bash scripts/start-railway.sh", "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", + "check:node": "tsx scripts/check-node-contract.ts", "test": "tsx --test test/*.test.ts test/*.test.mjs", "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", diff --git a/reference-implementations/dacs-directory/scripts/check-node-contract.ts b/reference-implementations/dacs-directory/scripts/check-node-contract.ts new file mode 100644 index 0000000..93e5b04 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-node-contract.ts @@ -0,0 +1,15 @@ +/** + * Read-only node contract probe: `npm run check:node -- `. + * Uses DEMOS_RPC (default as in src/catalog/chain.ts); prints a dated JSON report and exits 1 when + * the contract does not hold. It sends no transactions. + */ +import { probeNodeContract } from "../src/catalog/nodeContract.js"; + +const programName = process.argv[2]; +if (!programName) { + console.error("usage: npm run check:node -- "); + process.exit(2); +} +const report = await probeNodeContract(process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/", programName); +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); diff --git a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts index 7fcee87..8efd961 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundleBinding.ts @@ -100,15 +100,8 @@ const bindingOrder = (left: BundleBinding, right: BundleBinding): number => left.nativeAddress.localeCompare(right.nativeAddress) || contentHash(left).localeCompare(contentHash(right)); -/** - * Deterministic work ceiling per signer of each jobId and role. Overflow is - * sticky in ScanState and must make that signer's side indeterminate; - * truncation can never manufacture absence. - */ -export function boundedBundleBindings( - bindings: Iterable, - limit = MAX_BUNDLE_BINDINGS_PER_JOB_ROLE, -): { bindings: BundleBinding[]; overflowKeys: string[] } { +/** Unique bindings grouped per signer of each jobId and role, in a deterministic order. */ +function bindingsBySigner(bindings: Iterable): Array<[string, BundleBinding[]]> { const unique = new Map(); for (const binding of bindings) unique.set(contentHash(binding), binding); const byRole = new Map(); @@ -118,16 +111,45 @@ export function boundedBundleBindings( values.push(binding); byRole.set(key, values); } + return [...byRole].sort(([a], [b]) => a.localeCompare(b)).map(([key, values]) => [key, values.sort(bindingOrder)]); +} + +/** + * Deterministic work ceiling per signer of each jobId and role, for the + * Directory's own BB-6 resolution. Overflow is sticky in ScanState and must + * make that signer's side indeterminate; truncation can never manufacture + * absence. The catalog itself never truncates (catalogBundleBindings). + */ +export function boundedBundleBindings( + bindings: Iterable, + limit = MAX_BUNDLE_BINDINGS_PER_JOB_ROLE, +): { bindings: BundleBinding[]; overflowKeys: string[] } { const kept: BundleBinding[] = []; const overflowKeys: string[] = []; - for (const [key, values] of [...byRole].sort(([a], [b]) => a.localeCompare(b))) { - values.sort(bindingOrder); + for (const [key, values] of bindingsBySigner(bindings)) { kept.push(...values.slice(0, limit)); if (values.length > limit) overflowKeys.push(key); } return { bindings: kept, overflowKeys }; } +/** + * The catalog's copy of the BB-4-valid bindings. DACS-1 §6.3.6 requires a + * carrying catalog to serve every such record whoever authored it, so all of + * them are kept; the per-signer ceiling is only reported, as the sticky + * overflow that bounds the Directory's own BB-6 work. + */ +export function catalogBundleBindings( + bindings: Iterable, + limit = MAX_BUNDLE_BINDINGS_PER_JOB_ROLE, +): { bindings: BundleBinding[]; overflowKeys: string[] } { + const grouped = bindingsBySigner(bindings); + return { + bindings: grouped.flatMap(([, values]) => values), + overflowKeys: grouped.filter(([, values]) => values.length > limit).map(([key]) => key), + }; +} + export interface InspectedBundle { value: T; bundleContentHash: string; diff --git a/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts index 77c5163..275d6fc 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundleSignerPolicy.ts @@ -1,3 +1,4 @@ +import { canonicalDemosAgentClaim } from "./claimRef.js"; import { canonicalSigningIdentity } from "./primaryClaimKey.js"; const ROLES = new Set(["buyer", "seller", "orchestrator"] as const); @@ -13,6 +14,21 @@ interface BundleLike { parties?: unknown; } +/** + * DACS-5 §10.4.1 full signature standing: every declared buyer, seller and distinct orchestrator + * has a verified signature. A copy signed by its anchor alone has lesser standing, which BB-6 + * precedence and the §10.5.1 reconciliation rank below full. + */ +export function fullSignatureStanding(parties: unknown, verifiedSigners: Iterable): boolean { + const required = (Array.isArray(parties) ? parties : []) + .filter((party): party is Record => Boolean(party && typeof party === "object" && !Array.isArray(party)) && + ROLES.has((party as Record).role as BundleRole)) + .map((party) => typeof party.primaryClaim === "string" ? canonicalDemosAgentClaim(party.primaryClaim) : null) + .filter((claim): claim is string => claim !== null); + const signed = new Set([...verifiedSigners].map(canonicalDemosAgentClaim).filter((claim): claim is string => claim !== null)); + return required.length >= 2 && required.every((claim) => signed.has(claim)); +} + /** Explicit compatibility identity used only by the legacy SDK reader. */ export function demosSigningIdentity(claim: string): string { const key = claim.match(/^(?:did:demos:agent:|0x)?([0-9a-f]{64})$/i)?.[1]; diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index 8fc6e97..978a141 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -12,6 +12,7 @@ */ import { boundedJson, storageResponseJson, StorageResponseTooLarge } from "./artifactLimits.js"; import { sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { gcrAuthHeaders } from "./gcr.js"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); export const LIVE_STORAGE_SALT = ""; @@ -63,7 +64,18 @@ export async function readAnchorResult(address: string): Promise { } catch { return { status: "unavailable" }; } - if ([400, 401, 403, 404].includes(res.status)) return { status: "absent" }; + // Only the node's own answer is definitive: `{ success: false, errorCode }` with NOT_FOUND on a 404 + // or PERMISSION_DENIED on a 403 (kynesyslabs/node storageprogram routes). Any other 4xx, such as a + // proxy's or a wrong route's, leaves the read incomplete (RB-6). + if (res.status === 404 || res.status === 403) { + try { + const answer = await storageResponseJson(res) as { success?: unknown; errorCode?: unknown } | null; + const expected = res.status === 404 ? "NOT_FOUND" : "PERMISSION_DENIED"; + return answer && answer.success === false && answer.errorCode === expected ? { status: "absent" } : { status: "unavailable" }; + } catch { + return { status: "unavailable" }; + } + } if (!res.ok) return { status: "unavailable" }; let json: { success?: boolean; data?: unknown; owner?: string; programName?: string }; try { @@ -94,6 +106,37 @@ export type OwnedAnchorResolution = type ProgramCandidate = { storageAddress?: unknown; programName?: unknown }; +/** kynesyslabs/node manageNodeCall's answer to a message it does not know (ac8f45f, manageNodeCall.ts:996-1000). */ +export const UNKNOWN_NODE_CALL = '{ error: "Unknown message"}'; + +type RpcCall = + | { status: "ok"; body: { result?: unknown; response?: unknown } } + | { status: "failed" } + | { status: "invalid-json" }; + +/** One bounded JSON-RPC POST to the node, classified. */ +async function rpcCall(headers: Record, payload: unknown): Promise { + let response: Response; + try { + response = await fetch(RPC + "/", { + method: "POST", + headers, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + body: JSON.stringify(payload), + }); + } catch { + return { status: "failed" }; + } + if (!response.ok) return { status: "failed" }; + try { + const body = await storageResponseJson(response) as { result?: unknown; response?: unknown } | null; + return body && typeof body === "object" ? { status: "ok", body } : { status: "invalid-json" }; + } catch { + return { status: "invalid-json" }; + } +} + /** * Producer-side resume lookup for one exact StorageProgram name. * @@ -112,44 +155,34 @@ export async function resolveOwnedAnchorByName( const owner = normalizedOwner(expectedOwner); if (!owner) return { status: "indeterminate", reason: "expected owner is invalid" }; - let response: Response; - try { - response = await fetch(RPC + "/", { - method: "POST", - headers: { "content-type": "application/json" }, - cache: "no-store", - signal: AbortSignal.timeout(15_000), - body: JSON.stringify({ - method: "nodeCall", - params: [{ - type: "nodeCall", - message: "searchStoragePrograms", - sender: null, - receiver: null, - timestamp: null, - data: { - query: programName, - options: { exactMatch: true, limit: 32, offset: 0 }, - }, - extra: "", - }], - }), + const options = { exactMatch: true, limit: 32, offset: 0 }; + let search = await rpcCall({ "content-type": "application/json" }, { + method: "nodeCall", + params: [{ + type: "nodeCall", + message: "searchStoragePrograms", + sender: null, + receiver: null, + timestamp: null, + data: { query: programName, options }, + extra: "", + }], + }); + // The pinned node revision (README "Pinned upstreams") has no searchStoragePrograms nodeCall: it + // answers an unknown message with result 200 and this literal, and serves the same exact-name + // search as a read-authenticated gcr_routine. A deployment with the nodeCall answers it directly. + if (search.status === "ok" && search.body.result === 200 && search.body.response === UNKNOWN_NODE_CALL) { + search = await rpcCall(await gcrAuthHeaders(), { + method: "gcr_routine", + params: [{ method: "searchStoragePrograms", params: [programName, options] }], }); - } catch { - return { status: "indeterminate", reason: "program-name lookup failed" }; } - if (!response.ok) return { status: "indeterminate", reason: "program-name lookup failed" }; - - let candidates: ProgramCandidate[]; - try { - const body = await storageResponseJson(response) as { result?: unknown; response?: unknown }; - if (body.result !== 200 || !Array.isArray(body.response)) { - return { status: "indeterminate", reason: "program-name lookup returned an invalid response" }; - } - candidates = body.response as ProgramCandidate[]; - } catch { - return { status: "indeterminate", reason: "program-name lookup returned invalid JSON" }; + if (search.status === "failed") return { status: "indeterminate", reason: "program-name lookup failed" }; + if (search.status === "invalid-json") return { status: "indeterminate", reason: "program-name lookup returned invalid JSON" }; + if (search.body.result !== 200 || !Array.isArray(search.body.response)) { + return { status: "indeterminate", reason: "program-name lookup returned an invalid response" }; } + const candidates = search.body.response as ProgramCandidate[]; if (candidates.length >= 32) { return { status: "indeterminate", reason: "program-name lookup reached its candidate bound" }; } diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 4b6d057..39d3fa3 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -306,9 +306,16 @@ export const openApiDocument = (origin: string) => ({ }, "/api/dacs/bundles/{jobId}": { get: { - summary: "Retrieve BB-4-verified DACS-5 BundleBindings known to this catalog", - parameters: [{ name: "jobId", in: "path", required: true, schema: { type: "string", minLength: 1, maxLength: 160 } }], - responses: { "200": { description: "Signed BundleBinding candidates" }, "400": { description: "Invalid jobId" } }, + summary: "Retrieve every BB-4-verified DACS-5 BundleBinding this catalog holds for the job, whoever signed it", + parameters: [ + { name: "jobId", in: "path", required: true, schema: { type: "string", minLength: 1, maxLength: 160 } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, + { name: "cursor", in: "query", description: "Opaque cursor from the previous page", schema: { type: "string" } }, + ], + responses: { + "200": { description: "A page of signed BundleBindings with `total` and, when more remain, the next `cursor`" }, + "400": { description: "Invalid jobId or pagination" }, + }, }, }, "/api/dacs/inspect-service/{listingId}/{version}": { diff --git a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts index a01e7c5..c4416bb 100644 --- a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts +++ b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts @@ -34,16 +34,53 @@ export function phaseSummariesDiverge(left: unknown, right: unknown): boolean { return false; } +/** Callers pass verified copies: a current FaultAttestationBundle, or an AttestationBundle (current or SDK profile). */ +const bundleType = (bundle: Record) => bundle.faultBundleVersion === "1" ? "fault" : "legacy"; + +/** DACS-5 §10.4.3 outcome class: completed, failed-substrate, abort, or failure. */ +function outcomeClass(outcome: unknown): string { + return outcome === "aborted-by-self" || outcome === "aborted-by-other" ? "abort" + : outcome === "failed-perm" || outcome === "failed-counterparty" ? "failure" : String(outcome); +} + +/** + * The §10.4.1 permissible-set mapping of a legacy copy's role-relative outcome to the parties it + * may fault: none for completed or failed-substrate, the anchoring role for its own fault, and + * every other session role for a counterparty fault (a singleton in a two-party session). + */ +function impliedFaultSet(legacy: Record): Set { + const anchored = String(legacy.anchoredByRole); + const roles = records(legacy.parties).map((party) => String(party.role)); + const outcome = legacy.outcome; + if (outcome === "completed" || outcome === "failed-substrate") return new Set(["none"]); + if (outcome === "failed-perm" || outcome === "aborted-by-self") return new Set([anchored]); + if (outcome === "failed-counterparty" || outcome === "aborted-by-other") return new Set(roles.filter((role) => role !== anchored)); + return new Set(); +} + +/** + * DACS-5 §10.4.3 "canonically diverge", applied by version: a FaultAttestationBundle pair compares + * the absolute faultedParty and outcome class; a legacy pair compares outcomes after the + * perspective flip; a mixed pair checks the fault bundle's faultedParty against the legacy copy's + * implied-fault set and the outcome classes. The shared-index phaseSummary limb applies to all. + */ export function currentBundleCopiesDiverge( buyerBundle: Record, sellerBundle: Record, ): boolean { - const buyerType = buyerBundle.faultBundleVersion === "1" ? "fault" : buyerBundle.bundleVersion === "1" ? "legacy" : "unknown"; - const sellerType = sellerBundle.faultBundleVersion === "1" ? "fault" : sellerBundle.bundleVersion === "1" ? "legacy" : "unknown"; - return buyerType !== sellerType || - (buyerType === "fault" && buyerBundle.faultedParty !== sellerBundle.faultedParty) || - flipOutcome(String(buyerBundle.outcome)) !== String(sellerBundle.outcome) || - phaseSummariesDiverge(buyerBundle.phaseSummary, sellerBundle.phaseSummary); + if (phaseSummariesDiverge(buyerBundle.phaseSummary, sellerBundle.phaseSummary)) return true; + const buyerType = bundleType(buyerBundle); + const sellerType = bundleType(sellerBundle); + if (buyerType === "fault" && sellerType === "fault") { + return buyerBundle.faultedParty !== sellerBundle.faultedParty || + outcomeClass(buyerBundle.outcome) !== outcomeClass(sellerBundle.outcome); + } + if (buyerType === "legacy" && sellerType === "legacy") { + return flipOutcome(String(buyerBundle.outcome)) !== String(sellerBundle.outcome); + } + const [fault, legacy] = buyerType === "fault" ? [buyerBundle, sellerBundle] : [sellerBundle, buyerBundle]; + return !impliedFaultSet(legacy).has(String(fault.faultedParty)) || + outcomeClass(fault.outcome) !== outcomeClass(legacy.outcome); } function sellerRelativeOutcome(graph: EvidenceGraph, sellerClaim: string): string { @@ -81,7 +118,12 @@ export function reconcileCurrentCopies( const divergent = Boolean( buyerOk && sellerOk && currentBundleCopiesDiverge(buyerGraph.bundle, sellerGraph!.bundle), ); - const authoritative = sellerOk ? sellerGraph! : buyerGraph; + // The scored party's own copy is authoritative, except that in a non-divergent mixed-version pair + // the FaultAttestationBundle copy is (§10.4.3(c), §10.5.1). + const mixed = buyerOk && sellerOk && !divergent && bundleType(buyerGraph.bundle) !== bundleType(sellerGraph!.bundle); + const authoritative = mixed + ? bundleType(buyerGraph.bundle) === "fault" ? buyerGraph : sellerGraph! + : sellerOk ? sellerGraph! : buyerGraph; const refsVerified = Boolean(sellerOk && buyerOk && !divergent && authoritative.refsVerified); const sellerOutcome = sellerRelativeOutcome(authoritative, sellerClaim); const selectedLocator = authoritative === sellerGraph ? deal.sellerBundleRef! : deal.buyerBundleRef; diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 8398c2f..333a84e 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -10,9 +10,13 @@ import { type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; -export type ArtifactKind = "agreement" | "evidence" | "verify-result" | "composite" | "rating" | "listing" | "bundle" | "attestation"; +export type ArtifactKind = "agreement" | "evidence" | "amendment" | "verify-result" | "composite" | "rating" | "listing" | "bundle" | "attestation"; export interface CurrentRef { anchor: { kind: string; locator: string }; contentHash: string; [key: string]: unknown } -export interface ResolvedEvidence { kind: ArtifactKind; ref?: CurrentRef; locator: string; raw: Record; contentHash: string } +export interface ResolvedEvidence { + kind: ArtifactKind; ref?: CurrentRef; locator: string; raw: Record; contentHash: string; + /** For a bundle's settlementEvidence entry: the BundlePhaseEntry.index it settles (SB-1), when the bundle determines it. */ + phaseIndex?: number; +} export interface EvidenceGraph { profile: "dacs-v0.1"; ok: boolean; @@ -20,11 +24,17 @@ export interface EvidenceGraph { bundle: Record; bundleContentHash: string; signaturesVerified: boolean; + /** Canonical claims of the parties whose bundle signatures verified; standing is computed from these only. */ + signedParties: string[]; refsVerified: boolean; artifacts: ResolvedEvidence[]; + /** The bundle's own settlementEvidence entries, each paired with its phase index when the bundle determines it. */ + settlement: ResolvedEvidence[]; agreement?: Record; listing?: Record; ratings: Record[]; + /** DACS-4 AMEND-4: amendments that fail AMEND-1..3 are flagged, kept for audit, and never an unwind. */ + flaggedAmendments: Array<{ locator: string; reason: string }>; } export interface EvidenceGraphDeps { read: (locator: string) => Promise | null>; @@ -50,9 +60,16 @@ const priceTermOk = (value: unknown): boolean => { typeof price.currency === "string" && price.currency.length > 0 && price.currency.length <= 64); }; const SEPARATORS: Record, string> = { - agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", "verify-result": "dacs-verifyresult:v1:", + agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", amendment: "dacs-amendment:v1:", "verify-result": "dacs-verifyresult:v1:", composite: "dacs-composite:v1:", rating: "dacs-rating:v1:", listing: "dacs-listing:v1:", bundle: "dacs-bundle:v1:", }; +/** DACS-4 §9.7 PaymentPhaseType and DeliveryPhaseType: the phases that produce SettlementEvidence. */ +const PAYMENT_PHASES = new Set(["pay-evm-erc20", "pay-solana-spl", "pay-cross-chain-htlc", "pay-cross-chain-liquidity-tank", "pay-ap2", "pay-x402", "pay-dem"]); +const DELIVERY_PHASES = new Set(["deliver-storage-program", "deliver-entitlement", "deliver-attested-payload"]); +const settlesPhase = (kind: unknown): boolean => PAYMENT_PHASES.has(String(kind)) || DELIVERY_PHASES.has(String(kind)); +/** DACS-4 SettlementFinalityRecord.model. */ +const FINALITY_MODELS = new Set(["block-depth", "commitment-level", "provider-receipt", "htlc-reveal", "liquidity-tank", "bft-final"]); +const AMENDMENT_TYPES = new Set(["refund", "partial-refund", "correction"]); const PAYEE_BOUND_AGREEMENT_SEPARATOR = "dacs-payee-bound-agreement:v1:"; const FAULT_BUNDLE_SEPARATOR = "dacs-fault-bundle:v1:"; @@ -82,7 +99,7 @@ function payoutBindingShapeOk(value: unknown): boolean { } /** DACS-3 §8.5: bind the artifact type and every payout destination to the pinned pipeline. */ -function agreementMatchesListing(agreement: Record, listing: Record): boolean { +export function agreementMatchesListing(agreement: Record, listing: Record): boolean { const profile = agreementProfile(agreement); const terms = rec(agreement.terms); const pipeline = arr(listing.pipeline); @@ -137,15 +154,21 @@ const AGREEMENT_PATTERNS: Record = { "negotiate-sealed-envelope-procurement": "sealed-envelope", }; +/** Returned when every evaluated check passed but check 5 or 6 needs a committedAt that was not supplied. */ +export const COMMITTED_AT_INDETERMINATE = "committed-at-indeterminate"; + /** - * DACS-3 §8.5.2 listing conformance, checks 1-4 and 7-9 in order; returns the first failed check. + * DACS-3 §8.5.2 listing conformance, checks 1-9 in order; returns the first failed check. * Checks 5 (deadline) and 6 (not expired) compare against the CommitmentRecord's anchored - * committedAt, which a bundle does not reference, so they are not evaluated here. + * `committedAt`. Without it they are vacuous only when the listing sets neither bound; otherwise + * a later failure still wins, and if nothing fails the result is COMMITTED_AT_INDETERMINATE, + * which a caller must never read as conformance. */ export function agreementListingConformance( agreement: Record, listing: Record, publisher: string, + committedAt: number | null = null, ): string | null { const terms = rec(agreement.terms); const pricing = rec(listing.pricing); @@ -160,6 +183,18 @@ export function agreementListingConformance( const amount = decimal(price.amount); if (pricing.kind === "fixed" || (pricing.kind === "negotiable" && agreement.derivedFromPattern === "fixed-price")) { if (!listed || compareDecimals(amount, decimal(listed.amount)) !== 0) return "price"; + // The band still has to be a valid one: a listing whose lower bound is ≤ 0 is rejected (DACS-4). + if (pricing.kind === "negotiable") { + const lower = bandBound(listed.amount, pricing.minPct, -1n); + if (!lower || lower.units <= 0n) return "price"; + } + } else if (pricing.kind === "auction") { + // DACS-4 PricingSpec: the reserve is an inclusive ceiling for lowest-price and an inclusive + // floor for every other selection rule (§8.4.3 step 5). + if (listed) { + const order = compareDecimals(amount, decimal(listed.amount)); + if (pricing.selectionRule === "lowest-price" ? order > 0 : order < 0) return "price"; + } } else if (pricing.kind === "negotiable") { const lower = listed ? bandBound(listed.amount, pricing.minPct, -1n) : null; const upper = listed ? bandBound(listed.amount, pricing.maxPct, 1n) : null; @@ -172,7 +207,7 @@ export function agreementListingConformance( const total = { units: unitPrice.units * BigInt(metered.quantity), scale: unitPrice.scale }; const minimum = rec(pricing.minTotal) ? decimal(rec(pricing.minTotal)!.amount) : null; if (compareDecimals(amount, minimum && compareDecimals(minimum, total) > 0 ? minimum : total) !== 0) return "price"; - } else if (pricing.kind !== "auction") return "unrecognized-pricing-kind"; + } else return "unrecognized-pricing-kind"; // 3. Rail: present exactly when the pipeline pays, and one the listing accepts. const rail = rec(terms.rail); const pays = pipeline.some((phase) => typeof phase.kind === "string" && phase.kind.startsWith("pay-")); @@ -184,6 +219,21 @@ export function agreementListingConformance( const offered = rec(rec(listing.offering)?.deliverable); if (!deliverable || !offered || deliverable.deliverableType !== offered.kind || normalizedHash(deliverable.hash) !== contentHash(offered) || deliverable.schemaUrl !== offered.schemaUrl) return "deliverable"; + // 5. Deadline: terms.deadline ≤ committedAt + deadlineSecAfterCommit (unix ms against seconds). + // 6. Not expired: validity.notAfter ≥ committedAt. + const window = rec(listing.terms)?.deadlineSecAfterCommit; + const notAfter = rec(listing.validity)?.notAfter; + let unresolved = false; + if (window !== undefined) { + if (typeof window !== "number" || typeof terms.deadline !== "number") return "deadline"; + if (committedAt === null) unresolved = true; + else if (terms.deadline > committedAt + window * 1000) return "deadline"; + } + if (notAfter !== undefined) { + if (typeof notAfter !== "number") return "not-expired"; + if (committedAt === null) unresolved = true; + else if (notAfter < committedAt) return "not-expired"; + } // 7. Pattern. An RFQ step declaring fixedPriceFallback also admits the fixed-price fallback, // whose price check 2 already held to the band centre. const negotiation = pipeline.find((phase) => typeof phase.kind === "string" && phase.kind.startsWith("negotiate-")); @@ -196,7 +246,8 @@ export function agreementListingConformance( return "role-direction"; } // 9. Artifact type and payout bindings match the commitment phase. - return agreementMatchesListing(agreement, listing) ? null : "commitment-artifact"; + if (!agreementMatchesListing(agreement, listing)) return "commitment-artifact"; + return unresolved ? COMMITTED_AT_INDETERMINATE : null; } export function signedScope(raw: Record, kind: ArtifactKind): Record { @@ -261,7 +312,7 @@ async function verifyBundleSignature( )); } -function faultAttributionOk(raw: Record, parties: Record[]): boolean { +export function faultAttributionOk(raw: Record, parties: Record[]): boolean { if (bundleProfile(raw) !== "fault") return true; const anchored = raw.anchoredByRole; const faulted = raw.faultedParty; @@ -288,7 +339,23 @@ function shapeOk(raw: Record, kind: ArtifactKind): boolean { ? terms.payoutBindings === undefined : Array.isArray(terms.payoutBindings) && terms.payoutBindings.every(payoutBindingShapeOk))); } - if (kind === "evidence") return raw.evidenceVersion === "1" && typeof raw.jobId === "string" && typeof raw.phase === "string" && (raw.outcome === "success" || raw.outcome === "failure") && typeof raw.observedAt === "number" && (raw.amendmentRefs === undefined || Array.isArray(raw.amendmentRefs)) && (raw.attestationRef === undefined || isCurrentRef(raw.attestationRef)); + if (kind === "evidence") { + const payment = PAYMENT_PHASES.has(String(raw.phase)); + return raw.evidenceVersion === "1" && typeof raw.jobId === "string" && typeof raw.phase === "string" && settlesPhase(raw.phase) && + (raw.outcome === "success" || raw.outcome === "failure") && typeof raw.observedAt === "number" && + (raw.paymentTxRefs === undefined || Array.isArray(raw.paymentTxRefs)) && + (raw.amendmentRefs === undefined || Array.isArray(raw.amendmentRefs)) && (raw.attestationRef === undefined || isCurrentRef(raw.attestationRef)) && + // DACS-4 §9.7 and PC-6: a successful payment states its settled amount, its finality model and the + // transactions it cites; delivery evidence and a failed payment carry no finality record. + (payment && raw.outcome === "success" + ? priceTermOk(raw.paymentAmount) && FINALITY_MODELS.has(String(rec(raw.settlementFinality)?.model)) && + Array.isArray(raw.paymentTxRefs) && raw.paymentTxRefs.length > 0 + : raw.settlementFinality === undefined); + } + // DACS-4 §9.7.1 SettlementAmendment; AMEND-1..3 are evaluated against the amended evidence in the graph. + if (kind === "amendment") return raw.amendmentVersion === "1" && typeof raw.jobId === "string" && isCurrentRef(raw.amendsEvidenceRef) && + AMENDMENT_TYPES.has(String(raw.amendmentType)) && (raw.refundAmount === undefined || priceTermOk(raw.refundAmount)) && + (raw.refundTxRefs === undefined || Array.isArray(raw.refundTxRefs)) && typeof raw.reason === "string" && typeof raw.observedAt === "number"; // DACS-2 §7.5 VerifyResult and §7.7 CompositeVerificationRecord required members. if (kind === "verify-result") return raw.resultVersion === "1" && typeof raw.scheme === "string" && typeof raw.identifier === "string" && Number.isSafeInteger(raw.recipeVersion) && typeof raw.method === "string" && typeof raw.reason === "string" && typeof raw.fetchedAt === "number" && typeof raw.verifiedAt === "number" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.decision)) && isCurrentRef(raw.attestation); if (kind === "composite") { @@ -353,7 +420,7 @@ async function resolveRef(kind: ArtifactKind, ref: unknown, deps: EvidenceGraphD export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: EvidenceGraphDeps): Promise { const raw = await deps.read(bundleLocator); // A failed graph carries no content hash: a rejected bundle is never hashed again. - const fail = (reason: string): EvidenceGraph => ({ profile: "dacs-v0.1", ok: false, reason, bundle: raw ?? {}, bundleContentHash: "", signaturesVerified: false, refsVerified: false, artifacts: [], ratings: [] }); + const fail = (reason: string): EvidenceGraph => ({ profile: "dacs-v0.1", ok: false, reason, bundle: raw ?? {}, bundleContentHash: "", signaturesVerified: false, signedParties: [], refsVerified: false, artifacts: [], settlement: [], ratings: [], flaggedAmendments: [] }); try { return await verifyCurrentEvidenceGraph(raw, deps, fail); } catch { @@ -370,7 +437,7 @@ async function verifyCurrentEvidenceGraph( const resolveKey = deps.resolvePrimaryClaimKey ?? resolveDemosPrimaryClaimKey; const phases = arr(raw?.phaseSummary); const phaseIndexes = phases.map((phase) => phase.index); - if (!raw || !withinArtifactLimit(raw) || raw.signature !== undefined || !bundleProfile(raw) || typeof raw.jobId !== "string" || !OUTCOMES.has(String(raw.outcome)) || !["buyer", "seller", "orchestrator"].includes(String(raw.anchoredByRole)) || !rec(raw.listingRef) || arr(raw.parties).length < 2 || !Array.isArray(raw.phaseSummary) || phases.length !== raw.phaseSummary.length || phases.some((phase) => !Number.isSafeInteger(phase.index) || Number(phase.index) < 0 || typeof phase.kind !== "string" || !PHASE_OUTCOMES.has(String(phase.outcome)) || (phase.errorClass !== undefined && !ERROR_CLASSES.has(String(phase.errorClass)))) || new Set(phaseIndexes).size !== phaseIndexes.length || !Array.isArray(raw.vetRecords) || !Array.isArray(raw.settlementEvidence) || (raw.ratingRefs !== undefined && !Array.isArray(raw.ratingRefs)) || (raw.amendments !== undefined && (!Array.isArray(raw.amendments) || raw.amendments.length > 0)) || !Number.isSafeInteger(raw.recipeRegistryVersion) || Number(raw.recipeRegistryVersion) < 1 || !Number.isSafeInteger(raw.railRegistryVersion) || Number(raw.railRegistryVersion) < 1 || typeof raw.finalisedAt !== "number") return fail("invalid current DACS-5 bundle shape"); + if (!raw || !withinArtifactLimit(raw) || raw.signature !== undefined || !bundleProfile(raw) || typeof raw.jobId !== "string" || !OUTCOMES.has(String(raw.outcome)) || !["buyer", "seller", "orchestrator"].includes(String(raw.anchoredByRole)) || !rec(raw.listingRef) || arr(raw.parties).length < 2 || !Array.isArray(raw.phaseSummary) || phases.length !== raw.phaseSummary.length || phases.some((phase) => !Number.isSafeInteger(phase.index) || Number(phase.index) < 0 || typeof phase.kind !== "string" || !PHASE_OUTCOMES.has(String(phase.outcome)) || (phase.errorClass !== undefined && !ERROR_CLASSES.has(String(phase.errorClass)))) || new Set(phaseIndexes).size !== phaseIndexes.length || !Array.isArray(raw.vetRecords) || !Array.isArray(raw.settlementEvidence) || (raw.ratingRefs !== undefined && !Array.isArray(raw.ratingRefs)) || (raw.amendments !== undefined && !Array.isArray(raw.amendments)) || !Number.isSafeInteger(raw.recipeRegistryVersion) || Number(raw.recipeRegistryVersion) < 1 || !Number.isSafeInteger(raw.railRegistryVersion) || Number(raw.railRegistryVersion) < 1 || typeof raw.finalisedAt !== "number") return fail("invalid current DACS-5 bundle shape"); const signatures = arr(raw.signatures); const parties = arr(raw.parties); if (new Set(parties.map((party) => party.role)).size !== parties.length || !parties.some((party) => party.role === "buyer") || !parties.some((party) => party.role === "seller") || @@ -388,6 +455,9 @@ async function verifyCurrentEvidenceGraph( const signaturesVerified = bundleSignerPolicy(raw, validParties, signatureChecks.length > 0 && signatureChecks.every((check) => check.valid)); if (!signaturesVerified) return fail("required current bundle signatures are missing or invalid"); + const signedParties = [...new Set(validParties.map(canonicalSigningIdentity))]; + // Every later failure keeps the verified signature facts, so a caller can tell the two apart. + const failSigned = (reason: string): EvidenceGraph => ({ ...fail(reason), signaturesVerified, signedParties }); const abort = raw.outcome === "aborted-by-self" || raw.outcome === "aborted-by-other"; @@ -410,35 +480,37 @@ async function verifyCurrentEvidenceGraph( }; let agreement: Record | undefined; if (raw.agreementRef !== undefined) { - if (reserveTopLevelRef("agreement", raw.agreementRef) !== "new") return { ...fail("duplicate or invalid top-level reference"), signaturesVerified }; - const resolved = await resolveRef("agreement", raw.agreementRef, deps); if (!resolved) return { ...fail("agreement reference failed"), signaturesVerified }; - if (resolved.raw.jobId !== raw.jobId) return { ...fail("agreement belongs to another job"), signaturesVerified }; + if (reserveTopLevelRef("agreement", raw.agreementRef) !== "new") return failSigned("duplicate or invalid top-level reference"); + const resolved = await resolveRef("agreement", raw.agreementRef, deps); if (!resolved) return failSigned("agreement reference failed"); + if (resolved.raw.jobId !== raw.jobId) return failSigned("agreement belongs to another job"); artifacts.push(resolved); agreement = resolved.raw; - } else if (!abort) return { ...fail("non-abort bundle has no agreement"), signaturesVerified }; - for (const [kind, refs] of [["evidence", raw.settlementEvidence], ["composite", raw.vetRecords], ["rating", raw.ratingRefs]] as Array<[ArtifactKind, unknown]>) { + } else if (!abort) return failSigned("non-abort bundle has no agreement"); + const topLevelEvidence: ResolvedEvidence[] = []; + for (const [kind, refs] of [["evidence", raw.settlementEvidence], ["composite", raw.vetRecords], ["rating", raw.ratingRefs], ["amendment", raw.amendments]] as Array<[ArtifactKind, unknown]>) { for (const ref of Array.isArray(refs) ? refs : []) { - if (artifacts.length >= MAX_GRAPH_ARTIFACTS) return { ...fail("evidence graph exceeds size limit"), signaturesVerified }; + if (artifacts.length >= MAX_GRAPH_ARTIFACTS) return failSigned("evidence graph exceeds size limit"); const reserved = reserveTopLevelRef(kind, ref); - if (reserved === "invalid") return { ...fail("duplicate or invalid top-level reference"), signaturesVerified }; + if (reserved === "invalid") return failSigned("duplicate or invalid top-level reference"); if (reserved === "duplicate-rating") continue; - const resolved = await resolveRef(kind, ref, deps); if (!resolved) return { ...fail(`${kind} reference failed`), signaturesVerified }; - if ((kind === "evidence" || kind === "rating" || kind === "composite") && resolved.raw.jobId !== raw.jobId) return { ...fail(`${kind} belongs to another job`), signaturesVerified }; + const resolved = await resolveRef(kind, ref, deps); if (!resolved) return failSigned(`${kind} reference failed`); + if (resolved.raw.jobId !== raw.jobId) return failSigned(`${kind} belongs to another job`); if (kind === "rating") { const target = parties.find((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.target)); if (!parties.some((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.rater)) || !target || target.role !== resolved.raw.targetRole) { - return { ...fail("rating party binding failed"), signaturesVerified }; + return failSigned("rating party binding failed"); } } - if (kind === "evidence" && !parties.some((party) => + if ((kind === "evidence" || kind === "amendment") && !parties.some((party) => sameSigningIdentity(party.primaryClaim, rec(resolved.raw.signature)?.signer))) { - return { ...fail("evidence signer is not a session party"), signaturesVerified }; + return failSigned(`${kind} signer is not a session party`); } + if (kind === "evidence") topLevelEvidence.push(resolved); if (kind === "composite") { // bundleHash and evaluatedParty bind the vet to one session party's IdentityBundle (DACS-2 §7.7). const evaluated = parties.find((party) => sameSigningIdentity(party.primaryClaim, resolved.raw.evaluatedParty)); if (!evaluated || normalizedHash(evaluated.bundleHash) !== normalizedHash(resolved.raw.bundleHash)) { - return { ...fail("composite record is not bound to a session party's bundle"), signaturesVerified }; + return failSigned("composite record is not bound to a session party's bundle"); } } artifacts.push(resolved); @@ -460,34 +532,47 @@ async function verifyCurrentEvidenceGraph( if (known) return known.contentHash === hash && recipeOk(known); if (artifacts.length >= MAX_GRAPH_ARTIFACTS) { graphLimitExceeded = true; return false; } const nested = await resolveRef(kind, value, deps); if (!nested || !recipeOk(nested)) return false; - if ((kind === "evidence" || kind === "composite") && nested.raw.jobId !== raw.jobId) return false; - if (kind === "evidence" && !parties.some((party) => + if ((kind === "evidence" || kind === "composite" || kind === "amendment") && nested.raw.jobId !== raw.jobId) return false; + if ((kind === "evidence" || kind === "amendment") && !parties.some((party) => sameSigningIdentity(party.primaryClaim, rec(nested.raw.signature)?.signer))) return false; seen.set(key, nested); artifacts.push(nested); return true; }; + const flaggedAmendments: EvidenceGraph["flaggedAmendments"] = []; + const amendedEvidence = new Map(); for (let index = 0; index < artifacts.length; index++) { const artifact = artifacts[index]; if (artifact.kind === "composite") { for (const key of ["freshness", "dealSpecific"] as const) for (const value of Array.isArray(artifact.raw[key]) ? artifact.raw[key] as unknown[] : []) { - if (!(await addNested("verify-result", value))) return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "nested VerifyResult reference failed"), signaturesVerified }; + if (!(await addNested("verify-result", value))) return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "nested VerifyResult reference failed"); } for (const signal of arr(artifact.raw.supplementary)) if (signal.attestation !== undefined && !(await addNested("attestation", signal.attestation))) { - return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "supplementary attestation reference failed"), signaturesVerified }; + return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "supplementary attestation reference failed"); } } if (artifact.kind === "evidence") { if (artifact.raw.attestationRef !== undefined && !(await addNested("attestation", artifact.raw.attestationRef))) { - return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "delivery attestation reference failed"), signaturesVerified }; + return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "delivery attestation reference failed"); } + // amendmentRefs name SettlementAmendment records (DACS-4 §9.7.1), never more evidence. for (const value of Array.isArray(artifact.raw.amendmentRefs) ? artifact.raw.amendmentRefs as unknown[] : []) { - if (!(await addNested("evidence", value))) return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "settlement amendment reference failed"), signaturesVerified }; + if (!(await addNested("amendment", value))) return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "settlement amendment reference failed"); } - for (const key of ["supersedesEvidenceRef", "amendsEvidenceRef"] as const) if (artifact.raw[key] !== undefined && !(await addNested("evidence", artifact.raw[key]))) { - return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "settlement evidence chain failed"), signaturesVerified }; + // An ST-8 supersession is a same-phase evidence chain, not an amendment. + if (artifact.raw.supersedesEvidenceRef !== undefined && !(await addNested("evidence", artifact.raw.supersedesEvidenceRef))) { + return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "settlement evidence chain failed"); } } + if (artifact.kind === "amendment") { + // AMEND-1: the amended record resolves to SettlementEvidence of this job. A failure flags the + // amendment (AMEND-4) rather than failing the session. + const target = artifact.raw.amendsEvidenceRef as CurrentRef; + if (await addNested("evidence", target)) { + amendedEvidence.set(artifact, seen.get(`evidence\n${target.anchor.locator}`)!); + } else if (graphLimitExceeded) return failSigned("evidence graph exceeds size limit"); + else flaggedAmendments.push({ locator: artifact.locator, reason: "AMEND-1: the amended evidence does not resolve for this job" }); + } if (artifact.kind === "verify-result" && !(await addNested("attestation", artifact.raw.attestation))) { - return { ...fail(graphLimitExceeded ? "evidence graph exceeds size limit" : "VerifyResult attestation reference failed"), signaturesVerified }; + return failSigned(graphLimitExceeded ? "evidence graph exceeds size limit" : "VerifyResult attestation reference failed"); } } const verifiedPhaseRefs = new Set(artifacts.filter((artifact) => artifact.ref && @@ -501,49 +586,114 @@ async function verifyCurrentEvidenceGraph( if (phase.attestationRef === undefined) continue; if (!isCurrentRef(phase.attestationRef) || !verifiedPhaseRefs.has(`${phase.attestationRef.anchor.locator}\n${normalizedHash(phase.attestationRef.contentHash)}`)) { - return { ...fail("phase attestation reference is outside the verified graph"), signaturesVerified }; + return failSigned("phase attestation reference is outside the verified graph"); } // DACS-2 VPC-4: a vet phase whose decision is not pass fails, so it cannot have succeeded. const decision = compositeDecisions.get(`${phase.attestationRef.anchor.locator}\n${normalizedHash(phase.attestationRef.contentHash)}`); if (phase.outcome === "ok" && decision !== undefined && decision !== "pass") { - return { ...fail("a successful vet phase rests on a composite record that did not pass"), signaturesVerified }; + return failSigned("a successful vet phase rests on a composite record that did not pass"); } } + // DACS-5 §10.4.3: settlementEvidence holds one record per executed settle-phase invocation and + // nothing else, so a completed payment phase cannot stand on missing or borrowed evidence. + const refKey = (ref: CurrentRef) => `${ref.anchor.locator}\n${normalizedHash(ref.contentHash)}`; + const settlePhases = phases.filter((phase) => settlesPhase(phase.kind)); + const tally = (kinds: unknown[]) => kinds.reduce>((counts, kind) => + counts.set(String(kind), (counts.get(String(kind)) ?? 0) + 1), new Map()); + const phaseCounts = tally(settlePhases.map((phase) => phase.kind)); + const evidenceCounts = tally(topLevelEvidence.map((evidence) => evidence.raw.phase)); + if (phaseCounts.size !== evidenceCounts.size || [...phaseCounts].some(([kind, count]) => evidenceCounts.get(kind) !== count)) { + return failSigned("settlement evidence does not match the executed settle phases"); + } + // Pair each record with its phase entry: by the entry's attestationRef, else as the only entry of + // its kind. The phase outcome and the evidence outcome must agree. The pairing gives SB-1 its + // phaseIndex, which is never read from the evidence itself. + const outcomesAgree = (phase: Record, evidence: ResolvedEvidence) => + (phase.outcome === "ok") === (evidence.raw.outcome === "success"); + const paired = new Map(); + for (const phase of settlePhases) { + if (phase.attestationRef === undefined) continue; + const evidence = topLevelEvidence.find((candidate) => refKey(candidate.ref!) === refKey(phase.attestationRef as CurrentRef)); + if (!evidence || evidence.raw.phase !== phase.kind || paired.has(evidence) || !outcomesAgree(phase, evidence)) { + return failSigned("a settle phase's attestationRef does not name its own settlement evidence"); + } + paired.set(evidence, Number(phase.index)); + } + for (const evidence of topLevelEvidence) { + const same = settlePhases.filter((phase) => phase.kind === evidence.raw.phase); + if (!paired.has(evidence) && same.length === 1 && same[0].attestationRef === undefined) { + if (!outcomesAgree(same[0], evidence)) return failSigned("a settle phase's outcome contradicts its settlement evidence"); + paired.set(evidence, Number(same[0].index)); + } + evidence.phaseIndex = paired.get(evidence); + } + // AMEND-2 and AMEND-3, in observation order so the record that overdraws is the one flagged. + const refunded = new Map(); + const amendments = artifacts.filter((artifact) => artifact.kind === "amendment" && amendedEvidence.has(artifact)) + .sort((a, b) => Number(a.raw.observedAt) - Number(b.raw.observedAt) || (a.contentHash < b.contentHash ? -1 : a.contentHash > b.contentHash ? 1 : 0)); + for (const amendment of amendments) { + const evidence = amendedEvidence.get(amendment)!; + const refund = rec(amendment.raw.refundAmount); + const paid = rec(evidence.raw.paymentAmount); + const flag = (reason: string) => flaggedAmendments.push({ locator: amendment.locator, reason }); + if (amendment.raw.amendmentType === "correction") { + if (refund) flag("AMEND-2: a correction carries a refundAmount"); + continue; + } + if (evidence.raw.outcome !== "success") { flag("AMEND-2: a refund amends evidence that did not succeed"); continue; } + if (!refund || !paid || refund.currency !== paid.currency) { flag("AMEND-3: the refund is not in the settled currency"); continue; } + const before = refunded.get(evidence) ?? { units: 0n, scale: 0 }; + const total = decimal(refund.amount); + const scale = Math.max(before.scale, total.scale); + const sum = { units: before.units * 10n ** BigInt(scale - before.scale) + total.units * 10n ** BigInt(scale - total.scale), scale }; + if (compareDecimals(sum, decimal(paid.amount)) > 0) { flag("AMEND-3: refunds exceed the settled amount"); continue; } + refunded.set(evidence, sum); + } // A session that reached its agreement passed vet for every party it vetted (VPC-2, VPC-4); // a failed attempt may sit beside the passing one. const passed = new Set(composites.filter((artifact) => artifact.raw.overallDecision === "pass") .map((artifact) => canonicalSigningIdentity(String(artifact.raw.evaluatedParty)))); if (agreement && composites.some((artifact) => !passed.has(canonicalSigningIdentity(String(artifact.raw.evaluatedParty))))) { - return { ...fail("a committed session has a vetted party with no passing composite record"), signaturesVerified }; + return failSigned("a committed session has a vetted party with no passing composite record"); } - if (artifacts.length >= MAX_GRAPH_ARTIFACTS) return { ...fail("evidence graph exceeds size limit"), signaturesVerified }; + if (artifacts.length >= MAX_GRAPH_ARTIFACTS) return failSigned("evidence graph exceeds size limit"); const listingRef = rec(raw.listingRef)!; const listed = await deps.resolveListing(listingRef); const listingHash = normalizedHash(listingRef.contentHash); - const verifiedListing = listed && artifactHash(listed.raw, "listing") === listingHash + // DACS-1 LR-1: the session pins the whole (listingId, listingVersion, contentHash) tuple. + const pinnedVersion = listingRef.version ?? listingRef.listingVersion; + const tupleOk = listed && listed.raw.listingId === listingRef.listingId && + Number.isSafeInteger(pinnedVersion) && listed.raw.listingVersion === pinnedVersion; + const verifiedListing = listed && tupleOk && artifactHash(listed.raw, "listing") === listingHash ? await verifyListing(listed.raw, resolveKey) : null; - if (!listed || !verifiedListing) return { ...fail("listing reference failed"), signaturesVerified }; + if (!listed || !verifiedListing) return failSigned("listing reference failed"); // DACS-2 §7.7: requirementHash binds a vet to the listing's BundleRequirement, which gates the // publisher's counterparty. The publisher itself is vetted against requirements the listing omits. const requirement = rec(listed.raw.buyerRequirement); if (requirement && composites.some((artifact) => !sameSigningIdentity(artifact.raw.evaluatedParty, verifiedListing.sellerClaim) && normalizedHash(artifact.raw.requirementHash) !== contentHash(requirement))) { - return { ...fail("composite record is not bound to the listing's requirement"), signaturesVerified }; + return failSigned("composite record is not bound to the listing's requirement"); } - if (agreement && JSON.stringify(agreement.listingRef) !== JSON.stringify(raw.listingRef)) return { ...fail("agreement listing binding failed"), signaturesVerified }; + if (agreement && JSON.stringify(agreement.listingRef) !== JSON.stringify(raw.listingRef)) return failSigned("agreement listing binding failed"); if (agreement) { const agreementParties = arr(agreement.parties); if (!["buyer", "seller"].every((role) => agreementParties.some((candidate) => candidate.role === role && parties.some((party) => party.role === role && sameSigningIdentity(party.primaryClaim, candidate.primaryClaim))))) { - return { ...fail("agreement party binding failed"), signaturesVerified }; + return failSigned("agreement party binding failed"); + } + // The bundle references no CommitmentRecord, so its anchored committedAt is not available here: + // checks 5 and 6 decide only when the listing sets neither bound, and otherwise leave the + // session indeterminate, which is never counted (DACS-3 §8.5.2 ordering note). + const nonconforming = agreementListingConformance(agreement, listed.raw, verifiedListing.sellerClaim, null); + if (nonconforming === COMMITTED_AT_INDETERMINATE) { + return failSigned("agreement listing conformance is indeterminate: checks 5 and 6 need the commitment's committedAt"); } - const nonconforming = agreementListingConformance(agreement, listed.raw, verifiedListing.sellerClaim); - if (nonconforming) return { ...fail(`agreement fails listing conformance check: ${nonconforming}`), signaturesVerified }; + if (nonconforming) return failSigned(`agreement fails listing conformance check: ${nonconforming}`); } artifacts.push({ kind: "listing", locator: listed.locator, raw: listed.raw, contentHash: listingHash }); - return { profile: "dacs-v0.1", ok: true, bundle: raw, bundleContentHash: artifactHash(raw, "bundle"), signaturesVerified, refsVerified: true, artifacts, agreement, listing: listed.raw, ratings: artifacts.filter((artifact) => artifact.kind === "rating").map((artifact) => artifact.raw) }; + return { profile: "dacs-v0.1", ok: true, bundle: raw, bundleContentHash: artifactHash(raw, "bundle"), signaturesVerified, signedParties, refsVerified: true, artifacts, settlement: topLevelEvidence, agreement, listing: listed.raw, ratings: artifacts.filter((artifact) => artifact.kind === "rating").map((artifact) => artifact.raw), flaggedAmendments }; } export function agreementPrice(agreement: Record | undefined): { amount: string; currency: string } | null { diff --git a/reference-implementations/dacs-directory/src/catalog/gcr.ts b/reference-implementations/dacs-directory/src/catalog/gcr.ts index 9dd73df..57c1bf9 100644 --- a/reference-implementations/dacs-directory/src/catalog/gcr.ts +++ b/reference-implementations/dacs-directory/src/catalog/gcr.ts @@ -33,7 +33,8 @@ function indexerSeed(): Uint8Array { return Uint8Array.from(seed); } -async function authHeaders(): Promise> { +/** Read-auth headers for an unprivileged gcr_routine call, signed with the throwaway indexer key. */ +export async function gcrAuthHeaders(): Promise> { const seed = indexerSeed(); const priv = privateKeyFromSeed(seed); const pubHex = Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex"); @@ -53,7 +54,7 @@ async function authHeaders(): Promise> { export async function gcrGetIdentities(addressHex: string): Promise { const res = await fetch(RPC + "/", { method: "POST", - headers: await authHeaders(), + headers: await gcrAuthHeaders(), signal: AbortSignal.timeout(30_000), body: JSON.stringify({ method: "gcr_routine", diff --git a/reference-implementations/dacs-directory/src/catalog/identityVerification.ts b/reference-implementations/dacs-directory/src/catalog/identityVerification.ts index 5bb350a..a30f449 100644 --- a/reference-implementations/dacs-directory/src/catalog/identityVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/identityVerification.ts @@ -75,6 +75,11 @@ async function verifiedClaim( return { scheme: parts.scheme }; } +/** IT-1..IT-3 for a seller: the highest tier any of its current listings' IdentityBundles reaches. */ +export function sellerIdentityTier(tiers: readonly IdentityTier[]): IdentityTier { + return tiers.includes("institutional") ? "institutional" : tiers.includes("verified") ? "verified" : "self-declared"; +} + export async function deriveIdentityTier( identityBundle: Obj | undefined, resolveRecipe: ResolveRecipe = resolveConfiguredRecipe, diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index ac5e58a..960c21e 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -54,17 +54,20 @@ import { type ListingRejectionCode, throwIfInfrastructureError, } from "./store.js"; -import { deriveSellerReputation, flipOutcome, isNeutralCancellation } from "./reputation.js"; -import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph } from "./evidenceGraph.js"; +import { categoryReputationHint, deriveSellerReputation, flipOutcome, isNeutralCancellation } from "./reputation.js"; +import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph, type ResolvedEvidence } from "./evidenceGraph.js"; +import { legacySettlementTxId, settlementTxId } from "./settlementKey.js"; +import { fullSignatureStanding } from "./bundleSignerPolicy.js"; import { agreementRail } from "./agreementMetadata.js"; import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "./currentReconciliation.js"; import { + boundedBundleBindings, bundleBindingOverflowKey, resolveBundleSide, verifyBundleBinding, } from "./bundleBinding.js"; import { safePublicEndpoint } from "./publicEndpoint.js"; -import { deriveIdentityTier, type ResolveRecipe } from "./identityVerification.js"; +import { deriveIdentityTier, sellerIdentityTier, type ResolveRecipe } from "./identityVerification.js"; import { bundleMatchesRegisteredDeal, bundleCategory, @@ -90,6 +93,45 @@ import type { const verify = (b: Uint8Array, s: Uint8Array, p: Uint8Array): boolean => ed25519Verify(b, s, publicKeyFromRaw(p)); +type SettlementTxIds = NonNullable; + +/** + * DACS-4 SB-1 claims of a current bundle's successful payment evidence, keyed on + * (settlement-tx-id, jobId, phaseIndex). The phase index comes from the bundle's phase entry, + * never from the evidence. Null means a claim could not be keyed (`error`). + */ +function currentSettlementClaims(settlement: ResolvedEvidence[]): SettlementTxIds | null { + const claims: SettlementTxIds = []; + for (const evidence of settlement) { + if (evidence.raw.outcome !== "success" || typeof evidence.raw.phase !== "string" || !evidence.raw.phase.startsWith("pay-")) continue; + const refs = Array.isArray(evidence.raw.paymentTxRefs) ? evidence.raw.paymentTxRefs : []; + if (evidence.phaseIndex === undefined || refs.length === 0) return null; + for (const ref of refs) { + const id = settlementTxId(ref); + if (!id) return null; + claims.push({ id, observedAt: Number(evidence.raw.observedAt), phaseIndex: evidence.phaseIndex, evidenceHash: evidence.contentHash }); + } + } + return claims; +} + +/** The pinned SDK's legacy evidence carries its own phaseIndex and pre-SB-1 TxRefs. */ +function legacySettlementClaims(artifacts: ResolvedArtifact[]): SettlementTxIds | null { + const claims: SettlementTxIds = []; + for (const artifact of artifacts) { + const raw = artifact.raw; + if (artifact.kind !== "dacs-4-evidence" || raw.outcome !== "success") continue; + const refs = Array.isArray(raw.paymentTxRefs) ? raw.paymentTxRefs : []; + if (!Number.isSafeInteger(raw.phaseIndex) || Number(raw.phaseIndex) < 0 || refs.length === 0) return null; + for (const ref of refs) { + const id = legacySettlementTxId(raw.phase, ref); + if (!id) return null; + claims.push({ id, observedAt: Number(raw.observedAt), phaseIndex: Number(raw.phaseIndex), evidenceHash: contentHash(raw) }); + } + } + return claims; +} + /** Resolve the raw GCR identity payload for a bare-hex Demos address. */ export type ResolveIdentities = (addressHex: string) => Promise; @@ -277,10 +319,14 @@ export async function indexRegistration( .map((claim) => `${deal.jobId}\n${canonicalDemosAgentClaim(claim)}`))); const signedByHolder = (binding: BundleBinding) => typeof binding?.signer === "string" && canonicalDemosAgentClaim(binding.signer) !== null && holders.has(`${binding.jobId}\n${canonicalDemosAgentClaim(binding.signer)}`); - const rawBindings = [ + // The catalog keeps every binding; the Directory's own BB-6 work is capped per signer, and a + // signer over the cap leaves its side indeterminate (sticky overflow). + const workCapped = boundedBundleBindings([ ...[...relevantJobs].flatMap((jobId) => ownArray(scanState.bundleBindings, jobId)), ...(reg.bundleBindings ?? []).filter((binding) => relevantJobs.has(binding.jobId)), - ].filter(signedByHolder); + ].filter(signedByHolder)); + for (const key of workCapped.overflowKeys) overflowBindings.add(key); + const rawBindings = workCapped.bindings; const verifiedBindings = (await Promise.all(rawBindings.map((binding) => verifyBundleBinding(binding)))) .filter((binding): binding is BundleBinding => binding !== null); onOmitted?.("bindings", rawBindings.length - verifiedBindings.length); @@ -331,24 +377,11 @@ export async function indexRegistration( typeof roleHolder !== "string" || canonicalDemosAgentClaim(roleHolder) !== canonicalDemosAgentClaim(binding.signer) ) return null; - const signed = new Set( - (Array.isArray(graph.bundle.signatures) ? graph.bundle.signatures : []) - .map((signature) => signature && typeof signature === "object" && !Array.isArray(signature) - ? (signature as Record).party : undefined) - .filter((party): party is string => typeof party === "string") - .map(canonicalDemosAgentClaim) - .filter((party): party is string => party !== null), - ); - const required = parties.filter((party) => - party.role === "buyer" || party.role === "seller" || party.role === "orchestrator") - .map((party) => party.primaryClaim) - .filter((claim): claim is string => typeof claim === "string") - .map(canonicalDemosAgentClaim) - .filter((claim): claim is string => claim !== null); return { value: graph, bundleContentHash: graph.bundleContentHash, - fullSignatureStanding: required.length >= 2 && required.every((claim) => signed.has(claim)), + // Standing counts only signatures the graph verified, never the parties a bundle presents. + fullSignatureStanding: fullSignatureStanding(parties, graph.signedParties), }; }; const buyerResolution = await resolveBundleSide({ @@ -395,17 +428,12 @@ export async function indexRegistration( ratedAt: Number(rating.ratedAt), contentHash: authoritative!.artifacts.find((artifact) => artifact.raw === rating)?.contentHash ?? "", })).filter((rating) => (rating.targetRole === "buyer" || rating.targetRole === "seller") && Number.isFinite(rating.ratedAt)); - const settlementTxIds = (authoritative?.artifacts ?? []).filter((artifact) => artifact.kind === "evidence") - .flatMap((artifact) => { - const refs = Array.isArray(artifact.raw.paymentTxRefs) ? artifact.raw.paymentTxRefs : []; - const phaseIndex = Number(artifact.raw.phaseIndex ?? 0); - return refs.map((ref) => typeof ref === "string" ? ref : String((ref as Record)?.txId ?? (ref as Record)?.id ?? "")) - .filter(Boolean).map((id) => ({ id, observedAt: Number(artifact.raw.observedAt), phaseIndex, evidenceHash: artifact.contentHash })); - }); + const settlement = currentSettlementClaims(authoritative?.settlement ?? []); const cancellation = [sellerOk ? sellerGraph?.bundle.cancellation : undefined, buyerOk ? buyerGraph.bundle.cancellation : undefined] .find((value) => value && typeof value === "object" && !Array.isArray(value)) as Record | undefined; const cancellationNeutral = isNeutralCancellation( sellerOutcome, cancellation, authoritative?.listing?.terms, authoritative?.bundle.phaseSummary, + authoritative?.bundle.agreementRef, ); dealCandidates.push({ ...resolvedDeal, signatureVerified: Boolean(authoritative?.signaturesVerified), refsVerified, @@ -414,12 +442,13 @@ export async function indexRegistration( anchoredByRole: authoritative?.bundle.anchoredByRole as DealRecord["anchoredByRole"], bundleContentHash: authoritative?.bundleContentHash, fullSignatureStanding: (authoritative === sellerGraph ? sellerResolution : buyerResolution).inspected.fullSignatureStanding, - reputationEligible: refsVerified, + // SB-1: a settlement the deal cites without a canonical key is `error`, so the deal is not counted. + reputationEligible: refsVerified && settlement !== null, cancellationNeutral, finalisedAt: typeof authoritative?.bundle.finalisedAt === "number" ? authoritative.bundle.finalisedAt : undefined, anchorTimestamp: artifactAnchorTime(selectedLocator), agreementPrice: agreementPrice(authoritative?.agreement) ?? undefined, - ratings, settlementTxIds, + ratings, settlementTxIds: settlement ?? [], category: typeof (authoritative?.listing?.offering as Record | undefined)?.category === "string" ? (authoritative!.listing!.offering as Record).category as string : undefined, verifiedAt: now, @@ -488,8 +517,9 @@ export async function indexRegistration( authoritative.resolvedArtifacts, strictRefsVerified, ); - const cancellationNeutral = isNeutralCancellation(sellerOutcome, cancellation, listingTerms, bundle?.phaseSummary); + const cancellationNeutral = isNeutralCancellation(sellerOutcome, cancellation, listingTerms, bundle?.phaseSummary, bundle?.agreementRef); const selectedLocator = authoritative === sellerCopy ? deal.sellerBundleRef! : deal.buyerBundleRef; + const settlement = legacySettlementClaims(authoritative.resolvedArtifacts); dealCandidates.push({ ...deal, signatureVerified: authoritative.signaturesOk, @@ -501,10 +531,11 @@ export async function indexRegistration( // Only a bundle the verifier already hashed is hashed again; a rejected raw bundle gets no hash. bundleContentHash: bundle && signedBundleScope ? contentHash(signedBundleScope) : undefined, fullSignatureStanding: legacyFullSignatureStanding(authoritative.verification), - reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? ""), + reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? "") && settlement !== null, cancellationNeutral, finalisedAt: bundle?.finalisedAt, anchorTimestamp: artifactAnchorTime(selectedLocator), + settlementTxIds: settlement ?? [], category: bundleCategory(bundle, categoriesByListing), verifiedAt: now, }); @@ -529,22 +560,7 @@ export async function indexRegistration( const windowEnd = now; const listingsWithHint = listings.map((l) => ({ ...l, - reputationHint: (() => { - const categoryDeals = counted.filter( - (d) => d.category === l.offering.category || d.category?.startsWith(l.offering.category + "."), - ); - const categoryCompleted = categoryDeals.filter((d) => d.sellerOutcome === "completed").length; - const categoryDenominator = categoryDeals.filter((d) => d.sellerOutcome !== "failed-substrate" && !d.cancellationNeutral).length; - return { - categoryScope: l.offering.category, - completionRate: categoryDenominator ? categoryCompleted / categoryDenominator : null, - averageSellerRating: deriveSellerReputation(categoryDeals, windowStart, windowEnd).averageSellerRating ?? null, - bundleCount: categoryDeals.length, - windowStart, - windowEnd, - computedAt: now, - }; - })(), + reputationHint: categoryReputationHint(l.offering.category, counted, windowStart, windowEnd, now), })); // Owner badge: re-verify the stored signature each pass (freshness is a @@ -552,9 +568,7 @@ export async function indexRegistration( const ownerRegistered = reg.ownerSignature ? await verifyOwnerSignature(reg, { ignoreFreshness: true }).catch(() => false) : false; - const identityTiers = await Promise.all(identityBundles.map((bundle) => deriveIdentityTier(bundle, resolveRecipe))); - const identityTier = identityTiers.includes("institutional") ? "institutional" - : identityTiers.includes("verified") ? "verified" : "self-declared"; + const identityTier = sellerIdentityTier(await Promise.all(identityBundles.map((bundle) => deriveIdentityTier(bundle, resolveRecipe)))); return { primaryClaim: reg.primaryClaim, diff --git a/reference-implementations/dacs-directory/src/catalog/nodeContract.ts b/reference-implementations/dacs-directory/src/catalog/nodeContract.ts new file mode 100644 index 0000000..e292d20 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/nodeContract.ts @@ -0,0 +1,66 @@ +/** + * Read-only probe of the node RPC contract the Directory depends on (README "Node deployment + * contract"). It sends no transactions: one storage GET for an address no program occupies, and the + * exact-name program search over both the deployed nodeCall and the pinned revision's gcr_routine. + * Each search observation is dated runtime evidence, not a pin. + */ +import { storageResponseJson } from "./artifactLimits.js"; +import { UNKNOWN_NODE_CALL } from "./chain.js"; +import { gcrAuthHeaders } from "./gcr.js"; + +export interface NodeContractReport { + rpc: string; + observedAt: string; + /** The node's own not-found answer for an unoccupied address: 404 with errorCode NOT_FOUND. */ + storageNotFound: boolean; + nodeCallSearch: "supported" | "unknown-message" | "failed"; + gcrRoutineSearch: "supported" | "failed"; + /** Both searches answered and named the same programs for the exact name. */ + searchesAgree: boolean | null; + ok: boolean; +} + +const UNOCCUPIED = `stor-${"0".repeat(40)}`; + +async function post(rpc: string, headers: Record, payload: unknown): Promise { + try { + const response = await fetch(`${rpc}/`, { method: "POST", headers, cache: "no-store", signal: AbortSignal.timeout(15_000), body: JSON.stringify(payload) }); + return response.ok ? await storageResponseJson(response) : null; + } catch { + return null; + } +} + +const addresses = (body: unknown): string[] | null => { + const value = body as { result?: unknown; response?: unknown } | null; + return value?.result === 200 && Array.isArray(value.response) + ? value.response.map((entry) => String((entry as { storageAddress?: unknown })?.storageAddress)).sort() : null; +}; + +export async function probeNodeContract(rpc: string, programName: string): Promise { + const base = rpc.replace(/\/$/, ""); + let storageNotFound = false; + try { + const response = await fetch(`${base}/storage-program/${UNOCCUPIED}`, { cache: "no-store", signal: AbortSignal.timeout(15_000) }); + const body = await storageResponseJson(response) as { success?: unknown; errorCode?: unknown } | null; + storageNotFound = response.status === 404 && body?.success === false && body.errorCode === "NOT_FOUND"; + } catch { /* storageNotFound stays false */ } + const options = { exactMatch: true, limit: 32, offset: 0 }; + const nodeCall = await post(base, { "content-type": "application/json" }, { + method: "nodeCall", + params: [{ type: "nodeCall", message: "searchStoragePrograms", sender: null, receiver: null, timestamp: null, data: { query: programName, options }, extra: "" }], + }); + const gcr = await post(base, await gcrAuthHeaders(), { method: "gcr_routine", params: [{ method: "searchStoragePrograms", params: [programName, options] }] }); + const viaNodeCall = addresses(nodeCall); + const viaGcr = addresses(gcr); + const nodeCallSearch = viaNodeCall ? "supported" + : (nodeCall as { result?: unknown; response?: unknown } | null)?.result === 200 && (nodeCall as { response?: unknown }).response === UNKNOWN_NODE_CALL + ? "unknown-message" : "failed"; + const searchesAgree = viaNodeCall && viaGcr ? JSON.stringify(viaNodeCall) === JSON.stringify(viaGcr) : null; + return { + rpc: base, observedAt: new Date().toISOString(), storageNotFound, nodeCallSearch, + gcrRoutineSearch: viaGcr ? "supported" : "failed", searchesAgree, + // The Directory's resume lookup works when either search answers, and they must not disagree. + ok: storageNotFound && (nodeCallSearch === "supported" || viaGcr !== null) && searchesAgree !== false, + }; +} diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index c326e0f..54eca27 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -9,7 +9,7 @@ import { MAX_REGISTRATION_BUNDLE_BINDINGS, parseRegistration } from "./registrat import { boundedJson } from "./artifactLimits.js"; import { createHash } from "node:crypto"; import { indexRegistration, type ResolveIdentities } from "./indexer"; -import { boundedBundleBindings, verifyBundleBinding } from "./bundleBinding"; +import { boundedBundleBindings, catalogBundleBindings, verifyBundleBinding } from "./bundleBinding"; import { readChainTip, scanChain, @@ -143,14 +143,14 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise verifyBundleBinding(binding)))).filter((binding) => binding !== null); omitted.omittedBindings += raw.length - verified.length; - state.bundleBindings[jobId] = boundedBundleBindings(verified).bindings; + state.bundleBindings[jobId] = catalogBundleBindings(verified).bindings; } // Registrations are untrusted carriage (BB-3). Re-verify on every ingest so // hand-edited or legacy persisted JSON cannot bypass the BB-4 gate. for (const reg of regs) { const verified = reg.bundleBindings ?? []; for (const binding of verified) { - const bounded = boundedBundleBindings([...ownArray(state.bundleBindings, binding.jobId), binding]); + const bounded = catalogBundleBindings([...ownArray(state.bundleBindings, binding.jobId), binding]); state.bundleBindings[binding.jobId] = bounded.bindings; state.bundleBindingOverflow = [...new Set([ ...state.bundleBindingOverflow, @@ -216,7 +216,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise : undefined; @@ -32,11 +40,11 @@ export function isNeutralCancellation( ? listingTerms as Record : undefined; const phases = Array.isArray(phaseSummary) ? phaseSummary.filter((phase): phase is Record => Boolean(phase && typeof phase === "object" && !Array.isArray(phase))) : []; - const commitReached = phases.some((phase) => - (phase.kind === "commit-agreement" || phase.kind === "commit-payee-bound-agreement") && - phase.outcome === "ok"); + const preCommit = Array.isArray(phaseSummary) && phases.length === phaseSummary.length && + phases.every((phase) => typeof phase.kind === "string" && /^(?:vet|negotiate)-/.test(phase.kind)); return (sellerOutcome === "aborted-by-self" || sellerOutcome === "aborted-by-other") && - marker?.claimedPolicy === "pre-commit" && terms?.cancellationPolicy === "pre-commit" && !commitReached; + marker?.claimedPolicy === "pre-commit" && terms?.cancellationPolicy === "pre-commit" && + agreementRef === undefined && preCommit; } /** @@ -66,19 +74,18 @@ export function deriveSellerReputation( const timestamp = timestampOf(deal); return typeof timestamp === "number" && timestamp >= windowStart && timestamp <= windowEnd; }); - // SB-2: a settlement transaction reused across jobs contributes once, at - // its earliest evidence observation; the lower evidence hash breaks a tie, - // then the jobId, so the choice is deterministic. - const settlementOwner = new Map(); - for (const deal of scoped) for (const tx of deal.settlementTxIds ?? []) { - const prior = settlementOwner.get(tx.id); - const priorTx = prior?.settlementTxIds?.find((candidate) => candidate.id === tx.id); - const priorTime = priorTx?.observedAt ?? Infinity; - const order = byteOrder(tx.evidenceHash ?? "", priorTx?.evidenceHash ?? ""); - if (!prior || tx.observedAt < priorTime || - (tx.observedAt === priorTime && (order < 0 || (order === 0 && deal.jobId < prior.jobId)))) settlementOwner.set(tx.id, deal); + // SB-2: one settlement-tx-id counts under one (jobId, phaseIndex) only. Claims are admitted in + // the winning order, earliest evidence observation first, then the lower evidence hash, then the + // jobId so the choice is deterministic; a job holding any rejected claim is not counted. + const claims = scoped.flatMap((deal) => (deal.settlementTxIds ?? []).map((tx) => ({ deal, tx }))) + .sort((a, b) => a.tx.observedAt - b.tx.observedAt || byteOrder(a.tx.evidenceHash ?? "", b.tx.evidenceHash ?? "") || + byteOrder(a.deal.jobId, b.deal.jobId) || a.tx.phaseIndex - b.tx.phaseIndex); + const ledger = new Map(); + const rejected = new Set(); + for (const { deal, tx } of claims) { + if (admitSettlement(ledger, { id: tx.id, jobId: deal.jobId, phaseIndex: tx.phaseIndex }) === "reject") rejected.add(deal); } - scoped = scoped.filter((deal) => !(deal.settlementTxIds ?? []).some((tx) => settlementOwner.get(tx.id) !== deal)); + scoped = scoped.filter((deal) => !rejected.has(deal)); const completed = scoped.filter((deal) => deal.sellerOutcome === "completed").length; const neutral = scoped.filter((deal) => deal.sellerOutcome === "failed-substrate" || deal.cancellationNeutral).length; const counterpartyFault = scoped.filter((deal) => !deal.cancellationNeutral && @@ -137,6 +144,32 @@ export function deriveSellerReputation( }; } +/** + * DACS-1 §6.3.6 ReputationHint for one listing: categoryScope is the listing's own category, and + * the metrics come from the §10.5.1 derivation over the deals in that scope only (the category and + * its sub-categories), so SB-2, the window and the neutral outcomes apply exactly as for the seller. + */ +export function categoryReputationHint( + category: string, + deals: DealRecord[], + windowStart: number, + windowEnd: number, + computedAt: number, +): ReputationHint { + const scoped = deriveSellerReputation( + deals.filter((deal) => deal.category === category || deal.category?.startsWith(`${category}.`)), windowStart, windowEnd, + ); + return { + categoryScope: category, + completionRate: scoped.completionRate, + averageSellerRating: scoped.averageSellerRating ?? null, + bundleCount: scoped.bundleCount ?? 0, + windowStart, + windowEnd, + computedAt, + }; +} + /** Exact non-negative decimal addition without binary floating-point drift. */ function sumDecimals(values: string[]): string | null { let scale = 0; diff --git a/reference-implementations/dacs-directory/src/catalog/scan.ts b/reference-implementations/dacs-directory/src/catalog/scan.ts index 45004a1..25f1556 100644 --- a/reference-implementations/dacs-directory/src/catalog/scan.ts +++ b/reference-implementations/dacs-directory/src/catalog/scan.ts @@ -27,7 +27,7 @@ import { programBindingKey } from "./store.js"; import { discoveredDealKey } from "./scanState.js"; import { agreementRail } from "./agreementMetadata.js"; import { - boundedBundleBindings, + catalogBundleBindings, verifyBundleBinding, } from "./bundleBinding.js"; import type { BundleBinding, RegisteredDeal } from "./types.js"; @@ -88,15 +88,15 @@ export type StorageReadFailureCode = /** Safe cause classification; response bodies and upstream text are never persisted. */ export function storageReadFailureCode(status: number, errorCode?: string): StorageReadFailureCode { const normalized = typeof errorCode === "string" ? errorCode.trim().toUpperCase() : undefined; - if (status === 404) { - return "STORAGE_NOT_FOUND"; - } - if (status === 401 || status === 403) { - return "STORAGE_NOT_PUBLIC"; - } // A server-side or transport-class status remains retryable even if an // untrusted error body happens to carry a terminal-looking code. if (status >= 500 || status < 100) return "STORAGE_RPC_UNAVAILABLE"; + // Only the node's own answer is terminal: NOT_FOUND on a 404, PERMISSION_DENIED on a 403. Any + // other 4xx (a proxy's, a wrong route's; the node never answers 401) stays retryable rather than + // hiding an artifact, such as a revocation marker, for good (RB-6). + if (status === 404) return normalized === "NOT_FOUND" ? "STORAGE_NOT_FOUND" : "STORAGE_RPC_UNAVAILABLE"; + if (status === 403) return normalized === "PERMISSION_DENIED" ? "STORAGE_NOT_PUBLIC" : "STORAGE_RPC_UNAVAILABLE"; + if (status === 401) return "STORAGE_RPC_UNAVAILABLE"; if (normalized === "NOT_FOUND" || normalized === "STORAGE_NOT_FOUND" || normalized === "PROGRAM_NOT_FOUND") { return "STORAGE_NOT_FOUND"; } @@ -544,7 +544,7 @@ export async function scanChain( if (verifiedBundleBinding) { artifactKind = "bundle-binding"; const prior = bundleBindings.get(verifiedBundleBinding.jobId) ?? []; - const bounded = boundedBundleBindings([...prior, verifiedBundleBinding]); + const bounded = catalogBundleBindings([...prior, verifiedBundleBinding]); bundleBindings.set(verifiedBundleBinding.jobId, bounded.bindings); for (const key of bounded.overflowKeys) bundleBindingOverflow.add(key); } else if (isListingRevocationCandidate(data)) { diff --git a/reference-implementations/dacs-directory/src/catalog/settlementKey.ts b/reference-implementations/dacs-directory/src/catalog/settlementKey.ts new file mode 100644 index 0000000..992bed4 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/settlementKey.ts @@ -0,0 +1,86 @@ +/** + * DACS-4 §9.5.8 SB-1 settlement keys and the SB-2 cross-session uniqueness ledger. + * + * The key is the uniqueness defence, so it is byte-exact: lower-case hex hashes without `0x`, + * decimal indices, and Solana signatures that decode to exactly 64 bytes. A reference that fails + * canonicalisation, or whose kind SB-1 gives no key, is malformed and yields `null` (`error`); it + * never mints a distinct key. + */ +const record = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +const index = (value: unknown): string | null => + Number.isSafeInteger(value) && Number(value) >= 0 ? String(value) : null; +const txHash = (value: unknown): string | null => + typeof value === "string" ? value.match(/^(?:0x)?([0-9a-fA-F]{64})$/)?.[1].toLowerCase() ?? null : null; +const CLUSTERS = new Set(["mainnet", "devnet", "testnet"]); +const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/** Byte length of a base58 string, or null when it is not base58. */ +function base58Bytes(value: string): number | null { + if (!value) return null; + let number = 0n; + for (const character of value) { + const digit = BASE58.indexOf(character); + if (digit < 0) return null; + number = number * 58n + BigInt(digit); + } + const leadingZeros = value.length - value.replace(/^1+/, "").length; + return leadingZeros + (number === 0n ? 0 : Math.ceil(number.toString(16).length / 2)); +} + +/** The SB-1 `settlement-tx-id` of one current ChainTxRef, or null (`error`). */ +export function settlementTxId(ref: unknown): string | null { + const value = record(ref); + if (!value) return null; + if (value.kind === "demos") { + const hash = txHash(value.txHash); + return hash ? `demos:${hash}` : null; + } + if (value.kind === "evm" || value.kind === "x402") { + // The upstream evm and x402 ChainTxRef variants carry no logIndex, yet SB-1 requires one; a + // reference without it is malformed under SB-1 and is never counted. + const chainId = index(value.chainId); + const hash = txHash(value.kind === "evm" ? value.txHash : value.settlementTxHash); + const logIndex = index(value.logIndex); + return chainId && hash && logIndex ? `evm:${chainId}:${hash}:${logIndex}` : null; + } + if (value.kind === "solana") { + const instruction = index(value.instructionIndex); + return typeof value.cluster === "string" && CLUSTERS.has(value.cluster) && + typeof value.signature === "string" && base58Bytes(value.signature) === 64 && instruction + ? `solana:${value.cluster}:${value.signature}:${instruction}` : null; + } + return null; +} + +/** + * The pinned SDK's pre-SB-1 TxRef `{ rail, txHash, kind }` on a `pay-dem` phase is a native Demos + * transfer, keyed like the current `demos` variant. No other legacy rail carries the event index + * SB-1 needs, so its references are `error`. + */ +export function legacySettlementTxId(phase: unknown, ref: unknown): string | null { + const value = record(ref); + return phase === "pay-dem" && value ? settlementTxId({ kind: "demos", txHash: value.txHash }) : null; +} + +export interface SettlementClaim { + id: string; + jobId: string; + phaseIndex: number; +} + +export type Sb2Effect = "count" | "already-counted" | "reject"; + +/** + * SB-2 for one claim against the consumer's ledger of consumed ids. The caller presents claims in + * the winning order (earlier observedAt, then lower evidence hash), so the first binding of an id + * stands and any binding under another (jobId, phaseIndex) is rejected. + */ +export function admitSettlement(ledger: Map, claim: SettlementClaim): Sb2Effect { + const prior = ledger.get(claim.id); + if (!prior) { + ledger.set(claim.id, { jobId: claim.jobId, phaseIndex: claim.phaseIndex }); + return "count"; + } + return prior.jobId === claim.jobId && prior.phaseIndex === claim.phaseIndex ? "already-counted" : "reject"; +} diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index 4059eef..48fd93e 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -5,40 +5,26 @@ * The catalog's numbers are §6.3.6 hints. This component repeats strict bundle * signer coverage plus referenced-artifact signature/hash checks in-browser. * Bytes arrive through /api/dacs/artifact, so this is cryptographic consistency - * verification rather than an independent chain-inclusion proof. + * verification rather than an independent chain-inclusion proof. The checks, + * verdict and labels live in verifyDealModel.ts; this file only renders them. */ import { useState } from "react"; // Side-effect: patches the browser Buffer polyfill with base64url support // (the SDK decodes signature bytes with Buffer.from(x, "base64url")). import "@/src/shims/buffer"; -// Import ONLY pure modules: the package barrel re-exports createAgent, whose +// The model imports ONLY pure modules: the package barrel re-exports createAgent, whose // lazy `import("../substrate")` gets statically traced by Next's bundler and // drags demosdk (node-only) into the client bundle. (SDK finding: a pure // "./verify" subpath export would fix this properly — see dacs-sdk#14.) -import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; +import type { BundleVerification } from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; import { - verifyBundleCore, - type BundleVerification, -} from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; -import { - browserDealVerdict, - hasRequiredBundleSignatures, - refsPassStrictPolicy, - type ResolvedArtifact, -} from "@/src/catalog/bundlePolicy"; - -const keyFromDid = (did: string): Uint8Array | null => { - const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; - return hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; -}; - -// Mirrors the SDK's sessionAnchorName, which the pinned SDK_REV does not export -// publicly (fixed upstream after the pin: dacs-sdk#14). -const anchorName: Record string> = { - "dacs-3-agreement": (j) => `dacs3:agreement:${j}`, - "dacs-4-evidence": (j) => `dacs4:evidence:${j}`, - "dacs-2-verifyresult": (j) => `dacs2:verifyrecord:${j}`, -}; + detailRows, + verdictPresentation, + verificationErrorMessage, + verifyDealInBrowser, + type DealVerdict, + type RegisteredBundleRefs, +} from "./verifyDealModel"; async function fetchArtifact(params: string): Promise | null> { const res = await fetch(`/api/dacs/artifact?${params}`); @@ -46,46 +32,16 @@ async function fetchArtifact(params: string): Promise | return json.value ?? null; } -async function registeredBundleRefs(jobId: string, bundleRef: string): Promise<{ - owners: unknown; - buyerBundleRef: unknown; - sellerBundleRef: unknown; -} | null> { +async function registeredBundleRefs(jobId: string, bundleRef: string): Promise { try { const res = await fetch(`/api/dacs/deal-owners?jobId=${encodeURIComponent(jobId)}&bundleRef=${encodeURIComponent(bundleRef)}`); if (!res.ok) return null; - return await res.json() as { owners: unknown; buyerBundleRef: unknown; sellerBundleRef: unknown }; + return await res.json() as RegisteredBundleRefs; } catch { return null; } } -// Plain-language translations of the protocol's check names. -const REF_LABELS: Record = { - "dacs-3-agreement": { - what: "The agreement", - means: "what was promised, by whom, at what price — unchanged since it was signed", - }, - "dacs-4-evidence": { - what: "The payment record", - means: "the settlement evidence, including the transaction reference — unchanged", - }, - "dacs-2-verifyresult": { - what: "The identity check", - means: "the vetting that ran before any money moved — unchanged", - }, - "dacs-1-listing": { - what: "The original listing", - means: "the service as it was advertised when this deal was struck — unchanged", - }, -}; -const REF_FAIL: Record = { - missing: "can't be found on the chain", - "invalid-shape": "isn't a valid record", - "hash-mismatch": "has been ALTERED since the deal was signed", - unresolved: "couldn't be located", -}; - export default function VerifyDeal({ bundleRef, buyerOwner, @@ -98,53 +54,22 @@ export default function VerifyDeal({ const [state, setState] = useState<"idle" | "running" | "done" | "error">("idle"); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [verdict, setVerdict] = useState<"strict-pass" | "limited" | "fail" | null>(null); + const [verdict, setVerdict] = useState(null); const run = async () => { setState("running"); setError(null); setResult(null); setVerdict(null); try { - const resolvedArtifacts: ResolvedArtifact[] = []; - let rawBundle: Record | null = null; - const verification = await verifyBundleCore(bundleRef, { - readArtifact: async (r) => { - const raw = await fetchArtifact(`ref=${encodeURIComponent(r)}`); - if (r === bundleRef) rawBundle = raw; - // The pinned SDK uses readArtifact only for the root and final listing; - // agreement/evidence/vet artifacts enter through resolveRef below. - if (raw && r !== bundleRef) resolvedArtifacts.push({ kind: "dacs-1-listing", raw }); - return raw; - }, - resolveRef: async (kind, jobId) => { - const name = anchorName[kind]?.(jobId); - if (!name) return null; - const raw = await fetchArtifact( - `owner=${encodeURIComponent(buyerOwner)}&name=${encodeURIComponent(name)}`, - ); - if (raw) resolvedArtifacts.push({ kind, raw }); - return raw; - }, - resolvePublicKey: async (did) => keyFromDid(did), - verify: async (b, s, p) => ed25519Verify(b, s, publicKeyFromRaw(p)), - }); - const registered = verification.bundle?.jobId - ? await registeredBundleRefs(verification.bundle.jobId, bundleRef) : null; - const signaturesOk = hasRequiredBundleSignatures( - verification, - rawBundle, - ); - const refsOk = signaturesOk ? await refsPassStrictPolicy(verification, resolvedArtifacts) : false; - const outcome = browserDealVerdict({ verification, bundleRef, buyerOwner, expectedSeller, registered, signaturesOk, refsOk }); - setResult({ ...verification, ok: outcome.verdict === "strict-pass", reason: outcome.reason }); + const outcome = await verifyDealInBrowser({ bundleRef, buyerOwner, expectedSeller }, { fetchArtifact, registeredBundleRefs }); + setResult(outcome.result); setVerdict(outcome.verdict); setState("done"); } catch (cause) { - const message = cause instanceof TypeError - ? "The chain records could not be reached. Check the network and try again." - : (cause as Error).message || "The artifact version is unsupported or malformed."; - setError(message); setState("error"); + setError(verificationErrorMessage(cause)); setState("error"); } }; + const shown = result ? verdictPresentation(result, verdict) : null; + const rows = result ? detailRows(result) : null; return (