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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ discovery layer (DACS-1 §6.3.6 catalog API), with a browsable directory UI and

Agents do NOT need to register to appear here: the indexer **crawls the chain**
(see *Discovery — three channels* below) and picks up current structured listings and
the pinned SDK's legacy artifacts through program-name and content-shape detection. Registration adds a display
the pinned SDK's explicit legacy read profile through program-name and content-shape detection. Current
listings must pass the pinned SDK's normative `isListing()` gate before catalog admission. They are exposed as
authenticated catalog candidates with `transactionReadiness.disposition = unassessed`: buyer-local revocation,
rail-authority, payload-capability and delegated signer-control policy remains the transacting SDK's job. Registration adds a display
name and (when owner-signed) the "owner-registered" badge — it is never a gate.

Live thesis: a Web2 marketplace *asks you to trust its database*. This directory is a
Expand All @@ -20,7 +23,7 @@ checks in-browser, while chain inclusion still depends on the disclosed proxy/RP
| Surface | Spec | How |
|---|---|---|
| Catalog API | DACS-1 §6.3.6 | Full normative listing filters plus `q`, profile and identity-tier extensions; canonical current listings, explicitly labelled legacy SDK artifacts, and unauthenticated BB-4-verified `GET /api/dacs/bundles/{jobId}` candidates |
| Seller publication | DACS-1 §6.3.4 | `/register` builds and wallet-signs a current Listing, creates its StorageProgram with the live next-nonce/empty-salt mapping from SDK #70, persists non-secret recovery coordinates before broadcast, and independently verifies native address, owner, program name, tuple, identity, signature and content hash before catalog registration |
| Seller publication | DACS-1 §6.3.4 | `/register` uses the SDK rail resolver over release-authenticated PA-1 definitions, refuses attested-payload methods whose seller-runtime production capability it cannot prove, shows the exact server-built unsigned Listing before signature, creates its StorageProgram with the current SDK name/next-nonce/empty-salt mapping, persists non-secret recovery coordinates before broadcast, obtains a portable BFT-finalized `AnchorReceipt`, and independently verifies native address, owner, program name, tuple, identity, signature and content hash before catalog registration |
| Registration | — (catalog-side) | `POST /api/dacs/register` with bounded discovery hints. Nothing in the payload is trusted: listings are read from chain, BundleBindings are independently BB-4 verified, CCI badges are resolved from the on-chain GCR, and every offered bundle is cryptographically verified before it counts |
| Identity links | DACS-1 / DACS-2 / CCI | GCR links remain informational; identity tiers elevate only from hash/signature/identifier/method/version/freshness-verified `verifiedBy` evidence under an explicit recipe policy |
| Reputation derivation | DACS-5 §10.4–§10.5 | logical bundle-address derivation and bounded BB-4/BB-5/BB-6 resolution, strict two-sided evidence graphs, legacy and v0.3 absolute-fault bundles, seller perspective, ratings, exact-decimal volume, settlement uniqueness, SR-2 windows and deterministic receipts |
Expand Down Expand Up @@ -175,6 +178,32 @@ and response failures retain bounded retries. `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.

The same status response and the catalog UI expose `listingRejectionDiagnostics` with scope
`listing-admission`. It reports stable public-safe classes for normative shape,
verification-method, signature, identity-presentation, owner/seller binding and
declared-hash failures, including bounded listing ID/version coordinates when they can
be safely recovered and explicit republishing guidance. For example, string-valued deliverable verification methods
are excluded as `VERIFICATION_METHOD_INVALID`; the Directory never aliases them to a
registered structured verification-method variant.

Before replacing malformed live Listings, run the read-only deployment acceptance check
against the deployed Directory and Demos RPC. The replacement manifest must map all three
known old listing IDs to their new anchors/versions and the independent verifier's supported
payload-method kinds:

```bash
NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \
DACS_LISTING_UPGRADE_REPLACEMENTS='[...]' \
npm run check:listing-upgrade
```

It proves the actual old anchor bytes fail with `VERIFICATION_METHOD_INVALID`, their public
diagnostics carry the affected listing coordinates, each replacement passes the complete
SDK reader, the reindexed catalog exposes the exact replacement pin with transaction
readiness still unassessed. It never contacts a gateway and never writes, reindexes,
purchases, or invokes an agent. Gateway demo deployment and smoke testing are owned by the
private gateway release and are not Directory/indexer acceptance criteria.

## Discovery — three channels

1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets,
Expand Down Expand Up @@ -211,8 +240,11 @@ The Next app and the indexer speak to the node over **plain HTTP** (storage read
unauthenticated GETs; `gcr_routine` uses hand-rolled timestamp-bound auth headers signed
with the SDK's pure ed25519). demosdk is NOT a runtime dependency — its dependency tree
(rubic bridge → pancakeswap/cetus/…) has unresolvable optionals in consumer installs and
is bundler-hostile. The SDK's pure barrel does all cryptography, on both server and
client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer).
is bundler-hostile. SDK verification names resolve through one compatibility seam because
the current top-level SDK barrel statically re-exports those optional rail modules; replace
that seam when the SDK publishes a browser-safe verification subpath. The same verification
code runs on server and client (browser: @noble-shimmed `node:crypto`, a narrow `node:util`
shim over JSON-owned values, and base64url-patched Buffer).

## Honest limitations (MVP)

Expand All @@ -224,10 +256,16 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer).
`DACS_ADMIN_TOKEN` as a Bearer token. Run indexing from cron/CI, not public UI.
- **Wallet publication uses three signatures**: the embedded IdentityBundle presentation,
the Listing, and the catalog pointer/deal set. Registration remains catalog-side and non-normative.
Before any StorageProgram broadcast, the browser persists the public signed artifact,
exact native/write coordinates and unsigned registration. A reload re-verifies that
same anchor and refreshes only the catalog-pointer signature; it never creates another
listing version or re-sends a chain transaction automatically.
The identity signature is used to build the exact unsigned Listing shown on screen; no
broadcastable transaction is returned until the wallet signs those exact bytes. Before
any StorageProgram broadcast, the browser persists the public signed artifact, exact
native/write coordinates and unsigned registration. After broadcast, registration waits
for canonical transaction inclusion, a BFT-confirmed block, portable finalized
`AnchorReceipt`, and independent native readback. A reload re-verifies that same anchor
and refreshes only the catalog-pointer signature; it never creates another listing version
or re-sends a chain transaction automatically. The browser publisher intentionally omits
attested-payload delivery: publish that shape through the seller runtime's current SDK so
DPA-1 can prove the exact producer capability.
- **Scanner depth is bounded** per pass. Increase `DACS_SCAN_MAX_TXS` if a backfill or
unusually large interval exceeds the configured cap.
- **DACS-2 recipe governance is deployment policy.** `verifiedBy` evidence cannot
Expand All @@ -245,10 +283,12 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer).

## DACS surface / conformance declaration

`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile reading,
`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile catalog reading,
§6.3.5 well-known generation/crawling, and §6.3.6 catalog discovery. Current artifacts
use directory-native, current-contract evidence-graph validation; the pinned SDK verifier
is retained only for labelled legacy artifacts. DACS-2 tier derivation fails closed on
pass the pinned SDK's normative shape and component-signature APIs and are explicitly
labelled transaction-readiness-unassessed until a buyer runs the complete SDK reader; current-contract
evidence graphs use directory-native validation, while the SDK bundle verifier is retained
for labelled legacy artifacts. DACS-2 tier derivation fails closed on
unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, volume,
settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog
computations remain advisory and independently reproducible from their refs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,30 @@
* POST /api/dacs/build-listing — everything the wallet flow needs to publish
* a listing, precomputed server-side:
* { claim, serviceId, name, description, rails[], delivery[] }
* → { listing, message, anchorAddress, exists, tx }
* → identity preimage, then the exact unsigned Listing, then (only after its
* wallet signature is verified) a broadcastable anchor transaction.
*
* For a new version the client wallet-signs `message`, inserts the signed
* listing into `tx`, and broadcasts it. When the same owner/name already holds
* For a new version the client wallet-signs `message`; the server verifies the
* signature and binds that exact artifact plus its hashes into `tx`. When the same owner/name already holds
* a verified immutable version, the response carries `exists:true` and no
* transaction so the client can resume registration without another write.
* Ownership is intrinsic: the native address uses the seller's next account
* nonce plus the live empty salt, and readback re-binds owner/name/content.
*/
import { NextRequest, NextResponse } from "next/server";
import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical";
import {
canonicalize,
contentHash,
listingAddress,
logicalToStorageProgramName,
sha256Hex,
} from "@kynesyslabs/dacs/canonical";
import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto";
import { isListing, type ListingDraft } from "@kynesyslabs/dacs/artifacts";
import {
resolveListingPayloadVerificationCapability,
resolveListingRails,
} from "@/src/sdkListingValidation";
import {
deriveStorageAddress,
LIVE_STORAGE_SALT,
Expand All @@ -28,6 +40,7 @@ import {
negotiationPhaseForPricing,
publishableRail,
PUBLISHABLE_PRICING_KINDS,
PUBLISHER_IN_CODE_RAIL_DEFINITIONS,
type PublishablePricingKind,
} from "@/src/catalog/listingOptions";

Expand Down Expand Up @@ -61,6 +74,7 @@ export async function POST(req: NextRequest) {
claim?: string; serviceId?: string; name?: string; description?: string;
rails?: string[]; delivery?: string[]; category?: string; tags?: string[];
publicEndpoint?: string; identityPresentedAt?: number; identitySignature?: string;
listingSignature?: string;
pricing?: {
kind?: PublishablePricingKind; amount?: string; currency?: string; unit?: string; minTotal?: string;
minPct?: number; maxPct?: number; selectionRule?: "lowest-price" | "highest-price" | "first-acceptable";
Expand Down Expand Up @@ -147,7 +161,8 @@ export async function POST(req: NextRequest) {

// A current Listing embeds a separately signed IdentityBundle. The first
// request returns that preimage; the second includes the wallet signature
// and receives the final listing preimage + anchor transaction.
// and receives the exact Listing preimage. Only a third request carrying the
// Listing signature receives a broadcastable anchor transaction.
const serverNow = Date.now();
const identityPresentedAt = body.identitySignature ? Number(body.identityPresentedAt) : serverNow;
if (body.identitySignature && (
Expand Down Expand Up @@ -203,7 +218,11 @@ export async function POST(req: NextRequest) {
? { kind: "storage-program", accessModel: "public" }
: deliverableKind === "entitlement"
? { kind: "entitlement", durationSec: 2_592_000, renewable: false }
: { kind: "attested-payload", payloadFormat: "application/json" };
: {
kind: "attested-payload",
payloadFormat: "application/json",
verificationMethod: { kind: "self-signed" },
};
const auctionDeadline = identityPresentedAt + 7 * 24 * 60 * 60 * 1000;
const negotiationKind = negotiationPhaseForPricing(pricingKind);
const negotiationStep = negotiationKind === "negotiate-rfq"
Expand Down Expand Up @@ -238,15 +257,69 @@ export async function POST(req: NextRequest) {
terms: {},
validity: { notBefore: identityPresentedAt, ...(pricingKind === "auction" ? { notAfter: auctionDeadline } : {}) },
};
const railResolution = resolveListingRails({
trustPhase: "PA-1",
trustPolicyAcceptsPA1: true,
payPhases: listing.pipeline
.filter((phase) => phase.kind.startsWith("pay-"))
.map((phase) => ({
kind: phase.kind,
rail: (phase.parameters as Record<string, unknown> | undefined)?.rail,
})),
acceptedRails: listing.acceptedRails,
registry: { state: "not-used", entries: [], definitions: [] },
inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS,
});
if (railResolution.disposition !== "verified") {
return NextResponse.json(
{ error: `listing rail authority is ${railResolution.disposition} (${railResolution.reason}); publication refused` },
{ status: 409 },
);
}
const payloadCapability = await resolveListingPayloadVerificationCapability(
listing as unknown as ListingDraft,
"produce",
);
if (
payloadCapability.disposition !== "not-applicable" &&
payloadCapability.disposition !== "supported"
) {
return NextResponse.json(
{ error: "This browser publisher cannot prove the seller runtime's exact attested-payload production capability. Publish this delivery type through the current SDK seller runtime." },
{ status: 409 },
);
}
const hash = contentHash(listing as Record<string, unknown>);
const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII
let signedCandidate: Record<string, unknown> | undefined;
if (body.listingSignature !== undefined) {
const listingSigHex = body.listingSignature.replace(/^(0x)+/i, "");
if (!/^[0-9a-fA-F]{128}$/.test(listingSigHex)) {
return NextResponse.json({ error: "listing signature must be a 64-byte Ed25519 signature" }, { status: 400 });
}
let listingSignatureValid = false;
try {
listingSignatureValid = ed25519Verify(
Buffer.from(message, "utf8"),
Uint8Array.from(Buffer.from(listingSigHex, "hex")),
publicKeyFromRaw(Uint8Array.from(Buffer.from(hex, "hex"))),
);
} catch { /* malformed signatures fail closed */ }
signedCandidate = {
...listing,
signature: { algorithm: "ed25519", signer: did, value: listingSigHex },
};
if (!listingSignatureValid || !isListing(signedCandidate)) {
return NextResponse.json({ error: "the signed listing failed current SDK validation" }, { status: 400 });
}
}

const logicalAddress = listingAddress(did, serviceId, listingVersion);
// StorageProgram names are producer-held write inputs, not public resolution
// keys. This deterministic colon-free encoding lets THIS producer recover a
// broadcast whose response was lost without pretending consumers can derive
// the native address from the logical one.
const programName = `dacs1-${Buffer.from(logicalAddress, "utf8").toString("base64url")}`;
const programName = logicalToStorageProgramName(logicalAddress);
const resolution = await resolveOwnedAnchorByName(programName, owner);
if (resolution.status === "indeterminate") {
return NextResponse.json(
Expand All @@ -270,16 +343,33 @@ export async function POST(req: NextRequest) {
verified.scope.listingVersion !== listingVersion
) {
return NextResponse.json(
{ error: "the existing owner-bound program does not contain the expected verified listing version" },
{ error: "the existing owner-bound program does not contain the expected authenticated listing candidate" },
{ status: 409 },
);
}
// Listing versions are immutable. Recover the already-signed artifact and
// continue registration; never overwrite it with newly generated bytes.
anchorAddress = resolution.address;
publishedListing = verified.listing as Record<string, unknown>;
publishedListing = verified.listing as unknown as Record<string, unknown>;
publishedHash = verified.contentHash;
} else {
// Do not prepare a broadcastable transaction until the wallet has signed
// the exact artifact returned by the preview response.
if (!signedCandidate) {
return NextResponse.json({
listing,
message,
contentHash: hash,
artifactProfile: "dacs-v0.1",
logicalAddress,
programName,
exists: false,
tx: null,
publicationReady: false,
railResolution,
payloadCapability,
});
}
const nonce = await accountNonce(hex);
const txNonce = nonce + 1;
anchorAddress = deriveStorageAddress(owner, programName, txNonce, LIVE_STORAGE_SALT);
Expand All @@ -288,8 +378,12 @@ export async function POST(req: NextRequest) {
storageAddress: anchorAddress,
programName,
encoding: "json",
data: "__SIGNED_LISTING__",
metadata: { logicalAddress },
data: signedCandidate,
metadata: {
logicalAddress,
contentHash: hash,
envelopeHash: sha256Hex(canonicalize(signedCandidate)),
},
acl: { mode: "public" },
salt: LIVE_STORAGE_SALT,
storageLocation: "onchain",
Expand All @@ -310,6 +404,7 @@ export async function POST(req: NextRequest) {
status: "",
blockNumber: null,
};
publishedListing = signedCandidate;
}

const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did);
Expand Down Expand Up @@ -342,6 +437,9 @@ export async function POST(req: NextRequest) {
anchorAddress,
exists,
tx,
publicationReady: true,
railResolution,
payloadCapability,
registration: {
...registration,
ownerSignature: {
Expand Down
Loading