A user connects a wallet, signs a message, and Rails verifies the signature itself. There is no
third party in this path: `Onchain::Signature` does secp256k1 public-key recovery on Ruby's stdlib
OpenSSL bindings.

## Why no gem

Ruby's OpenSSL binding exposes `EC::Point#mul(bn1, bn2)`, which computes `bn1 * P + bn2 * G`. That
is exactly the ECDSA recovery equation, so no `libsecp256k1` binding is needed: no C extension, no
autotools in the Dockerfile, nothing new for a fresh clone to fail on. A full verification measures
around three milliseconds against a five-minute challenge window.

Keccak-256 is hand-written for a sharper reason. **OpenSSL ships SHA3-256, which is a different
function**: NIST changed the padding byte between Keccak's submission and the SHA-3 standard.
Substituting it produces confident, wrong addresses with no error at all. A spec asserts the two
disagree, so nobody makes that swap later.

Correctness comes from implementations that are not ours. `spec/fixtures/onchain/` holds nine
`(address, message, signature, digest)` quadruples generated by **viem**, and the address and hash
specs use the published EIP-55 and Keccak vectors. A round trip through our own signing code would
only prove it is self-consistent with itself.

## The server builds the message

Rails builds the canonical EIP-4361 message, stores it verbatim in `siwe_nonces.message`, and
**never parses a client-supplied one**. Verification re-reads the stored text.

Every SIWE vulnerability to date has lived in a lenient message parser: the grammar is fiddly, and a
parser that disagrees with what the wallet displayed is exploitable. Storing the exact signed string
deletes that entire class of bug for the cost of one column.

The challenge is also **bound to the host it was issued for**, and that is re-checked at
verification. A challenge minted on one domain cannot be spent on another.

## Cold signup has two steps, and both matter

A wallet proves control of an address and nothing else, while this app's accounts are keyed on an
email. So a visitor the app has never seen signs **once**, and then supplies an email in a plain
form (not a second wallet popup):

1. **Signature.** A known wallet signs in. A signed-in visitor *links* the wallet to their existing
   account. An unknown wallet is recorded as verified, and asked for an email.
2. **Email.** Claimed through `User.upsert_by_email!`, the kit's single account-creation chokepoint,
   so the welcome mail, the Stripe customer and the GDPR export all keep working untouched.

Two guards in step two are load-bearing:

**The pending challenge is bound to the browser session.** Without that, anyone who observed the
nonce could claim the wallet under their own email address.

**An email that already has an account is refused, never linked.** Signing proves control of the
wallet; it proves nothing about the mailbox. Auto-linking would let anyone holding any wallet seize
any account simply by typing its email. This is the same reasoning as
`User::UnverifiedProviderEmail` in the OAuth path, and it gets the same explicit refusal.

`upsert_by_email!` is called with `verified: false`, so `email_verified_at` stays nil. Anything in
your app that needs a proven email address still gates on it.

## Two limits to know about

**One sign-in wallet per user.** The `identities` table is uniquely indexed on `(user_id, provider)`,
which is obviously right for "link Google at most once" and debatable for wallets, where people hold
several. Relaxing it is a schema decision (which address is *the* identity when any of three can
sign?), so v1 keeps the existing contract and fails with an actionable message rather than a raw
constraint violation.

**Smart-contract wallets need a real RPC.** Coinbase Smart Wallet and Safe sign via ERC-1271, which
requires a contract call to verify. Against the offline fake they cannot sign in. That is a
configuration limit rather than a bug, but it presents as an intermittent one if you do not know it.

## Trying it

Open `/onchain` and click Connect. Signing costs no gas and sends no transaction: it is a proof of
control, not a payment.
