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/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 7badae7..477e9d3 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -1,8 +1,15 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDemosWallet } from "@/src/components/useDemosWallet"; +import { + clearPendingListingPublication, + readPendingListingPublication, + writePendingListingPublication, + type PendingListingPublication, +} from "@/src/components/listing-publication-recovery"; +import { safePublicEndpoint } from "@/src/catalog/publicEndpoint"; import { negotiationPhaseForPricing, publishableRail, @@ -18,10 +25,25 @@ 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"; +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,13 +67,42 @@ export default function Register() { const [publicEndpoint, setPublicEndpoint] = useState(""); const [status, setStatus] = useState(null); const [profileUrl, setProfileUrl] = useState(null); + const [pendingPublication, setPendingPublication] = useState(null); + const [recoveryLoaded, setRecoveryLoaded] = useState(false); + const operationInFlight = useRef(false); - const claim = wallet.address ? `did:demos:agent:${wallet.address.replace(/^0x/, "")}` : null; + 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" @@ -66,8 +117,127 @@ 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, + 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" }, + 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, + 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", { + 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, + 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, onStep); + }; + + const resumePublication = async () => { + if (!claim || !pendingPublication || pendingPublication.claim !== claim || operationInFlight.current) return; + operationInFlight.current = true; + setScreen("publish"); + setStatus(null); setFailedAt(null); + 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, (step) => { activeStep = step; }); + } catch (error) { + setFailedAt(activeStep); + setPublishStep("failed"); + setStatus((error as Error).message); + } finally { + operationInFlight.current = false; + } + }; + const publish = async () => { - if (!claim || !validDescription) return; + if (!claim || !validDescription || pendingPublication || operationInFlight.current) return; + operationInFlight.current = true; setScreen("publish"); setStatus(null); setFailedAt(null); let activeStep: PublishStep = "building"; @@ -76,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, @@ -105,52 +275,70 @@ 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, (step) => { activeStep = step; }); } catch (error) { setFailedAt(activeStep); setPublishStep("failed"); setStatus((error as Error).message); + } finally { + operationInFlight.current = false; } }; @@ -172,7 +360,23 @@ export default function Register() { {wallet.address ? ( <>
connected{wallet.address.slice(0, 22)}…
- + {!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 unfinished listing for a different Demos wallet. Switch to that account in Demos Wallet, then reload this page to recover it.

+ ) : ( + + )} ) : wallet.available ? ( @@ -189,20 +393,20 @@ export default function Register() {
step 2

Describe the buyer's outcome

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