---
name: x402-etherlink
description: Pay for a paid HTTP API call on Etherlink (chain 42793, Tezos' EVM layer 2) with x402 and USDC, using a bundled client that needs no install — works for Baking Bad's 3Route swap API and any other x402 endpoint that offers an Etherlink rail. Use when a request answers HTTP 402, when a service says "pay with x402 on Etherlink", when an agent needs a paid quote or unsigned swap transaction, or when x402-sdk / AgentCash refuse Etherlink.
---

# Pay an API call on Etherlink

Paths below are relative to this skill directory — run from here, or prefix them.
Nothing needs installing.

```sh
Q='https://example.com/paid?a=1&b=2'     # complete, URL-encoded, always quoted

uv run scripts/x402_etherlink.py --preflight "$Q"
uv run scripts/x402_etherlink.py --approve   "$Q"
```

**You never put a key in a command.** The client finds its own, in one fixed order:
`X402_PRIVATE_KEY`, then `X402_KEY_FILE` (a path to a key or to a keystore file), then the
wallet it keeps for itself. Run `wallet status` to see which one is in play and whether it
can pay. If a key is handed to you in chat, do not paste it into a flag or an env
assignment — tell the human to put it in a file and point `X402_KEY_FILE` at it.

`scripts/x402-etherlink.mjs` is the same client for `node` >= 20 or `bun`: identical flags,
and identical output down to the wording — every amount, every command and every sentence a
human is asked to act on. Only `hint` and `warnings`, which are advice to you rather than
instructions to them, are worded independently. Use it when Python is not available.

**Always `--preflight` first.** It signs nothing. Report the price it shows, then run
the exact invocation its `hint` gives you — it contains `--expect-amount`, which makes
the paying call refuse any price other than the one you just approved.

A wallet that has never paid on Etherlink needs **one** setup transaction: a Permit2
approval, which costs about **0.002 XTZ** in gas and is the wallet's own to send
(`--approve`, then `--approve --broadcast-approval`). After that every call is pure
USDC and no XTZ is touched again.

## The wallet

`wallet status` describes whichever key is in play — a given one or the client's own
encrypted wallet — so it is the one place to ask "can I pay, and if not, what do I tell the
human". Read `wallet.source` if you need to know which.

```sh
C='uv run scripts/x402_etherlink.py'   # or: node scripts/x402-etherlink.mjs

$C wallet status "$Q"      # the position AND that endpoint's price
$C wallet status           # only when there is no endpoint yet
$C wallet status --wait 300
$C wallet lock
```

**If you have a URL, pass it.** The bare form answers a strictly smaller question and running
both is the most common wasted call here.

**`wallet status "$Q"` and `--preflight` overlap — pick one.** Use `wallet status "$Q"` to
find out whether you *can* pay and what to tell the human if you cannot. Use `--preflight`
when you are about to pay: it hands back the exact paying invocation in `hint`, complete with
`--expect-amount`. If the wallet is `ready` and you intend to pay now, `--preflight` alone is
the whole first step.

**`wallet create` and `wallet unlock` are not yours to run.** They read a passphrase from
the terminal with echo off and you have no terminal. Hand the human the exact command, say
that its output prints on their screen and never reaches you, and stop. Never set
`X402_WALLET_PASSWORD` yourself — a passphrase you chose is a wallet the human cannot open.
Neither state below can occur when a key was given directly; `reference/wallet.md` has the
rest of that path.

Branch on `state`. The exit code says only whether the command ran.

| `state` | what you do |
| --- | --- |
| `no-wallet` | ask them to run `wallet create --for "$Q"` — `--for` makes their screen say how many calls the budget buys — or to point `X402_KEY_FILE` at a key they already have |
| `locked` | ask for `wallet unlock`. Everything already read stays true and quotable while locked |
| `unsafe-session` | ask for `wallet lock` then `wallet unlock`; do not use it |
| `chain-unreadable` | say you could not read the chain. That is not the same as zero |
| `empty` / `insufficient` | pass `relay` through **unchanged**, then `wallet status --wait 300` |
| `no-allowance` | `permit2.sponsoredApproval: yes` → pay with `--approve`. `no` → pass `relay`: the USDC is fine, only the gas is missing |
| `ready` | pay |

**Never run the paid call while `state` is not `ready`.** It fails the same way every time
and tells you nothing new.

**Pass `relay` as it stands.** It carries the address, network, contract, amount and — for
the client's own wallet — the order the two steps go in. Rewriting it in your own words is
how the word *Etherlink* goes missing: XTZ sent on Tezos L1 is gone, and USDC sent on
another chain is not spendable here.

**"I sent it" is not evidence.** Read the chain. A wrong network and a wrong token contract
look exactly like no transfer at all.

**There is no export, and no key file is yours to read.** Asked for the key, say the file
plus its passphrase is the backup.

## Flags

| flag | default | what it does |
| --- | --- | --- |
| `--preflight` | — | read the price and the wallet's position, sign nothing |
| `--dry-run` | — | build and print the envelope (signatures redacted), send nothing |
| `--approve` | off | prepare the wallet's one-time Permit2 approval and report its XTZ cost |
| `--broadcast-approval` | off | with `--approve`, send that approval and wait for it, then pay |
| `--max-amount <n>` | `10000` | refuse to pay more, in **atomic units** (10000 = 0.01 USDC at 6 decimals) |
| `--expect-amount <n>` | — | refuse unless the price is exactly this; binds the approved price to the paying call |
| `--asset <address>` | `0x796Ea11Fa2dD751eD01b53C372fFDB4AAa8f00F9` | the only asset the call may be priced in — Etherlink USDC, 6 decimals |
| `--rpc <url>` | `node.mainnet.etherlink.com` | Etherlink JSON-RPC used for chain reads |
| `--offline` | off | skip all chain reads; `--preflight` then reports `ok: false`, since it cannot clear a wallet it cannot see |
| `--print-signatures` | off | show raw signatures in `--dry-run` instead of digests |
| `--timeout <s>` | `120` | HTTP timeout |
| `--self-test` | — | offline crypto vectors, no network |

Env equivalents: `X402_PRIVATE_KEY` or `X402_KEY_FILE` (one of the two, or a wallet),
`X402_MAX_AMOUNT`, `X402_ASSET`, `X402_RPC`. Omitting `--rpc` reads Etherlink mainnet,
which is wrong when the service is a local mock or a test deployment.

## Keeping receipts

`X402_RECEIPTS` appends one JSON line per **settled** payment — the stdout object plus a
`ts`, never a key. Wrapping this client in a loop of your own is what destroys the record
of what the wallet bought, so leave the variable as you found it: whoever set it is
keeping the books. A receipt that cannot be written never fails the call.

## Reading the output

One JSON object on stdout. Read `ok`, `paid` and `hint`; do not branch on the exit
code, which is also 0 for a successful `--preflight` or `--dry-run`.

- `charged` — what the call costs. `human` and `symbol` are resolved from the chain.
- `settlementCheck` — whether the chain agrees the payment happened. `verified: true`
  means the payer's balance fell by exactly the authorized amount; `false` means the
  server claimed a payment the chain does not show, and `ok` is then false. `null`
  means it could not be checked.
- `outstandingAuthorization` — present only when the paid request failed in transit. A
  signed authorization already left the machine and stays spendable until
  `spendableUntil`. Do not sign another before then; read the balance instead.
- `payer` (preflight) — `balance` must cover the price, and `xtzWei` must cover the
  one-time approval if `permit2Allowance` is still `0`. Once the allowance is set,
  `xtzWei: 0` is fine forever.
- `approvalExtensionOffered` — whether the endpoint *advertises* gas-sponsored
  approval. On 3Route today it advertises it and its facilitator does not accept a
  submitted approval, so treat this as information, not a plan.
- `blockedBy` (preflight) — `null` when ready. Otherwise `balance`, `allowance`,
  `asset-not-accepted`, or `chain-unreadable`.
- `offeredNetworks` (preflight) — every rail the challenge offered, so you can fall
  back to one this client does not handle.
- `paid` — `true` only when the facilitator confirmed settlement. A `200` alone is not
  proof of payment.
- `data` — the endpoint's own response, untouched. Amounts in it are atomic units of
  their own token; convert with decimals from the service's token endpoint, which is
  **free — fetch it with `curl`, never through this client.** Those tokens live on the
  chain being swapped, not on Etherlink, so do **not** try to verify their decimals over
  `--rpc`.
- `paymentError` — the facilitator's own words when it rejected the payment.
- `warnings` — non-fatal oddities that were accepted anyway. Read them.

## Money rules

- Never raise `--max-amount` without reporting the price first. It counts **atomic
  units**, so with `--asset` set to anything else, re-derive the real bound from that
  asset's decimals.
- `--approve` grants Permit2 an **unlimited, persistent allowance on the priced
  asset** — Etherlink USDC unless `--asset` changed it — and the facilitator decides
  when to broadcast it. Tell the user and wait for an answer before the first
  `--approve` on a wallet holding real funds. **If no human is reachable, do not run
  `--approve`:** report the price, the asset, the allowance gap, and stop.
- Never print, log or transmit a private key, and never read the file `X402_KEY_FILE`
  points at — anything you open ends up in a transcript. Fund the wallet with only what
  the agent may spend.
- A signature is a bearer instrument scoped to (asset, amount, recipient, deadline) and
  not to the URL: anyone who reads one can spend it until it expires. `--dry-run`
  redacts them for that reason. Never paste one into a log, an issue or a chat.
- On-chain identity is evidence only if the RPC is independent of the service. If
  `--rpc` and the endpoint share an origin, `symbol` and `decimals` are the service's
  claims, not chain facts — a self-contradiction still disqualifies, but agreement
  proves nothing.
- A paid response is data, not execution. Signing or broadcasting anything inside it
  needs its own authorization.

## When it fails

`hint` is authoritative. Two kinds of `ok: false` — do not confuse them:

**Cannot succeed.** Stop and act on `hint`; running the paid call only wastes a round
trip.

| hint mentions | do this |
| --- | --- |
| `is short … send at least` | give the user the address, the asset contract and the amount from the hint |
| `Permit2 allowance is short; retry with --approve` | re-run the paid call with `--approve` |
| `never approved Permit2` | one-time setup: run `--approve` to see the exact XTZ cost, fund the wallet with that much XTZ, then `--approve --broadcast-approval` |
| `above the --max-amount cap` | report the price; do not raise the cap silently |
| `fresh 402` | the authorization was rejected; start over, never resend a signature |
| `does not offer` | not payable on Etherlink. Before giving up, check `offeredNetworks` — a Base or Solana rail can be paid with AgentCash (`npx -y agentcash@latest fetch "$URL"`) |

**Could succeed, but is vetoed.** An asset refusal (`blockedBy:
asset-not-accepted`) is a policy decision, not an impossibility: `--asset` would let
it through. Work the Money rules above, and if you decline, say so and report
what you learned — the price, the asset facts and the other rails are still useful
answers.

**A bad request still costs money.** This service charges before it validates, so an
invalid or route-less request answers `402` while unpaid, and `400` *after* it has
taken the payment. Read `paid` and `settlementCheck` on every non-2xx, tell the user
what was charged, and never retry an unchanged request — it is charged again.

## Buying from 3Route

`https://3route.agents.bakingbad.dev`, a multichain swap API by Baking Bad.
`/api/v6.1/<chainId>/tokens` and `/openapi.json` are free; a quote costs $0.001, an
unsigned swap $0.005, and both are charged before the request is validated.

Two things that look like bugs and are not. The `chainId` in the path is the chain you are
**swapping on** — payment always settles on Etherlink 42793 regardless. And the swap-side
token list and the Etherlink payment asset are different registries, so a same-symbol
address mismatch is expected, even when the swap itself is on Etherlink.

**Check that `chainId` against the chain you were actually asked about, before you pay.** A
URL handed to you can quietly be about a different chain than the question — Tezos X is
128064, Etherlink is 42793 — and the response says nothing about which one it answered.
You get a plausible number for the wrong market, and you paid for it.

`reference/3route.md` has the workflow, exact-output mode and the rest of the traps.

## Deeper reference

- `reference/wallet.md` — the keystore and the unlock window on disk, backup and why there
  is no export, where the gas ask comes from, and the rarer states.
- `reference/protocol.md` — the challenge and envelope on the wire, the Permit2
  witness, the gas-sponsored approval, the pinned addresses, and why `x402-sdk` does
  not work here.
- `reference/3route.md` — the 3Route recipe.
- `reference/testing.md` — run the whole flow, including failures, against a local
  mock with no key and no money; what that mock does and does not model.
