## How a swap happens

1. The browser asks Rails for a quote.
2. Rails calls 0x with the API key it holds, and returns the calldata.
3. The browser asks the user's wallet to send that transaction.
4. The browser reports the resulting hash back to Rails and stops.
5. A Solid Queue job watches for the receipt and streams status into a turbo-frame.

The browser never polls a chain. That is what keeps `connect-src` at `'self'`, and it means a user
can close the tab without losing the outcome.

## The fake cannot be signed, and that is the point

Without a `ZEROX_API_KEY`, `Swap.for(chain_id)` returns `Swap::Local`, whose quotes carry
**`transaction: nil`**.

This is the sharpest safety property in the module. If the fake returned plausible-looking calldata,
a buyer who deployed without a key would get a swap screen whose button asks a **real wallet** to
sign **real calldata** routing nowhere, and somebody would lose money to a demo. With `nil`, the
quote is structurally unexecutable: `Quote#executable?` is false, the UI can only render "Simulated
quote", and the button is disabled.

A spec asserts it, because the temptation to make a fake more realistic is exactly how this would
regress.

## Slippage is clamped twice

In the controller and again in the adapter, to 10–500 basis points. Two places on purpose: the
controller guards the public endpoint against a client asking to be sandwiched and then blaming the
app, and the adapter guards every other caller, including a console session and whatever gets
written next.

## No aggregator runs on a testnet

There is no real liquidity to route against, so 0x supports mainnets only. **The kit's default chain
is Base Sepolia**, so this refusal is the first thing you will meet rather than an edge case. It
names the chain and says what to do:

> Swaps are not available on Base Sepolia. Aggregators only route on mainnets, because that is where
> the liquidity is. Switch to Base to try one.

`Swap::SUPPORTED_CHAIN_IDS` is deliberately the same set as the mainnets in `Chain::REGISTRY`, and a
spec asserts they cannot drift apart.

## Swapping the aggregator

The interface is deliberately small and aggregator-shaped:

```ruby
#price(sell_token:, buy_token:, sell_amount:, taker:, slippage_bps:)  # indicative
#quote(sell_token:, buy_token:, sell_amount:, taker:, slippage_bps:)  # firm, with the transaction
```

`Swap::Lifi`, 1inch, or a direct Uniswap router can replace `Swap::Zerox` without touching the
controller or the island. Cross-chain bridging is deliberately out of scope: it is asynchronous,
multi-step and refundable, which is a lot of failure surface for a starter kit.

## What is not covered by tests

`Swap::Zerox` is spec'd for its interface, its clamping and its error handling, but never against
live 0x responses, which need a real key. That is in the manual matrix in `docs/ONCHAIN.md`.
