Docs
Contents

Gateway page API

Each ZOL operator runs zol-gateway serve, which serves a small public HTTP API to app pages (browsers, scripts). This page documents that API as implemented, plus the deposit memo and payment link a page builds before calling it.

Sources:

Base URLs

Testnet beta (Zcash testnet, Solana devnet), three operators behind one Caddy:

OperatorBase URL
op0https://zol.cash/op0
op1https://zol.cash/op1
op2https://zol.cash/op2

Testnet app (zol-coinflip program): 7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo.

Every operator lists all operators' base URLs in AppInfo.operators, so a page can start from one URL and discover the rest.

Conventions

Endpoints

MethodPathReturns
GET/healthoperator name and halt flag
GET/v1/apps/{app}AppInfo
GET/v1/apps/{app}/sessions/{session}SessionInfo
POST/v1/apps/{app}/intentsSessionInfo
GET/v1/apps/{app}/sessions/{session}/depositsDepositStatus[]
POST/v1/apps/{app}/sessions/{session}/payoutPayoutCheckResult

POST /peer/* is the operator-to-operator API (attestation and spend signing rounds, chain reports, intent gossip). It requires signed x-zol-peer / x-zol-ts / x-zol-nonce / x-zol-sig headers from a configured peer guardian key and is not public; the testnet Caddy returns 404 for /opN/peer/*. It is not documented here.


GET /health

Liveness of this operator. Any method on /health answers the same.

Response 200:

FieldTypeMeaning
okboolalways true
namestringoperator name (op0, …)
haltedstring | nullreason this operator's node halted (it then signs nothing), or null
curl -s https://zol.cash/op0/health
# {"halted":null,"name":"op0","ok":true}

GET /v1/apps/{app}

Everything a page needs before building a deposit memo.

Response 200 — AppInfo:

FieldTypeMeaning
appstringprogram ID, base58
networkstring"mainnet" | "testnet" | "regtest" (Zcash network)
vault_addressstringthe app vault's external receiver at diversifier index 0, raw 43 bytes hex. The deposit memo signature covers it. Not the address to pay (see SessionInfo.ua).
denominationsnumber[]permitted deposit amounts in zatoshi (ledger Config.params.denominations, zeros dropped). A deposit of any other value is voided.
tipnumberZcash tip height this operator's scanner has seen (0 before the first block). Memo expiry is a height.
early_depthnumberblocks after which a deposit can be credited early (subject to cap/float)
full_depthnumberblocks for full depth
statestringledger App state: "unregistered" (no App account), "pending", "activating", "active", "frozen". activating is reported as active once its activation time has passed, even if the ledger has not flipped it yet.
operatorsstring[]every operator's public page API base URL, in coordinator rotation order

early_depth and full_depth are converted to plain block counts at the current tip. Internally the gateway counts depth in 25 s blocks, with each pre-NU7 (75 s) block counting three times (DepthRule in gateway/src/policy.rs), so these values can change as the tip moves across the NU7 height.

Errors:

StatusWhen
400{app} is not base58 ("app must be a base58 program id")
503the ledger (Solana RPC) read failed ("ledger unavailable, try again: …"). Checked before the unknown-app check.
404the operator does not serve this app
curl -s https://zol.cash/op0/v1/apps/7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo
{"app":"7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo","network":"testnet",
 "vault_address":"25e45332989eeaf6341def5f6596421d3ad7f384227d1ad6f30c17d86bc949a2e1874d27809b260f92ee05",
 "denominations":[1000000,10000000],"tip":4489919,"early_depth":3,"full_depth":60,"state":"active",
 "operators":["https://zol.cash/op0","https://zol.cash/op1","https://zol.cash/op2"]}

GET /v1/apps/{app}/sessions/{session}

The session's own deposit address and whether an intent is registered for it on this operator. Any 32-byte value is accepted as {session}; it is not checked to be a valid key.

Response 200 — SessionInfo:

FieldTypeMeaning
uastringthe session's own vault receiver as an Orchard-only unified address. Put this in the payment link.
receiver_indexstring88-bit diversifier index of that receiver, decimal string (exceeds JS number precision). Derived as the first 11 bytes, little-endian, of BLAKE2b-256(personal "ZOL_SessionRecv1", app_id ‖ session_key) (session_receiver_index in zol-types).
intent_registeredboolthis operator holds an intent for this session's receiver

Errors: 400 ("session must be 32-byte hex", bad app), 404 (unknown app), 502 (the operator's zol-zcashd could not derive the receiver).

curl -s https://zol.cash/op1/v1/apps/7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo/sessions/00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
# {"ua":"utest14zzmgm5v…","receiver_index":"178960308578357930303823353","intent_registered":false}

POST /v1/apps/{app}/intents

Registers a signed deposit memo ("intent") before the player pays. A wallet that drops or mangles the memo can then still be credited: the gateway matches the note by the session receiver it was paid to and uses the registered memo instead.

Request body — Intent:

{ "memo": "<hex>" }

memo is the hex of either the canonical 512-byte binary memo or the bytes of its ZOL1: text form (see Deposit memo). Up to 512 bytes; shorter input is zero-padded to 512.

Validation (Node::register_intent): the memo must decode as a v2 deposit memo (binary or text), and its session signature must verify against this operator's network and the app's vault_address. Expiry, denomination and payout address are not checked here; they are checked when the deposit note arrives. The memo is stored in canonical binary form, keyed by (app, session receiver index): registering again for the same session replaces the earlier intent.

On success the operator passes the intent on to every other operator over the peer API (gossip_intent in server.rs): in background threads, retrying after 1, 4, 16 and 64 s, and giving up silently if a peer stays unreachable or refuses it. A receiving operator stores it but does not gossip further. Gossip is best-effort; see Registering with every operator.

Response 200 — SessionInfo for the memo's session key (intent_registered is true).

Errors:

StatusBody error
400body must be {"memo": hex}, memo hex, memo longer than 512 bytes
400bad intent: not a deposit memo, bad intent: bad session signature, store: …
404unknown app
502intent stored (and gossiped) but zol-zcashd failed to return the session receiver
curl -s -X POST -H 'content-type: application/json' \
  -d "{\"memo\":\"$MEMO_HEX\"}" \
  https://zol.cash/op0/v1/apps/7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo/intents

$MEMO_HEX is produced by hex(canonicalMemo(buildSignedMemo(...))) (see typescript-client.md).


GET /v1/apps/{app}/sessions/{session}/deposits

Deposit notes this operator has scanned for the session: notes paid to the session's receiver, or notes anywhere in the vault whose memo names this session key. Rolled-back notes are omitted. Read errors in the operator's store yield [].

Response 200 — array of DepositStatus:

FieldTypeMeaning
cmxstringnote commitment, hex (internal byte order)
valuenumberzatoshi
heightnumberZcash block height of the note
confirmationsnumbertip - height + 1 in plain blocks (0 if above tip); compare with AppInfo.early_depth/full_depth
statestringsee below
txidstringpaying transaction, hex in display order (as wallets show it)

state:

ValueMeaning
seenscanned, no decision yet (waiting for depth or a signing round)
heldno usable memo/intent, or a payout address that cannot be refunded to (invalid, or a vault's own receiver); waits for the sender to prove the payment and an operator to release it
crediteda credit attestation was signed; the amount is on the session's ledger balance
voideda void attestation was signed; a refund to the memo's payout address is pending
refundedvoided, and the refund spend has been seen

Void reasons (from decide.rs): bad session signature, expired (tip >= expiry_height), amount not a denomination, app not accepting deposits, over the deposit cap, payout address different from the one already bound to the session's balance, or released by an operator. The reason is not returned by this endpoint.

Errors: 400 (bad session/app), 404 (unknown app).

curl -s https://zol.cash/op0/v1/apps/7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo/sessions/$SESSION_HEX/deposits
# [{"cmx":"…","value":1000000,"height":4489900,"confirmations":4,"state":"credited","txid":"…"}]

POST /v1/apps/{app}/sessions/{session}/payout

Asks whether an address is the payout address bound to the session's ledger balance. Answers only yes/no; the bound address is never returned. Use it on a new device before signing a deposit memo: a deposit naming a different payout address than the bound one is voided.

Request body — PayoutCheck:

{ "address": "<43-byte raw receiver, hex>" }

Response 200 — PayoutCheckResult:

FieldTypeMeaning
boundboolthe session has a ledger Balance account, so a payout commitment is bound
matchesbool | nullwhether address is the bound address; null when bound is false

The check recomputes the payout commitment from address, the session and the app using this operator's app secret and compares it with Balance.payout_commitment on the ledger.

Errors:

StatusBody error
400session must be 32-byte hex, body must be {"address": hex}, address must be a 43-byte receiver in hex
503ledger read failed (checked before the unknown-app check)
404unknown app
curl -s -X POST -H 'content-type: application/json' \
  -d '{"address":"<86 hex chars>"}' \
  https://zol.cash/op0/v1/apps/7G2o74qyLh477EuWUFdgumeB13kTCK1cuHgKoRqM1yUo/sessions/$SESSION_HEX/payout
# {"bound":false,"matches":null}

Registering with every operator

Each operator decides each deposit from its own scan and its own store. A note that carries its memo on chain needs no intent. A note whose memo the wallet dropped can only be credited by operators that hold the intent for its session receiver, and a credit needs a threshold of operators.

What the code does:

Recommendation: register with every URL in AppInfo.operators and check intent_registered on each (via GET …/sessions/{session}) rather than relying on gossip alone. The two-player page (main.ts) registers with every gateway in its comma-separated VITE_GATEWAY; the games pages (house-page.ts) register with every URL in AppInfo.operators.

Deposit memo

Canonical form: 512 bytes, version 2 (DepositMemo in zol-wire).

BytesFieldNotes
00xFFZIP 302 "arbitrary data" marker
1–3"ZOL"
4kind/version0x02 = deposit v2 (0x10 head, 0x11 top-up are vault-internal)
5flagsbit 0 FLAG_AUTO_EXIT (exit automatically after cash-out); other bits must be 0
6–37session public keyEd25519, 32 bytes
38–80payout addressraw 43-byte Orchard-format receiver; refunds and payouts go here
81–84expiry heightu32 LE; the deposit is voided if the tip reaches it before a decision
85–116pot IDreserved; the page writes zeros
117–180session signatureEd25519, 64 bytes
181–182app data lengthu16 LE, ≤ 329
183–511app datathen zero padding (non-zero padding is rejected)

What the session key signs

Ed25519 (pure, no prehash; verified with verify_strict) over the 508-byte message (DepositMemo::signing_message):

"ZOL/memo-sig/v2\0"        16 bytes, domain tag
network                     1 byte: 0 mainnet, 1 testnet, 2 regtest
vault_address              43 bytes: AppInfo.vault_address
memo[0..117]              117 bytes: header, flags, session key, payout address, expiry, pot ID
memo[181..512]            331 bytes: app data length and app data

So the signature binds every memo field except itself, plus the network and the app's vault.

Text form (ZOL1:)

Many wallets drop or mangle a binary 0xFF memo from a ZIP 321 link, so the link carries a text memo instead:

"ZOL1:" ‖ base64url_nopad( canonical_memo[1..] with trailing zero bytes stripped )

It is ASCII, at most 511 bytes (Zingo's text limit), which limits app data to 197 bytes in this form (TEXT_APP_DATA_MAX). Decoding is strict (only canonical base64url). The scanner and POST /intents accept either form; the signature and all digests are over the canonical binary memo.

Built by depositLink in wire.ts, called from prepareDeposit:

zcash:{SessionInfo.ua}?amount={ZEC decimal}&memo={base64url_nopad(utf8("ZOL1:…"))}

Typical flow

  1. GET /v1/apps/{app} → check state == "active", pick an amount in denominations, read network, vault_address, tip, operators.
  2. (New device, existing session) POST …/sessions/{session}/payout → if bound && !matches, ask for the original payout address.
  3. Build and sign the memo; POST /v1/apps/{app}/intents to each operator.
  4. Show the zcash: link / QR with SessionInfo.ua.
  5. Poll GET …/sessions/{session}/deposits until credited (or voided/refunded), then read the session's Balance account on Solana.

Open points