Skip to content
OpenSouk

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:

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.

VariableRead bySet in the committed files
NEXT_PUBLIC_API_URLevery backend callyes
NEXT_PUBLIC_IDENTITY_REGISTRY_ADDRESSidentity minting and every ownership checkyes
NEXT_PUBLIC_MERCHANT_REGISTRY_ADDRESSisActive, setActiveyes
NEXT_PUBLIC_PRODUCT_REGISTRY_ADDRESSaddProduct, setCommission, setProductActiveyes
NEXT_PUBLIC_USDC_ADDRESSthe test-USDC mint buttonyes
NEXT_PUBLIC_COMMISSION_ESCROW_ADDRESSthe unbound-escrow scan, and bindno
NEXT_PUBLIC_REVIEW_REGISTRY_ADDRESSpublishReviewWithEscrowProofno
NEXT_PUBLIC_RPC_URLthe read client, optionaldeclared, left empty
NEXT_PUBLIC_ESCROW_FROM_BLOCKthe floor of the unbound-escrow log scandeclared, 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.

ElementWhat it is
Primary naveight fixed links — merchant register, products, dashboard, profile, wallet setup, reviewer, buyer, explore
Connect buttonthe wallet connector, in the header on every route
WalletGatewraps four routes and replaces the page with a connect prompt when no wallet is connected
IdentitySwitchera 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:

  1. The user's explicit pick, if they still own it. It is remembered per wallet address in browser local storage and survives reloads.
  2. 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.
  3. 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 record

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

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

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

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

StepWhat happens
WalletConnect, then accept the terms. The acceptance is a checkbox in page state and is not persisted anywhere
IdentityTwo tabs: mint a new ERC-8004 identity, or import an existing id after an ownership check
ActivateReads isActive first and skips the transaction if already active; otherwise sends setActive(merchantId, true). Then syncs
ProfileOptional description and landing-page URL. Skippable, and blank input is treated as a skip
ProductsLists x402 endpoints discovery found for the wallet, with a publish wizard. Also skippable
DoneThe 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 }   optional

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

StateWhat you see
Terms uncheckedContinue stays disabled
Import: not your identitythe owner's abbreviated address, and a refusal
Import: non-numeric idEnter a valid merchant ID (positive integer)
Mint receipt lacks the eventa message asking you to refresh, and the step does not advance
Backend sync failedthe failure, a note that the on-chain half already succeeded, and Retry sync
Already active on-chainthe 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 namekeccak256 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.

StateWhat you see
Merchant not owned, inactive, suspended, or unknown to usa specific message per case, and no way forward until it is fixed
Commission outside 0–100%, or a malformed URLinline validation, before any transaction
Transaction revertedthe wallet's own error text, unmapped
Backend sync failedRetry 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 product

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

StateWhat you see
No walletthe connect prompt from WalletGate
No merchant record on any identitya panel and a link to register
Merchant inactive or suspendedan amber banner; existing products still list, every mutating control is disabled with a reason on hover
Discovery failedthe error text on both tabs — the Live tab shares the discovery query's error state
Nothing discoverablea note that the proxy has found nothing new — a normal outcome, not an error
Malformed product id on a cardthe card's actions are replaced with Invalid product id — actions disabled.
Backend sync failed after publishingRetry 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 added

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

CardNotes
Conversionscount of attributed purchases
Gross revenueUSDC, formatted from base units
In escrowa count of purchases held, not an amount
Settleda count of purchases settled
Productsactive 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 list

Errors.

StateWhat you see
No walletthe connect prompt
No merchant recorda panel and a register link
Stats call faileda red banner with the message; the reviews section still renders
Zero conversions and zero revenuea note that analytics populate as purchases complete
Reviews call failedCouldn'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.

StateWhat you see
No walletthe connect prompt
No merchant recorda panel and a register link
Prefill faileda note that you can still save a new profile — the form stays usable
Validation failedthe specific message, and no request is sent
Save failedFailed to save: …, with the form intact
SavedProfile 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 content

Errors. 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=20

Errors. 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 id

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