# The x402 exchange on Etherlink, on the wire

Read this when the client's output is not enough: a service behaves unlike the ones
below, a facilitator rejects an envelope for a reason the `hint` does not cover, or
you are adding a second Etherlink service to this skill.

## Pinned addresses

| what | address | why it is pinned |
| --- | --- | --- |
| chain | `eip155:42793` (42793) | Etherlink mainnet, Tezos' EVM layer 2 |
| Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | canonical Permit2, the EIP-712 verifying contract |
| x402 proxy | `0xB6FD384A0626BfeF85f3dBaf5223Dd964684B09E` | the only spender the authorization may name |
| USDC | `0x796Ea11Fa2dD751eD01b53C372fFDB4AAa8f00F9` | 6 decimals; the default priced asset |

All four were confirmed deployed on Etherlink mainnet. Never replace them with
addresses copied out of a challenge — the challenge names the *asset* and the
*recipient*, never the contracts that move the money.

## The challenge

An unpaid request answers `402` with a base64-encoded JSON `PAYMENT-REQUIRED`
header. The Etherlink entry inside `accepts[]` looks like this:

```json
{
  "scheme": "exact",
  "network": "eip155:42793",
  "amount": "1000",
  "asset": "0x796Ea11Fa2dD751eD01b53C372fFDB4AAa8f00F9",
  "payTo": "0x5D89060682fcE50eCaa7600aF6A5645Bd2b107Eb",
  "maxTimeoutSeconds": 300,
  "extra": { "assetTransferMethod": "permit2", "name": "USD Coin", "version": "2" }
}
```

`amount` is in atomic units, so `1000` is $0.001. A challenge may also offer Base,
Solana or MPP rails; this client deliberately ignores them.

### resource.url may disagree with the URL you requested

`resource.url` is the string the *origin* believes it serves, and a service behind a
TLS-terminating proxy reports `http://` for a call made over `https://`. That is the
case on `3route.agents.bakingbad.dev` today.

The client therefore compares host, port, path and query exactly, and tolerates a
scheme difference **only** in the http→https direction, emitting a warning. It echoes
the origin's own `resource` object verbatim, because the facilitator compares the
envelope against what the origin generated.

Note what that does and does not buy. **The resource string is not part of the signed
message** — the Permit2 authorization commits to the token, amount, spender, nonce,
deadline and witness, and to no URL at all. The URL check is a client-side guard
against paying for something other than what you asked for; it is not cryptographic.
What actually bounds the damage is the amount, the asset and the recipient inside the
authorization. This is also why paying over plaintext `http` is refused outright: there
the whole challenge, recipient included, is attacker-controlled.

## The authorization

Etherlink settles through Permit2's `PermitWitnessTransferFrom`, signed as EIP-712
with domain `{ name: "Permit2", chainId: 42793, verifyingContract: <Permit2> }` —
note there is no `version` field in the domain.

```
PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,
  uint256 deadline,Witness witness)
TokenPermissions(address token,uint256 amount)
Witness(address to,uint256 validAfter,bytes extra)
```

`spender` is the x402 proxy, `witness.to` is the challenge's `payTo`,
`witness.validAfter` is `0`, and `witness.extra` is `0x`. The generic x402 "exact
Permit2" signer uses a two-field witness, which the deployed Etherlink proxy rejects.

`nonce` is a random 256-bit value (Permit2 nonces are unordered), and `deadline` is
capped at 5 minutes regardless of a larger `maxTimeoutSeconds`.

The envelope goes back as a base64 `PAYMENT-SIGNATURE` header:

```json
{
  "x402Version": 2,
  "resource": { "...": "copied from the challenge" },
  "accepted": { "...": "the selected accepts[] entry, unmodified" },
  "payload": { "signature": "0x...", "permit2Authorization": { "...": "..." } },
  "extensions": { "erc20ApprovalGasSponsoring": { "...": "optional, see below" } }
}
```

Settlement comes back in a base64 `PAYMENT-RESPONSE` header; `success: true` is the
only proof the call was paid. A `200` without it is not a paid response.

## The Permit2 approval, and the extension that does not do what it says

Permit2 can only move tokens it has an ERC-20 allowance for, so a wallet that has never
approved Permit2 cannot pay. That approval is a normal on-chain transaction from the
payer, and **Etherlink prices it around 921,000 gas** — roughly fifteen times an
Ethereum `approve()` — which is about 0.002 XTZ. Any client that hardcodes a 21k–60k
limit has its transaction rejected by the node, so the limit must come from
`eth_estimateGas`.

The endpoint advertises a way to avoid that in `extensions`:

```json
"erc20ApprovalGasSponsoring": {
  "info": { "description": "The facilitator broadcasts a pre-signed ERC-20 approve() transaction to grant Permit2 allowance.", "version": "1" },
  "schema": { "...": "requires from, asset, spender, amount, signedTransaction, version" }
}
```

**It does not work today.** Two things are true at once, and conflating them is how the
first reading of this went wrong.

*The descriptor must be echoed.* Replacing it with a schema-conforming payload gets the
envelope rejected outright:

| envelope's `extensions` | facilitator's answer |
| --- | --- |
| the advertised descriptor, echoed verbatim | passes |
| absent entirely | passes |
| a payload **replacing** the descriptor | `extension_echo_mismatch` |

*Adding a payload beside the descriptor is accepted — and ignored.* Probed with a wallet
holding USDC, no XTZ and no Permit2 allowance, so a granted approval would have been
visible on chain. Six placements, each carrying a payload built to the published schema:
inside the descriptor as `payload`, merged into the descriptor's own fields, as `data`,
as a sibling `erc20ApprovalGasSponsoringPayload` extension, inside `envelope.payload`,
and at the envelope root. **All six answered `transaction_simulation`** — the same answer
as an envelope carrying no payload at all — and afterwards the payer's allowance was
still `0`, its nonce still `0`, and no transaction had appeared on Etherlink.

`transaction_simulation` is the facilitator simulating `permitWitnessTransferFrom` and
watching it revert for want of an allowance. It does not name that cause, so it is also
the first thing every new wallet sees, with nothing in the message to act on.

So: the extension is advertised with a full JSON Schema, the echo gate lets a payload
through, and nothing reads it. The client therefore copies `challenge.extensions` into
the envelope untouched and prepares the approval as the payer's own transaction.

Worth knowing if this is ever wired up: a pre-signed EIP-1559 transaction pays gas from
the *signer's* balance, so broadcasting one on the payer's behalf does not sponsor
anything. Submitting an unfundable approval to an Etherlink node returns a transaction
hash — the RPC hashes the bytes without checking the balance — and then silently drops
it: `eth_getTransactionByHash` answers `null` and the nonce never moves. Real
sponsorship needs the facilitator to fund the payer first, and a relayer whose success
signal is "I got a hash back" will report success for a no-op.

## Verification without money

`--self-test` checks the client's crypto against fixed vectors offline; `--dry-run`
builds the envelope and sends nothing. See `reference/testing.md`.

One of those vectors is worth naming: the client's EIP-712 domain separator is checked
against `DOMAIN_SEPARATOR()` read from the deployed Permit2 on Etherlink
(`0xb0d43efd8e8dcddb60c20a4e77ad2a99df2390acc7561cf3cc729f257d9b6286`). A wrong domain
would produce signatures that look valid and are unspendable, and no amount of testing
against a mock would catch it.

A `402` that answers `insufficient_funds` proves less than it appears to: the
facilitator checks funds before it checks the signature, so an empty wallet gets that
answer even with a corrupted signature, a wrong witness or a rewritten resource. Use it
to confirm the envelope parses and reaches the facilitator, and nothing more.
