Relayer
The relayer is one Node process in relayer/, deployed on Fly.io as zecpass-relayer. It owns the order lifecycle, talks to NEAR Intents and Relay, and sends two kinds of transactions: the Relay deposit on Arbitrum One and the deliver / sweep calls on Robinhood Chain.
One key, two jobs
The relayer key (0x5C9A100eC739Bd7d80F1502e97Be9f549986951F) is:
- the recipient on Arbitrum One of every NEAR Intents settlement, and the sender of every Relay deposit from there;
- the signer on Robinhood Chain of
deliver,sweepand, on launch day,setToken.
It needs a little ETH on both chains for gas. It never holds $ZECPASS. In bridge mode it holds one order's ETH on Arbitrum One for the seconds between the settlement and the Relay deposit. In instant mode it holds nothing: the hub vault pays and the Arbitrum ETH is bridged to the hub afterward.
Storage
DATA_DIR/orders.json, written atomically (temp file plus rename) after every change. No database. Each order:
{
"id": "3f9c1a7e2b6d4c80",
"createdAt": "2026-09-21T10:00:00.000Z",
"amountZec": "0.25",
"beneficiary": "0x…",
"refundTo": "t1…",
"pass": "0x…",
"rail": "relay",
"depositAddress": "t1…",
"quote": { "amountOut": "142000000000000000", "amountOutFormatted": "0.142", "amountOutUsd": "375.10", "timeEstimate": 454, "deadline": "2026-09-21T10:20:00.000Z" },
"state": "awaiting_deposit",
"events": [{ "at": "…", "state": "awaiting_deposit", "note": "quote 0.25 ZEC → 0.142 ETH (arb)" }],
"ethOnArbWei": null, "arbTxHash": null, "relayRequestId": null, "hoodTxHash": null,
"deliverTxHash": null, "tokensOut": null, "ethIn": null, "fee": null, "mode": null
}
Terminal orders older than thirty days are pruned on start.
The state machine, edge by edge
| From | To | Condition |
|---|---|---|
| (request) | awaiting_deposit |
validation passed and 1Click returned a deposit address |
awaiting_deposit |
deposit_seen |
1Click status KNOWN_DEPOSIT_TX or INCOMPLETE_DEPOSIT |
awaiting_deposit |
expired |
no deposit and now > quote deadline + 30 min |
deposit_seen |
settling |
1Click status PROCESSING |
awaiting_deposit, deposit_seen, settling |
settled_arb |
1Click status SUCCESS; records ethOnArbWei and arbTxHash from swapDetails |
| any open | refunded |
1Click status REFUNDED |
| any open | failed |
1Click status FAILED, or a Zecpass transaction reverted (reason recorded) |
settled_arb |
delivering (instant) |
INSTANT_MODE on, hub vault balance ≥ ethOnArbWei, ethOnArbWei ≤ maxPerOrder, day spend + amount ≤ dailyCap, token set, hub not paused |
settled_arb |
bridging (bridge) |
otherwise; the Relay deposit transaction was sent from Arbitrum One with recipient = pass |
delivering (instant) |
delivered |
deliver receipt is success; tokensOut, ethIn, fee read from the Delivered log; then a Relay deposit of ethOnArbWei toward the hub is sent to refill the vault (refillRequestId) |
bridging |
bridged |
Relay status success; hoodTxHash recorded |
bridged |
delivering |
token set and hub not paused; sweep(beneficiary, 0) sent |
bridged |
bridged |
token not set or hub paused: note recorded, retried every tick; the beneficiary can withdraw meanwhile |
delivering (bridge) |
delivered |
sweep receipt is success; fields read from the Swept log |
Sending discipline: a transaction hash is persisted before the receipt is awaited, so a crash between the two never resends. On restart, orders in delivering or bridging with a hash are resumed by fetching the receipt or the Relay status, not by sending again.
Environment
| Variable | Default | Meaning |
|---|---|---|
RELAYER_PK |
required | the relayer key |
ZECPASS_HUB |
required | hub address |
ZECPASS_TOKEN |
empty | $ZECPASS; optional, the hub's token() is the source of truth |
ROBINHOOD_RPC |
https://rpc.mainnet.chain.robinhood.com |
|
ARBITRUM_RPC |
https://arb1.arbitrum.io/rpc |
|
DATA_DIR |
/data |
where orders.json lives |
PORT |
8080 |
|
TICK_MS |
20000 |
loop period |
ONECLICK_JWT |
empty | partner key; when set it is sent as X-API-Key and removes 1Click's 25 bps unauthenticated surcharge |
APP_FEE_BPS / APP_FEE_RECIPIENT |
0 / empty |
an optional 1Click app fee, charged in ZEC on the input; off in version one |
MIN_ZEC / MAX_ZEC |
0.01 / 5 |
order bounds |
INSTANT_MODE |
true |
use the vault when it can cover an order |
SITE_URL |
for CORS notes and logs only |
HTTP API
All responses are JSON. CORS is open for GET and POST.
| Method and path | Body / query | Returns |
|---|---|---|
POST /orders |
{amountZec, beneficiary, refundTo} |
the new order (state awaiting_deposit) |
GET /orders/:id |
the order with events and hashes | |
POST /orders/:id/deposit |
{txHash} |
forwards the Zcash transaction id to 1Click; {ok:true} |
GET /quote?amountZec=0.25 |
dry quote through both rails plus the hub fee, see API | |
GET /rails |
direct rail and Relay availability, vault balance and caps, token phase, relayer balances | |
GET /stats |
hub totals plus order counts by state | |
GET /health |
{ok:true, tick, openOrders} |
Validation on POST /orders: amountZec within bounds, beneficiary a checksummed or lowercase EVM address, refundTo matching ^t[13][a-km-zA-HJ-NP-Z1-9]{33}$. Errors are 400 with {error}.
Probes
pnpm probe prints a live picture: a dry ZEC to Arbitrum quote, whether the direct rail has liquidity, a dry Relay quote for the resulting amount, and the relayer's balances on both chains. Run it before a launch and whenever the stats page looks wrong.
Deployment on Fly
cd relayer
fly launch --no-deploy --copy-config --name zecpass-relayer
fly volumes create zecpass_data --size 1 --region cdg
fly secrets set RELAYER_PK=0x… ZECPASS_HUB=0x… ZECPASS_TOKEN=0x…
fly deploy
fly logs
One shared-cpu-1x machine with 512 MB, min_machines_running = 1, never auto-stopped. The volume is the only state; back it up with fly ssh console -C "cat /data/orders.json" if you care about the history.
When an order is stuck
| Symptom | Cause | Action |
|---|---|---|
awaiting_deposit for long |
the user has not sent, or sent after the deadline | nothing; it expires or settles at market |
settling for more than 30 min |
1Click slow, or Zcash congestion | curl https://1click.chaindefuser.com/v0/status?depositAddress=…; if REFUNDED, the loop will pick it up |
settled_arb for more than 2 ticks |
the relayer has no gas on Arbitrum One, or Relay refused the quote | check GET /rails for relayerArb.balanceWei; top up; the loop retries |
bridging for more than 5 min |
Relay did not fill | check https://api.relay.link/intents/status?requestId=…; on refund, the ETH is back at the relayer on Arbitrum, resend by clearing relayRequestId in the store |
bridged and not moving |
token unset or hub paused | the ETH is safe in the pass; set the token or unpause; the user can withdraw any time |
delivering for more than 2 ticks |
a Robinhood transaction is pending or reverted | look at the hash in the order; a revert reason is written to the events |