TOON

Open harness for the agent economy

Sending a message and sending money are the same action.


What a TOON node is

A TOON node is a paid reverse proxy. You put it in front of an ordinary HTTP app, you set a price, and it collects that price from whoever calls: per request, in tokens, without your app ever learning that payment exists.

Underneath, the node is an Interledger connector. Value arrives wrapped in a protocol your app never speaks, and at the last hop the node unwraps it, verifies it was paid for, and hands your app a plain HTTP request. It terminates payments the way nginx terminates SSL.

How the money moves

Who it is for

TOON is built for high-volume recurring senders moving through a mesh: agents paying for inference, storage and relays; services metering each other; machines that transact thousands of times a day. It is not a consumer checkout, and does not try to be.

A page that paid its way

This page is not on a web server. It is stored permanently on Arweave, and the write that put it there was bought through a TOON node: a client paid the node's edge, the packet crossed a second paid hop inside the node's connector graph, and the terminating store put it on Arweave. Each hop priced the packet by the kibibyte, so the size of this page is on the bill twice, and the claims that covered both hops are redeemable on Solana mainnet.

One honest caveat, because this page does not get to overclaim: the storage leg of this revision rode the bundler's free signed tier. The node's storage account is funded in ARIO, but the paid upload path currently refuses to debit it (turbo-sdk#455, found in the course of storing this very revision), so the page was trimmed under the free-tier ceiling until that is fixed. The two TOON hops charged real money either way.

A page cannot contain the hash that stores it, so each revision embeds the receipts of the one before: Appendix E below is revision 1's, hashes, claims and the purchase of the name included. The earlier artifact at boughtviatoonnode.permagate.io documents the node's first paid writes receipt by receipt, through an earlier generation of this pipeline; every hash on it can be checked against a public chain.

Run a node

The connector is one static binary that reads one TOML file, shipped as a Docker image. The operator guide takes you from a pulled image to a priced, answering node, and it is embedded in full in Appendix A below.

github.com/toon-protocol/connector

Links


Colophon

One HTML file, no external assets, no scripts. The appendices below embed the protocol's own primary sources, verbatim from the repository at commit deded9f9, the exact commit the node that stored this page runs, so the page stays checkable even if every link on it rots. Every hop prices a packet as base + per_kib, so their size is exactly what each hop charged for: moving this page through the mesh cost real money, which is the point.

A. The operator guide, verbatim

README.md at toon-protocol/connector. Running a node, putting an app behind it, getting paid.

# Connector

[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**A paid reverse proxy.** You put it in front of an ordinary HTTP app, you set a
price, and it collects that price from whoever calls — in tokens, per request,
without your app knowing payment exists.

It does that by being an [Interledger](https://interledger.org) connector: value
arrives wrapped in a protocol your app never speaks, and at the last hop this
binary unwraps it, verifies it was paid for, and hands the app a plain HTTP
request. **It terminates payments the way nginx terminates SSL.**

You do not need to know anything about Interledger to run one. This page is the
journey, in three steps:

|       | Step                                                | You end up with                                     |
| ----- | --------------------------------------------------- | --------------------------------------------------- |
| **1** | [Run a node](#1-run-a-node)                         | One binary, one config file, answering on a port.   |
| **2** | [Put your app behind it](#2-put-your-app-behind-it) | Your app served through it, unchanged.              |
| **3** | [Get paid](#3-get-paid)                             | A settlement chain, so anyone can pay what you ask. |

Then [peering](#peering), [the operator surface](#the-operator-surface) for
inspecting a node and moving its money, and [operating it](#operating-it) day to
day. When you do want the protocol, [`docs/rfcs/`](docs/rfcs/README.md) is the ten
RFCs it is built from and where this connector departs from each.

---

## 1. Run a node

The connector is one static binary that reads one TOML file. Two ways to get it.

### With Docker

The published image is how this is meant to be deployed. It runs as uid `10001`
and creates `/app/state` owned by that uid, so a fresh named volume inherits the
ownership.

```bash
docker pull ghcr.io/toon-protocol/connector:rust-main
mkdir -p node/config node/data && cd node
openssl rand -hex 32 > data/signer.key && chmod 600 data/signer.key
```

> [!NOTE]
> **The published image is `linux/amd64` only.** On Apple Silicon (or any other
> arm64 host), pull and run it under emulation:
>
> ```bash
> docker pull --platform linux/amd64 ghcr.io/toon-protocol/connector:rust-main
> ```
>
> and add `platform: linux/amd64` next to `image:` on the `connector` service in
> `compose.yml` below.

`compose.yml`:

```yaml
services:
  connector:
    image: ghcr.io/toon-protocol/connector:rust-main
    command: ['/app/config/connector.toml']
    volumes:
      - ./config/connector.toml:/app/config/connector.toml:ro
      - ./data:/app/data:ro
      # A NAMED volume, not a bind mount. A host bind mount arrives root-owned
      # and the connector refuses to start.
      - connector-state:/app/state
    ports:
      # Loopback to begin with. The client edge is the paid surface; put a
      # TLS-terminating reverse proxy in front of it before it faces the world.
      - '127.0.0.1:3000:3000'
    restart: unless-stopped
    healthcheck:
      # Free and unauthenticated. Answering it means the config loaded, every
      # settlement backend connected, and the router is serving. `docker ps`
      # showing "Up" proves none of that.
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/ilp/identity']
      interval: 10s
      timeout: 3s
      retries: 5

  quotes:
    image: your-quotes-app:latest
  search:
    image: your-search-app:latest

volumes:
  connector-state:
```

```bash
docker compose up -d
```

### From source

For changing the connector rather than running it. Rust stable, and clone with
submodules — `packages/contracts` vendors OpenZeppelin and forge-std:

```bash
git clone --recurse-submodules https://github.com/toon-protocol/connector.git
cd connector && cargo build --workspace
./target/debug/connector path/to/connector.toml
```

Paths in the config are then yours, not the image's: `state_dir = "./state"`,
`key_file = "./signer.key"`. [`CONTRIBUTING.md`](CONTRIBUTING.md) has the test
gate and the chain binaries it needs.

### The config

```toml
client_edge_addr = "0.0.0.0:3000"
state_dir        = "/app/state"

[signer]
key_file = "/app/data/signer.key"   # 32 raw bytes, or 64 hex characters

# One route per thing you serve. Longest matching prefix wins, so a more
# specific prefix can sit beneath a broader one and take precedence.
#
# `price` is a whole number of the SMALLEST UNIT of the token you settle in
# (step 3) — the way a card terminal counts in cents, never in dollars. How
# many of those units make one token is the token's `decimals`. USDC has 6,
# so 1,000,000 units are one USDC, and:
#
#     price = 1000        0.001 USDC   a tenth of a cent
#     price = 100000      0.10  USDC   ten cents
#     price = 1000000     1.00  USDC   one dollar
#
# A wallet or a dashboard shows the whole-token figure. This file never does.
[[routes]]
prefix      = "g.example.quotes"
handler_url = "http://quotes:8080/"
price       = 1000                       # 0.001 USDC per request

[[routes]]
prefix      = "g.example.search"
handler_url = "http://search:8080/"
price       = 2500                       # 0.0025 USDC

# Same app, deeper prefix, different price. This wins over g.example.search
# for g.example.search.bulk because it matches more labels.
[[routes]]
prefix      = "g.example.search.bulk"
handler_url = "http://search:8080/bulk/"
price       = 10000                      # 0.01 USDC, a cent

# If your app's own costs go up with the size of what it is handed — storage,
# uploads, anything you pay an upstream by the byte for — price it by size
# instead of picking one number and losing money at one end of the range:
#
#     base     what every request pays, whatever it carries
#     per_kib  added for each started kibibyte of the request's payload
#
# A caller is charged `base + per_kib × ceil(payload_size / 1024)`, and both
# figures are published, so it can work out what a request costs before
# sending it. Leave `price` a plain number when one number is right — that is
# still what most routes want.
[[routes]]
prefix      = "g.example.store"
handler_url = "http://store:8080/"
price       = { base = 1000, per_kib = 30 }   # 0.001 USDC + 0.00003 per KiB
```

Then ask the node what it is:

```bash
curl http://localhost:3000/ilp
```

That free, unauthenticated `GET` returns the node's self-description — its
addresses, endpoints, identity key and settlement facts. A connector answers; it
never announces. It is also the whole of what another operator needs to peer with
you, once [`[node]` and `peer_expose`](#being-peerable) are set — this minimal
config's own self-description has no endpoints and nothing to dial.

> [!IMPORTANT]
> **Three things about the config that bite people.**
>
> - **One TOML file, read once, immutable for the process lifetime.** There is
>   no environment-variable layer — `CONFIG_FILE` and friends do nothing, and the
>   only variable read is `RUST_LOG`. An unknown key is a hard load failure and a
>   removed key is refused **by name**, so a stale config says so at boot instead
>   of quietly doing nothing.
> - **Every key is a path, never a value.** No inline keys, no mnemonic, nowhere
>   to smuggle one through.
> - **`state_dir` is where this node records which claims it has already been
>   paid.** In a container it must be a mounted volume; a watermark that dies
>   with the container hands every payer their spent claims back as free
>   service.

## 2. Put your app behind it

A route with a `handler_url` **terminates** there. The connector opens the sealed
payload, makes exactly that HTTP request of your app, and seals the app's
complete response back.

**Your app is payment-oblivious, and that is the whole design.** It receives an
ordinary HTTP request. It holds no key on your behalf, and it supplies nothing
toward the packet's fulfilment — the connector derives that itself. So "the app
answered" and "the packet was paid for" stay separable, and an app that knows
nothing about payment cannot leak, forge or withhold one.

The one thing this connector does add is attribution, on a request it took the
payment for itself: `X-TOON-Payer` (the paying channel), `X-TOON-Amount` (what
that request was charged) and `X-TOON-Chain`. Your app is free to ignore all
three — it is handed them so it can log or rate-limit by payer if it wants to,
not so it can decide anything about the payment. They are absent on a request
this node did not take the payment for, so treat them as optional. Whatever a
caller writes under those names is stripped before your app sees it.

Two consequences before you price anything:

- **You are paid for an answer, not the answer the caller wanted.** A `404` from
  your app is a real answer: it rides home on a `FULFILL` and costs the same as a
  `200`. Only unreachability or a refused target produces a reject.
- **The trailing slash on `handler_url` is load-bearing**, and a request's target
  is resolved _beneath_ the handler's path — an absolute path, a `..` segment, a
  scheme or an authority is refused before your app is touched.

A terminated route **must** carry a `price`. Write `price = 0` if free is
deliberate, because it is never silently free.

## 3. Get paid

A price makes a route cost something. A **settlement backend** is what lets
anyone actually pay it. There are two chains. A node may carry either table or
both — with both, it accepts claims on both at once.

```toml
# EVM — Base Sepolia. These are live addresses, not placeholders.
[settlement.evm]
rpc_url          = "https://base-sepolia-rpc.publicnode.com"
contract_address = "0x0c41D9D424d6B075A3cEa1068a694f7847a8CCa5"  # the TokenNetworkRegistry, not a TokenNetwork
token_address    = "0x49beE1Bca5d15Fb0963117923403F9498119a9Ce"  # the token every price on this node is in
decimals         = 6              # units per token: 6 means 1,000,000 = 1.00

[settlement.evm.key]
key_file = "/app/data/settlement.key"

# Solana — public devnet. Note `program_id` where EVM has `contract_address`:
# there is no registry to resolve a channel contract through, so this names the
# payment-channel program itself, and `token_address` is an SPL mint.
[settlement.solana]
rpc_url       = "https://api.devnet.solana.com"
program_id    = "2aEVJ8koKD8LTZrLRSGtAtU7LBt4e7QjjCgf1kzQ7Rip"
token_address = "34eSxY7qxQ4GzyhDJ8GpUcTz1WWzruGbJbR8q6TtxfQU"
decimals      = 6

[settlement.solana.key]
key_file = "/app/data/settlement-solana.key"
```

Those are the addresses the devnet fleet itself runs on, and copying them is the
point rather than a shortcut: a claim resolves against **one** deployment, so
every node that might accept a given claim has to name the same one.

|                  | EVM                                                                                                                                                      | Solana                                                                                                                                                                          |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Chain            | Base Sepolia, chain id `84532`                                                                                                                           | public devnet (`solana:devnet`)                                                                                                                                                 |
| RPC              | `https://base-sepolia-rpc.publicnode.com`                                                                                                                | `https://api.devnet.solana.com`                                                                                                                                                 |
| Channels live in | [`0x0c41D9D424d6B075A3cEa1068a694f7847a8CCa5`](https://sepolia.basescan.org/address/0x0c41D9D424d6B075A3cEa1068a694f7847a8CCa5) — `TokenNetworkRegistry` | [`2aEVJ8koKD8LTZrLRSGtAtU7LBt4e7QjjCgf1kzQ7Rip`](https://explorer.solana.com/address/2aEVJ8koKD8LTZrLRSGtAtU7LBt4e7QjjCgf1kzQ7Rip?cluster=devnet) — the payment-channel program |
| Token            | [`0x49beE1Bca5d15Fb0963117923403F9498119a9Ce`](https://sepolia.basescan.org/address/0x49beE1Bca5d15Fb0963117923403F9498119a9Ce) — mock USDC, 6 dp        | [`34eSxY7qxQ4GzyhDJ8GpUcTz1WWzruGbJbR8q6TtxfQU`](https://explorer.solana.com/address/34eSxY7qxQ4GzyhDJ8GpUcTz1WWzruGbJbR8q6TtxfQU?cluster=devnet) — mock USDC mint, 6 dp        |
| Funding the key  | Base Sepolia ETH for gas; mock USDC from the [devnet faucet](https://faucet.devnet.toonprotocol.dev)                                                     | devnet SOL (`solana airdrop 1 <address> -u devnet`); mock USDC from the same faucet                                                                                             |
| Full record      | [`packages/contracts/deployments/base-sepolia.md`](packages/contracts/deployments/base-sepolia.md)                                                       | [`packages/solana-program/deployments/devnet-public.md`](packages/solana-program/deployments/devnet-public.md)                                                                  |

**That table is the whole list, and the omissions are deliberate.** There is no
Solana _testnet_ deployment — the program is on devnet and nowhere else — and
there is no mainnet on either chain. No EVM mainnet carries a `TokenNetwork` or a
token for a registry to resolve, so `contract_address` has nothing to point at;
and because a Solana claim's signed message binds the settlement program, a node
pointing `[settlement.solana]` at a mainnet RPC while naming the devnet program
id would take money for claims it can never redeem. Production is a **named,
empty tier** ([ADR 0056](docs/adr/0056-production-is-a-named-empty-tier.md)), and
[`connector.production.toml`](deploy/connector-rust/connector.production.toml) is
a skeleton in which every value fails to load on purpose. Do not fill it in.

`decimals` is what turns a `price` into money. Every `price` in the config is a
count of the token's smallest unit, and `decimals` says how many of those make
one whole token — so with `decimals = 6`, `price = 1000` is 0.001 of the token,
and a route meant to cost ten cents of USDC is `price = 100000`. The node reads
the token's own decimals at boot and **refuses to start** if the config
disagrees, because a wrong `decimals` is not a rounding error: it misprices
every route by a factor of ten or more.

That check is one of several, and they are why there is no `--network` flag and
no environment variable anywhere in this: which chain a node is on **is** these
values and nothing else, so every one of them is verified against the chain
before the node serves a packet. The EVM backend reads the chain id off the RPC
and calls `getTokenNetwork()` to prove the address really is a registry; the
Solana backend proves `program_id` is executable _and_ behaves like the
payment-channel program, that `token_address` is an SPL mint, and asks the chain
its own genesis hash so a claim declaring the wrong cluster is refused. A node
that boots is a node whose chain agreed with its config.

That is all of it. **You do not list the channels your payers will use, and you
could not** — a client's channel does not exist until that client opens it on
chain, long after your node booted. The settlement section does double duty: it
gives this node its on-chain identity, and it is where a claim naming a channel
you have never heard of is **resolved from chain** and accepted. That resolution
is what makes paying you permissionless rather than an arrangement.

> [!WARNING]
> **Fund the settlement key _before_ you start the node**, and know that
> **booting a config is not a dry run**. A Solana backend submits a real
> transaction at `connect`; with no gas the connector exits 1 on a chain error
> that reads like a config bug. With a funded key, starting a node "just to see
> whether the TOML parses" spends real money.

You do not need to build the payer —
[`toon-client`](https://github.com/toon-protocol/toon-client) is that — but two
things help when debugging "why is nobody paying me":

- **An ILP outcome is never an HTTP one.** A `FULFILL` and a `REJECT` both come
  back at HTTP **200**.
- **A caller with no claim on a priced route gets `402`**, with an x402 document
  quoting the same price a real request would be charged.

Claims are the truth; a balance is a projection of them. Turning them into money
on chain is [the operator surface](#the-operator-surface)'s job.

---

## Peering

Terminating your own routes earns from callers who know your address. Peering
puts you on paths that start somewhere else.

### Being peerable

Step 1's config boots and serves, but nobody can peer _with_ it: its
self-description has no endpoints and `"peerCarriages": []`, so a
counterparty's `POST /peers` at it answers `502`. Three more keys, none of
them shown above, close that gap:

```toml
# Top level, so it goes above every table — beside step 1's client_edge_addr.
peer_expose = "http"             # "btp", "http", "both", or "neither" (default)

[node]
addresses     = ["g.your.node"]
http_endpoint = "https://your-node.example/ilp"
```

`peer_expose` says which carriage(s) _this_ node opens a peer listener for.
`[node]` publishes where clients reach it — both listeners are served
whatever `peer_expose` says, so publishing either endpoint is always allowed.
What `peer_expose` decides is what you may _omit_: `btp_endpoint` is required
only when `"btp"` or `"both"` is exposed, and `http_endpoint` is required
whenever anything is exposed, because a peer pays you by asking your client
edge over HTTP whichever carriage its packets ride. So an HTTP-only node
writes `http_endpoint` and simply leaves `btp_endpoint` out; a BTP node writes
both; and with `"neither"` (the default) a `[node]` naming only `addresses` is
legal and still answers `GET /ilp`, it is just not dialable.

For a local or pre-TLS trial only, add `peer_allow_plaintext_endpoints = true`
at the top level so `http://`/`ws://` endpoints are accepted too — every
deployed config should stay on `https://`/`wss://`.

With that in place, `GET /ilp` really is the whole of what another operator
needs to peer with you. It is one authenticated write:

```
POST /peers   { "id": "their-node", "url": "https://their-node.example/ilp",
                "fee": 100, "max_packet_amount": 1000000 }
```

`url` is their connector's self-description URL — the one whose `GET` answers
with that description (ADR 0050) — not their origin. The node fetches it, picks
the carriage from their endpoint's scheme (`wss://` → BTP, `https://` →
ILP-over-HTTP), finds the shared settlement chain, and derives the channel from
the two participants — no channel identifier is ever exchanged, and there is no
shared secret.

A route can then **forward** to that peering instead of terminating:

```toml
[[routes]]
prefix  = "g.partner"
peer_id = "their-node"
price   = 1500          # what a client pays you for the whole path
```

A route sets **exactly one** of `handler_url` or `peer_id`. A forwarding route
carries a `price` too — it is what the caller pays for the path — while the
`fee` you keep for your own hop lives on the peering, not the route.

> [!NOTE]
> **Four things to know before you run it.**
>
> - **It can spend gas**, because it may open a channel and wait for
>   confirmation. Safe to retry: the same request against an established
>   peering finds the same channel rather than opening a second one.
> - **`fee` and `max_packet_amount` are yours to choose.** No document can
>   supply them — they are your policy about this counterparty.
> - **A `502` is about them, a `400` is about you.** Unreachable, redirecting,
>   or describing a node you cannot peer with is `502` — go look at the URL.
> - **Their identity is trust-on-first-use over TLS, pinned by nothing.** You
>   are trusting whoever answers that URL today.

### A route is a path, not a destination

Every `PREPARE` you forward carries its own covering claim, so nothing is ever
owed between packets — and equally, a hop can take your claim and decline to
carry. That is not a defect to be engineered away; it is the shape of the
protocol, and payment channels exist precisely so that it costs you almost
nothing. Once a packet leaves you, its value is signed away: a fulfilment is a
delivery receipt, not a payment trigger, and a `REJECT` (an `F02` for a name
nobody routes, a `T01` for a peer that was not there) comes back with your
claim already spent. What the channel buys you is that this can only ever
happen to **one packet** — the last one in flight. Nobody holds your deposit;
every hop holds only what you have already signed to it, and the most the next
hop can walk away with is the one packet you just handed it.

So the risk of a hop is not something you check, it is something you **size**.
Keep packets small — a relay write is 1 micro-USDC, a store upload is priced
per kibibyte, and "large volumes of low-value packets" is what ILPv4 is designed
for (RFC 0027; RFC 0018 calls the small packet the default risk mitigation).
Then let the amount grow with the route's record: a path that has fulfilled a
thousand packets has earned a bigger one, a path you opened this morning has
not. `max_packet_amount` is the same number seen from the other side — the
largest single packet you will carry _for_ a peer, which is the most that peer
can cost you at once — and it is yours to choose for the same reason.

That is why this section is called peering and not addressing. A destination
is just a prefix; what you actually commit money to is the **path** the packet
takes to it — the hops between you and the prefix, each one a peering someone
chose, each one taking its fee and each one a place the packet can stop. The
relay in this fleet does not "send to the store"; it forwards
`g.toon.relay.store` across the one peering it holds with the store, on the
one channel it funded, at the one cap it set. Two paths to the same prefix
are two different things to trust, and a well-trodden one is worth more than
a short one. The kill switch for a path you have stopped trusting is
`DELETE /peers/:id`.

Hop count is worth thinking about the same way. Fees add up per hop; exposure
does not. You hand your packet to the first hop and that hop is your only
counterparty — what happens further down is the next hop's business, on the
next hop's channel, under its own cap — so a packet that dies anywhere costs
you the one packet you sent, whether it died at the second hop or the tenth.
Ten well-walked hops therefore beat two with a stranger in them: the extra
hops cost a few micro-USDC in fees, and the stranger can cost you the whole
packet. Longer roads also tend to run through nodes that peer widely, which
have another way onward when one leg goes dark.

The long version of this — why Glinda says _follow the yellow brick road_
rather than giving Dorothy an address — is
[`docs/the-yellow-brick-road.md`](docs/the-yellow-brick-road.md).

---

## The operator surface

The control plane: how you inspect a running node and how you move its money.

It mounts **only** when `[operator]` is configured, and merges onto
`client_edge_addr` — there is no second port and no second listener.

```toml
[operator]
bearer_token_file = "/app/data/operator-bearer-token"
write_keys_file   = "/app/data/operator-write-keys"
```

Each setting is spelled as **exactly one of** a literal or a path:
`bearer_token` / `bearer_token_file`, `write_keys` / `write_keys_file`. The file
forms are the deployed forms, because a fleet's config files are committed to a
public repository and a literal cannot be.

### Read and write are different authorities

This is the whole design (ADR 0008), and the rules are numbered in
[`operator-spec.md`](docs/protocol/operator-spec.md):

- **Reads** take `Authorization: Bearer <token>` and nothing more.
- **Writes** take an RFC 9421 HTTP Message Signature from an ed25519 key on
  `write_keys`, with the body bound by an RFC 9530 `Content-Digest`.
- **A bearer token is never sufficient to move value** (OP-03). Read authority
  must not confer write authority.
- **Every write is attributable and individually revocable** (OP-02). A shared
  secret is neither: it cannot say which operator did a thing, and losing it
  loses everything at once.
- **An accepted write cannot be replayed** (OP-05). Signatures carry `created`
  and `expires`, and an accepted signature is remembered until its own expiry.
- **A surface with neither half authenticated refuses to start** (OP-04). An
  unauthenticated operator surface is worse than none, because it looks like a
  control plane.

`write_keys` holds only **public** halves. The private half lives with whoever is
calling and never on the node. `connector send --operator-key <file>
--print-keyid` prints the value that goes in the allowlist, derived by the binary
that will do the signing.

### Reads

| Endpoint             | Answers                                                   |
| -------------------- | --------------------------------------------------------- |
| `GET /peers`         | The peerings this node holds, config and runtime alike.   |
| `GET /routes`        | The full routing table, with each row's source.           |
| `GET /routes/leased` | TTL-bound pushed routes that lapse on their own.          |
| `GET /routes/peers`  | The durable runtime peer-route table.                     |
| `GET /channels`      | Every channel this node knows, with deposits and status.  |
| `GET /claims`        | The claim journal — what you have been paid, and by whom. |
| `GET /identity`      | This node's operator-facing identity.                     |
| `GET /audit-log`     | Every accepted write, with the key that made it.          |
| `GET /metrics`       | Prometheus text.                                          |

`/metrics` is a bearer-gated read like any other. There is **no** unauthenticated
metrics path and **no** health endpoint; absent `[operator]`, `/metrics` is not
mounted and answers 404 rather than 401. A _public_ status page — one strangers
load — therefore needs a server-side holder for the token, never the token
embedded in the page. Your own browser session is different, and that is what
the dashboard below is.

The counters are `toon_packets_total`, `toon_packets_rejected_total`,
`toon_fees_earned_total`, `toon_settlement_total`, and `toon_exposure`, which is
always zero and kept only so scrape configs do not break.

### The dashboard

`GET /dashboard` is the operator's own view of all of the above on one page the
node serves (ADR 0066): packet traffic and rejects by code, fees earned, inbound
and outbound claims, peerings and channels, every route with its source, and
the audit log. It needs no token to load, because it holds nothing. Paste the
bearer token in and it reads; paste an operator key in and it can peer, write a
runtime route or lease one, signing each write in your browser exactly as
`connector send` would. The key stays in the tab's memory — never stored, never
sent — and config-file rows are shown with no button, because a price or a fee
still changes by editing the file and restarting. Reach the page the way you
reach `/metrics` on that box: on the fleet, an SSH tunnel to `client_edge_addr`.

### Writes

| Endpoint                               | Does                                                                      |
| -------------------------------------- | ------------------------------------------------------------------------- |
| `POST /packets`                        | Originate a packet from this node.                                        |
| `POST /peers`                          | Establish a peering from a URL. `DELETE /peers/:id` removes it.           |
| `POST /routes/peers`                   | Write a durable runtime route. `DELETE /routes/peers/:prefix` removes it. |
| `POST /routes/leased`                  | Push a TTL-bound route that lapses on its own.                            |
| `POST /channels`                       | Open a payment channel.                                                   |
| `POST /channels/:id/fund`              | **Self-deposit** — put your own collateral behind your own claims.        |
| `POST /channels/:id/redeem`            | Redeem a specific claim on chain.                                         |
| `POST /channels/:id/redeem-latest`     | Redeem the latest claim — **this is how you get paid**.                   |
| `POST /channels/:id/settle`            | Settle the channel.                                                       |
| `POST /channels/:id/close`             | Close it. `cooperative-close` is the agreed variant.                      |
| `POST /channels/:id/cooperative-close` | Close by agreement with the counterparty.                                 |

Channel operations answer **503** when no `[settlement]` backend is configured —
the node cannot reach a chain, and says so rather than pretending.

`POST`/`DELETE` on `/peers*` and `/routes/peers*` are the durable runtime table.
Unlike a leased route, they survive a restart — and they are **refused outright**,
never silently accepted as a shadow, when they would collide with a row the
config file already owns.

### Signing a write

The signature covers exactly three components — `@method`, `@path` and
`content-digest` — with `alg="ed25519"` and `keyid` set to the signer's own
ed25519 public key in hex. `connector send` is a worked example: it signs a
`POST /packets` this way, and `--expect-fulfill` makes a non-fulfilled packet a
non-zero exit, which is what turns a rehearsal into a gate.

For every other write — `POST /peers` above all —
[`docs/operators/sign-write.sh`](docs/operators/sign-write.sh) is a shell-and-`openssl` signer with
a worked example in [`docs/operators/signing-a-write.md`](docs/operators/signing-a-write.md).

---

## Operating it

**Logs** are structured JSON on stdout. Every line emitted while handling a
packet carries the same `correlation_id` — the packet's execution condition — and
because that value is invariant across hops, the same id appears in every
connector that handled it. `RUST_LOG=debug` for more.

**Releases.** A release is one dispatch of `release-connector.yml`: it builds the
image, cuts a dated handle and opens a GitHub Release. It does not deploy, and
nothing here moves a tag onto a box (ADR 0068) — a node repository pins the
connector image it runs, by release handle, in its own `deploy/` bundle. Because
the binary and a box's mounted TOML are a matched pair in both directions,
**adding a required config key is a breaking deploy**: land the config first, then
bump that pin.

**Devnet** settles on Base Sepolia and Solana devnet; test funds come from the
[devnet faucet](https://faucet.devnet.toonprotocol.dev). **Production is a named,
empty tier** — no machines, no mainnet contracts, no keys.

---

## Where to go next

| Path                                                                         | What it is                                                                                  |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [`docs/the-yellow-brick-road.md`](docs/the-yellow-brick-road.md)             | **The idea.** Why you pay a path and not a destination, and why the road earns the traffic. |
| [`docs/rfcs/`](docs/rfcs/README.md)                                          | **The protocol.** Interledger, the ten vendored RFCs, and where TOON departs from each.     |
| [`docs/protocol/configuration-spec.md`](docs/protocol/configuration-spec.md) | Every config key, and what each one binds.                                                  |
| [`docs/protocol/operator-spec.md`](docs/protocol/operator-spec.md)           | The operator surface's rules, numbered.                                                     |
| [`docs/operators/`](docs/operators/)                                         | Runbooks: peering bring-up, key rotation, fleet release and health.                         |
| [`deploy/connector-rust/README.md`](deploy/connector-rust/README.md)         | The container path in full, including a hand-built image.                                   |
| [`local/`](local/README.md)                                                  | The shipped image against real chains — `make local-verify`.                                |
| [`CONTEXT.md`](CONTEXT.md)                                                   | The vocabulary. Read before writing docs or naming anything.                                |
| [`docs/adr/`](docs/adr/README.md)                                            | Why any of this is the way it is. The tiebreaker for everything.                            |
| [`CONTRIBUTING.md`](CONTRIBUTING.md)                                         | Building from source, the test gate, the chain binaries it needs.                           |

## License

MIT — see [`LICENSE`](LICENSE). Except [`docs/rfcs/`](docs/rfcs/README.md), which
is CC BY-SA 4.0: it holds the Interledger Foundation's RFCs, reproduced
unmodified.
B. The ten RFCs, and where TOON departs from each

docs/rfcs/README.md. TOON is built from the Interledger RFCs; this is the honest list of departures.

# The protocol a connector speaks

The connector routes **Interledger** packets. This page is the protocol half of
the documentation: enough of Interledger to read the rest, the ten RFCs vendored
into this directory, and — for each — where this connector deliberately does
something else. [`README.md`](../../README.md) is the operator guide and assumes
none of it.

## Interledger in five paragraphs

A payment travels as a **packet**. A sender builds a `PREPARE` carrying an
amount, an expiry, a 32-byte execution condition and an opaque `data` payload,
addressed to an ILP address like `g.example.app`.

Each **connector** along the way matches the longest prefix in its routing table
and either forwards the packet to a **peer** or **terminates** it — meaning the
address belongs to an app it serves.

The terminating connector answers with a `FULFILL` carrying the preimage of that
condition, or a `REJECT` carrying a code. That answer travels back along the
same path.

What each hop keeps is a flat **fee**; what the caller pays for the work at the
end of the path is a **price**. Neither is a percentage. A fee is flat per
packet; a price may carry a per-kibibyte slope over the packet's sealed payload
length, and is flat when that slope is zero.

Money does not move inside the packet. Each hop is backed by a payment channel,
and a packet carries a signed **claim** on that channel — an off-chain IOU whose
cumulative total only ever rises. Settling means taking the latest claim to the
chain and redeeming it: rare and deliberate, the opposite of a claim.

## Where TOON departs

Read the profile before the body. They often disagree, and the profile is where
you find out why.

| RFC                                                                                                       | Where TOON departs                                                                                                                              |
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| [0001 Architecture](0001-interledger-architecture/0001-interledger-architecture.md)                       | Two of its five layers are absent: no transport layer, no ledger abstraction                                                                    |
| [0015 ILP Addresses](0015-ilp-addresses/0015-ilp-addresses.md)                                            | Addresses are self-asserted; allocation schemes (`peer.`, `self.`, `private.`) have no behaviour here                                           |
| [0018 Risk Mitigations](0018-connector-risk-mitigations/0018-connector-risk-mitigations.md)                | Exposure limits are deleted, not reduced; one per-packet cap replaces them                                                                      |
| [0019 Glossary](0019-glossary/0019-glossary.md)                                                           | [`CONTEXT.md`](../../CONTEXT.md) is this repo's vocabulary and wins; "ledger", "transfer" and "receiver" are gone                                |
| [0023 BTP](0023-bilateral-transfer-protocol/0023-bilateral-transfer-protocol.md)                          | The frame grammar is the deployed client's dialect; the `auth` frame authenticates nothing                                                      |
| [0027 ILPv4](0027-interledger-protocol-4/0027-interledger-protocol-4.md)                                  | ⚠ **The wire encoding is TOON's, not this RFC's** ([ADR 0063](../adr/0063-the-ilp-packet-is-toons-dialect-not-rfc-0027s.md)); `data` is sealed to the terminating connector |
| [0030 OER Encoding](0030-notes-on-oer-encoding/0030-notes-on-oer-encoding.md)                              | Stricter: length determinants must be canonical, trailing bytes are refused                                                                     |
| [0032 Peering & Settlement](0032-peering-clearing-settlement/0032-peering-clearing-settlement.md)          | Clearing is per packet. No balance, no threshold, no netting cycle, no credit limit                                                             |
| [0034 Connector Requirements](0034-connector-requirements/0034-connector-requirements.md)                  | No route discovery, no advertisement, no exchange rates, no quoting                                                                             |
| [0035 ILP over HTTP](0035-ilp-over-http/0035-ilp-over-http.md)                                             | Adds the claim header, the `402` payment-required document, and an anonymous-by-default caller                                                  |

**"Speaks ILPv4" is retired** as a description of this connector
([ADR 0063](../adr/0063-the-ilp-packet-is-toons-dialect-not-rfc-0027s.md) D3).
The accurate form is **ILPv4 semantics, TOON encoding**: the packet types, the
field meanings, `condition = sha256(fulfilment)` and the `F`/`T`/`R` taxonomy are
RFC 0027's; the byte layout is not, and an off-the-shelf ILPv4 encoder does not
produce a packet this connector accepts.

## What TOON does not use, and why

If you know Interledger, these are the absences to notice. Each is deliberate,
and none is vendored — a copy would assert a relevance it does not have
([ADR 0062](../adr/0062-an-rfc-is-vendored-verbatim-and-profiled-never-forked.md) D4).

- **SPSP (0009)** — no payment-setup handshake. A payer reads a free `GET` on the
  node's URL, and `GET /ilp/routes/price`.
- **STREAM (0029)** and **STREAM receipts (0039)** — no transport layer at all.
  One sealed request envelope per packet; no chunking, no flow control.
- **Payment pointers (0026)** — a route is an ILP address, a node is a URL.
- **HTLA (0022)** — no ledger-layer trust spectrum. Every peering is backed by a
  payment channel and authorised by a signed claim.
- **ILDCP (0031)** — the connector neither discovers nor advertises. Configuration
  is one TOML file; a peering is an operator write.
- **Settlement engines (0038)** — settlement is in-process, not a sidecar.
- **Relationship between protocols (0033)** — the map it draws is not this stack.

## How a vendored copy is arranged

Each file is in two halves:

```
docs/rfcs/0027-interledger-protocol-4/0027-interledger-protocol-4.md

    # RFC 0027 — ...          ← this project's words
    ## TOON profile           ← where TOON departs, each citing an ADR
    <!-- BEGIN VERBATIM UPSTREAM BODY -->
    ...                       ← the Interledger Foundation's words, untouched
```

**The body is never edited.** Not to strike a paragraph about exchange rates
this connector does not have, not to rewrite `data` to describe the gift wrap.
[ADR 0062](../adr/0062-an-rfc-is-vendored-verbatim-and-profiled-never-forked.md)
argues why, and `crates/connector-bin/tests/vendored_rfcs_are_unmodified.rs`
enforces it: each preface records the SHA-256 of its own body, and the workspace
gate recomputes it.

If you are correcting a profile, edit **above** the marker. If you are
re-vendoring from a newer upstream, use the script.

## The pin

All ten are pinned at `interledger/rfcs` commit
[`1eb8d73b67a1d048f74ded508406a7e1ae1e00d5`](https://github.com/interledger/rfcs/tree/1eb8d73b67a1d048f74ded508406a7e1ae1e00d5).
Every file in this directory is that commit's text, unmodified, under a profile.
"Where TOON departs" above links each one.

## Where a vendored RFC sits in the order of authority

```
vectors  >  ADRs  >  docs/protocol/ specs  >  a TOON profile  >  an RFC body
```

An RFC body never overrides anything local — it is what the local rules are
stated _against_. `vectors/wire-vectors.json` is normative and prose is not
([ADR 0021](../adr/0021-vectors-are-normative-prose-is-not.md)); most of what a
profile cites is prose-normative with a vector still owed
([ADR 0045](../adr/0045-a-behavioural-rule-is-normative-prose-until-its-vector-lands.md)),
and the profiles say so where it matters rather than dressing prose up as law.

## Re-vendoring

```bash
tools/vendor-rfc.sh 0027-interledger-protocol-4 <40-char-upstream-commit>
cargo test -p connector --test vendored_rfcs_are_unmodified
```

The script fetches, splices below the marker, and rewrites the pinned commit and
the digest. Everything above the marker is preserved, so a profile survives a
re-vendor. Called with no commit it re-fetches at the pin already recorded, which
also makes it the repair tool for a body somebody edited.

**Read the diff.** A protocol document changing under a running fleet is news.
The gate here is deliberately offline and answers only "unmodified since
vendored"; "still matches upstream today" is a question for a person, and this is
how they ask it:

```bash
curl -sSfL https://raw.githubusercontent.com/interledger/rfcs/main/0027-interledger-protocol-4/0027-interledger-protocol-4.md \
  | sha256sum
```

Compare against the `**Body SHA-256:**` line in the vendored copy.

## Licence and attribution

The RFC bodies in this directory are © the Interledger Foundation and
contributors, from [`interledger/rfcs`](https://github.com/interledger/rfcs),
and are licensed under
[Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/)
(CC BY-SA 4.0).

**Statement of changes, as CC BY-SA 4.0 requires:** the bodies are reproduced
**unmodified**. The only change is this project's preface, added above the
`<!-- BEGIN VERBATIM UPSTREAM BODY -->` marker in each file, and this README.
Those additions are also offered under CC BY-SA 4.0.

**This directory is CC BY-SA 4.0, not MIT.** The rest of this repository is MIT
([`LICENSE`](../../LICENSE)); the share-alike term applies to `docs/rfcs/` and
its contents, and to adaptations of them. Nothing in `crates/` derives from these
documents' text, so the binary is unaffected — an implementation of a
specification is not a derivative work of the specification.
C. ADR 0046: a connector answers, it does not announce

Why there is no discovery layer. A node describes itself at GET /ilp and pushes nothing anywhere.

# The kind:10032 announce is removed: a connector must work with no relay in the world

**Status:** Accepted — **built** (#1074). **Retires [0030](0030-an-operator-announces-a-node-the-node-still-does-not.md) in full.** Restores [0022](0022-a-connector-answers-it-does-not-announce.md) and [0006](0006-the-connector-is-mechanism-not-policy.md) without qualification: a connector answers, and does nothing else about being found. The `[announce]` section did **not** become a tombstone — two of its fields feed the packet path and were re-homed into `[node]` by [0050](0050-a-connectors-url-resolves-to-its-self-description.md) (#1080), which landed in the same change. Extended by [0067](0067-a-route-declares-its-request-shape-and-the-connector-never-reads-it.md): the answer a connector gives now also says what to send a route.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

**A connector does not announce itself, and there is no mechanism by which it could.**
`connector announce` is removed, the kind:10032 `IlpPeerInfo` event is no longer produced by this
implementation, and nothing in a conforming connector depends on a Nostr relay existing.

## Why — the reason that must survive, or this gets rebuilt

**An announce assumes a relay.** Publishing one requires, at minimum:

- a **Nostr relay** to exist and accept the event;
- a **connector fronting that relay**, with a funded payment channel, to pay for the write;
- **BIP-340 Schnorr** signing, **NIP-01** regular-replaceable semantics, and a **NIP-40** expiration
  tag.

That is an entire second protocol stack, made mandatory, in service of one thing: being discovered.
And it is not reachable at all for the case this protocol has to serve first — **a network of pure
connectors**, peering with each other, with no relay anywhere in it. Such a network cannot announce,
so a discovery design that only works when a relay exists is not a property of the protocol. It is a
property of one application built on top of it, compiled into the connector.

[ADR 0030](0030-an-operator-announces-a-node-the-node-still-does-not.md) reasoned carefully about
**who** may announce, and reached the right answer to the question it asked: the operator, from the
box holding the key, because only the announced node holds all three of the identity key, the
settlement facts and a channel to pay with. That argument is not wrong. It is answering a question
this record removes.

**What replaces it: nothing, inside the connector.** A connector answers what it is asked
(ADR 0022), and a `GET` on its URL resolves to its self-description (issue #1060). Whether those
facts are then copied into a discovery network, by whom, in what format, and signed by which key, is
the **controller's** business — outside the connector by definition (ADR 0006), and now outside it in
fact as well as in principle.

## Consequences

**The `[announce]` section is not deleted, and is not a tombstone.** Issue #807 amended ADR 0030 so
that the packet path reads two of its fields — `addresses` and `btp_endpoint` — to _answer_ with,
carrying them in the x402 greeting so a client with a stale or missing genesis seed can bootstrap
against an edge it can reach (`connector-cli/src/runtime.rs` maps them into
`connector_client_edge::BootstrapIdentity`). That is answering, not announcing, and it survives this
record intact. Those two fields are the seed of the self-description document and must be re-homed
into a section named for what they now do. The remaining announce-only keys — `publish_to`,
`publish_btp_url`, `pay_channel`, `relay_url`, `ttl_secs`, `identity_key_file`, `notice` — are
removed and parsed-to-be-rejected by name, per this repo's rule that a removed key never silently
drops.

**A third party's announce is a different object, and this record does not define one.** If a
controller publishes facts about a node it did not sign, the event is that controller's claim about
the node rather than the node's claim about itself — a materially different security property from
what kind:10032 has meant. Anyone building that is defining a new thing and should say so.

**Downstream consumers read kind:10032 today** — `toon-client`'s `discovery-subscription.ts`,
`@toon-protocol/core`'s `parseIlpPeerInfo`, `rig`, and genesis peer seeds. Removing the producer does
not remove them, and the corpus stops being refreshed. Sequencing that is an operational task, not a
protocol one, and is tracked separately.

**The `requiredTransport` defect closes by construction.** It was enforced long before it was
advertised, because there were two descriptions of one node and only one of them was checked
(verified live 2026-08-14: not one announce in the fleet's corpus carried the key). With a single
authoritative self-description there is no second copy to fall behind.

## Update (issue #1074)

Built, in one change with [0050](0050-a-connectors-url-resolves-to-its-self-description.md)'s
`GET /ilp` (#1080) — the two could not be separated, because renaming `[announce]` while
`connector announce` still read eleven of its keys would have left a broken subcommand.

What came out: the `announce` subcommand (`crates/connector-cli/src/announce.rs`) and its test;
`IlpPeerInfo`, `RouteHints`, the `deriveRouteHints` suffix heuristic, `EdgeIdentity` and `Notice`;
`sign_ilp_peer_info`, `ILP_PEER_INFO_KIND` and the whole `connector-signer` NIP-01 signing module the
kind:10032 event was the only author of; each box's scheduled announce compose overlay and the relay
box's second `connector-rust.swap-announce.toml`; and `fleet-ops.yml`'s `announce` operation with
`fleet-health.yml`'s probe for the loop it forced. `nip59` stays — wrapping a claim to a receiver is
not announcing.

What did not come out: `[announce]`'s `addresses`, `http_endpoint` and `btp_endpoint`, now `[node]`'s
three fields. Every other key it carried is parsed solely to be **refused by name**, and so is a
stale `[announce]` heading, which is refused with the new name in the message
(`crates/connector-bin/tests/refuses_to_start.rs`).

The verb itself is refused by name too. `connector announce` does not resolve to a config path that
does not exist; it prints what removed it and where a node's facts are answered instead. The boxes
are driven by scripts and units that lead this repo, and "No such file or directory: announce" is not
an answer anybody can act on.

**Downstream consumers were not touched, and this is the sequencing ADR 0046 already recorded as a
separate operational task.** `toon-client`'s `discovery-subscription.ts`, `@toon-protocol/core`'s
`parseIlpPeerInfo`, `rig` and the genesis peer seeds all still read kind:10032. Removing the producer
does not remove them: the corpus simply stops being refreshed, and what those readers hold goes stale
rather than wrong. `g.toon.ario`'s discoverability depended on an announce; whatever replaces it is a
controller concern, outside the connector by definition (ADR 0006).
D. ADR 0058: a peering is established from a URL

One authenticated write creates a peering: the node reads the counterparty's self-description and derives the payment channel from the two participants.

# A peering is established from a URL, and its identity is trust-on-first-use

**Status:** Accepted — **built** (#1160), and **corrected** (#1217): the written peering could accept a claim but not sign one until #1217, because nothing populated the outbound CLIENT hop `Connector::cover_forward` reads — see "Update (issue #1217)" below. `POST /peers { id, url, fee, max_packet_amount }` reads the counterparty's self-description, derives the channel, opens it if absent, registers both the PEER-role and the CLIENT-role halves of the channel binding, and writes a durable runtime peering; `build_peer_transport` adds and removes a carriage while the process serves. Built on [0050](0050-a-connectors-url-resolves-to-its-self-description.md) (#1080), [0059](0059-a-channel-is-derived-from-its-participants.md) (#1158) and [0060](0060-a-claim-proves-a-peering-and-the-shared-secret-is-deleted.md) (#1157). Completes [0034](0034-a-runtime-peer-route-table-never-shadows-the-config-file.md), whose precedence rules governed a table that could not hold a peering. Satisfies both falsifiers of [0049](0049-the-cap-bounds-one-packet-is-discovered-by-t04-and-is-set-from-outside.md). Leaves [0043](0043-purchasable-peering-is-removed.md) and [0006](0006-the-connector-is-mechanism-not-policy.md) intact; **narrows** [0022](0022-a-connector-answers-it-does-not-announce.md), and says how below.

**Scope:** connector architecture — internal to this codebase. The document it reads is protocol law ([0050](0050-a-connectors-url-resolves-to-its-self-description.md)); the request that reads it is not. See the [ADR index](README.md).

The falsifier this record carried while it was unbuilt — no file at
`crates/connector-runtime/src/peer_route_store.rs` matching `endpoint` — is **satisfied and
removed**. `RuntimePeering::endpoint` is that field, and it is precisely the one the marker said no
implementation of this record could avoid: a durable peering has to persist somewhere to reach its
peer.

**An operator adds a peering to a running node by naming the peer's URL.** The connector `GET`s that
URL's self-description, derives the payment channel from the two participants, opens it on chain if
it is absent, and writes a durable runtime peering — with no restart and no edit to the config file.
**The peer's identity is trust-on-first-use over TLS:** whatever that URL serves is who the peering
is with, and this record does not pin it.

## What is wrong today: a peering cannot be added to a running node

[ADR 0034](0034-a-runtime-peer-route-table-never-shadows-the-config-file.md) shipped a durable
runtime peer/route table with careful rules — config wins by refusing the write, a route's `peer_id`
must resolve on every mutation, a peer in use cannot be removed. Those rules are right. They govern a
row that cannot hold a peering:

```rust
pub struct UpsertPeerRequest { id: String }   // connector-operator/src/lib.rs:371-374
```

That is the whole body. `PeerView` is a one-field struct, and `peer_route_store.rs:81-86` persists an
id and nothing else. Meanwhile the dial carriage is built once, at boot, exclusively from
`config.peers()` (`connector-cli/src/peer_transport.rs:74-88`). **A runtime peer has no endpoint, no
carriage and no channel binding.** It is a name a route or a lease may legally reference, and nothing
more.

The config path is no better, because it cannot be walked without stopping the process. A peering
needs four tables to agree (`config.rs:436-548`): `[[peers]]`, a `[[peer_channels]]` row or boot
fails `PeerChannelUnbound`, a `[[pay_channels]]` row for anything forwarded to, and `[[routes]]`. The
channel id in the second of those comes from a chain operation — `POST /channels`, an operator write
that only exists **after** boot. So onboarding today is:

1. Boot with no peering at all, because a declared peer with no channel refuses to start.
2. `POST /channels` to open the channel and read back its id.
3. Stop the node, hand-edit four TOML tables, restart.

A chain operation is sandwiched between two config states, and the node that comes back is a
different process. "Add a peer" is not an operation this connector has.

## The decision

**One operator write establishes a peering.**

```
POST /peers { id, url, fee, max_packet_amount }
```

- **`url`** — the peer's connector URL. The node `GET`s its self-description
  ([0050](0050-a-connectors-url-resolves-to-its-self-description.md)) and takes from it the endpoint,
  the carriage that endpoint's scheme implies, the edge identity, and the per-chain settlement
  addresses and chain facts.
- **`id`** — the operator's own name for the peering.
- **`fee`** and **`max_packet_amount`** — the operator's policy about this peer, which no document can
  supply.

The channel is then derived from the two participants and opened if absent
([0059](0059-a-channel-is-derived-from-its-participants.md)). The route is a second, separate write —
`POST /routes/peers { prefix, peer_id, price }` — because a peering and a route are different
decisions and one may exist without the other.

**The dial transport becomes rebuildable.** `build_peer_transport` running once at boot is what makes
the runtime row hollow; it must be able to add and remove a carriage while the process serves.

**Every load-time cross-table rule gains a runtime twin**, enforced continuously, exactly as
[0034](0034-a-runtime-peer-route-table-never-shadows-the-config-file.md) did for `UnknownPeerId`: a
peering with no channel binding is refused at write time rather than discovered at the first arriving
frame, and a peer forwarded to with no pay-channel likewise — checked, as of #1217, against the
CLIENT-role hop this write registers (the thing that lets this node SIGN a covering claim), not
against the PEER-role channel binding (the thing that lets it ACCEPT one), which is non-empty for
every peering this write ever produces and so cannot by itself distinguish a payable peering from an
accept-only one.

## What comes from the document, and what cannot

| Field                                                                                                             | Source                                                                                                     |
| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| endpoint                                                                                                          | the self-description                                                                                       |
| carriage (BTP or HTTP)                                                                                            | the endpoint's scheme (`peer.rs:64-72`)                                                                    |
| edge identity — the key a payload is sealed to ([0018](0018-a-payload-is-sealed-to-the-terminating-connector.md)) | the self-description                                                                                       |
| per-chain settlement address, chain id, token network, registry, token, decimals                                  | the self-description                                                                                       |
| the channel                                                                                                       | **derived** from the two settlement addresses ([0059](0059-a-channel-is-derived-from-its-participants.md)) |
| `id`                                                                                                              | **the operator.** A local label                                                                            |
| `fee`, `max_packet_amount`                                                                                        | **the operator.** Policy about them, not facts about them                                                  |

**A node has three identities, and they are not interchangeable.** The edge identity is a secp256k1
key from `[signer]`; the EVM settlement address is 20 bytes (`peer_channel.rs:88`); the Solana one is
a base58 Ed25519 public key (`peer_channel.rs:52`). An Ed25519 key and a secp256k1 key cannot be the
same value. The channel derives from the **settlement address of the chain in question**, never from
the edge identity — `TokenNetwork.sol:330-335` recovers a balance proof's signer and requires it to
**be** a channel participant, which is what forces the claim key and the on-chain participant to be
one address.

**`fee` and `max_packet_amount` are the operator's, and that is the whole reason they are in the
request.** [ADR 0049](0049-the-cap-bounds-one-packet-is-discovered-by-t04-and-is-set-from-outside.md)
already requires this — _"The operator surface must be able to express a cap, and today it cannot"_ —
and declares two falsifiers naming `connector-operator` and `peer_route_store.rs`. This record is what
satisfies them.

## Trust-on-first-use, stated plainly so nobody assumes otherwise

**Whatever the URL serves is who the peering is with.** The connector does not verify the fetched
identity against anything the operator supplied. A party who controls that hostname's DNS, or a
certificate for it, chooses the counterparty — and under
[0059](0059-a-channel-is-derived-from-its-participants.md) that choice determines the channel address,
so it is a party you would fund.

**Pinning was considered and rejected.** The alternative was a required `settlement_address` in the
request, with the write refused if the document disagreed. It was rejected because the protection is
conditional on something the mechanism cannot enforce: an operator who copies the address out of the
same document they are pointing the node at has pinned nothing, and that is the path of least
resistance. A pin that is usually theatre invites the belief that a peering's identity is
cryptographically bound when it is not.

**So the belief is refused directly instead.** No record, spec or runbook may describe a peering's
identity as pinned, verified or attested. It is trust-on-first-use over TLS, and the operator's
vetting of the URL is the whole of the assurance. Anyone who later wants a stronger property is
adding a mechanism, not documenting an existing one.

**What this does not weaken.** Every value-bearing check downstream is unchanged and remains
cryptographic: a claim's signature is verified against the counterparty key recorded for the channel
and never against anything the claim declares about itself
(`connector-runtime/src/claim.rs:439-443`), and a payload is sealed to the edge identity
([0018](0018-a-payload-is-sealed-to-the-terminating-connector.md)). A wrong document produces a
peering that does not work; it does not produce one that silently misroutes value to a third party
while appearing to work.

## Why the id is the operator's

An **ILP address is a claim, not a grant** (`CONTEXT.md`, **ILP address**): it is self-asserted, and
nothing in the world allocates one. Deriving the local identifier from the peer's advertised address
would therefore let a stranger
choose what this node's route table is keyed on and what its logs say (ADR 0014 labels every packet
line with the peer id). Deriving it from the URL host has a milder form of the same problem and
breaks when they move hosts.

The id is a label in this operator's namespace. The thing that must be globally unambiguous is the
channel, and [0059](0059-a-channel-is-derived-from-its-participants.md) makes that derivable and
unique without consulting a name at all.

The cost is real and accepted: two nodes peering with each other will use different ids for the same
relationship, so a support conversation needs a translation step.

## How this narrows ADR 0022, and how it does not

[ADR 0022](0022-a-connector-answers-it-does-not-announce.md) and `CONTEXT.md`'s **Controller** entry
say the connector _"never learns, announces, or discovers."_ A connector that fetches a URL and
populates its own peer table from the response is doing something that word covers, and pretending
otherwise would be the kind of quiet redefinition this folder exists to prevent.

**The line this record draws instead:** the connector learns nothing about **whether** to peer, or
**with whom**. The operator decided both, and named the URL. What the connector learns is **how to
reach a counterparty already chosen** — the same relationship it has to `handler_url`, which is also
an operator-supplied URL the connector dereferences without being said to discover anything.

**Announcing is still forbidden** and no mechanism for it is reintroduced
([0046](0046-the-kind-10032-announce-is-removed-a-connector-needs-no-relay.md)). **A peering still
cannot be bought, earned or announced into existence**
([0043](0043-purchasable-peering-is-removed.md)); it is created by an operator, and this record only
changes which surface the operator uses. **`POST /ilp` gains nothing**: the document is read with a
`GET`, and [0050](0050-a-connectors-url-resolves-to-its-self-description.md)'s prohibition on a
`POST` there is untouched.

What is genuinely new is that the connector now makes an **outbound** request to an
operator-supplied host from inside an authenticated write handler. That request must be bounded —
timeout, response size, redirect policy — and must never be made on the packet path.

## Rejected: a CLI verb that fetches and confirms

`connector peer add <url>` — fetching the document on the operator's workstation, printing it,
requiring confirmation, then issuing the signed write with explicit fields — was considered. It has a
real precedent (`connector send` already forms a packet and makes an RFC 9421-signed `POST /packets`
from outside the serving process) and it keeps the outbound fetch out of the connector entirely.

It was rejected as the **primary** mechanism because it makes the capability depend on having the
binary installed wherever the operator is, and the API would then need the explicit-field form
anyway — two surfaces for one operation. Nothing here forbids adding the verb later as a convenience
over the same endpoint.

## The sweep

**Does not survive:**

- **[0034](0034-a-runtime-peer-route-table-never-shadows-the-config-file.md)**'s implicit premise
  that a runtime row is an id. Its **decision is untouched and is strengthened** — config wins by
  refusing the write, the `UnknownPeerId` twin is enforced continuously, a peer in use cannot be
  removed, and the whole-table JSON snapshot is still how durability works. What changes is that the
  table now holds the thing those rules are about.
- **[0009](0009-one-typed-config-file-no-environment-layer.md)**'s reach, narrowly. Config remains
  one typed file, validated once, immutable for the process lifetime, with no environment layer —
  **none of that moves.** What moves is that the _peer transport_ is no longer derived once from that
  file and never again. A config-file peering is still immutable; a runtime peering is not a config
  peering, and 0034 already refuses any collision between the two.

**Survives unchanged:**

- **[0043](0043-purchasable-peering-is-removed.md)** — an operator creates a peering and nothing
  else does. A URL is not a purchase.
- **[0006](0006-the-connector-is-mechanism-not-policy.md)** — `fee` and `max_packet_amount` are in
  the request precisely because they are policy the operator holds.
- **[0046](0046-the-kind-10032-announce-is-removed-a-connector-needs-no-relay.md)** — nothing here
  needs a relay, a Nostr event or a directory. Two operators and one URL are sufficient, which is
  the case 0046 said had to work.
- **[0018](0018-a-payload-is-sealed-to-the-terminating-connector.md)** and
  **[0024](0024-peer-wire-claims-sign-the-eip-712-balance-proof.md)** — what the edge identity and the
  counterparty key are _for_ is unchanged; only how they arrive is new.
- **[0008](0008-operator-surface-splits-read-from-write.md)** — `POST /peers` is a write and is
  RFC 9421-signed like every other. No bearer token reaches it.

## Consequences

**Onboarding becomes three calls, and two of them already exist.** `POST /peers`, then
`POST /routes/peers`, with `POST /channels` still available for an operator who wants to open a
channel explicitly rather than let the peering derive it.

**The endpoint can spend gas.** Deriving-and-opening a channel means `POST /peers` may submit a
transaction and wait for confirmations, so it can fail _after_ money has moved. The durable table row
must be written from a **confirmed** channel, and the endpoint must be safely retryable: a repeat of
the same request against a peering already established is a success, not a second channel. Under
[0059](0059-a-channel-is-derived-from-its-participants.md) the derivation makes that idempotence
structural rather than a matter of care.

**The response must say which branch it took** — whether the channel was found or opened — so an
unintended second channel is visible in the operator's own output rather than discovered later on a
block explorer.

**A peering added at runtime and one written in the config file become the same object.** They differ
only in where they are recorded and which wins a collision, which is what 0034 already decided.

**`CONTEXT.md`'s Peering entry changes.** _"a counterparty key, a carriage to reach it on, a fee, and
a cap"_ stays accurate in substance, but "created by an operator — in the config file or through the
operator surface" stops being aspirational for the second half.

## Update (issue #1160) — what the build settled that the decision left open

Three questions the record did not answer had to be answered to build it, and each is answered the
way this folder's existing rules already pointed.

**Which chain, when two nodes settle on more than one.** The write takes an optional `chain`, and
refuses by name when several are shared and none was named — the same posture `POST /channels`
already takes for the identical ambiguity (issue #630). Picking one silently would be picking which
asset a peering settles in, which is the operator's decision and not this connector's
([0006](0006-the-connector-is-mechanism-not-policy.md)).

**Which endpoint, when a node publishes both.** BTP where both are published. A dialed BTP session
is symmetric once established, so either side may originate on it (`peer-carriage-spec.md` §2.3),
where an ILP-over-HTTP peering can only ever be originated on by the dialer (§6.4). Preferring the
carriage that leaves both directions open forecloses least, and an operator who wants the other
writes the peering in the config file.

**How long the opened channel's settlement window is.** A fixed day, and not a field on this
request. An operator who wants a different one opens the channel with `POST /channels` first, and
this write then **finds** it — which is the derive-or-open branch working as designed rather than a
special case, and is why that endpoint stays available.

**The bounds on the outbound fetch, stated as numbers.** Ten seconds for the whole exchange, a 64
KiB body cap enforced as the body streams rather than after it is buffered, and **no redirect
followed at all** — a `3xx` is refused by name, because following one would let the named host hand
the peering to a different host, and under [0059](0059-a-channel-is-derived-from-its-participants.md)
that choice determines the channel address.

**Trust-on-first-use is unchanged and unstrengthened.** No `settlement_address` pin, no fingerprint,
no confirmation step was added, and no doc comment, log line or error message describes a peering's
identity as pinned, verified or attested. The one thing the build does check is the _shape_ of a
published settlement address — 20 bytes on EVM, 32 base58 bytes on Solana — and that is not a check
against anything the operator supplied. It is the refusal to coerce bytes into an address that names
a participant no chain holds.

## Update (issue #1217) — the runtime twin of the pay-channel rule was never wired

`connector_config::pay_channel`'s own header calls a channel binding "the deployed shape": one
channel, bound in **both** roles at once — the PEER role for what arrives, the CLIENT role for what
this node sends. #1160's build bound only the first half. `establish_peering` called
`bind_runtime_peer_channel` (peer role) and wrote a durable row whose `channels` field was therefore
always non-empty, and `upsert_runtime_peer_route`'s guard tested exactly that field — so it accepted
every peering `POST /peers` ever produced, never catching the one it exists to catch. The result: a
peering answered `200` to `POST /peers`, `200` to `POST /routes/peers`, and `T00` to every packet
originated over it, naming a `[[pay_channels]]` row the operator had been told they no longer needed
to write (ADR 0042 item 2).

**The fix adds the missing half**, not a new mechanism. `establish_peering` now also calls
`register_outbound_client_hop` — the CLIENT-role counterpart of `bind_runtime_peer_channel`, and the
runtime twin of what `wire_outbound_client_hops` does for a `[[pay_channels]]` row at boot — using the
same `document.httpEndpoint` the durable row already carried as `client_edge_url` (`POST
/ilp/claim-state` is always asked over plain HTTP, whichever carriage the packet itself rides). A
document with no `httpEndpoint` is now refused at write time
(`EstablishPeeringError::NoDialableClientEdge`) rather than silently producing an accept-only peering:
such a peering could never be paid, so the write is not worth a channel being opened for. Boot
rehydration (`with_runtime_peer_route_store`) re-arms both roles the same way establishing did, so a
restart does not turn a payable peering back into an accept-only one. And the outbound client ledger
(`OutboundClientLedger`) now opens for every node with a `state_dir`, not only one with
`[[pay_channels]]` rows — a runtime peering can need one that no config table predicted.

**`upsert_runtime_peer_route`'s guard now tests the CLIENT-role hop** (`outbound_client_hops`), not
`RuntimePeering::channels` — the fix for the gap above, and the reason the guard could never have
fired before it. `PeerHasNoPayChannel` still exists and still means the same thing operationally
("this peering cannot be paid"); what changed is which fact it is computed from.

None of this changes what the write asks an operator for, what it returns, or the idempotence and
gas-spending properties above — a repeat of the same `POST /peers` still finds the same channel, and
both roles are re-bound on every call the same way the channel-derivation logic already was.
E. Revision 1, receipt by receipt

landing-self.json. A page cannot contain its own hash, so each revision embeds the receipts of the one before it. These are revision 1's: the Arweave txid and sha256, the claim on each hop, the x402 leg to AR.IO that revision 1 paid with (a path retired on 2026-08-29, when the store began paying for its uploads in ARIO directly), and the purchase of the name this page lives under.

{
  "what": "TOON landing page rev 1, served as a paid TOON write (same discipline as boughtviatoonnode self.json: the next revision embeds these receipts)",
  "date": "2026-08-27",
  "file": "landing.html",
  "sha256": "359c7a97ddbd9766d93170c89d2253543a833e4d6462bdc93ab31774b6a08554",
  "bytes": 134294,
  "arweave_txid": "Sl49EXVNvlSveojCf1-JPL9K_tRTwEwBA_U3lvamdPk",
  "route": "g.drew.ario.xl",
  "hop1_client_edge": {
    "channel": "DcW6wGmZChYD674SnibLYwMJSWzdR4rYwrgq5ecc8efz",
    "paid_base_units": 61000,
    "watermark": "nonce 16 -> 17, cumulative 376000 -> 437000"
  },
  "hop2_edge_store": {
    "channel": "27XcKjUVe3SbVfkrj72bqMcZf3QuEasGxci4kberf8fu",
    "claim": "nonce 7, cumulative 182,700, advance 60,000",
    "captured": "2026-08-27 from the store connector's peer-claims.log (inbound_claim_accepted), closing the receipt gap noted at publication"
  },
  "x402_leg": {
    "chain": "base-mainnet",
    "tx": "0x327477ee7d0dc3b9f8424a4f630401399374af09be5d322d419bc9fdda5241a4",
    "block": 50533113,
    "from": "0x025bf59AD141552A935E54A83d2D9D6571e38A55",
    "to_ario_payto": "0x6A0A10FFD285c971B841bee8892878c0d583Bf67",
    "usdc": "0.004298",
    "verified_via": ["base.blockscout.com", "mainnet.base.org"],
    "winc": "0"
  },
  "arns": {
    "name": "toon",
    "bought": "2026-08-27, 1yr lease, 44,170.416 ARIO (quote was 44,843), funded by Phil via Drew",
    "buy_tx": "24mSdCCz6JkiFLt9JBxriYbd1FgSfwXZYrkMSFyJtx9vVVGJBV3y9JSLUTk2swKfEwBRhYewByaHAttg8FW6khdX",
    "ant": "H5VJzteoQ1t2sZ1VgWvE1XDbyMKY9Qk4eZY6EBcKx8mn",
    "ant_owner": "DQCzjWpsDsbwaTkAvzu9pD3QhEK8QAHCmNHvUmgAKdQt",
    "set_tx": "5MqvFKUbYmLjYWkTx7ESCS6E9joLYBwxaLKT3K5PN6WjNE586RYjLf2ha3KvnRY1HyyU41NAy3KLD7vdNAMMT7tG",
    "lease_ends": "2027-08-27 (endTimestamp 1819394542000)",
    "url": "https://toon.permagate.io/",
    "alias_undername": {
      "url": "https://toon_boughtviatoonnode.permagate.io/",
      "ant": "BiW6dxwkphDjKWsDEqHRKMvNoTbebEDb5i83DUhTwsB1",
      "set_tx": "5AG6Q95o6E51bnedTjR5FLAGjQqdKfo8o6Y1JiWJg6Xg9pNqQnwrjsW2m8EGorr8t5jwQ9CvdZ1Ka7St1WFAa9Pu"
    }
  }
}