From 863abbc94e8f3e82cc16c2b8cd77f27c32cc6799 Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 15:36:40 +0100 Subject: [PATCH 1/2] fix(directory): make listing publication restart-safe --- .../dacs-directory/README.md | 5 + .../app/api/dacs/build-listing/route.ts | 152 +++++++---- .../app/api/dacs/confirm-listing/route.ts | 80 ++++++ .../api/dacs/prepare-registration/route.ts | 34 +++ .../dacs-directory/app/register/page.tsx | 239 ++++++++++++++---- .../dacs-directory/e2e/register.spec.ts | 127 ++++++++++ .../dacs-directory/package.json | 2 +- .../dacs-directory/src/catalog/chain.ts | 139 +++++++++- .../listing-publication-recovery.ts | 83 ++++++ .../dacs-directory/test/build-listing.test.ts | 123 +++++++++ .../dacs-directory/test/chain-storage.test.ts | 116 +++++++++ .../test/confirm-listing.test.ts | 131 ++++++++++ .../test/listing-publication-recovery.test.ts | 64 +++++ .../test/prepare-registration.test.ts | 42 +++ 14 files changed, 1238 insertions(+), 99 deletions(-) create mode 100644 reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts create mode 100644 reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts create mode 100644 reference-implementations/dacs-directory/e2e/register.spec.ts create mode 100644 reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts create mode 100644 reference-implementations/dacs-directory/test/chain-storage.test.ts create mode 100644 reference-implementations/dacs-directory/test/confirm-listing.test.ts create mode 100644 reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts create mode 100644 reference-implementations/dacs-directory/test/prepare-registration.test.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 5fb8458..48b8e3a 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -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 | @@ -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 diff --git a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts index 2a73fa7..e94052f 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts @@ -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, @@ -41,9 +46,12 @@ async function accountNonce(addressHex: string): Promise { 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) { @@ -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 }); } @@ -233,60 +241,104 @@ export async function POST(req: NextRequest) { const hash = contentHash(listing as Record); 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 = listing; + let publishedHash = hash; + let tx: Record | 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; + 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 + : 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, diff --git a/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts new file mode 100644 index 0000000..cae346e --- /dev/null +++ b/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts @@ -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, + }); +} diff --git a/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts b/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts new file mode 100644 index 0000000..3f3efdc --- /dev/null +++ b/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts @@ -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 | 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, + }, + }, + }); +} diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index 7badae7..5744a85 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -1,8 +1,14 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useDemosWallet } from "@/src/components/useDemosWallet"; +import { + clearPendingListingPublication, + readPendingListingPublication, + writePendingListingPublication, + type PendingListingPublication, +} from "@/src/components/listing-publication-recovery"; import { negotiationPhaseForPricing, publishableRail, @@ -22,6 +28,20 @@ const SCREENS = ["Connect", "Describe", "Review", "Publish"]; type Screen = "connect" | "describe" | "review" | "publish" | "done"; type PublishStep = "idle" | "building" | "signing" | "anchoring" | "confirming" | "registering" | "failed" | "complete"; +type BuiltListing = { + listing: Record; + message?: string; + contentHash: string; + logicalAddress: string; + programName: string; + anchorAddress: string; + exists: boolean; + tx: Record | null; + registration: Record & { + ownerSignature?: { message?: string; signedAt?: number }; + }; +}; + export default function Register() { const wallet = useDemosWallet(); const [screen, setScreen] = useState("connect"); @@ -45,8 +65,11 @@ export default function Register() { const [publicEndpoint, setPublicEndpoint] = useState(""); const [status, setStatus] = useState(null); const [profileUrl, setProfileUrl] = useState(null); + const [pendingPublication, setPendingPublication] = useState(null); - const claim = wallet.address ? `did:demos:agent:${wallet.address.replace(/^0x/, "")}` : null; + useEffect(() => { setPendingPublication(readPendingListingPublication(window.localStorage)); }, []); + + const claim = wallet.address ? `did:demos:agent:${wallet.address.replace(/^0x/, "").toLowerCase()}` : null; const slug = serviceId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); const selectedRail = publishableRail(rails[0] ?? ""); const validDescription = name.trim() && description.trim() && slug && selectedRail && delivery && @@ -66,8 +89,109 @@ export default function Register() { : { kind: pricingKind, price: priceTermPreview }; const negotiationPhase = negotiationPhaseForPricing(pricingKind); + const savePending = (pending: PendingListingPublication): boolean => { + const saved = writePendingListingPublication(window.localStorage, pending); + if (saved) setPendingPublication(pending); + return saved; + }; + + const confirmAnchoredListing = async (pending: PendingListingPublication): Promise => { + setPublishStep("confirming"); + setStatus("Waiting for the exact signed listing to become readable and independently verifiable…"); + for (let attempt = 0; attempt < 20; attempt++) { + const response = await fetch("/api/dacs/confirm-listing", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + anchorAddress: pending.anchorAddress, + programName: pending.programName, + contentHash: pending.contentHash, + sellerClaim: pending.claim, + listingId: pending.listingId, + listingVersion: pending.listingVersion, + }), + }); + const body = await response.json(); + if (response.ok && body.confirmed === true) return; + if (response.status !== 202) { + throw new Error(body.error ?? "The anchored listing failed independent verification."); + } + if (attempt < 19) await new Promise((resolve) => setTimeout(resolve, 2500)); + } + throw new Error("The exact listing is not visible yet. No new transaction was sent; use Check chain and resume to follow this same anchor."); + }; + + const registerPendingListing = async (pending: PendingListingPublication): Promise => { + const registering = { ...pending, stage: "registering" as const }; + if (!savePending(registering)) { + throw new Error("This browser cannot preserve the listing recovery record, so directory registration was not attempted."); + } + setPublishStep("registering"); + setStatus("One final wallet signature connects this verified listing to the directory."); + const prepared = await fetch("/api/dacs/prepare-registration", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(registering.registration), + }); + const preparedBody = await prepared.json(); + if (!prepared.ok) throw new Error(preparedBody.error ?? "Could not prepare the directory registration."); + const registration = preparedBody.registration as Record & { + ownerSignature?: { message?: string; signedAt?: number }; + }; + const message = registration.ownerSignature?.message; + if (!message) throw new Error("The directory returned no registration signing message."); + const registrationSignature = await wallet.sign(message); + if (!registrationSignature) throw new Error(wallet.error ?? "The directory registration signature was declined."); + const signedRegistration = { + ...registration, + ownerSignature: { + ...registration.ownerSignature, + signature: registrationSignature.replace(/^(0x)+/i, ""), + }, + }; + const registered = await fetch("/api/dacs/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(signedRegistration), + }); + const registeredBody = await registered.json(); + if (!registered.ok) throw new Error(registeredBody.error ?? "Directory registration failed."); + + clearPendingListingPublication(window.localStorage); + setPendingPublication(null); + setPublishStep("complete"); + setStatus("Your signed listing is anchored, independently verified, and queued for the next index pass."); + setProfileUrl(`/seller/${encodeURIComponent(pending.claim)}`); + setScreen("done"); + }; + + const finishPendingPublication = async (pending: PendingListingPublication): Promise => { + await confirmAnchoredListing(pending); + const confirmed = { ...pending, stage: "registering" as const }; + if (!savePending(confirmed)) { + throw new Error("The listing verified, but this browser cannot preserve its recovery record; directory registration was not attempted."); + } + await registerPendingListing(confirmed); + }; + + const resumePublication = async () => { + if (!claim || !pendingPublication || pendingPublication.claim !== claim) return; + setScreen("publish"); + setStatus(null); setFailedAt(null); + const activeStep: PublishStep = "confirming"; + try { + // Re-verify on every resume even when the prior browser session had + // already reached registration; persisted client state is only a hint. + await finishPendingPublication(pendingPublication); + } catch (error) { + setFailedAt(activeStep); + setPublishStep("failed"); + setStatus((error as Error).message); + } + }; + const publish = async () => { - if (!claim || !validDescription) return; + if (!claim || !validDescription || pendingPublication) return; setScreen("publish"); setStatus(null); setFailedAt(null); let activeStep: PublishStep = "building"; @@ -105,48 +229,64 @@ export default function Register() { }); const built = await build.json(); if (!build.ok) throw new Error(built.error); - setStatus("Now approve the complete structured listing."); - const signature = await wallet.sign(built.message); - if (!signature) throw new Error(wallet.error ?? "The listing signature was declined."); - const signedListing = { - ...built.listing, - signature: { algorithm: "ed25519", signer: claim, value: signature.replace(/^(0x)+/i, "") }, + const publication = built as BuiltListing; + const { ownerSignature: _ownerSignature, ...unsignedRegistration } = publication.registration; + let signedListing = publication.listing; + let transaction = publication.tx; + if (!publication.exists) { + if (!publication.message || !transaction) throw new Error("The listing builder returned an incomplete create transaction."); + 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."); + signedListing = { + ...publication.listing, + signature: { algorithm: "ed25519", signer: claim, value: signature.replace(/^(0x)+/i, "") }, + }; + transaction = structuredClone(transaction); + const content = transaction.content as Record | undefined; + const data = content?.data; + if (!Array.isArray(data) || !data[1] || typeof data[1] !== "object" || Array.isArray(data[1])) { + throw new Error("The listing builder returned an invalid StorageProgram transaction."); + } + (data[1] as Record).data = signedListing; + } + const listingVersion = Number(signedListing.listingVersion); + if (!Number.isSafeInteger(listingVersion) || listingVersion < 1) { + throw new Error("The listing builder returned an invalid listing version."); + } + let pending: PendingListingPublication = { + version: 1, + claim, + listingId: slug, + listingVersion, + anchorAddress: publication.anchorAddress, + programName: publication.programName, + contentHash: publication.contentHash, + signedListing, + transaction, + registration: unsignedRegistration, + stage: publication.exists ? "confirming" : "broadcast-uncertain", + createdAt: Date.now(), }; - - activeStep = "anchoring"; setPublishStep("anchoring"); - setStatus("Approve the on-chain anchor transaction."); - built.tx.content.data[1].data = signedListing; - const sent = await wallet.send(built.tx); - if (!sent) throw new Error(wallet.error ?? "The anchor transaction was declined."); - - activeStep = "confirming"; setPublishStep("confirming"); - setStatus("The transaction was sent. Waiting for the listing to become readable…"); - let confirmed = false; - for (let attempt = 0; attempt < 20; attempt++) { - const probe = await fetch(`/api/dacs/artifact?ref=${encodeURIComponent(built.anchorAddress)}`).then((response) => response.json()); - if (probe.value) { confirmed = true; break; } - await new Promise((resolve) => setTimeout(resolve, 2500)); + if (!savePending(pending)) { + throw new Error("This browser cannot durably save the listing recovery record, so no on-chain transaction was sent."); } - if (!confirmed) throw new Error("The anchor is not visible yet. Your transaction may still confirm; retry publishing without re-entering the form."); - activeStep = "registering"; setPublishStep("registering"); - setStatus("One final wallet signature connects this listing to the directory."); - const registrationSignature = await wallet.sign(built.registration.ownerSignature.message); - if (!registrationSignature) throw new Error(wallet.error ?? "The directory registration signature was declined."); - const registration = { - ...built.registration, - ownerSignature: { ...built.registration.ownerSignature, signature: registrationSignature.replace(/^(0x)+/i, "") }, - }; - const registered = await fetch("/api/dacs/register", { - method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(registration), - }); - const registeredBody = await registered.json(); - if (!registered.ok) throw new Error(registeredBody.error ?? "Directory registration failed."); + if (!publication.exists) { + activeStep = "anchoring"; setPublishStep("anchoring"); + setStatus("Approve the on-chain anchor transaction. Its recovery coordinates are already saved in this browser."); + const sent = await wallet.send(transaction); + if (!sent) throw new Error(wallet.error ?? "The anchor transaction was not acknowledged; check this same anchor before trying anything else."); + pending = { ...pending, stage: "confirming" }; + if (!savePending(pending)) { + throw new Error("The transaction was sent, but this browser could not update its recovery record. Do not publish again; preserve this page and check the anchor."); + } + } else { + setStatus("Recovered the existing immutable listing version; no new chain transaction will be sent."); + } - setPublishStep("complete"); - setStatus("Your signed listing is anchored and queued for the next index pass."); - setProfileUrl(`/seller/${encodeURIComponent(claim)}`); - setScreen("done"); + activeStep = "confirming"; + await finishPendingPublication(pending); } catch (error) { setFailedAt(activeStep); setPublishStep("failed"); @@ -172,7 +312,16 @@ export default function Register() { {wallet.address ? ( <>
connected{wallet.address.slice(0, 22)}…
- + {pendingPublication?.claim === claim ? ( +
+

A listing publication from this browser is still unresolved. Resume its exact saved anchor; starting another transaction could create a duplicate version.

+ +
+ ) : pendingPublication ? ( +

This browser has an unresolved listing for a different Demos wallet. Reconnect that wallet to recover it before publishing another listing.

+ ) : ( + + )} ) : wallet.available ? ( @@ -263,7 +412,11 @@ export default function Register() { {status &&

{status}

} - {publishStep === "failed" &&
} + {publishStep === "failed" && pendingPublication ? ( +
+ ) : publishStep === "failed" ? ( +
+ ) : null} {screen === "done" && profileUrl &&
View seller profileBrowse directory
} )} diff --git a/reference-implementations/dacs-directory/e2e/register.spec.ts b/reference-implementations/dacs-directory/e2e/register.spec.ts new file mode 100644 index 0000000..f724f86 --- /dev/null +++ b/reference-implementations/dacs-directory/e2e/register.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Route } from "@playwright/test"; +import { LISTING_PUBLICATION_KEY } from "../src/components/listing-publication-recovery.js"; + +const keyHex = "12".repeat(32); +const claim = `did:demos:agent:${keyHex}`; +const anchorAddress = `stor-${"34".repeat(20)}`; +const programName = "dacs1-ZGFjczEtdGVzdA"; +const contentHash = "56".repeat(32); + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + headers: { "access-control-allow-origin": "*" }, + }); +} + +test("seller publication survives registration failure and reload without rebroadcasting", async ({ context, page }) => { + await page.addInitScript((address) => { + const calls: Array<{ method: string; params?: unknown[] }> = []; + Object.assign(window, { + __sellerWalletCalls: calls, + demos: { + request: async (request: { method: string; params?: unknown[] }) => { + calls.push(request); + if (request.method === "connect") return { success: true, data: { address } }; + if (request.method === "sign") return { success: true, data: { signature: "ab".repeat(64) } }; + if (request.method === "sendTransaction") return { success: true, data: { hash: "mock-listing-tx" } }; + throw new Error(`unexpected wallet method ${request.method}`); + }, + }, + }); + }, `0x${keyHex}`); + + const unsignedRegistration = { + primaryClaim: claim, + displayName: "Recovery service", + listingAnchors: [anchorAddress], + deals: [], + }; + let registrationPosts = 0; + let confirmationPosts = 0; + await context.route("**/api/dacs/build-listing", async (route) => { + const input = route.request().postDataJSON() as { identitySignature?: string }; + if (!input.identitySignature) { + return json(route, { identityMessage: "identity-message", identityPresentedAt: 1_786_360_000_000 }); + } + return json(route, { + listing: { dacsVersion: "1", listingId: "recovery-service", listingVersion: 1 }, + message: "listing-message", + contentHash, + logicalAddress: `dacs1:did%3Ademos%3Aagent%3A${keyHex}:recovery-service:v1`, + programName, + anchorAddress, + exists: false, + tx: { + content: { + type: "storageProgram", + data: ["storageProgram", { operation: "CREATE_STORAGE_PROGRAM", data: "__SIGNED_LISTING__", salt: "" }], + nonce: 8, + }, + }, + registration: { + ...unsignedRegistration, + ownerSignature: { message: "initial-registration-message", signedAt: 1_786_360_000_000 }, + }, + }); + }); + await context.route("**/api/dacs/confirm-listing", async (route) => { + confirmationPosts++; + return json(route, { confirmed: true, state: "verified" }); + }); + await context.route("**/api/dacs/prepare-registration", (route) => json(route, { + registration: { + ...unsignedRegistration, + ownerSignature: { message: `registration-message-${registrationPosts + 1}`, signedAt: Date.now() }, + }, + })); + await context.route("**/api/dacs/register", async (route) => { + registrationPosts++; + return registrationPosts === 1 + ? json(route, { error: "temporary registry write failure" }, 503) + : json(route, { ok: true, ownerVerified: true, queued: true }); + }); + + await page.goto("/register"); + await page.getByRole("button", { name: "Connect Demos wallet" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByLabel("Service title").fill("Recovery service"); + await page.getByLabel("What the buyer receives").fill("A signed result with restart-safe publication recovery."); + await page.getByLabel("Service ID").fill("recovery-service"); + await page.getByLabel("Fixed amount").fill("1"); + await page.getByRole("button", { name: "Review listing" }).click(); + await page.getByRole("button", { name: "Sign and publish" }).click(); + + await expect(page.getByText("temporary registry write failure", { exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => ( + (window as unknown as { __sellerWalletCalls: Array<{ method: string }> }).__sellerWalletCalls + .filter((call) => call.method === "sendTransaction").length + ))).toBe(1); + const sentPayload = await page.evaluate(() => { + const calls = (window as unknown as { + __sellerWalletCalls: Array<{ method: string; params?: unknown[] }>; + }).__sellerWalletCalls; + return calls.find((call) => call.method === "sendTransaction")?.params?.[0] as { + content?: { data?: [string, { salt?: string; data?: { signature?: { signer?: string } } }] }; + }; + }); + expect(sentPayload.content?.data?.[1].salt).toBe(""); + expect(sentPayload.content?.data?.[1].data?.signature?.signer).toBe(claim); + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), LISTING_PUBLICATION_KEY)).not.toBeNull(); + + await page.reload(); + await page.getByRole("button", { name: "Connect Demos wallet" }).click(); + await expect(page.getByText(/listing publication from this browser is still unresolved/)).toBeVisible(); + await page.getByRole("button", { name: "Check chain and resume" }).click(); + + await expect(page.getByText(/anchored, independently verified, and queued/)).toBeVisible(); + await expect.poll(() => page.evaluate(() => ( + (window as unknown as { __sellerWalletCalls: Array<{ method: string }> }).__sellerWalletCalls + .filter((call) => call.method === "sendTransaction").length + ))).toBe(0); + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), LISTING_PUBLICATION_KEY)).toBeNull(); + expect(registrationPosts).toBe(2); + expect(confirmationPosts).toBe(2); +}); diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index 0b6bf83..f3937b4 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -13,7 +13,7 @@ "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", "test": "tsx --test test/*.test.ts test/*.test.mjs", - "test:e2e": "playwright test e2e/home.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", + "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", "test:e2e:ui": "playwright test --ui", "test:seed": "tsx --test test/seed-smoke.test.ts", diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index 50976a7..fce0af5 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -3,7 +3,9 @@ * * Address derivation reproduces Demos StorageProgram's documented * sha256(deployer:name:nonce:salt) mapping using the already-vendored DACS - * canonical primitive. Storage-program READS stay a plain + * canonical primitive. New writes use the live empty-salt convention; the + * legacy helper remains available only for old nonce-0/`dacs:v1` fallbacks. + * Storage-program READS stay a plain * unauthenticated HTTP GET (`/storage-program/{address}`): that is the read * path the StorageProgram API prescribes. The app stays free of the Demos * client's unrelated multichain dependency tree. @@ -11,13 +13,24 @@ import { sha256Hex } from "@kynesyslabs/dacs/canonical"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); +export const LIVE_STORAGE_SALT = ""; +export const LEGACY_STORAGE_SALT = "dacs:v1"; + +function normalizedOwner(value: string): string | null { + const hex = value.match(/([0-9a-fA-F]{64})$/)?.[1]; + return hex ? `0x${hex.toLowerCase()}` : null; +} + +/** Derive a native address from the exact producer-held write inputs. */ +export function deriveStorageAddress(owner: string, name: string, nonce: number, salt: string): string { + const deployer = normalizedOwner(owner) ?? owner; + return `stor-${sha256Hex(`${deployer}:${name}:${nonce}:${salt}`).slice(0, 40)}`; +} // Callers should supply the observed transaction nonce. The default exists // only for reading anchors produced by the legacy nonce-0 DACS SDK. export function deriveAnchorAddress(owner: string, name: string, nonce = 0): string { - const hex = owner.match(/([0-9a-fA-F]{64})$/)?.[1]; - const deployer = hex ? `0x${hex}` : owner; - return `stor-${sha256Hex(`${deployer}:${name}:${nonce}:dacs:v1`).slice(0, 40)}`; + return deriveStorageAddress(owner, name, nonce, LEGACY_STORAGE_SALT); } /** Read an anchored artifact (null if absent / non-public). */ @@ -41,7 +54,7 @@ export async function readAnchorRecord(address: string): Promise | null> { return (await readAnchorRecord(address))?.data ?? null; } + +export type OwnedAnchorResolution = + | { status: "present"; address: string; record: AnchorRecord } + | { status: "absent" } + | { status: "indeterminate"; reason: string }; + +type ProgramCandidate = { storageAddress?: unknown; programName?: unknown }; + +/** + * Producer-side resume lookup for one exact StorageProgram name. + * + * A native address includes the create-time nonce and cannot be recomputed on + * retry. Search results are therefore owner-confirmed with fresh reads. Any + * failed read, invalid candidate, or duplicate owned program is indeterminate: + * callers MUST NOT turn that uncertainty into another create transaction. + */ +export async function resolveOwnedAnchorByName( + programName: string, + expectedOwner: string, +): Promise { + if (!programName || programName.length > 512) { + return { status: "indeterminate", reason: "program name is invalid" }; + } + 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: "", + }], + }), + }); + } 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 response.json() 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 (candidates.length >= 32) { + return { status: "indeterminate", reason: "program-name lookup reached its candidate bound" }; + } + + const exact = candidates.filter((candidate) => candidate.programName === programName); + const records: Array<{ address: string; record: AnchorRecord }> = []; + for (const candidate of exact) { + if (typeof candidate.storageAddress !== "string" || !/^stor-[0-9a-f]{40}$/.test(candidate.storageAddress)) { + return { status: "indeterminate", reason: "program-name lookup returned an invalid candidate" }; + } + let read: Response; + try { + read = await fetch(`${RPC}/storage-program/${candidate.storageAddress}`, { + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + } catch { + return { status: "indeterminate", reason: "a candidate could not be read to confirm ownership" }; + } + if (!read.ok) { + return { status: "indeterminate", reason: "a candidate could not be read to confirm ownership" }; + } + let record: AnchorRecord | null = null; + try { + const body = await read.json() as { + success?: boolean; + data?: Record; + owner?: string; + programName?: string; + }; + if ( + body.success && body.data != null && typeof body.data === "object" && !Array.isArray(body.data) && + body.programName === programName + ) { + record = { data: body.data, owner: body.owner, programName: body.programName }; + } + } catch { /* classified below */ } + if (!record) { + return { status: "indeterminate", reason: "a candidate returned invalid storage metadata" }; + } + if (normalizedOwner(record.owner ?? "") === owner) { + records.push({ address: candidate.storageAddress, record }); + } + } + + if (records.length > 1) { + return { status: "indeterminate", reason: "multiple owner-bound programs use the same name" }; + } + return records[0] + ? { status: "present", address: records[0].address, record: records[0].record } + : { status: "absent" }; +} diff --git a/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts new file mode 100644 index 0000000..e53a271 --- /dev/null +++ b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts @@ -0,0 +1,83 @@ +export const LISTING_PUBLICATION_KEY = "dacs-register:pending-publication"; + +export type PendingPublicationStage = "broadcast-uncertain" | "confirming" | "registering"; + +export type PendingListingPublication = { + version: 1; + claim: string; + listingId: string; + listingVersion: number; + anchorAddress: string; + programName: string; + contentHash: string; + signedListing: Record; + transaction: Record | null; + registration: Record; + stage: PendingPublicationStage; + createdAt: number; +}; + +const record = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + +export function parsePendingListingPublication(value: unknown): PendingListingPublication | null { + const pending = record(value); + const signedListing = record(pending?.signedListing); + const registration = record(pending?.registration); + const transaction = pending?.transaction === null ? null : record(pending?.transaction); + if ( + pending?.version !== 1 || + typeof pending.claim !== "string" || !/^did:demos:agent:[0-9a-f]{64}$/.test(pending.claim) || + typeof pending.listingId !== "string" || !/^[a-z0-9-]{1,64}$/.test(pending.listingId) || + !Number.isSafeInteger(pending.listingVersion) || Number(pending.listingVersion) < 1 || + typeof pending.anchorAddress !== "string" || !/^stor-[0-9a-f]{40}$/.test(pending.anchorAddress) || + typeof pending.programName !== "string" || !pending.programName || pending.programName.length > 512 || + typeof pending.contentHash !== "string" || !/^[0-9a-f]{64}$/.test(pending.contentHash) || + !signedListing || + !registration || registration.primaryClaim !== pending.claim || + !Array.isArray(registration.listingAnchors) || !registration.listingAnchors.includes(pending.anchorAddress) || + (pending.transaction !== null && !transaction) || + (pending.stage !== "broadcast-uncertain" && pending.stage !== "confirming" && pending.stage !== "registering") || + !Number.isSafeInteger(pending.createdAt) || Number(pending.createdAt) <= 0 + ) return null; + + return { + version: 1, + claim: pending.claim, + listingId: pending.listingId, + listingVersion: Number(pending.listingVersion), + anchorAddress: pending.anchorAddress, + programName: pending.programName, + contentHash: pending.contentHash, + signedListing, + transaction, + registration, + stage: pending.stage, + createdAt: Number(pending.createdAt), + }; +} + +export function readPendingListingPublication(storage: Pick): PendingListingPublication | null { + try { + const raw = storage.getItem(LISTING_PUBLICATION_KEY); + return raw ? parsePendingListingPublication(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +export function writePendingListingPublication( + storage: Pick, + pending: PendingListingPublication, +): boolean { + try { + storage.setItem(LISTING_PUBLICATION_KEY, JSON.stringify(pending)); + return true; + } catch { + return false; + } +} + +export function clearPendingListingPublication(storage: Pick): void { + try { storage.removeItem(LISTING_PUBLICATION_KEY); } catch { /* best effort */ } +} diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index a40766b..12b8293 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -6,6 +6,7 @@ import test from "node:test"; import { NextRequest } from "next/server"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { deriveStorageAddress, LIVE_STORAGE_SALT } from "../src/catalog/chain.js"; import { verifyListing } from "../src/catalog/listingVerification.js"; const dataDir = mkdtempSync(join(tmpdir(), "dacs-build-listing-")); @@ -57,6 +58,10 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind const originalFetch = globalThis.fetch; globalThis.fetch = async (_input, init) => { if (init?.method === "POST") { + const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }; + if (rpc.params?.[0]?.message === "searchStoragePrograms") { + return Response.json({ result: 200, response: [] }); + } return new Response(JSON.stringify({ result: 200, response: 0 }), { status: 200, headers: { "content-type": "application/json" }, @@ -77,6 +82,12 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind const built = await response.json() as { listing: Record; message: string; + contentHash: string; + logicalAddress: string; + programName: string; + anchorAddress: string; + exists: boolean; + tx: { content: { nonce: number; data: [string, Record] } }; }; assert.deepEqual(built.listing.pricing, { kind: "metered", @@ -91,6 +102,16 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind { kind: "pay-ap2", parameters: { rail: "ap2:stripe-paymentintents" } }, { kind: "deliver-attested-payload" }, ]); + assert.equal(built.exists, false); + assert.equal(built.logicalAddress, `dacs1:did%3Ademos%3Aagent%3A${keyHex}:metered-ap2:v1`); + assert.ok(!built.programName.includes(":"), "the producer-held Demos name must be colon-free"); + assert.equal(built.tx.content.nonce, 1); + assert.equal(built.tx.content.data[1].salt, "", "SDK #70 uses the live empty-salt convention"); + assert.equal( + built.anchorAddress, + deriveStorageAddress(`0x${keyHex}`, built.programName, 1, LIVE_STORAGE_SALT), + ); + assert.equal(built.contentHash.length, 64); const listingSignature = Buffer.from( ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), @@ -104,6 +125,81 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind } }); +test("publisher recovers an immutable owner-bound listing instead of creating a duplicate", async () => { + const identityResponse = await POST(request(base)); + const identity = await identityResponse.json() as { identityMessage: string; identityPresentedAt: number }; + const identitySignature = Buffer.from( + ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey), + ).toString("hex"); + const finalInput = { ...base, identityPresentedAt: identity.identityPresentedAt, identitySignature }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + if (init?.method === "POST") { + const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }; + return rpc.params?.[0]?.message === "searchStoragePrograms" + ? Response.json({ result: 200, response: [] }) + : Response.json({ result: 200, response: 6 }); + } + return new Response(null, { status: 404 }); + }; + try { + const builtResponse = await POST(request(finalInput)); + assert.equal(builtResponse.status, 200); + const built = await builtResponse.json() as { + listing: Record; + message: string; + contentHash: string; + programName: string; + anchorAddress: string; + }; + const listingSignature = Buffer.from( + ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), + ).toString("hex"); + const signedListing = { + ...built.listing, + signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, + }; + + globalThis.fetch = async (input, init) => { + if (init?.method === "POST") { + const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }; + assert.equal(rpc.params?.[0]?.message, "searchStoragePrograms", "recovery must not request a fresh write nonce"); + return Response.json({ + result: 200, + response: [{ storageAddress: built.anchorAddress, programName: built.programName }], + }); + } + assert.match(String(input), new RegExp(`${built.anchorAddress}$`)); + return Response.json({ + success: true, + data: signedListing, + owner: `0x${keyHex}`, + programName: built.programName, + }); + }; + + const recoveredResponse = await POST(request(finalInput)); + assert.equal(recoveredResponse.status, 200); + const recovered = await recoveredResponse.json() as { + exists: boolean; + tx: unknown; + message?: string; + listing: Record; + contentHash: string; + anchorAddress: string; + }; + assert.equal(recovered.exists, true); + assert.equal(recovered.tx, null); + assert.equal(recovered.message, undefined); + assert.equal(recovered.anchorAddress, built.anchorAddress); + assert.equal(recovered.contentHash, built.contentHash); + assert.deepEqual(recovered.listing, signedListing); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("publisher rejects a metered listing without its deterministic unit", async () => { const response = await POST(request({ ...base, @@ -112,3 +208,30 @@ test("publisher rejects a metered listing without its deterministic unit", async assert.equal(response.status, 400); assert.match(String((await response.json()).error), /metered pricing needs a unit/); }); + +test("publisher refuses a new write when existing-publication lookup is indeterminate", async () => { + const identityResponse = await POST(request(base)); + const identity = await identityResponse.json() as { identityMessage: string; identityPresentedAt: number }; + const identitySignature = Buffer.from( + ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey), + ).toString("hex"); + + let calls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + calls += 1; + return new Response("temporarily unavailable", { status: 503 }); + }; + try { + const response = await POST(request({ + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature, + })); + assert.equal(response.status, 503); + assert.match(String((await response.json()).error), /could not safely determine/); + assert.equal(calls, 1, "an indeterminate search must not fall through to a nonce request"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/chain-storage.test.ts b/reference-implementations/dacs-directory/test/chain-storage.test.ts new file mode 100644 index 0000000..19e2d74 --- /dev/null +++ b/reference-implementations/dacs-directory/test/chain-storage.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveAnchorAddress, + deriveStorageAddress, + LIVE_STORAGE_SALT, + resolveOwnedAnchorByName, +} from "../src/catalog/chain.js"; + +const owner = `0x${"12".repeat(32)}`; +const programName = "dacs1-ZGFjczE"; + +test("live StorageProgram derivation matches SDK #70 and preserves legacy fallback", () => { + assert.equal( + deriveStorageAddress(owner, programName, 42, LIVE_STORAGE_SALT), + "stor-225d925e0427753fdea2a5e5ac040d58e7c47ac3", + ); + assert.equal( + deriveAnchorAddress(owner, programName, 42), + "stor-5fa6216130a97190c3b4e1bc2b66f4d3b8738fee", + ); +}); + +test("producer resume lookup binds an exact program name to exactly one owner", async () => { + const honest = "stor-1111111111111111111111111111111111111111"; + const squatter = "stor-2222222222222222222222222222222222222222"; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + if (init?.method === "POST") { + const request = JSON.parse(String(init.body)) as { params: Array<{ data: { options: Record } }> }; + assert.deepEqual(request.params[0]?.data.options, { exactMatch: true, limit: 32, offset: 0 }); + return Response.json({ + result: 200, + response: [ + { storageAddress: squatter, programName }, + { storageAddress: honest, programName }, + { storageAddress: "stor-3333333333333333333333333333333333333333", programName: `${programName}-suffix` }, + ], + }); + } + const address = String(input).split("/").at(-1); + return Response.json({ + success: true, + data: { listingId: "service" }, + owner: address === honest ? owner.toUpperCase() : `0x${"34".repeat(32)}`, + programName, + }); + }; + try { + const resolution = await resolveOwnedAnchorByName(programName, owner); + assert.equal(resolution.status, "present"); + if (resolution.status === "present") { + assert.equal(resolution.address, honest); + assert.equal(resolution.record.programName, programName); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("producer resume lookup fails closed on lookup, read, and duplicate ambiguity", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => new Response("unavailable", { status: 503 }); + assert.equal((await resolveOwnedAnchorByName(programName, owner)).status, "indeterminate"); + + globalThis.fetch = async (_input, init) => init?.method === "POST" + ? Response.json({ result: 200, response: [{ storageAddress: `stor-${"4".repeat(40)}`, programName }] }) + : new Response("unavailable", { status: 503 }); + assert.equal((await resolveOwnedAnchorByName(programName, owner)).status, "indeterminate"); + + globalThis.fetch = async (input, init) => init?.method === "POST" + ? Response.json({ + result: 200, + response: [ + { storageAddress: `stor-${"5".repeat(40)}`, programName }, + { storageAddress: `stor-${"6".repeat(40)}`, programName }, + ], + }) + : Response.json({ success: true, data: {}, owner, programName, ref: String(input) }); + const duplicate = await resolveOwnedAnchorByName(programName, owner); + assert.equal(duplicate.status, "indeterminate"); + if (duplicate.status === "indeterminate") assert.match(duplicate.reason, /multiple owner-bound/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("producer resume lookup reports a proven absence", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ result: 200, response: [] }); + try { + assert.deepEqual(await resolveOwnedAnchorByName(programName, owner), { status: "absent" }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("producer resume lookup fails closed when the bounded result page is full", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ + result: 200, + response: Array.from({ length: 32 }, (_, index) => ({ + storageAddress: `stor-${index.toString(16).padStart(40, "0")}`, + programName, + })), + }); + try { + const resolution = await resolveOwnedAnchorByName(programName, owner); + assert.equal(resolution.status, "indeterminate"); + if (resolution.status === "indeterminate") assert.match(resolution.reason, /candidate bound/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts new file mode 100644 index 0000000..37882e2 --- /dev/null +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { NextRequest } from "next/server"; + +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; + +const { POST } = await import("../app/api/dacs/confirm-listing/route.js"); + +const privateKey = privateKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))); +const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))))).toString("hex"); +const sellerClaim = `did:demos:agent:${keyHex}`; +const owner = `0x${keyHex}`; +const anchorAddress = `stor-${"ab".repeat(20)}`; +const programName = "dacs1-ZGFjczEtdmVyaWZpZWQ"; +const identity = { + bundleVersion: "1", + presentedBy: sellerClaim, + presentedAt: Date.now(), + claims: [{ ref: sellerClaim }], +}; +const identitySignature = Buffer.from(ed25519Sign( + Buffer.from(`dacs-bundle-presentation:v1:${contentHash(identity)}`, "utf8"), + privateKey, +)).toString("hex"); +const scope = { + dacsVersion: "1", + listingId: "verified-service", + listingVersion: 1, + requiredCapabilities: ["SR-2"], + seller: { + identity: { + ...identity, + presentation: { kind: "per-claim", signatures: [{ ref: sellerClaim, signature: identitySignature }] }, + }, + displayName: "Verified seller", + }, + offering: { + title: "Verified service", + description: "A deterministic verified service.", + category: "services.other", + tags: [], + deliverable: { kind: "attested-payload", payloadFormat: "application/json" }, + }, + buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-dem", parameters: { rail: "pay-dem" } }, + { kind: "deliver-attested-payload" }, + ], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM", unit: "per-job" } }, + acceptedRails: [{ railId: "pay-dem" }], + terms: {}, + validity: { notBefore: identity.presentedAt }, +}; +const listingHash = contentHash(scope); +const listing = { + ...scope, + signature: { + algorithm: "ed25519", + signer: sellerClaim, + value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("hex"), + }, +}; +const coordinates = { + anchorAddress, + programName, + contentHash: listingHash, + sellerClaim, + listingId: scope.listingId, + listingVersion: scope.listingVersion, +}; +const request = (body: Record) => new NextRequest( + "https://directory.example/api/dacs/confirm-listing", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, +); + +test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ success: true, data: listing, owner, programName }); + try { + const response = await POST(request(coordinates)); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + confirmed: true, + state: "verified", + anchorAddress, + contentHash: listingHash, + sellerClaim, + listingId: scope.listingId, + listingVersion: 1, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation distinguishes pending visibility from binding failure", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => new Response("not found", { status: 404 }); + const pending = await POST(request(coordinates)); + assert.equal(pending.status, 202); + assert.equal((await pending.json()).state, "not-visible"); + + globalThis.fetch = async () => Response.json({ + success: true, + data: listing, + owner: `0x${"cd".repeat(32)}`, + programName, + }); + const mismatch = await POST(request(coordinates)); + assert.equal(mismatch.status, 409); + assert.equal((await mismatch.json()).state, "binding-mismatch"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation rejects valid bytes under the wrong expected content hash", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({ success: true, data: listing, owner, programName }); + try { + const response = await POST(request({ ...coordinates, contentHash: "ef".repeat(32) })); + assert.equal(response.status, 409); + assert.equal((await response.json()).state, "verification-failed"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts b/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts new file mode 100644 index 0000000..b5bc1aa --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + LISTING_PUBLICATION_KEY, + clearPendingListingPublication, + parsePendingListingPublication, + readPendingListingPublication, + writePendingListingPublication, + type PendingListingPublication, +} from "../src/components/listing-publication-recovery.js"; + +const claim = `did:demos:agent:${"12".repeat(32)}`; +const anchorAddress = `stor-${"34".repeat(20)}`; +const pending: PendingListingPublication = { + version: 1, + claim, + listingId: "code-review", + listingVersion: 1, + anchorAddress, + programName: "dacs1-ZGFjczE", + contentHash: "56".repeat(32), + signedListing: { dacsVersion: "1", listingId: "code-review", listingVersion: 1, signature: {} }, + transaction: { content: { nonce: 4 } }, + registration: { primaryClaim: claim, displayName: "Code reviewer", listingAnchors: [anchorAddress], deals: [] }, + stage: "broadcast-uncertain", + createdAt: 1_786_360_000_000, +}; + +test("listing publication recovery records round-trip and clear", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + removeItem: (key: string) => { values.delete(key); }, + }; + assert.equal(writePendingListingPublication(storage, pending), true); + assert.deepEqual(readPendingListingPublication(storage), pending); + clearPendingListingPublication(storage); + assert.equal(values.has(LISTING_PUBLICATION_KEY), false); +}); + +test("listing publication recovery fails closed on malformed or cross-bound state", () => { + assert.equal(parsePendingListingPublication({ ...pending, version: 2 }), null); + assert.equal(parsePendingListingPublication({ ...pending, claim: `did:demos:agent:${"AB".repeat(32)}` }), null); + assert.equal(parsePendingListingPublication({ ...pending, listingVersion: 0 }), null); + assert.equal(parsePendingListingPublication({ ...pending, contentHash: "not-a-hash" }), null); + assert.equal(parsePendingListingPublication({ + ...pending, + registration: { ...pending.registration, primaryClaim: `did:demos:agent:${"78".repeat(32)}` }, + }), null); + assert.equal(parsePendingListingPublication({ + ...pending, + registration: { ...pending.registration, listingAnchors: [] }, + }), null); + assert.equal(parsePendingListingPublication({ ...pending, transaction: [] }), null); +}); + +test("storage failures refuse unsafe publication persistence", () => { + const brokenWrite = { setItem: () => { throw new Error("quota"); } }; + const brokenRead = { getItem: () => "{" }; + assert.equal(writePendingListingPublication(brokenWrite, pending), false); + assert.equal(readPendingListingPublication(brokenRead), null); +}); diff --git a/reference-implementations/dacs-directory/test/prepare-registration.test.ts b/reference-implementations/dacs-directory/test/prepare-registration.test.ts new file mode 100644 index 0000000..2108c24 --- /dev/null +++ b/reference-implementations/dacs-directory/test/prepare-registration.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { NextRequest } from "next/server"; + +import { registrationMessage } from "../src/catalog/registrationSig.js"; + +const { POST } = await import("../app/api/dacs/prepare-registration/route.js"); +const registration = { + primaryClaim: `did:demos:agent:${"12".repeat(32)}`, + displayName: "Recoverable seller", + listingAnchors: [`stor-${"34".repeat(20)}`], + deals: [], +}; +const request = (body: Record) => new NextRequest( + "https://directory.example/api/dacs/prepare-registration", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, +); + +test("prepare-registration issues a fresh content-bound signing message", async () => { + const before = Date.now(); + const response = await POST(request(registration)); + const after = Date.now(); + assert.equal(response.status, 200); + const body = await response.json() as { + registration: typeof registration & { ownerSignature: { message: string; signedAt: number } }; + }; + assert.ok(body.registration.ownerSignature.signedAt >= before); + assert.ok(body.registration.ownerSignature.signedAt <= after); + assert.equal( + body.registration.ownerSignature.message, + registrationMessage(registration, body.registration.ownerSignature.signedAt), + ); +}); + +test("prepare-registration rejects a caller-supplied owner signature", async () => { + const response = await POST(request({ + ...registration, + ownerSignature: { message: "attacker", signature: "00", signedAt: Date.now() }, + })); + assert.equal(response.status, 400); + assert.match(String((await response.json()).error), /unsigned registration/); +}); From 728a9c165c18dedb7d7e2ad4803cd967b5d2054e Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 16:06:37 +0100 Subject: [PATCH 2/2] fix(directory): clarify seller recovery UX --- .../dacs-directory/app/globals.css | 17 +++ .../dacs-directory/app/register/page.tsx | 119 +++++++++++++----- .../dacs-directory/e2e/register.spec.ts | 49 +++++++- 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index c055d09..bcee314 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -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); } diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index 5744a85..477e9d3 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDemosWallet } from "@/src/components/useDemosWallet"; import { clearPendingListingPublication, @@ -9,6 +9,7 @@ import { writePendingListingPublication, type PendingListingPublication, } from "@/src/components/listing-publication-recovery"; +import { safePublicEndpoint } from "@/src/catalog/publicEndpoint"; import { negotiationPhaseForPricing, publishableRail, @@ -24,6 +25,7 @@ const DELIVERY_OPTIONS = [ { id: "deliver-entitlement", label: "Access or entitlement", hint: "A time-bound API, subscription, quota, or access grant." }, ]; const SCREENS = ["Connect", "Describe", "Review", "Publish"]; +const CANONICAL_DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d*[1-9])?$/; type Screen = "connect" | "describe" | "review" | "publish" | "done"; type PublishStep = "idle" | "building" | "signing" | "anchoring" | "confirming" | "registering" | "failed" | "complete"; @@ -66,15 +68,41 @@ export default function Register() { const [status, setStatus] = useState(null); const [profileUrl, setProfileUrl] = useState(null); const [pendingPublication, setPendingPublication] = useState(null); + const [recoveryLoaded, setRecoveryLoaded] = useState(false); + const operationInFlight = useRef(false); - useEffect(() => { setPendingPublication(readPendingListingPublication(window.localStorage)); }, []); + useEffect(() => { + setPendingPublication(readPendingListingPublication(window.localStorage)); + setRecoveryLoaded(true); + }, []); const claim = wallet.address ? `did:demos:agent:${wallet.address.replace(/^0x/, "").toLowerCase()}` : null; const slug = serviceId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); const selectedRail = publishableRail(rails[0] ?? ""); - const validDescription = name.trim() && description.trim() && slug && selectedRail && delivery && - Number(amount) > 0 && currency.trim() && - (pricingKind !== "metered" || (unit.trim() && (!minTotal.trim() || Number(minTotal) > 0))); + const tagValues = tags.split(",").map((tag) => tag.trim()).filter(Boolean); + const validTags = tagValues.length <= 16 && tagValues.every((tag) => tag.length <= 32); + const validAmount = CANONICAL_DECIMAL.test(amount.trim()) && Number(amount) > 0; + const validCurrency = /^[A-Za-z0-9._:-]{1,32}$/.test(currency.trim()); + const validMinimum = !minTotal.trim() || (CANONICAL_DECIMAL.test(minTotal.trim()) && Number(minTotal) > 0); + const validNegotiation = pricingKind !== "negotiable" || ( + Number.isFinite(Number(minPct)) && Number(minPct) >= 0 && Number(minPct) < 100 && + Number.isFinite(Number(maxPct)) && Number(maxPct) >= 0 + ); + const validEndpoint = !publicEndpoint.trim() || Boolean(safePublicEndpoint(publicEndpoint.trim())); + const validationIssues = [ + !name.trim() ? "service title" : null, + !description.trim() ? "buyer outcome" : null, + !slug || slug.length > 64 ? "service ID (maximum 64 characters)" : null, + !selectedRail || !delivery ? "payment and delivery choices" : null, + !validAmount ? "canonical positive price, such as 1 or 0.25" : null, + !validCurrency ? "currency or asset identifier" : null, + pricingKind === "metered" && (!unit.trim() || unit.trim().length > 64) ? "metered unit" : null, + pricingKind === "metered" && !validMinimum ? "canonical positive minimum total" : null, + !validNegotiation ? "valid negotiation percentages" : null, + !validTags ? "at most 16 tags of 32 characters each" : null, + !validEndpoint ? "HTTPS agent endpoint without embedded credentials" : null, + ].filter((issue): issue is string => Boolean(issue)); + const validDescription = validationIssues.length === 0; const activeIndex = screen === "connect" ? 0 : screen === "describe" ? 1 : screen === "review" ? 2 : 3; const priceTermPreview = { amount, currency, ...(pricingKind !== "metered" && unit ? { unit } : {}) }; const pricingPreview = pricingKind === "negotiable" @@ -95,10 +123,17 @@ export default function Register() { return saved; }; - const confirmAnchoredListing = async (pending: PendingListingPublication): Promise => { + const confirmAnchoredListing = async ( + pending: PendingListingPublication, + onStep?: (step: PublishStep) => void, + ): Promise => { + onStep?.("confirming"); setPublishStep("confirming"); setStatus("Waiting for the exact signed listing to become readable and independently verifiable…"); for (let attempt = 0; attempt < 20; attempt++) { + if (attempt > 0 && attempt % 4 === 0) { + setStatus(`The listing is still finalising on-chain. Verification check ${attempt + 1} of 20; no new transaction will be sent.`); + } const response = await fetch("/api/dacs/confirm-listing", { method: "POST", headers: { "content-type": "application/json" }, @@ -121,11 +156,15 @@ export default function Register() { throw new Error("The exact listing is not visible yet. No new transaction was sent; use Check chain and resume to follow this same anchor."); }; - const registerPendingListing = async (pending: PendingListingPublication): Promise => { + const registerPendingListing = async ( + pending: PendingListingPublication, + onStep?: (step: PublishStep) => void, + ): Promise => { const registering = { ...pending, stage: "registering" as const }; if (!savePending(registering)) { throw new Error("This browser cannot preserve the listing recovery record, so directory registration was not attempted."); } + onStep?.("registering"); setPublishStep("registering"); setStatus("One final wallet signature connects this verified listing to the directory."); const prepared = await fetch("/api/dacs/prepare-registration", { @@ -165,33 +204,40 @@ export default function Register() { setScreen("done"); }; - const finishPendingPublication = async (pending: PendingListingPublication): Promise => { - await confirmAnchoredListing(pending); + const finishPendingPublication = async ( + pending: PendingListingPublication, + onStep?: (step: PublishStep) => void, + ): Promise => { + await confirmAnchoredListing(pending, onStep); const confirmed = { ...pending, stage: "registering" as const }; if (!savePending(confirmed)) { throw new Error("The listing verified, but this browser cannot preserve its recovery record; directory registration was not attempted."); } - await registerPendingListing(confirmed); + await registerPendingListing(confirmed, onStep); }; const resumePublication = async () => { - if (!claim || !pendingPublication || pendingPublication.claim !== claim) return; + if (!claim || !pendingPublication || pendingPublication.claim !== claim || operationInFlight.current) return; + operationInFlight.current = true; setScreen("publish"); setStatus(null); setFailedAt(null); - const activeStep: PublishStep = "confirming"; + let activeStep: PublishStep = "confirming"; try { // Re-verify on every resume even when the prior browser session had // already reached registration; persisted client state is only a hint. - await finishPendingPublication(pendingPublication); + await finishPendingPublication(pendingPublication, (step) => { activeStep = step; }); } catch (error) { setFailedAt(activeStep); setPublishStep("failed"); setStatus((error as Error).message); + } finally { + operationInFlight.current = false; } }; const publish = async () => { - if (!claim || !validDescription || pendingPublication) return; + if (!claim || !validDescription || pendingPublication || operationInFlight.current) return; + operationInFlight.current = true; setScreen("publish"); setStatus(null); setFailedAt(null); let activeStep: PublishStep = "building"; @@ -200,7 +246,7 @@ export default function Register() { const listingInput = { claim, serviceId: slug, name: name.trim(), description: description.trim(), rails, delivery: [delivery], category: category.trim(), publicEndpoint: publicEndpoint.trim() || undefined, - tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean), + tags: tagValues, pricing: { kind: pricingKind, amount: amount.trim(), currency: currency.trim(), unit: unit.trim() || undefined, minTotal: minTotal.trim() || undefined, minPct: Number(minPct), maxPct: Number(maxPct), selectionRule, @@ -286,11 +332,13 @@ export default function Register() { } activeStep = "confirming"; - await finishPendingPublication(pending); + await finishPendingPublication(pending, (step) => { activeStep = step; }); } catch (error) { setFailedAt(activeStep); setPublishStep("failed"); setStatus((error as Error).message); + } finally { + operationInFlight.current = false; } }; @@ -312,13 +360,20 @@ export default function Register() { {wallet.address ? ( <>
connected{wallet.address.slice(0, 22)}…
- {pendingPublication?.claim === claim ? ( -
-

A listing publication from this browser is still unresolved. Resume its exact saved anchor; starting another transaction could create a duplicate version.

+ {!recoveryLoaded ? ( +

Checking this browser for an unfinished publication…

+ ) : pendingPublication?.claim === claim ? ( +
+
+ unfinished publication + {pendingPublication.listingId} · version {pendingPublication.listingVersion} + {pendingPublication.anchorAddress} +
+

Resume this exact saved anchor. The directory will verify it again and will not resend its chain transaction.

) : pendingPublication ? ( -

This browser has an unresolved listing for a different Demos wallet. Reconnect that wallet to recover it before publishing another listing.

+

This browser has an unfinished listing for a different Demos wallet. Switch to that account in Demos Wallet, then reload this page to recover it.

) : ( )} @@ -338,20 +393,20 @@ export default function Register() {
step 2

Describe the buyer's outcome

-
setName(event.target.value)} />
-