sub3 Specification v1

Versionv1 (contract ABI v2, HTTP API v1)
StatusFrozen once Arc Mainnet is live; until then may change, recorded in the changelog (§9)
Read it athttps://sub3.dev/spec · Markdown: https://sub3.dev/spec.md · ABI: https://sub3.dev/abi.json
Sourcedocs/INTERFACES.md in the sub3 repository

This document is the single authoritative list of everything sub3 promises to the outside world: the on-chain contract, the HTTP API, the hosted pages and their messages, website verification, and the entry points for coding agents. The contract, API, hosted pages, examples and guides all follow it; where code disagrees with this document, the code has a bug.

#0. Stability and change policy

Every section carries one of these marks:

MarkLevelApplies toAllowed changes
🔒PermanentDeployed on chain, or data formats third parties keep for a long timeNone. Only a new version (new contract and salt, new /v2 path); the old version keeps working
🧊FrozenHTTP API, hosted page parameters, message formats, URL pathsAdditive only within v1: new fields, parameters and endpoints. Nothing is renamed, removed, or changes meaning or type
✏️MutablePage appearance, wording, internal implementationAny time

Policy:

  1. Frozen items are never changed or removed. Within v1, changes are additive only.
  2. A breaking change is a new version: a new contract salt (sub3.checkout.v3), a new HTTP path prefix (/v2), or a new browser storage key suffix (.v2). The old version keeps being served.
  3. When it takes effect: 🔒 and 🧊 apply from the moment the contract is deployed on Arc Mainnet (chain 5042). Until then (local and testnet only) they may change, and every change is recorded in the changelog (§9) together with its reason and the compatibility plan.
  4. Clients must ignore fields they do not know, so that adding a field never requires integrators to change code.

#1. On-chain contract 🔒

  • Source: contracts/src/Sub3Checkout.sol
  • ABI: interfaces/abi/Sub3Checkout.v2.json, generated by forge inspect; CI checks that it matches the source. Served at https://sub3.dev/abi.json.
  • Address per environment: the contract field of GET /v1/network (§2.1), or address in deployments/<chainId>.json. Both are null until the contract is deployed on that chain.

#1.1 Deployment

ItemValue
MethodCREATE2 through the universal factory 0x4e59b44847b379578588920cA78FbF26c0B4956C (Arachnid's keyless deployer)
Saltkeccak256("sub3.checkout.v2")
Constructor argumentsOnly treasury
AddressDepends only on the salt, the bytecode and the treasury. As long as the Safe has the same address on every chain, the contract has the same address on every chain, whoever deploys it
InitializationAfter deployment the treasury (Safe) calls initialize(token) once to set the chain's stablecoin (6 to 18 decimals; emits Initialized). Until then every purchase and registration reverts with NotInitialized
AdministratorNone. No owner, not upgradeable, cannot be paused. initialize can be called only once; after that the treasury has no privileges and only receives fees
Treasurysub3's Safe multisig, fixed at deployment; signers change inside the Safe. Before a new chain is added, the same Safe address is first deployed there with the official Safe factory and the same parameters
Compiler settingscontracts/foundry.toml (solc 0.8.30, via_ir, optimizer_runs 200, no metadata hash). Changing any of them changes the address
New versionA new contract with a new salt (e.g. sub3.checkout.v3). The old contract stays readable forever

#1.2 ID derivation (anyone can compute IDs from the readable keys)

IDFormulaExample
merchantIdkeccak256(bytes(handle))keccak256("acme")
productIdkeccak256(bytes(handle + "/" + productKey))keccak256("acme/app")
itemIdkeccak256(bytes(itemKey)), unique within its productkeccak256("pro")
orderIdAny nonzero bytes32 chosen by the payer, unique per payer—
Order key(chainId, contract, payer, orderId)—

The contract computes the same IDs with merchantIdOf(handle), productIdOf(handle, key) and itemIdOf(key).

Key rules: only a-z, 0-9 and -, not starting or ending with -. A handle is 3–32 characters; product and item keys are 1–32 characters. Handles and keys never change once created.

#1.3 Economics (constants)

ConstantValue
Fee1% of each payment (FEE_BPS = 100, rounded down) + feeFlat() ($0.05)
minPrice()$0.10, the lowest price of any item
merchantFee()$20 per handle, paid entirely to the treasury
MONTH30 days
Year12 months = 360 days, billed as 10 months. Every full 12 months is billed as 10, e.g. 14 months bill 12
MAX_UNITS120 (at most 120 months or 120 packs in one payment)
MAX_ITEMS32 items per product
MAX_NAME_LENGTH64 bytes (product and item names)
MAX_URL_LENGTH200 bytes (merchant url)

Amounts are in the smallest unit of the chain's stablecoin. Dollar constants are scaled to the token's precision (multiplied by 10^(decimals − 6)), so one contract serves 6-decimal USDC on Arc, Base and Polygon and 18-decimal USDC on BSC alike. A single payment is at most 2^96 − 1 units (PaymentOverflow otherwise).

#1.4 Model and rules

Merchant

FieldChangeable
handleNever
ownerYes, in two steps: transferMerchant(merchantId, newOwner) → acceptMerchant(merchantId) (a zero newOwner cancels)
payTo (payout address)Yes, setMerchant(merchantId, payTo, url)
urlYes, setMerchant(merchantId, payTo, url)

All revenue of all of a merchant's products is paid to its payTo. A merchant is registered with registerMerchant(handle, url, payTo), which costs merchantFee().

Products belong to a merchant. A product's key never changes; its name can (setProduct(productId, name)). Products are created with createProduct(merchantId, key, name, items) and their items are added or changed with setItem(productId, item). A product has three kinds of items. An item's kind never changes; items can be deactivated (active=false) but never removed. A changed price applies to later payments only; paid memberships keep their end.

kindValueFieldsPurchase functionRules
Registration0——Appears only in Paid events (the merchant registration fee)
Tier1level > 0, price = monthly price, credits = 0subscribe(productId, tierId, months, beneficiary, expectedPrice, orderId) / upgrade(productId, tierId, months, expectedRevision, expectedPrice, orderId)A user has at most one membership per product
Lifetime2price, level = 0, credits = 0buyLifetime(productId, itemId, beneficiary, expectedPrice, orderId)Each beneficiary can buy it once (AlreadyOwned) and owns it forever, independent of tiers
Pack3price per pack, credits > 0 per pack, level = 0buyPack(productId, packId, quantity, beneficiary, expectedPrice, expectedCredits, orderId)Repeatable. The chain keeps only the receipt; the merchant keeps the credits off chain

Every price is at least minPrice() (PriceTooLow).

Every payment is pulled from the caller (the payer) with ERC-20 transferFrom, so the payer first approves the contract for at least the total. In the same transaction it is split between the merchant's payTo (total − fee) and the treasury (fee); the contract never holds funds.

Membership rules

SituationFunctionRule
First purchase, or any tier after the membership has endedsubscribeNew generation, start = now
Same tier (switching monthly to yearly included)subscribeAllowed at any time, and anyone can pay for the beneficiary. Adds time to end; the generation stays the same
A different tier while the membership is runningsubscribeReverts with TierMismatch
Upgrade: a strictly higher levelupgradeOnly the beneficiary themselves (the caller). Starts now at full price; the rest of the current tier is given up, with no refund. Requires expectedRevision (MembershipChangedSinceQuote otherwise)
Downgrade, or another tier of the same level—Wait until the current membership ends, then subscribe

A membership counts during [start, end). Allowance cycles are the MONTH-long intervals from start, the last one cut at end. A purchase after expiry and an upgrade start a new generation; every change increases revision.

Every payment carries expected* arguments (price, credits). If the merchant changed the terms since the checkout page was loaded, the payment reverts with TermsChanged. An orderId must be nonzero (ZeroOrderId) and can be used only once per payer (OrderUsed), so a retried payment can never succeed twice.

#1.5 Events (indexers depend on them, 🔒)

EventPurpose
Paid(merchantId, productId, beneficiary, payer, kind, itemId, units, amount, fee, orderId)The only event for moving money, merchant registration included (then productId = 0, itemId = 0, orderId = 0, units = 1, beneficiary = payer = the registrant, amount = fee = merchantFee(), i.e. 20e6 at 6 decimals). Summing it gives all revenue: merchant net revenue = amount − fee; sub3 revenue = fee
MembershipChanged(productId, user, tierId, action, generation, revision, start, end, orderId)A membership changed. action: 1 = purchase, 2 = renewal, 3 = upgrade
MerchantRegistered, MerchantUpdated, MerchantTransferStarted, MerchantTransferredMerchant lifecycle
ProductCreated, ProductUpdated, ItemSetProduct and item lifecycle
Initialized(token, decimals)The stablecoin was set (once per chain)

#1.6 Functions, errors and views

Function signatures, error names and the return structures of views are defined by the ABI JSON and are not repeated here. Writes are listed in §1.4. Reads:

GroupViews
Setup and constantstoken(), treasury(), feeFlat(), minPrice(), merchantFee(), and the public constants FEE_BPS, MONTH, MAX_UNITS, MAX_ITEMS, MAX_KEY_LENGTH, MIN_HANDLE_LENGTH, MAX_NAME_LENGTH, MAX_URL_LENGTH, PURCHASE, RENEWAL, UPGRADE
RecordsgetMerchant(merchantId), getProduct(productId), itemIdsOf(productId), getItem(productId, itemId)
What a user hasmembershipOf(productId, user), ownsLifetime(productId, itemId, user), orderOf(payer, orderId)
Pricesquote(productId, itemId, units) → (total, fee, net)
StatisticsglobalStats(), merchantStats(merchantId), productStats(productId), itemStats(productId, itemId)
Lists (paged with offset, limit; oldest first, new entries appended)allMerchants(offset, limit), merchantsOf(owner, offset, limit), allProducts(offset, limit), productsOf(merchantId, offset, limit), productsBoughtBy(user, offset, limit), customers(productId, offset, limit), payments(productId, offset, limit), ordersOf(productId, user, offset, limit)

When reading:

  • orderOf(payer, orderId) returning paidAt == 0 means the order does not exist.
  • membershipOf returns (Membership, active); tierId == 0 means the user never subscribed.
  • Statistics counters wrap around on overflow instead of reverting, so they can never block a payment. Statistics do not include merchant registration fees.

#2. HTTP API v1 🧊

Base URL:

EnvironmentURL
Productionhttps://api.sub3.dev
SandboxThe api field of deployments/5042002.json (currently https://api-sandbox.sub3.dev)

This document uses the production URLs (https://api.sub3.dev, https://sub3.dev); in the sandbox, replace them with the api and site values of deployments/5042002.json.

The API is free, needs no API key and has no database: every answer is read from the contract. Every response allows any origin (Access-Control-Allow-Origin: *).

General conventions:

  • Parameters:
    • product is either acme/app or a 0x productId.
    • item is either a key (pro) or a 0x itemId.
    • Addresses are case-insensitive. Addresses in responses may be checksummed; compare them case-insensitively.
  • Amounts come both as a display string "5.00" (no suffix) and as an integer string in token units "5000000" (suffix Units). *Units are the stablecoin's smallest unit; the precision is decimals in /v1/network.
  • Times are Unix seconds (numbers).
  • Errors have the body {"error": "<message for people>", "code": "<machine-readable>"}.
HTTPcodeWhat the integrator should do
400bad_requestFix the parameters
401unauthorizedSign in again
403forbidden—
404unknown_merchant / unknown_product / unknown_item / unknown_order / not_foundTreat as "does not exist"
413too_large—
429rate_limitedRetry later; the response has a Retry-After header
502unavailableReading the chain failed, or the contract is not deployed yet. Treat as "unknown", never as "not paid"
500internal—
  • Rate limits per IP address and minute: 600 read requests, 300 RPC proxy requests, 60 sign-in requests (/v1/auth/*), and 120 new relay connections. Server-side integrations should cache /v1/access results for 30–60 seconds and force a refresh after a checkout completes.
  • Caching (informational, ✏️): merchants, products and quotes may be up to 10 seconds old, statistics 30 seconds, /v1/network 60 seconds. /v1/access, /v1/orders and /v1/merchants?owner= are never cached.

#2.1 Network: GET /v1/network

The chain and contract of this environment.

{ "environment": "production|sandbox|local", "chainId": 5042, "name": "Arc", "token": "0x…", "symbol": "USDC", "decimals": 6,
  "contract": "0x…", "explorer": "https://…", "addEthereumChain": { … }, "confirmations": 1, "testnet": false }
  • contract is null until the contract is deployed on this chain.
  • addEthereumChain is the parameter object for a wallet's wallet_addEthereumChain.
  • confirmations is the number of confirmations this environment requires (at least 1).

#2.2 Merchants

GET /v1/merchants/{handle}

{ "handle": "acme", "merchantId": "0x…", "owner": "0x…", "pendingOwner": "0x000…", "payTo": "0x…",
  "url": "https://acme.com", "registeredAt": 1800000000,
  "verified": { "domain": "acme.com", "logo": "https://api.sub3.dev/v1/merchants/acme/logo" } }

verified is null when the domain is not verified (rules in §4).

GET /v1/merchants/{handle}/logo

  • When verified: the logo image bytes (proxied and cached).
  • Otherwise: 404.

GET /v1/merchants/{handle}/products

{ "products": [ { "key": "app", "productId": "0x…", "name": "App" } ] }

GET /v1/merchants?owner=0x…

{ "merchants": [ "acme", … ] }

#2.2a Sites: GET /v1/sites

GET /v1/sites?origin=https://app.acme.com&merchant=acme

Whether the site that opened the login or checkout page (its origin, as reported by the browser) declares that it belongs to this merchant, and its logo. Rules in §4. origin must be an https origin.

{ "origin": "https://app.acme.com", "merchant": "acme", "verified": true, "domain": "app.acme.com",
  "logo": "https://api.sub3.dev/v1/sites/logo?origin=…&merchant=acme" }

When verified is false, domain and logo are null.

GET /v1/sites/logo?origin=…&merchant=… returns the logo image bytes, or 404 when the site does not pass.

#2.3 Products: GET /v1/products/{handle}/{key}

Also accepts /v1/products/0x<productId>.

{ "product": "acme/app", "productId": "0x…", "key": "app", "name": "App",
  "merchant": { …as in 2.2, without products… },
  "items": [ { "key": "pro", "itemId": "0x…", "kind": "tier|lifetime|pack", "name": "Pro", "active": true,
               "level": 2, "price": "20.00", "priceUnits": "20000000", "credits": "0" } ],
  "chainId": 5042, "contract": "0x…", "token": "0x…" }
  • level is nonzero only for tiers; credits is nonzero only for packs.
  • A tier's price is its monthly price; a year costs price × 10.

#2.4 Access: GET /v1/access

GET /v1/access?product=acme/app&user=0x…[&view=confirmed|latest]

To decide what a user may use, call this endpoint and nothing else.

{ "product": "acme/app", "productId": "0x…", "user": "0x…",
  "membership": null | { "active": true, "tier": "pro", "tierId": "0x…", "level": 2,
      "start": 0, "end": 0, "generation": "1", "revision": "3",
      "cycle": { "id": "0x…", "index": "0", "start": 0, "end": 0 } | null },
  "lifetime": [ "forever" ],
  "chainId": 5042, "contract": "0x…", "blockNumber": 123, "confirmed": true, "checkedAt": 1800000000 }
  • membership is null when the user never subscribed. After a membership ends it is still returned, with active: false.
  • cycle is the current allowance cycle (§1.4), null unless the membership is active. cycle.id = keccak256(abi.encode(chainId, contract, productId, user, generation, index)), all integers as uint256.
    • A backend grants a cycle's allowance at most once per cycle.id.
    • An upgrade starts a new generation, so cycle.id always changes.
  • lifetime lists the keys of the lifetime items the user owns.
  • view defaults to confirmed: the state at the newest block with the required confirmations. latest reads the newest block; the checkout page uses it to refresh.

#2.5 Orders

GET /v1/orders/{payer}/{orderId}

The receipt of one order, of any kind. 404 unknown_order when it does not exist.

{ "payer": "0x…", "orderId": "0x…", "product": "acme/app", "productId": "0x…", "item": "credits", "itemId": "0x…",
  "kind": "tier|lifetime|pack", "action": "purchase|renewal|upgrade", "user": "0x…", "units": 3,
  "credits": "3000", "amount": "30.00", "amountUnits": "30000000", "paidAt": 0, "blockNumber": 0,
  "generation": "0", "revision": "0",
  "confirmations": 2, "requiredConfirmations": 1, "confirmed": true, "chainId": 5042, "contract": "0x…" }
  • user is the beneficiary. units are months (tier), packs (pack) or 1 (lifetime).
  • action is always purchase for lifetime items and packs. credits is the total granted by this order (packs only); generation and revision are the membership after this payment (tiers only), "0" otherwise.

GET /v1/orders?product=acme/app&user=0x…[&offset=0&limit=50]

Every order with this user as beneficiary, whoever paid; use it to recover lost callbacks. offset defaults to 0, limit to 50 (1–100). Orders are in payment order (oldest first) and new orders are only ever appended, so a client can store nextOffset and continue from there next time.

{ "total": "3", "offset": 0, "limit": 50, "nextOffset": "3", "orders": [ …as above… ] }

#2.6 Quotes: GET /v1/quote

GET /v1/quote?product=acme/app&item=pro&units=12

What units of an item cost now and how the payment is split. units defaults to 1 and is 1–120 (exactly 1 for lifetime items). Example for a $10/month tier, 12 months:

{ "units": 12, "total": "100.00", "fee": "1.05", "net": "98.95",
  "totalUnits": "100000000", "feeUnits": "1050000", "netUnits": "98950000" }

#2.7 Statistics: GET /v1/stats

GET /v1/stats[?merchant=acme | ?product=acme/app] (one parameter at most)

ParameterReturns
NoneGlobal: { "merchants": 1, "products": 1, "revenue": "…", "revenueUnits": "…", "fees": "…", "feesUnits": "…", "payments": 0, "customers": 0 }
merchantThe same revenue, fee, payment and customer fields for the merchant, plus its number of products
productThe same revenue, fee, payment and customer fields for the product, plus items: [ { "key", "revenue", "revenueUnits", "payments", "customers" } ]

revenue is what payers paid (the merchant received revenue − fees); customers counts distinct beneficiaries. Merchant registration fees are not included.

#2.8 Sign-in and RPC

EndpointDescription
POST /v1/auth/nonce→ {nonce, issuedAt, expiresAt, domain, uri}. A nonce is valid for 5 minutes
POST /v1/auth/verifyBody {message, signature} → {address, audience, token, expiresAt}
GET /.well-known/jwks.jsonThe public keys that sign session tokens (on the API host)
POST /v1/rpc/{chainId}Read-only JSON-RPC proxy for this environment's chain only (5042 or eip155:5042); see below
/relay/v1/<room>PeerConnect v1 relay (§6)

Session tokens (JWT):

  • Algorithm EdDSA (Ed25519); kid identifies the key, for rotation.
  • iss = the site origin (https://sub3.dev).
  • aud = the integrator's origin.
  • sub = the lowercase address.
  • Valid for 7 days (exp = iat + 7 days).

Signatures are checked as plain wallet signatures, or with ERC-1271 for smart accounts, on this environment's chain.

SIWE message template 🧊: server/src/api/auth.rs is authoritative, and website/src/lib/siwe.ts must match it byte for byte:

{domain} wants you to sign in with your Ethereum account:
{checksummed address}

Sign in to {integrator origin} with sub3.

URI: {uri}
Version: 1
Chain ID: {chainId}
Nonce: {nonce}
Issued At: {issuedAt}
Resources:
- {integrator origin}

domain and uri come from /v1/auth/nonce (sub3.dev and https://sub3.dev/login).

RPC proxy: the body is a JSON-RPC request or a batch of at most 32, at most 64 KB. Allowed methods: eth_chainId, eth_blockNumber, eth_call, eth_estimateGas, eth_getBalance, eth_gasPrice, eth_maxPriorityFeePerGas, eth_feeHistory, eth_getTransactionReceipt, eth_getTransactionByHash, eth_getTransactionCount, eth_getCode. eth_call and eth_estimateGas may only target the sub3 contract or the stablecoin (403 forbidden otherwise).


#3. Hosted pages 🧊

Site:

EnvironmentURL
Productionhttps://sub3.dev
SandboxThe site field of deployments/5042002.json (currently https://sandbox.sub3.dev)

#3.1 How pages are opened, and how results come back

A page can be opened in two ways:

  • Popup: the page sends {type:"sub3:ready"} to window.opener, and the integrator answers {type:"sub3:hello"} within 4 seconds. The page takes the answer's MessageEvent.origin as the site, and from then on sends results to that origin only, then closes itself:
    • Success: {type:"sub3:result", result}
    • Cancel: {type:"sub3:cancel", reason}
  • Redirect: given with a return=<url> parameter (an http or https URL). The page takes that URL's origin as the site and navigates back to the URL with the result as its fragment (replacing any fragment it had), so the result never reaches a server log:
    • Success: #sub3=<base64url(JSON)> (UTF-8 JSON, unpadded base64url)
    • Cancel: #sub3_error=<urlencoded reason>

If a popup gets no sub3:hello in time, the page falls back to return when it is given.

Cancel reasons: cancelled (the user cancelled), already_owned (checkout: the user already owns this lifetime item). Integrators must accept reasons they do not know.

#3.2 /login

Parameters: ?[return=…][&merchant=acme] (return is needed for redirects only)

  • If merchant is given and the opening site's /.well-known/sub3.json names that merchant (§4), the page shows the site's logo; otherwise only its origin.
  • Result: { "address", "token", "expiresAt", "siwe": { "message", "signature" } }. token is the session token (§2.8); siwe is the signed sign-in message, for integrators that verify the signature themselves.

#3.3 /checkout

Parameters: ?product=acme/app&item=pro[&units=1][&user=0x…][&return=…]

product and item accept the same forms as the API (§2).

units:

Item kindMeaningDefault
tierMonths (12 = a year)1
packNumber of packs1
lifetimeIgnored—

units is a whole number from 1 to 120. When it is given, the page charges exactly that; when it is not, the page starts at 1 and lets the payer choose.

user: the beneficiary. Defaults to the paying wallet.

The page decides the action from the chain:

StateAction
Never subscribed, or the membership has endedPurchase
The same tier is runningRenewal
A lower tier is running and the payer is the beneficiaryUpgrade. The page states clearly that the rest of the current term is given up, and the user must confirm before paying
Anything elseShows "can be changed after the current membership ends", with the end date

Price, payee and action always come from the chain, never from the link. The page always shows the merchant's identity (logo, @handle, verified domain).

Result:

{ "type": "tier|lifetime|pack", "action": "purchase|renewal|upgrade", "product": "acme/app", "productId": "0x…",
  "item": "pro", "itemId": "0x…", "user": "0x…", "payer": "0x…", "orderId": "0x…", "units": 1,
  "txHash": "0x…", "chainId": 5042, "contract": "0x…" }

txHash is null when the page found the order already paid (for example, a retry after a reload).

A checkout result is only a hint to refresh. Integrators must check it with /v1/access or /v1/orders/{payer}/{orderId} and never trust the callback itself.


#4. Merchant and product website verification 🧊

Two identities, one file:

IdentityWhere the website comes fromShown on
MerchantThe merchant url registered on chain (set by the merchant owner)Checkout, "who you pay": merchant logo, @handle, domain ✓
Product websiteThe site that opened the login or checkout page; the browser reports its origin, nothing to set upCheckout, "what you buy", and login: that website's own logo and domain

Both are verified by reading the website's /.well-known/sub3.json, whose merchant must equal the merchant's handle. A merchant can have any number of product websites, each with its own copy of the file. A website can claim any merchant, but payments always go to the merchant on chain, so the claim affects only what is displayed, never where money goes; the merchant's own ✓ is what users trust.

The same rules apply to both:

  1. The merchant url must start with https:// and use the default port. The verified domain is its host. (For a product website, its origin must be https likewise.) Exception: only in local development (SUB3_ENVIRONMENT=local) are http://localhost[:port] and http://127.0.0.1[:port] also accepted, so developers can preview their logo before deploying. Sandbox and production accept HTTPS only.
  2. sub3 reads https://<domain>/.well-known/sub3.json (it must answer 200 within 3 seconds, at most 16 KB):
    { "merchant": "acme", "logo": "/logo.png" }
  3. Verification passes only if all of these hold:
    • merchant equals the handle.
    • logo resolves, relative to the sub3.json URL, to the same origin: a path such as /logo.png, or an absolute https://<domain>/… URL.
    • The logo answers 200 with a png, jpeg, webp or svg content type and is at most 256 KB.
  4. Results, passed or failed, are cached for 10 minutes. Changing the merchant url is checked again at once.

Why not just read /logo.png: an impostor could set its url to someone else's website and borrow their logo. Only whoever controls the domain can place sub3.json there, so the file is required.


#5. Entry points (for agents and people) 🧊

These URL paths on the site never change:

PathContent
/llms.txtThe complete integration guide, in one fetch
/examples.txtThe list of example files
/examples/<path>One example file
/sub3.mjsThe management CLI (register a merchant, create products, set items, read statistics); needs viem
/specThis specification as a web page
/spec.mdThis specification as Markdown (text/markdown)
/abi.jsonThe contract ABI, interfaces/abi/Sub3Checkout.v2.json (application/json)

#6. Transport protocols 🔒

  • PeerConnect v1: relay path /relay/v1/<room> on the API host. The wire format is locked in server/pair/test-vectors/v1.json and enforced by tests.
  • WalletPair v2: message formats identical to upstream 83b45d7 (see website/src/lib/walletpair/README.md).

#7. Browser storage keys 🧊 (Maintainers)

Used by sub3.dev's own pages; integrators never read them.

KeyStoragePurpose
sub3.bridge.sessions.v1localStorageWallet bridge sessions
sub3.dapp.room.v1localStoragePairing room
sub3.dapp.sites.v1localStorageAuthorized sites
sub3.order.v2.<key>sessionStorageReuses the same orderId when a payment is retried, so it is never charged twice

Renaming a key loses sessions, or charges twice on a retry. To change a structure, use a new version suffix and migrate the old data.


#8. Deployment configuration (Maintainers)

  • deployments/<chainId>.json (and deployments/local.json for local development) records { environment, chainId, name, abi, salt, initialized, address, token, decimals, treasury, deployTx, block, api, site }. salt is the salt's preimage (sub3.checkout.v2); address is null until deployed.
  • NETWORK in server/wrangler.toml must agree with it (chain, token, decimals, contract); node scripts/check-interfaces.mjs checks this in CI.

#9. Changelog

DateChange
2026-11-12Specification rewritten in English as "sub3 Specification v1" and published at /spec, /spec.md, with the ABI at /abi.json. Corrected to match the code: the login result also carries siwe; checkout txHash can be null; cancel reasons listed; /v1/stats includes feesUnits; Initialized event; registration Paid uses merchantFee(); logo URLs may be any same-origin reference; deployment records list their real fields
2026-11-12The constructor takes only the treasury; the Safe sets the token with initialize, 6–18 decimals supported, same address on every chain; dollar constants became feeFlat() / minPrice() / merchantFee()
2026-11-12v2 contract and API v1 finalized (not yet on mainnet). Removed the owner, notBefore and the old standalone plan mode; added merchants, three item kinds, the single Paid event, /v1/access and /v1/orders