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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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 |
| 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 @@ -223,6 +224,10 @@ 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.
- **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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@
* { claim, serviceId, name, description, rails[], delivery[] }
* → { listing, message, anchorAddress, exists, tx }
*
* The client then: (1) wallet-signs `message` (it IS the §B.7 signing
* preimage — "dacs-listing:v1:" + contentHash, plain ASCII), (2) drops the
* signed listing into tx.content.data[1].data, (3) sends the tx through the
* wallet (sendTransaction signs + broadcasts). Ownership is intrinsic: the
* anchor address is derived from the SIGNER's account, and the listing's
* agentId must match it.
* 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
* 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 } from "@kynesyslabs/dacs/canonical";
import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical";
import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto";
import { deriveAnchorAddress, readAnchor } from "@/src/catalog/chain";
import {
deriveStorageAddress,
LIVE_STORAGE_SALT,
resolveOwnedAnchorByName,
} from "@/src/catalog/chain";
import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security";
import { loadCatalog, loadRegistrations } from "@/src/catalog/store";
import { registrationMessage } from "@/src/catalog/registrationSig";
import { safePublicEndpoint } from "@/src/catalog/publicEndpoint";
import { verifyListing } from "@/src/catalog/listingVerification";
import {
negotiationPhaseForPricing,
publishableRail,
Expand All @@ -41,9 +46,12 @@ async function accountNonce(addressHex: string): Promise<number> {
params: [{ type: "nodeCall", message: "getAddressNonce", sender: null, receiver: null, timestamp: null, data: { address: `0x${addressHex}` }, extra: "" }],
}),
});
if (!res.ok) throw new Error("could not fetch account nonce");
const json = (await res.json()) as { result?: number; response?: number };
if (json?.result !== 200) throw new Error("could not fetch account nonce");
return Number(json.response ?? 0);
const nonce = Number(json.response);
if (!Number.isSafeInteger(nonce) || nonce < 0) throw new Error("node returned an invalid account nonce");
return nonce;
}

export async function POST(req: NextRequest) {
Expand All @@ -58,7 +66,7 @@ export async function POST(req: NextRequest) {
minPct?: number; maxPct?: number; selectionRule?: "lowest-price" | "highest-price" | "first-acceptable";
};
} | null;
const hex = body?.claim?.match(/([0-9a-fA-F]{64})$/)?.[1];
const hex = body?.claim?.match(/([0-9a-fA-F]{64})$/)?.[1]?.toLowerCase();
if (!hex || !body?.serviceId?.trim() || !body?.name?.trim() || !body?.description?.trim()) {
return NextResponse.json({ error: "need claim, serviceId, name, description" }, { status: 400 });
}
Expand Down Expand Up @@ -233,60 +241,104 @@ export async function POST(req: NextRequest) {
const hash = contentHash(listing as Record<string, unknown>);
const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII

const logicalAddress = `dacs1:${encodeURIComponent(did)}:${serviceId}:v${listingVersion}`;
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 nonce = await accountNonce(hex);
const txNonce = nonce + 1;
const anchorAddress = deriveAnchorAddress(did, programName, txNonce);
const exists = (await readAnchor(anchorAddress)) != null;

// Storage-program payload (mirrors demosdk's create/write shapes).
const payload = exists
? { operation: "WRITE_STORAGE", storageAddress: anchorAddress, data: "__SIGNED_LISTING__", encoding: "json" }
: {
operation: "CREATE_STORAGE_PROGRAM",
storageAddress: anchorAddress,
programName,
encoding: "json",
data: "__SIGNED_LISTING__",
metadata: { logicalAddress },
acl: { mode: "public" },
salt: "dacs:v1",
storageLocation: "onchain",
};
const resolution = await resolveOwnedAnchorByName(programName, owner);
if (resolution.status === "indeterminate") {
return NextResponse.json(
{ error: `could not safely determine whether this listing version already exists: ${resolution.reason}` },
{ status: 503 },
);
}

const tx = {
content: {
type: "storageProgram",
from: owner,
to: anchorAddress,
amount: 0,
data: ["storageProgram", payload],
nonce: txNonce,
timestamp: Date.now(),
transaction_fee: { network_fee: 0, rpc_fee: 0, additional_fee: 0, rpc_address: null },
},
signature: null,
hash: "",
status: "",
blockNumber: null,
};
let anchorAddress: string;
let publishedListing: Record<string, unknown> = listing;
let publishedHash = hash;
let tx: Record<string, unknown> | null = null;
const exists = resolution.status === "present";
if (resolution.status === "present") {
const verified = await verifyListing(resolution.record.data);
if (
!verified ||
verified.profile !== "dacs-v0.1" ||
verified.sellerClaim !== did ||
verified.scope.listingId !== serviceId ||
verified.scope.listingVersion !== listingVersion
) {
return NextResponse.json(
{ error: "the existing owner-bound program does not contain the expected verified listing version" },
{ 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>;
publishedHash = verified.contentHash;
} else {
const nonce = await accountNonce(hex);
const txNonce = nonce + 1;
anchorAddress = deriveStorageAddress(owner, programName, txNonce, LIVE_STORAGE_SALT);
const payload = {
operation: "CREATE_STORAGE_PROGRAM",
storageAddress: anchorAddress,
programName,
encoding: "json",
data: "__SIGNED_LISTING__",
metadata: { logicalAddress },
acl: { mode: "public" },
salt: LIVE_STORAGE_SALT,
storageLocation: "onchain",
};
tx = {
content: {
type: "storageProgram",
from: owner,
to: anchorAddress,
amount: 0,
data: ["storageProgram", payload],
nonce: txNonce,
timestamp: Date.now(),
transaction_fee: { network_fee: 0, rpc_fee: 0, additional_fee: 0, rpc_address: null },
},
signature: null,
hash: "",
status: "",
blockNumber: null,
};
}

const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did);
const recoveredSeller = publishedListing.seller && typeof publishedListing.seller === "object" && !Array.isArray(publishedListing.seller)
? publishedListing.seller as Record<string, unknown>
: null;
const displayName = typeof recoveredSeller?.displayName === "string"
? recoveredSeller.displayName
: knownSeller?.displayName ?? body.name.trim();
const registration = {
primaryClaim: did,
displayName: knownSeller?.displayName ?? body.name.trim(),
listingAnchors: [...new Set([...(knownSeller?.listings.map((l) => l.anchor.locator) ?? []), anchorAddress])],
displayName,
listingAnchors: [...new Set([
...(priorRegistration?.listingAnchors ?? []),
...(knownSeller?.listings.map((l) => l.anchor.locator) ?? []),
anchorAddress,
])],
deals: priorRegistration?.deals ?? [],
...(priorRegistration?.bundleBindings ? { bundleBindings: priorRegistration.bundleBindings } : {}),
};
const signedAt = Date.now();

return NextResponse.json({
listing,
message,
listing: publishedListing,
...(exists ? {} : { message }),
contentHash: publishedHash,
artifactProfile: "dacs-v0.1",
logicalAddress,
programName,
anchorAddress,
exists,
tx,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* POST /api/dacs/confirm-listing — independently read and verify a listing
* after the wallet broadcasts its StorageProgram transaction.
*
* Visibility alone is insufficient: the native address, owner, producer-held
* program name, listing tuple, content hash, seller, identity presentation and
* listing signature must all bind before the browser may register the pointer.
*/
import { NextRequest, NextResponse } from "next/server";
import { readAnchorRecord } from "@/src/catalog/chain";
import { verifyListing } from "@/src/catalog/listingVerification";
import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security";

function canonicalOwner(value: string): string | null {
const hex = value.match(/([0-9a-fA-F]{64})$/)?.[1];
return hex ? `0x${hex.toLowerCase()}` : null;
}

export async function POST(req: NextRequest) {
const blocked = rateLimit(req, "confirm-listing", 60, 10 * 60_000) ?? rejectOversizeRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => null) as {
anchorAddress?: unknown;
programName?: unknown;
contentHash?: unknown;
sellerClaim?: unknown;
listingId?: unknown;
listingVersion?: unknown;
} | null;
if (
!body ||
typeof body.anchorAddress !== "string" || !/^stor-[0-9a-f]{40}$/.test(body.anchorAddress) ||
typeof body.programName !== "string" || !body.programName || body.programName.length > 512 ||
typeof body.contentHash !== "string" || !/^[0-9a-f]{64}$/.test(body.contentHash) ||
typeof body.sellerClaim !== "string" || canonicalOwner(body.sellerClaim) === null ||
typeof body.listingId !== "string" || !/^[a-z0-9-]{1,64}$/.test(body.listingId) ||
!Number.isSafeInteger(body.listingVersion) || Number(body.listingVersion) < 1
) {
return NextResponse.json({ error: "invalid listing confirmation coordinates" }, { status: 400 });
}

const anchored = await readAnchorRecord(body.anchorAddress);
if (!anchored) {
return NextResponse.json({ confirmed: false, state: "not-visible" }, { status: 202 });
}
if (
anchored.programName !== body.programName ||
canonicalOwner(anchored.owner ?? "") !== canonicalOwner(body.sellerClaim)
) {
return NextResponse.json(
{ confirmed: false, state: "binding-mismatch", error: "anchor coordinates do not bind to this seller publication" },
{ status: 409 },
);
}

const verified = await verifyListing(anchored.data);
if (
!verified ||
verified.profile !== "dacs-v0.1" ||
verified.contentHash !== body.contentHash ||
verified.sellerClaim !== body.sellerClaim ||
verified.scope.listingId !== body.listingId ||
verified.scope.listingVersion !== body.listingVersion
) {
return NextResponse.json(
{ confirmed: false, state: "verification-failed", error: "anchored listing failed signature, identity, hash, or tuple verification" },
{ status: 409 },
);
}

return NextResponse.json({
confirmed: true,
state: "verified",
anchorAddress: body.anchorAddress,
contentHash: verified.contentHash,
sellerClaim: verified.sellerClaim,
listingId: body.listingId,
listingVersion: body.listingVersion,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* POST /api/dacs/prepare-registration — issue a fresh, content-bound owner
* signing message for an already verified listing pointer.
*
* The browser can safely resume registration after a reload or an expired
* signature without rebuilding or rebroadcasting the on-chain listing.
*/
import { NextRequest, NextResponse } from "next/server";
import { parseRegistration } from "@/src/catalog/registration";
import { registrationMessage } from "@/src/catalog/registrationSig";
import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security";

export async function POST(req: NextRequest) {
const blocked = rateLimit(req, "prepare-registration", 20, 10 * 60_000) ?? rejectOversizeRequest(req);
if (blocked) return blocked;
const raw = await req.json().catch(() => null) as Record<string, unknown> | null;
if (raw?.ownerSignature !== undefined) {
return NextResponse.json({ error: "prepare-registration expects an unsigned registration" }, { status: 400 });
}
const parsed = parseRegistration(raw);
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 });

const registration = parsed.value;
const signedAt = Date.now();
return NextResponse.json({
registration: {
...registration,
ownerSignature: {
message: registrationMessage(registration, signedAt),
signedAt,
},
},
});
}
17 changes: 17 additions & 0 deletions reference-implementations/dacs-directory/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,23 @@ main { max-width: 1200px; }
}
textarea.form-control { min-height: 112px; resize: vertical; }
.field-hint { color: var(--text-muted); font-size: 0.72rem; }
.form-readiness, .wallet-approval-note {
margin-top: 16px; padding: 11px 13px; border: 1px solid var(--accent-border);
border-radius: var(--radius-button); background: var(--accent-soft);
color: var(--text-secondary); font-size: 0.78rem; line-height: 1.5;
}
.form-readiness.ready { border-color: var(--green-border); background: var(--green-soft); color: var(--green-strong); font-weight: 700; }
.seller-recovery {
margin-top: 14px; padding: 16px; border: 1px solid var(--accent-border);
border-radius: var(--radius-card); background: var(--accent-soft);
}
.seller-recovery strong { display: block; margin-top: 3px; font-size: 0.9rem; }
.seller-recovery .note { color: var(--text-secondary); }
.seller-recovery .btn { margin-top: 14px; }
.recovery-coordinate {
display: block; margin-top: 5px; color: var(--text-muted);
font: 0.68rem/1.45 var(--font-scp, monospace); overflow-wrap: anywhere;
}
.choice-grid { display: grid; gap: 8px; margin-top: 8px; }
.choice-card { display: flex; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-button); cursor: pointer; }
.choice-card:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
Expand Down
Loading