Skip to content

feat(marketplace): ship the Market V1 prompt purchase and delivery journey - #9

Open
dostertags wants to merge 3 commits into
Stellar-AgentVerse:mainfrom
dostertags:feat/market-v1-prompt-journey
Open

feat(marketplace): ship the Market V1 prompt purchase and delivery journey#9
dostertags wants to merge 3 commits into
Stellar-AgentVerse:mainfrom
dostertags:feat/market-v1-prompt-journey

Conversation

@dostertags

Copy link
Copy Markdown

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.md is the reference for everything below.


What changed

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 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

  • Purchase requires an authenticated session, not just a connected wallet. The two are tracked separately in lib/market/session.tsx because the endpoints are JWT-protected; showing a connected address as a session is how a Buy button 401s after the buyer has committed.
  • A retry cannot become a second purchase. The idempotency key is written to localStorage before 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 still PENDING and 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.
  • Consent is taken before the quote. createIntent builds 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 a txTooLate the buyer cannot explain.
  • The ledger is polled to a definitive result before the backend is told anything. verifyTransaction looks the transaction up through RPC exactly once and treats anything but SUCCESS — including NOT_FOUND, the normal answer for the first few seconds — as failure, persisting status = FAILED. Confirming immediately after submission, as main does 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.
  • RPC statuses are mapped individually. DUPLICATE and TRY_AGAIN_LATER were 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 what String(errorResult) actually produces.
  • Pending and retryable states are never rendered as terminal failure, and running out of poll budget is reported as "still pending" with a "check the ledger again" control.

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. 404 is rendered as pending with bounded polling; 401 exposes 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-id header 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 on NEXT_PUBLIC_SUPPORT_URL and 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 rightContent lived inside a hidden md:flex container, so the entire purchase journey was unreachable below the md breakpoint. Status is never carried by colour alone, the flow has one live region with focus moved to it after each transition, and every animation honours prefers-reduced-motion.


Evidence

Verified on this branch:

npx tsc --noEmit    clean
npx eslint .        0 problems   (main is also 0 — no regression, no improvement claimed)
npx next build      clean, 8 routes

Rendered against a contract-accurate mock backend ({data,meta} envelope, flat error bodies, x-request-id), driven in a real browser:

  • Catalog renders only PROMPTs; the seeded AGENT is absent. read_page reports the cards as link href="/assets/<uuid>" — cards navigate.
  • Unrated and unexecuted prompts render "No ratings yet" / "No recorded executions", not 0.0 and 0.
  • Opening the AGENT by URL renders the Market V1 explanation with no purchase UI.
  • Purchase is gated: "Buying requires a signed-in session, not just a connected wallet."
  • At 375px: document.body.scrollWidth === window.innerWidth (no horizontal overflow), no interactive target under 24px, heading order h1 → h2 → h3 with no skips, and the hamburger toggles aria-expanded and reveals Marketplace / Wallet / Dashboard / Connect wallet.
  • With the API returning 500: "The prompt catalog could not be loaded — Internal Server Error. No sample data is shown in its place." No fallback assets appear.

Static check on the server-rendered HTML with the API down — each of these appears 0 times across /marketplace, /wallet and /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, and lib/market/purchase-state.ts is deliberately pure and fully exported so that suite can cover it. Happy to contribute the file there or here. What is asserted:

  • no stage is both terminal and pending; settled is the only success
  • awaiting_ledger is pending, not terminal, and never carries an error tone
  • only expiry mints a new idempotency key
  • a reload mid-flight resumes (resume_ledger / resume_confirm) instead of restarting
  • an expired quote cannot be signed again
  • NOT_FOUND is pending; an unrecognised RPC status keeps waiting rather than failing
  • 409 "already verified" is an idempotent success, not an error
  • each 409 wording lands on its own stage (replay / expired / verification failed)
  • 0, 429, 500, 502, 503 all stay retryable, so an unanswered request never burns the attempt
  • 401 on confirm is retryable auth, so re-signing in resumes the purchase
  • message matching is case-insensitive
  • a quote refusal never carries a terminal purchase stage, and the one refusal a
    fresh key resolves is distinguished from the ones it does not
  • a throttled (429) delivery lookup stays retryable
  • the expired stage's copy warns that a payment may already be on chain

Pre-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 that
mattered most, all of which I had shipped in the first commit:

Defect What it would have cost Fix
A lost response from sendTransaction was reported as "nothing has been submitted", and the retry re-quoted — producing a transaction with a fresh sequence number If the first transaction had reached the network, the buyer pays twice The hash is now derived from the signed envelope before the network is touched, so a lost response is resolved by looking at the ledger instead of by guessing
With storage unavailable (private mode, blocked site data, quota) every read returned nothing, so each retry minted a fresh idempotency key The exact duplicate purchase this design exists to prevent Writes are mirrored in memory; a tab stays consistent with itself, and only resumption across a reload is lost
Starting a new quote overwrote the record expired is only reachable after the ledger reported success, so this deleted the purchase id and transaction hash of a payment that had already happened Earlier attempts are archived and rendered alongside the current one

One finding I rejected, for the record: a reviewer argued that checking
result.status === 'VERIFIED' after confirm was wrong because that string
"appears nowhere in the backend contract". It does —
PurchasesService.confirm returns purchase.status, and PurchaseStatus.VERIFIED = 'VERIFIED'
in purchase.entity.ts. The reviewer had reasoned from this PR's own docs
rather 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.verifyWallet does:

keypair.verify(Buffer.from(entry.challenge, 'utf-8'), Buffer.from(signature, 'hex'))

that is, a raw Ed25519 signature over the challenge bytes. Freighter's signMessage follows SEP-53: it signs SHA-256("Stellar Signed Message:\n" + message) and returns base64. I verified the consequence rather than assuming it — with @stellar/stellar-sdk 16.1.0:

verify(rawChallenge, legacySig) = true
verify(rawChallenge, sep53Sig)  = false
verify(sha256(prefix||challenge), sep53Sig) = true

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.acceptVerifiedEvent is not called from the confirm path, so GET /api/prompt-delivery/{purchaseId} returns 404 indefinitely 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: verifyTransaction catches every error and returns false, which writes status = 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

Deliberately not fixed here

  • The Material Symbols icon font is not loaded anywhere in this repository, so <span class="material-symbols-outlined">payments</span> paints the word "payments" — 27 occurrences on main. The market path sidesteps it with inline SVG and pulls no new dependency, but /publish still shows raw names. A real fix needs a decision about self-hosting, because the backend supplies icon names as data; next/font/google does not carry Material Symbols (I checked: 1907 families, no match). Worth its own issue.
  • components/menu/NavBar.tsx, components/cards/InfoCard.tsx, components/buttons/* and components/titles/* are unimported dead code. Left alone to keep this diff reviewable.

dostertags and others added 3 commits August 29, 2026 17:54
…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>
@dostertags

Copy link
Copy Markdown
Author

Follow-up: the refutation pass finished after I opened this

The adversarial review I mentioned in the description ran a second stage that
tried to prove each finding wrong. It landed after I pushed, so 3c3e9ea closes
the six that were still open. 42 candidates, 25 survived refutation, all now
fixed.

Nothing in the first two commits was walked back; these are additional:

  • Four role="alert" regions could render simultaneously when the API is
    down — three dashboard sections plus the session notice. ErrorState is
    polite by default now, and so are the delivery and network-mismatch callouts.
    The only assertive region left is a 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 /assets/<id>. It now requires an exact path match. The section
    highlight is unchanged — that is a different claim from "this is the page you
    are on".
  • Publish pre-selected a hardcoded beta tag the backend may not offer, and
    a comment I wrote 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 buttons containing them.
  • Two documented claims were stronger than the code. The idempotency
    guarantee omitted its caveat: with storage unavailable a tab stays consistent
    with itself, but resumption after a reload is lost, because there is nowhere
    to resume from. And NEXT_PUBLIC_STELLAR_EXPLORER_URL was documented as an
    "explorer origin" when the path is built to stellar.expert's scheme, so
    another explorer's URL shape would not work.

tsc, eslint (0 problems) and next build are clean on 3c3e9ea, and the 23
state-machine assertions still pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant