# sub3 Specification v1

| | |
|---|---|
| Version | v1 (contract ABI v2, HTTP API v1) |
| Status | Frozen once Arc Mainnet is live; until then may change, recorded in the changelog (§9) |
| Read it at | https://sub3.dev/spec · Markdown: https://sub3.dev/spec.md · ABI: https://sub3.dev/abi.json |
| Source | `docs/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:

| Mark | Level | Applies to | Allowed changes |
|---|---|---|---|
| 🔒 | Permanent | Deployed on chain, or data formats third parties keep for a long time | None. Only a new version (new contract and salt, new `/v2` path); the old version keeps working |
| 🧊 | Frozen | HTTP API, hosted page parameters, message formats, URL paths | Additive only within v1: new fields, parameters and endpoints. Nothing is renamed, removed, or changes meaning or type |
| ✏️ | Mutable | Page appearance, wording, internal implementation | Any 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: `0x5F7c7E293F3EaDBc730EF62Cf9B6ee11f2d60dF7` on Arc Mainnet (5042) and Arc Testnet (5042002), and on any
  chain it is deployed to later. It is also the `contract` field of `GET /v1/network` (§2.1) and `address` in
  `deployments/<chainId>.json` (`null` on a chain where it is not deployed yet).

### 1.1 Deployment

| Item | Value |
|---|---|
| Method | CREATE2 through the universal factory `0x4e59b44847b379578588920cA78FbF26c0B4956C` (Arachnid's keyless deployer) |
| Salt | `keccak256("sub3.checkout.v2")` |
| Constructor arguments | Only `treasury` |
| Address | Depends 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 |
| Initialization | After 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` |
| Administrator | None. No owner, not upgradeable, cannot be paused. `initialize` can be called only once; after that the treasury has no privileges and only receives fees |
| Treasury | sub3'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 settings | `contracts/foundry.toml` (solc 0.8.30, `via_ir`, `optimizer_runs` 200, no metadata hash). Changing any of them changes the address |
| New version | A 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)

| ID | Formula | Example |
|---|---|---|
| merchantId | `keccak256(bytes(handle))` | `keccak256("acme")` |
| productId | `keccak256(bytes(handle + "/" + productKey))` | `keccak256("acme/app")` |
| itemId | `keccak256(bytes(itemKey))`, unique within its product | `keccak256("pro")` |
| orderId | Any 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)

| Constant | Value |
|---|---|
| Fee | 1% 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 |
| `MONTH` | 30 days |
| Year | 12 months = 360 days, billed as 10 months. Every full 12 months is billed as 10, e.g. 14 months bill 12 |
| `MAX_UNITS` | 120 (at most 120 months or 120 packs in one payment) |
| `MAX_ITEMS` | 32 items per product |
| `MAX_NAME_LENGTH` | 64 bytes (product and item names) |
| `MAX_URL_LENGTH` | 200 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**

| Field | Changeable |
|---|---|
| handle | Never |
| owner | Yes, in two steps: `transferMerchant(merchantId, newOwner)` → `acceptMerchant(merchantId)` (a zero `newOwner` cancels) |
| payTo (payout address) | Yes, `setMerchant(merchantId, payTo, url)` |
| url | Yes, `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.

| kind | Value | Fields | Purchase function | Rules |
|---|---|---|---|---|
| Registration | 0 | — | — | Appears only in `Paid` events (the merchant registration fee) |
| Tier | 1 | `level` > 0, `price` = monthly price, `credits` = 0 | `subscribe(productId, tierId, months, beneficiary, expectedPrice, orderId)` / `upgrade(productId, tierId, months, expectedRevision, expectedPrice, orderId)` | A user has at most one membership per product |
| Lifetime | 2 | `price`, `level` = 0, `credits` = 0 | `buyLifetime(productId, itemId, beneficiary, expectedPrice, orderId)` | Each beneficiary can buy it once (`AlreadyOwned`) and owns it forever, independent of tiers |
| Pack | 3 | `price` per pack, `credits` > 0 per pack, `level` = 0 | `buyPack(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**

| Situation | Function | Rule |
|---|---|---|
| First purchase, or any tier after the membership has ended | `subscribe` | New generation, `start = now` |
| Same tier (switching monthly to yearly included) | `subscribe` | Allowed 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 running | `subscribe` | Reverts with `TierMismatch` |
| Upgrade: a strictly higher level | `upgrade` | Only 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, 🔒)

| Event | Purpose |
|---|---|
| `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`, `MerchantTransferred` | Merchant lifecycle |
| `ProductCreated`, `ProductUpdated`, `ItemSet` | Product 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:

| Group | Views |
|---|---|
| Setup and constants | `token()`, `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` |
| Records | `getMerchant(merchantId)`, `getProduct(productId)`, `itemIdsOf(productId)`, `getItem(productId, itemId)` |
| What a user has | `membershipOf(productId, user)`, `ownsLifetime(productId, itemId, user)`, `orderOf(payer, orderId)` |
| Prices | `quote(productId, itemId, units)` → `(total, fee, net)` |
| Statistics | `globalStats()`, `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, the same for the API and the hosted pages (§3) of an environment:

| Environment | URL |
|---|---|
| Production | `https://sub3.dev` (Arc Mainnet) |
| Sandbox | `https://sub3.dev/sandbox` (Arc Testnet) |

This document uses the production URLs. In the sandbox every path is the same under `https://sub3.dev/sandbox`
(`/sandbox/v1/access`, `/sandbox/login`, `/sandbox/.well-known/jwks.json`); the answers have the same shape, on the
sandbox's network, with its own sign-in keys. The `api` and `site` fields of `deployments/<chainId>.json` hold the
same base URLs.

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>"}`.

| HTTP | code | What the integrator should do |
|---|---|---|
| 400 | `bad_request` | Fix the parameters |
| 401 | `unauthorized` | Sign in again |
| 403 | `forbidden` | — |
| 404 | `unknown_merchant` / `unknown_product` / `unknown_item` / `unknown_order` / `not_found` | Treat as "does not exist" |
| 413 | `too_large` | — |
| 429 | `rate_limited` | Retry later; the response has a `Retry-After` header |
| 502 | `unavailable` | Reading the chain failed, or the contract is not deployed yet. **Treat as "unknown", never as "not paid"** |
| 500 | `internal` | — |

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

```json
{ "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}`**

```json
{ "handle": "acme", "merchantId": "0x…", "owner": "0x…", "pendingOwner": "0x000…", "payTo": "0x…",
  "url": "https://acme.com", "registeredAt": 1800000000,
  "verified": { "domain": "acme.com", "logo": "https://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`**

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

**`GET /v1/merchants?owner=0x…`**

```json
{ "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.

```json
{ "origin": "https://app.acme.com", "merchant": "acme", "verified": true, "domain": "app.acme.com",
  "logo": "https://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>`.

```json
{ "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.**

```json
{ "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.

```json
{ "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.

```json
{ "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:

```json
{ "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)

| Parameter | Returns |
|---|---|
| None | Global: `{ "merchants": 1, "products": 1, "revenue": "…", "revenueUnits": "…", "fees": "…", "feesUnits": "…", "payments": 0, "customers": 0 }` |
| `merchant` | The same revenue, fee, payment and customer fields for the merchant, plus its number of `products` |
| `product` | The 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

| Endpoint | Description |
|---|---|
| `POST /v1/auth/nonce` | → `{nonce, issuedAt, expiresAt, domain, uri}`. A nonce is valid for 5 minutes |
| `POST /v1/auth/verify` | Body `{message, signature}` → `{address, audience, token, expiresAt}` |
| `GET /.well-known/jwks.json` | The public keys that sign this environment's session tokens (`/sandbox/.well-known/jwks.json` in the sandbox) |
| `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 environment's base URL: `https://sub3.dev`, or `https://sub3.dev/sandbox` in the sandbox. Each
  environment signs with its own keys, so a sandbox token never verifies as a production one.
- `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:

```text
{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` (in the sandbox,
`sub3.dev` and `https://sub3.dev/sandbox/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 (the base URL of §2):

| Environment | URL |
|---|---|
| Production | `https://sub3.dev` |
| Sandbox | `https://sub3.dev/sandbox` |

### 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 kind | Meaning | Default |
|---|---|---|
| tier | Months (12 = a year) | 1 |
| pack | Number of packs | 1 |
| lifetime | Ignored | — |

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

| State | Action |
|---|---|
| Never subscribed, or the membership has ended | Purchase |
| The same tier is running | Renewal |
| A lower tier is running and the payer is the beneficiary | Upgrade. The page states clearly that the rest of the current term is given up, and the user must confirm before paying |
| Anything else | Shows "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:

```json
{ "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:

| Identity | Where the website comes from | Shown on |
|---|---|---|
| Merchant | The merchant `url` registered on chain (set by the merchant owner) | Checkout, "who you pay": merchant logo, @handle, domain ✓ |
| Product website | The site that opened the login or checkout page; the browser reports its origin, nothing to set up | Checkout, "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 and the logo must answer 200 within 5 seconds; the file at most 16 KB):
   ```json
   { "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 an `https` URL: a path such as `/logo.png`, or a logo on
     any other HTTPS host (a CDN, the brand's main site). Plain `http` only on the site's own origin, which happens
     only in local development.
   - The logo answers 200 with a `png`, `jpeg`, `webp` or `svg` content type and is at most 256 KB.
4. A passed check is cached for 10 minutes, a failed one for 1 minute. 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:

| Path | Content |
|---|---|
| `/llms.txt` | The complete integration guide, in one fetch |
| `/examples.txt` | The list of example files |
| `/examples/<path>` | One example file |
| `/sub3.mjs` | The management CLI (register a merchant, create products, set items, read statistics); needs `viem` |
| `/spec` | This specification as a web page |
| `/spec.md` | This specification as Markdown (`text/markdown`) |
| `/abi.json` | The 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.

| Key | Storage | Purpose |
|---|---|---|
| `sub3.bridge.sessions.v1` | localStorage | Wallet bridge sessions |
| `sub3.dapp.room.v1` | localStorage | Pairing room |
| `sub3.dapp.sites.v1` | localStorage | Authorized sites |
| `sub3.order.v2.<key>` | sessionStorage | Reuses 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.
- One Worker (`server/`, the `sub3` Worker on `sub3.dev`) serves the website, the API and the relay of both public
  environments, from one build: production at `/`, the sandbox under `/sandbox`. Production reads `NETWORK`, `SITE`,
  `AUTH_SECRET` and `JWT_KEYS`; the sandbox reads the same names with the prefix `SANDBOX_`. RPC endpoints are
  per chain (`RPC_<chainId>`). The two environments never share signing keys.
- Staging (`--env staging`, `https://staging.sub3.dev/sandbox`) runs the next release on the sandbox's network and
  contract with its own secrets, before it ships to `sub3.dev`.
- `NETWORK` and `SANDBOX_NETWORK` in `server/wrangler.toml` (and staging's `SANDBOX_NETWORK`) must agree with the
  deployment records (chain, token, decimals, contract); `node scripts/check-interfaces.mjs` checks this in CI.

---

## 9. Changelog

| Date | Change |
|---|---|
| 2026-11-12 | One domain: the sandbox moved from `sandbox.sub3.dev` and `api-sandbox.sub3.dev` to `https://sub3.dev/sandbox`, and the production API from `api.sub3.dev` to `https://sub3.dev` (`/v1`, `/relay`, `/.well-known/jwks.json` under each base). The sandbox's token issuer is `https://sub3.dev/sandbox`. Sub3Checkout deployed at `0x5F7c…dF7` on Arc Mainnet and Arc Testnet |
| 2026-11-12 | Specification 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-12 | The 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-12 | v2 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` |
