feat(marketplace): ship the Market V1 prompt purchase and delivery journey - #9
Open
dostertags wants to merge 3 commits into
Open
feat(marketplace): ship the Market V1 prompt purchase and delivery journey#9dostertags wants to merge 3 commits into
dostertags wants to merge 3 commits into
Conversation
…urney Market V1 sells exactly one product, a curated PROMPT, paid for from the buyer's own wallet. This makes that journey real. Catalog and scope - lib/market/scope.ts is the single source of truth for what Market V1 sells. The catalog is queried as type=PROMPT, so an unsupported type cannot reach the grid, and opening one by URL renders an explanation with no purchase UI. - Every hardcoded fallback dataset is gone: fake assets, creators, ratings, balances, a fabricated revenue chart, EVM-style transaction hashes and a literal "GD3...9X2Z" wallet address. Loading, empty and error are now three distinct rendered states. - Credit packages are removed. They were never a real product. - Every prompt card is a link to its real detail route, so cards navigate and are keyboard operable. "Execute" is gone; nothing in Market V1 executes. Purchase - Purchase requires an authenticated session, not just a connected wallet. The two are tracked separately because the endpoints are JWT-protected. - The idempotency key is written to storage before the first request leaves the browser and is reused across reloads, retries and tabs, so a repeat of the flow resolves to the same purchase rather than opening a second one. A new key is minted only when the quote expired. - Consent is taken before the quote, because the marketplace fixes a 30 second time bound when it builds the transaction. - The ledger is polled to a definitive result before the backend is told anything. Confirming earlier makes the backend's single RPC lookup return NOT_FOUND, which it records as a terminal failure. - RPC statuses are mapped individually: DUPLICATE and TRY_AGAIN_LATER are no longer treated as success, and a rejection reports its real protocol code instead of "[object Object]". - Pending and retryable states are never rendered as terminal failure. Delivery - The asset:// placeholder is never opened. The delivery result is read from the authenticated delivery API and rendered as the encrypted receipt that actually exists; the UI does not pretend to decrypt it. - 404 is rendered as pending with bounded polling. 401 exposes nothing. Support and accessibility - Purchase id, idempotency key, transaction hash with an explorer receipt, contract, network and API request id are all shown and copyable. - Support is gated on NEXT_PUBLIC_SUPPORT_URL and falls back to the issue tracker rather than a placeholder address. - The mobile navigation panel works and carries the wallet controls, every status carries an icon and text rather than colour alone, and the flow has one live region with focus moved to it after each transition. Refs Stellar-AgentVerse#6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial review of the previous commit, five lenses over the diff followed by a refutation pass. What survived, and what it would have cost a buyer: Payment safety - A lost response from sendTransaction claimed "nothing has been submitted", and the retry re-quoted, producing a transaction with a fresh sequence number. If the first one had in fact reached the network, the buyer paid twice. The hash is now derived from the signed envelope before the network is touched, so a lost response resolves by looking at the ledger instead of by guessing. A record left at 'submitting' therefore always carries a hash and resumes by polling rather than by signing again. - With storage unavailable (private mode, blocked site data, quota) every read returned nothing, so each retry minted a fresh idempotency key and opened a second purchase. Writes are now mirrored in memory, which keeps a tab consistent with itself; only resumption across a reload is lost. - Starting a new quote overwrote the record, deleting the purchase id and transaction hash of a payment that may already have settled on chain. Those references are archived and rendered instead. - "Try again" after a quote that expired on the clock reused the expired intent, because the new-key decision read only the stage. It now follows the action the buyer was actually offered. - The 'expired' stage is only reachable after the ledger reported success, so its copy no longer says "start a new attempt" without warning that a payment may already exist. Honest states - Pre-payment quote refusals (asset not found, not published, bad price) were persisted as a terminal verification failure whose copy discusses an on-chain payment that cannot exist. They no longer persist a terminal stage. - "Purchase already completed" left a settled record with no purchase id and rendered nothing at all. It now says the account already owns the prompt. - The detail page offered a Buy button for unpublished prompts the marketplace will refuse to quote. - The dashboard queried with no creator and presented platform-wide totals as the account's own; it now asks for a wallet first. - 429 from the global throttler was terminal for delivery, on the one panel that polls. It is retryable. - Delivery no longer claims the access record exists when that call failed, and the receipt is not headlined "end-to-end encrypted" when the marketplace holds the key. Robustness and accessibility - Support and explorer URLs are parsed rather than regex-matched, so a malformed value falls back instead of rendering. - A malformed signature from the wallet surfaced as "the marketplace could not be reached"; an HTML gateway page could be rendered as a callout title. - One live region for the whole purchase flow: the failure text joins it rather than opening a competing alert, and the elapsed-seconds counter sits outside it, so a 90-second ledger wait is one announcement rather than forty-five. - Focus follows the stage from an effect, so settling — which swaps the panel for a different branch — no longer focuses an unmounted node. - Controls no longer disable themselves on the click that activates them. - Escape returns focus to the menu button, the scrolling activity table is a focusable region, empty states can sit under an h1 without skipping a level, and scroll-padding keeps the fixed header off newly focused controls. - Publish: the asset-name input has a label, the tag group is labelled, and "Cancel" goes somewhere. Refs Stellar-AgentVerse#6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refutation pass finished after the previous commit; 25 of 42 candidate
findings survived it, and these six were still open.
- Four `role="alert"` regions could render at once when the API is down (three
dashboard sections plus the session notice). `ErrorState` is polite by
default now, and the delivery and network-mismatch callouts with it. The one
remaining assertive region is the sign-in failure, which interrupts something
the buyer is actively waiting on.
- `aria-current="page"` was asserted on the Marketplace link while the buyer
was on an asset detail route. It now requires an exact path match; the
section highlight is unchanged, because that is a different claim.
- Publish pre-selected a hardcoded "beta" tag the backend may not offer, and a
comment promised a tag-failure state that is never rendered.
- Publish icon spans leaked their Material Symbol names ("smart_toy") into the
accessible names of the controls containing them.
- The idempotency guarantee in docs/market-v1.md was stated without its one
caveat: with storage unavailable a tab stays consistent with itself, but
resumption after a reload is lost because there is nowhere to resume from.
- `NEXT_PUBLIC_STELLAR_EXPLORER_URL` was documented as an explorer origin, but
the path is built to stellar.expert's scheme, so another explorer's URL shape
would not work. Said so, in the docs and in .env.example.
Refs Stellar-AgentVerse#6
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Follow-up: the refutation pass finished after I opened thisThe adversarial review I mentioned in the description ran a second stage that Nothing in the first two commits was walked back; these are additional:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #6
Market V1 sells exactly one product — a curated PROMPT, paid for from the buyer's own wallet. This makes that journey real, and says so honestly where it is not yet real end to end.
docs/market-v1.mdis the reference for everything below.What changed
Catalog and scope
lib/market/scope.tsis the single source of truth for what Market V1 sells. The catalog is queried astype=PROMPT, so an unsupported type cannot reach the grid even if the catalog grows one; opening an unsupported asset by its direct URL renders an explanation and no purchase UI.Every hardcoded fallback dataset is gone — fake assets and creators, invented ratings, a fabricated revenue chart, EVM-style transaction hashes on a Stellar product, and a literal
GD3...9X2Z"connected wallet". Loading, empty and error are now three distinct rendered states. Credit packages are removed; they were never a real product.Every prompt card is a
<Link>to its real detail route, so cards navigate and are keyboard operable with one tab stop each. "Execute" is gone — nothing in Market V1 executes.Purchase
lib/market/session.tsxbecause the endpoints are JWT-protected; showing a connected address as a session is how a Buy button 401s after the buyer has committed.localStoragebefore the first request leaves the browser, and reused across reloads, retries and tabs. A new key is minted in exactly one case — the quote expired. After a rejected submission or a chain failure the marketplace's record is stillPENDINGand bound to no hash, so reusing the key returns that same record with a freshly built transaction; minting a new key there would create the duplicate this design exists to prevent.createIntentbuilds the transaction with.setTimeout(30), and that clock starts before the buyer has seen anything. Any step inserted between quote and approval spends the budget and produces atxTooLatethe buyer cannot explain.verifyTransactionlooks the transaction up through RPC exactly once and treats anything butSUCCESS— includingNOT_FOUND, the normal answer for the first few seconds — as failure, persistingstatus = FAILED. Confirming immediately after submission, asmaindoes today, is therefore a guaranteed loss: the buyer pays on chain and can never redeem. Measured Testnet ledger close time is 5.00s (10 consecutive intervals from Horizon), so the poll runs every 2s for up to 90s.DUPLICATEandTRY_AGAIN_LATERwere previously returned as success; they now mean "already in flight" and "retryable". A rejection reports its real protocol code (txTooLate,txBadSeq, …) instead of[object Object], which is whatString(errorResult)actually produces.Delivery
The
asset://placeholder is never opened. The delivery result is read from the authenticated delivery API and rendered as the encrypted receipt that actually exists — cipher, size, expiry, delivery id. The UI does not pretend to decrypt it: the envelope's data key is KMS-wrapped and no endpoint releases plaintext to a buyer.404is rendered as pending with bounded polling;401exposes nothing.Support, accessibility, mobile
Purchase id, idempotency key, transaction hash with an explorer receipt, contract and network are shown and individually copyable from the moment they exist, rather than only after something breaks; when the marketplace reports a problem, the API request id from the
x-request-idheader is shown alongside them. References from earlier attempts are archived rather than overwritten, because an attempt can leave a real payment on chain even when the marketplace did not settle it. Support is gated onNEXT_PUBLIC_SUPPORT_URLand falls back to this repository's issue tracker — no placeholder address, applying the lesson from the landing-page review. An explorer link is omitted rather than guessed when the network passphrase is unrecognised.The mobile navigation panel now works and carries the wallet controls; previously
rightContentlived inside ahidden md:flexcontainer, so the entire purchase journey was unreachable below themdbreakpoint. Status is never carried by colour alone, the flow has one live region with focus moved to it after each transition, and every animation honoursprefers-reduced-motion.Evidence
Verified on this branch:
Rendered against a contract-accurate mock backend (
{data,meta}envelope, flat error bodies,x-request-id), driven in a real browser:read_pagereports the cards aslink href="/assets/<uuid>"— cards navigate.0.0and0.document.body.scrollWidth === window.innerWidth(no horizontal overflow), no interactive target under 24px, heading orderh1 → h2 → h3with no skips, and the hamburger togglesaria-expandedand reveals Marketplace / Wallet / Dashboard / Connect wallet.Static check on the server-rendered HTML with the API down — each of these appears 0 times across
/marketplace,/walletand/dashboard:Nova-7 Strategist,NeuralLabs,QuantCore,GaiaSystems,CodeArchitect v2,Etherion Systems,GD3...9X2Z,0x82f...e31,Oct 24, 2023,12.5k XLM,24/7 priority support,Buy now,Execute,Launch App,href="#".Purchase state machine, 23 assertions, all passing (
node --test). Not committed: #7 and its open PR own the test runner and CI, andlib/market/purchase-state.tsis deliberately pure and fully exported so that suite can cover it. Happy to contribute the file there or here. What is asserted:settledis the only successawaiting_ledgeris pending, not terminal, and never carries an error toneresume_ledger/resume_confirm) instead of restartingNOT_FOUNDis pending; an unrecognised RPC status keeps waiting rather than failing409 "already verified"is an idempotent success, not an error0,429,500,502,503all stay retryable, so an unanswered request never burns the attempt401on confirm is retryable auth, so re-signing in resumes the purchasefresh key resolves is distinguished from the ones it does not
expiredstage's copy warns that a payment may already be on chainPre-submission self-review
Before marking this ready I ran an adversarial pass over my own diff — five
independent lenses (claim audit, purchase correctness, React/data layer,
accessibility, security and dead ends), then a refutation pass that tried to
prove each finding wrong. It surfaced 32 candidates; the ones that survived are
fixed in
2191ec6, and the commit message lists them in full. The three thatmattered most, all of which I had shipped in the first commit:
sendTransactionwas reported as "nothing has been submitted", and the retry re-quoted — producing a transaction with a fresh sequence numberexpiredis only reachable after the ledger reported success, so this deleted the purchase id and transaction hash of a payment that had already happenedOne finding I rejected, for the record: a reviewer argued that checking
result.status === 'VERIFIED'afterconfirmwas wrong because that string"appears nowhere in the backend contract". It does —
PurchasesService.confirmreturnspurchase.status, andPurchaseStatus.VERIFIED = 'VERIFIED'in
purchase.entity.ts. The reviewer had reasoned from this PR's own docsrather than from the source. Left as-is.
What I could not verify, and why
I cannot demonstrate acceptance criterion 2 (browse → … → receive result on Testnet) or 3 (proven on Testnet) end to end, because of two server-side gaps. Both are in Backend #9's scope, and I would rather say so than tick them.
1 — Wallet sign-in cannot succeed against a current Freighter build.
AuthService.verifyWalletdoes:that is, a raw Ed25519 signature over the challenge bytes. Freighter's
signMessagefollows SEP-53: it signsSHA-256("Stellar Signed Message:\n" + message)and returns base64. I verified the consequence rather than assuming it — with@stellar/stellar-sdk16.1.0:The encodings can be bridged (this PR normalises base64 or Buffer to hex, which also avoids a 500 the backend would otherwise throw on a base64 string). The payloads cannot: it is a hash preimage. So when the backend answers
401, the UI verifies the signature locally against both schemes and names the mismatch instead of repeating "Invalid signature".2 — Delivery results are never produced for purchases made through the API.
PromptDeliveryService.acceptVerifiedEventis not called from the confirm path, soGET /api/prompt-delivery/{purchaseId}returns404indefinitely for a purchase settled over HTTP. The UI reads that as pending — the honest reading — and it will stay pending until the backend publishes the verified-purchase event.Also worth flagging:
verifyTransactioncatches every error and returnsfalse, which writesstatus = FAILED. Polling to a definitive chain result before confirming avoids the common case, but a backend-side RPC outage during confirmation still burns the intent irreversibly. The UI does not present that as certain — it reports what the server said and points at the transaction receipt.Scope boundaries
lib/market/session.tsximplements only the minimum the purchase gate needs: connect → challenge → sign → JWT, plus clearing a session whose account no longer matches the wallet. Route protection, live account/network watching and restoring an interrupted action after sign-in are still feat(auth): complete Freighter sign-in and protected sessions #5's.package.jsonscripts here — feat: Implemented the critical journey test foundation. #8 is open and owns them.app/marketplace/page.tsx,README.mdandpackage.json. This PR rewrites that page and appends one section toREADME.md; whichever merges second will need a small rebase. Happy to go second.Deliberately not fixed here
<span class="material-symbols-outlined">payments</span>paints the word "payments" — 27 occurrences onmain. The market path sidesteps it with inline SVG and pulls no new dependency, but/publishstill shows raw names. A real fix needs a decision about self-hosting, because the backend supplies icon names as data;next/font/googledoes not carry Material Symbols (I checked: 1907 families, no match). Worth its own issue.components/menu/NavBar.tsx,components/cards/InfoCard.tsx,components/buttons/*andcomponents/titles/*are unimported dead code. Left alone to keep this diff reviewable.