The console
The shell every screen shares, and the merchant's own nine
The console is the human web app at app.opensouk.ai: nineteen routes, a connected wallet,
and a surface that overlaps most of MCP tools and The API
without being reducible to them. It exists for two reasons. Some steps need a person: accepting
terms, holding a private key, approving a commission change. And five of the on-chain writes it
makes have no agent-facing equivalent at all — minting an identity, activating a merchant,
adding a product, changing a commission, and deactivating a product.
The shell every screen inherits and the nine screens a merchant uses are below. The other two pages cover the rest:
- Agents and wallets —
/wallet-setup,/reviewer,/buy, and the public agent pages - Settings and simulators — spending caps, the Telegram link, and the two rank explorers
The network it is configured for
Every committed environment file points the console at Base Sepolia, and two things in the source are not configurable at all: the read client is constructed on Base Sepolia, defaulting to that network's public RPC, and the explorer links are built from a hardcoded Base Sepolia explorer host. The footer says so on every page. One wizard's final screen states outright that the identity just minted is live on Base Sepolia, whatever chain the wallet was actually on.
It also has testnet-only affordances wired in — a faucet link, and a button that mints ten test
USDC by calling mint on the configured token, which nobody can do on Base USDC.
The protocol has no deployment on either public chain yet, so the console
reads the addresses of whichever node it is pointed at.
The header wordmark, the footer and the browser title still carry the product's pre-rename name, which is cosmetic.
What the console is configured with
Contract addresses reach the app as build-time environment variables, not from a registry lookup —
so unlike the contracts themselves, the console does not derive anything from
ProtocolAddressRegistry.
| Variable | Read by | Set in the committed files |
|---|---|---|
NEXT_PUBLIC_API_URL | every backend call | yes |
NEXT_PUBLIC_IDENTITY_REGISTRY_ADDRESS | identity minting and every ownership check | yes |
NEXT_PUBLIC_MERCHANT_REGISTRY_ADDRESS | isActive, setActive | yes |
NEXT_PUBLIC_PRODUCT_REGISTRY_ADDRESS | addProduct, setCommission, setProductActive | yes |
NEXT_PUBLIC_USDC_ADDRESS | the test-USDC mint button | yes |
NEXT_PUBLIC_COMMISSION_ESCROW_ADDRESS | the unbound-escrow scan, and bind | no |
NEXT_PUBLIC_REVIEW_REGISTRY_ADDRESS | publishReviewWithEscrowProof | no |
NEXT_PUBLIC_RPC_URL | the read client, optional | declared, left empty |
NEXT_PUBLIC_ESCROW_FROM_BLOCK | the floor of the unbound-escrow log scan | declared, left empty |
NEXT_PUBLIC_COMMISSION_ESCROW_ADDRESS and NEXT_PUBLIC_REVIEW_REGISTRY_ADDRESS are set in no
committed file, and both land on one screen, where the two features behind them fail in opposite
ways — Agents and wallets has both. No merchant screen
reads either variable, so nothing on this page is affected.
The shell
Every route renders inside one layout: a sticky header, the page, a footer.
| Element | What it is |
|---|---|
| Primary nav | eight fixed links — merchant register, products, dashboard, profile, wallet setup, reviewer, buyer, explore |
| Connect button | the wallet connector, in the header on every route |
WalletGate | wraps four routes and replaces the page with a connect prompt when no wallet is connected |
IdentitySwitcher | a select, rendered only when the wallet owns two or more identities |
WalletGate wraps /merchant/dashboard, /merchant/profile, /merchant/products and
/reviewer. Every other route renders without a wallet and gates individual actions instead.
Which identity is in scope
A wallet can own several ERC-8004 identities, and an identity id doubles as its merchantId, so
resolving a wallet to one identity is a choice rather than a lookup. The console makes it like
this:
- The user's explicit pick, if they still own it. It is remembered per wallet address in browser local storage and survives reloads.
- A default that suits the surface. Merchant screens prefer an identity with an active merchant record, then any merchant record. The reviewer screen prefers the identity with the most recorded purchases.
- The first owned identity.
Two flags come out of that. One means "any identity on this wallet has a merchant record" and decides whether you see a merchant screen at all; the other means "the selected identity's merchant record is active" and decides whether its buttons work.
GET (any merchant screen)
→ browser local storage, keyed by wallet address the explicit pick, if it is still owned
→ GET /v1/agent?owner={address} every identity the wallet owns
→ MerchantRegistry.isActive(id) per identity; a revert means no recordIdentities are resolved through /v1/agent?owner={address} rather than on-chain because the
identity registry exposes no enumeration and a log scan is impractical against a public RPC.
One signature, then a bearer token
Authenticated calls do not sign per request. The console signs once, exchanges that signature for a session token at the internal auth route, and sends the token afterwards. The token is bound to the address that signed it, so switching wallets mints a new one.
POST /v1/auth/session one EIP-191 signature over an empty body
→ { token, expires_at } cached in the tab, bound to the signing address
GET /v1/agent/{id}/context and every other authenticated call afterwards
Authorization: Bearer … no further prompt until the token expires or the wallet changesThe consequence for reading these screens: a screen described as making five authenticated calls prompts the wallet once, at the start of the session, not five times. The exception is any two-step commitment write, which is a separate wallet signature over typed data — the cashback rate is the console's one example, and it is on Settings and simulators.
Two writes per action, and a retry that only redoes one
Almost every merchant action is a transaction followed by a call to us: addProduct then a
product sync, setActive then a merchant sync, register then an identity sync. When the first
half succeeds and the second fails, the screen says so explicitly, offers Retry sync, and
retries only the backend half.
POST /merchant/register (Activate, when the transaction landed and the sync did not)
→ Retry sync
→ POST /v1/merchant/register { merchant_id } the backend half only; 409 is successRe-running the transaction would spend gas again, and in the identity case would mint a second identity — which is why a wizard's Back button disappears once an on-chain step has succeeded rather than merely stepping backwards.
/ — the role chooser
What it needs. Nothing. No wallet, no session.
What it renders. Three cards — merchant, reviewer, buyer — each with two links, plus an Explore row pointing at the two public listings and the two simulators.
GET /
→ no requests at all; a static list of linksErrors. None. It cannot fail, and it does not tell you whether your wallet is set up for any of the three roles.
/merchant/register — the onboarding wizard
What it needs. A wallet, connected inside the wizard's own first step rather than by
WalletGate.
What it renders. Six labelled steps: Wallet, Identity, Activate, Profile, Products, Done.
| Step | What happens |
|---|---|
| Wallet | Connect, then accept the terms. The acceptance is a checkbox in page state and is not persisted anywhere |
| Identity | Two tabs: mint a new ERC-8004 identity, or import an existing id after an ownership check |
| Activate | Reads isActive first and skips the transaction if already active; otherwise sends setActive(merchantId, true). Then syncs |
| Profile | Optional description and landing-page URL. Skippable, and blank input is treated as a skip |
| Products | Lists x402 endpoints discovery found for the wallet, with a publish wizard. Also skippable |
| Done | The merchant id, the product id if one was added, and an explorer link |
Profile sits after Activate because the profile write is an update: the merchant record must already exist in our database, and the activate step's sync is what creates it.
What it writes.
POST /merchant/register (the Activate step)
→ IdentityRegistry.register() on-chain, in the Identity step, if minting
→ MerchantRegistry.isActive(merchantId) read first; a true skips the transaction
→ MerchantRegistry.setActive(merchantId, true) on-chain, unless it was skipped
→ POST /v1/agent/register { agent_id } 409 treated as success
→ POST /v1/merchant/register { merchant_id } 409 treated as success
→ GET /v1/merchant/{id} read-back before declaring success
→ POST /v1/merchant/profile { merchant_id, description, landing_page_url } optionalThe read-back is deliberate: a 409 alone is only trustworthy if it means "this merchant is
registered", so the step is not done until the record is actually readable. The agent
registration comes before the merchant one because the identity resolution every merchant
screen depends on reads our agents table, which that route is what populates.
Errors.
| State | What you see |
|---|---|
| Terms unchecked | Continue stays disabled |
| Import: not your identity | the owner's abbreviated address, and a refusal |
| Import: non-numeric id | Enter a valid merchant ID (positive integer) |
| Mint receipt lacks the event | a message asking you to refresh, and the step does not advance |
| Backend sync failed | the failure, a note that the on-chain half already succeeded, and Retry sync |
| Already active on-chain | the step relabels itself and skips straight to the sync |
The identity tab writes an identity per press, and its own copy — that there is one identity per address — is contradicted by the wizard's own logic and by the identity switcher, both of which exist because a wallet can own several.
/merchant/add-product — the standalone product wizard
What it needs. A wallet, and a merchant id that is already registered and active. It does not resolve your identities for you: you type the id.
What it renders. Four steps: Wallet, Merchant, Product, Done. The Merchant step verifies three things in order — on-chain ownership, on-chain active, and a readable backend record that is neither suspended nor inactive — then shows the merchant id, its owner and its rank before letting you continue. The Done step offers + Add another product, which returns to the form.
What it needs on the form. Name and endpoint URL are required; description and category are
optional; commission is entered as a percentage and echoed as basis points; product type is a
select over Repeat and One-off. The product id is derived from the name — keccak256 of
the trimmed name — so two products cannot share a name under one merchant, and renaming a product
later does not change its id. The metadata URI is sent to the contract as an empty string.
What it writes.
POST /merchant/add-product (the Product step)
→ GET /v1/merchant/{id} pre-flight: refuse if we do not know this merchant
→ ProductRegistry.addProduct(merchantId, keccak256(name), commissionBps, productType, "")
→ POST /v1/merchant/product { merchant_id, product_id, endpoint_url, name, description, category }The pre-flight read exists so a paid transaction is never sent for a merchant whose product write would then fail our foreign key.
Errors.
| State | What you see |
|---|---|
| Merchant not owned, inactive, suspended, or unknown to us | a specific message per case, and no way forward until it is fixed |
| Commission outside 0–100%, or a malformed URL | inline validation, before any transaction |
| Transaction reverted | the wallet's own error text, unmapped |
| Backend sync failed | Retry sync, with a note that the product is already registered on-chain |
The endpoint URL field arrives prefilled with a mock-merchant URL for local testing, and the field's own hint says to leave it only if you are exercising the mock.
/merchant/products — the catalogue
What it needs. WalletGate, plus an owned identity with a merchant record.
What it renders. Two tabs with counts. Discoverable is x402 endpoints our discovery proxy found for the merchant's pay-to addresses that are not yet in the protocol. Live is the union of the merchant's own registered products and discovery's live bucket, so a product registered minutes ago is visible to its own merchant before the external index catches up.
Each card carries the resource URL, price, source, category or type, any other accepted payment
options, thirty-day call and payer counts when discovery supplies them, an expandable API-details
block, and a status badge: discoverable, published or referral_mode, labelled Discoverable,
Published and Referral-only. A published card with an on-chain product id also carries
Edit commission and Deactivate.
Status is derived, never stored: publishing moves an endpoint out of the discoverable bucket on the next refetch, so there is no pending state to go stale when a merchant delists elsewhere.
What it reads.
GET /merchant/products
→ GET /v1/agent?owner={address} identities
→ MerchantRegistry.isActive(id) per identity
→ GET /v1/merchant/{id}/discovery unauthenticated; no signature prompt
→ GET /v1/merchant/{id} product ids
→ GET /v1/merchant/product?merchant_id&product_id per productWhat the publish wizard writes. Five steps — Metadata, Commission, Pay to, Readiness, Publish.
The Pay-to step is display-only and states the consequence: after publishing, payments route
through SplitRouter instead of straight to the merchant's own pay-to address, and the x402
facilitator becomes ours. The Readiness step is a probe of the endpoint, and Continue stays
disabled until it passes.
→ POST /v1/merchant/product/readiness { resource } merchant derived from the caller
→ GET /v1/merchant/{id} pre-flight, as above
→ ProductRegistry.addProduct(merchantId, keccak256(name), commissionBps, productType, "")
→ POST /v1/merchant/product { merchant_id, product_id, endpoint_url, name, description, category }What Edit commission writes. It first reads the current rate and prefills the field, so a
financial parameter is never edited blind, then sends
ProductRegistry.setCommission(merchantId, productId, commissionBps). Nothing here syncs to us
afterwards — the rate the router reads is the on-chain one, and our copy catches up by indexing.
GET /merchant/products (Edit commission, opening the form)
→ GET /v1/merchant/product?merchant_id&product_id prefill; shown as both a percentage and bps
POST /merchant/products (Edit commission, Save)
→ ProductRegistry.setCommission(merchantId, productId, commissionBps)A failed prefill is not fatal: the field stays blank, the form says so, and you can type the rate
yourself. The field steps in hundredths of a percent and is converted to basis points by rounding.
An empty or non-numeric field gives Commission rate is required, and anything outside 0–100
gives Commission percentage must be between 0 and 100 — both before any transaction.
What Deactivate writes. ProductRegistry.setProductActive(merchantId, productId, false), after
a confirmation that names the consequence: ref links for that product stop resolving. There is no
reactivate control on this screen.
POST /merchant/products (Deactivate, Confirm deactivate)
→ an in-card confirmation: This stops new ref-link resolution for this product. Are you sure?
→ ProductRegistry.setProductActive(merchantId, productId, false)Both controls are disabled, with the reason on hover, while the merchant record is inactive or suspended, and neither renders at all on a card without an on-chain product id.
Errors.
| State | What you see |
|---|---|
| No wallet | the connect prompt from WalletGate |
| No merchant record on any identity | a panel and a link to register |
| Merchant inactive or suspended | an amber banner; existing products still list, every mutating control is disabled with a reason on hover |
| Discovery failed | the error text on both tabs — the Live tab shares the discovery query's error state |
| Nothing discoverable | a note that the proxy has found nothing new — a normal outcome, not an error |
| Malformed product id on a card | the card's actions are replaced with Invalid product id — actions disabled. |
| Backend sync failed after publishing | Retry sync, on-chain half kept |
A referral_mode card — Referral-only on the badge — shows no controls at all: there is no
on-chain product record behind it to edit.
The screen also carries a development-only control for previewing discovery against an arbitrary pay-to address. It renders outside a production build, or in one when a build flag opts in, and it validates the address and a ten-address cap locally to match the backend.
POST /merchant/products (Dev only — Preview Bazaar for payTo)
→ GET /v1/merchant/{id}/discovery?pay_to=0x…&pay_to=0x… one extra parameter per address addedEach address is added to the query rather than replacing it, so the merchant's own pay-to
addresses stay in the result. A malformed address gives
Enter a valid 0x-prefixed 20-byte hex address, a duplicate gives
That payTo is already in the preview list, and the eleventh gives
Max 10 preview payTo addresses — none of them reaches the backend.
/merchant/dashboard — conversion and revenue
What it needs. WalletGate, an owned identity with a merchant record, and a session token.
What it renders. Five stat cards and a reviews section.
| Card | Notes |
|---|---|
| Conversions | count of attributed purchases |
| Gross revenue | USDC, formatted from base units |
| In escrow | a count of purchases held, not an amount |
| Settled | a count of purchases settled |
| Products | active over total |
Both escrow cards count purchase rows, split on whether a settlement timestamp is set.
The Reviews section aggregates every review across every product of the merchant, showing a total, a breakdown by comparison outcome, and a recent list that links into the public product pages.
What it reads.
GET /merchant/dashboard
→ POST /v1/auth/session one wallet signature, cached for the session
→ GET /v1/agent?owner={address} identities
→ MerchantRegistry.isActive(id) per identity
→ GET /v1/merchant/{id}/stats bearer; the five cards
→ GET /v1/merchant/{id} product ids
→ GET /v1/merchant/product?merchant_id&product_id per product, for names
→ GET /v1/review?merchant_id&product_id per product, for the review listErrors.
| State | What you see |
|---|---|
| No wallet | the connect prompt |
| No merchant record | a panel and a register link |
| Stats call failed | a red banner with the message; the reviews section still renders |
| Zero conversions and zero revenue | a note that analytics populate as purchases complete |
| Reviews call failed | Couldn't load reviews. and nothing else lost |
The per-product review fan-out settles each product independently, so one failing product degrades to its id rather than emptying the section.
/merchant/profile — the public description
What it needs. WalletGate, an owned identity with a merchant record, and a session token.
What it renders. Two fields — a description with a character counter, and a landing-page URL — prefilled from the current record and re-prefilled when the selected identity changes. Both are stated on the screen to be public.
What it writes.
GET /merchant/profile
→ GET /v1/merchant/{id} prefill
POST /merchant/profile (Save profile)
→ POST /v1/merchant/profile { merchant_id, description, landing_page_url }Validation runs client-side against the same rules the server enforces, so a typo costs neither a round trip nor a signature. The server remains the authority. An empty URL removes the link.
Errors.
| State | What you see |
|---|---|
| No wallet | the connect prompt |
| No merchant record | a panel and a register link |
| Prefill failed | a note that you can still save a new profile — the form stays usable |
| Validation failed | the specific message, and no request is sent |
| Save failed | Failed to save: …, with the form intact |
| Saved | Profile saved. |
/merchant/integration — the in-app integration note
What it needs. Nothing. No wallet, no requests.
What it renders. A static three-section explainer: return 402 with payment requirements,
point payTo at SplitRouter and echo the attribution token, and expect a product to stay
unlisted until the readiness probe passes. It links back to the register wizard and offers a
support address.
GET /merchant/integration
→ no requests; static contentErrors. None. It is a page of prose, reachable from the register wizard's first step and from the products header. The authoritative version of what it describes is x402 and MPP and Merchants and the facilitator — this screen is a summary, and the support address on it is a placeholder domain.
The three public catalogue screens
None of the three needs a wallet, a session, or any signature. They are server-rendered from public reads, which is what makes them the pages a merchant checks their own listing on.
/merchants
What it needs. An optional page query parameter, floored at 1. The page size is fixed at 20.
What it renders. A count of registered merchants and a card per merchant, with pagination.
GET /merchants?page=2
→ GET /v1/merchant?page=2&limit=20Errors. A failed fetch renders the message in a red panel; an empty result renders
No merchants registered yet.
/merchants/[id]
What it needs. A merchant id in the path.
What it renders. The merchant id with an active, inactive or suspended badge; owner address,
merchant_rank, registration time, and a metadata URI when present; then a card per product.
GET /merchants/12
→ GET /v1/merchant/12
→ GET /v1/merchant/product?merchant_id=12&product_id=… per product idErrors. A failed merchant fetch renders the message and no product list. A product that fails individually is dropped from the list rather than failing the page.
/merchants/[id]/products/[productId]
What it needs. A merchant id and a URL-encoded bytes32 product id in the path.
What it renders. Active or inactive, the product type and category as badges, the description, the commission as both a percentage and basis points, the Product Rank to four decimals, the product id, the creation time — then every review for the product.
GET /merchants/12/products/0x…
→ GET /v1/merchant/product?merchant_id=12&product_id=0x…
→ GET /v1/review?merchant_id=12&product_id=0x…Errors. Product and reviews are fetched independently, so either can fail without the other:
a failed product renders a red panel and the reviews still list, and a failed review fetch renders
its own panel under the product card. No reviews at all renders No reviews yet.
Next steps
- Agents and wallets — identity setup, the reviewer screen, the buyer flow
- Registries — the contract functions these screens send
- Identity and commitments — the merchant token and the product record behind all of it