smail-cli
v0.1.3
Published
Send and open SMAIL links from a terminal or an agent, on Robinhood Chain
Maintainers
Readme
smail-cli
Pay someone who has no account, from a terminal.
A snail is ETH sealed against a key that exists only in a link. Whoever holds the link takes the contents, so paying costs nothing but handing the link over: no address to ask for, no registration, no wallet extension. A private link goes one further: claiming it is a zero-knowledge proof, made here in JavaScript, so nothing on chain ties the claim to the sealing.
npm install -g smail-cliWalkthrough
1. Add your .env.local
Nothing works until this exists. Create a file called .env.local in your
project and put two variables in it:
SMAIL_PRIVATE_KEY=0x… # the key that signs and pays gas
SMAIL_NETWORK=robinhood-testnet # or robinhood, or anvilNo export, no quotes needed. Both are tolerated if you paste them anyway.
The file is found on its own: the directory you are standing in, then its parents up to the repository root, then one level down, which is where a framework tends to leave one. Exported shell variables work too and always win over a file, which is how a container should inject a key. Whatever holds that key holds the money: keep the file out of git.
Two more are optional. The network's own relayer is used by default, so claims and returns are delivered for nothing and need no ETH:
SMAIL_RPC=https://… # a node of your own
SMAIL_RELAYER_URL=https://… # another relayer, or `none` to submit from your keyCheck it landed:
smail whoami0xf39F…2266 holding 0.5 ETH
on Robinhood Chain testnet via https://rpc.testnet.chain.robinhood.com
snail 0x1cf9…b10b (sponsored by 0x9a48…925d: sealing costs 1% on top, delivery is free)
private pool: none recorded
read from /Users/you/project/.env.local
No relayer configured: this key submits every transaction, so the sender is visible.
Can: seal, open, refund, status.It prints the file it read, so you are never guessing which key is about to
sign. If more than one candidate turns up it refuses to choose and asks you to
name it with --env <path>.
Whether the contract is sponsored is read off the contract itself. A
SponsoredSmail is funded with 1% on top of the amount; that fee pays a
relayer network to deliver the claim and the refund, so the recipient gets the
whole amount and needs no ETH, and an expired snail only ever goes back to the
address that funded it. A plain Snail charges nothing at sealing, and a
relayer, if you use one, takes its fee out of the snail when it is opened.
2. Rehearse
--dry-run builds the transaction and prints it without signing or sending.
Worth doing once before this is wired into anything that spends on its own.
smail seal --amount 0.01 --dry-run3. Send
smail seal --amount 0.01 --expiry 24h --memo "invoice 1101"Sealed 0.01 ETH on Robinhood Chain testnet, claimable for 1 day, paying 0.0101 ETH: the amount and a 0.0001 ETH fee that has a relayer deliver it for nothing.
Funded from 0xf39F…2266: the chain sees this address pay. --private seals a private
link instead, whose claim nothing on chain ties back to you.
Claim link https://smail.cash/claim#s1.BSc7nv0…
Return link https://smail.cash/refund#r1.B4jwdCU…~0x7e43…
Anyone holding the claim link can take the contents, so send it the way you
would send cash. Keep the return link: after the window shuts it is the only
way to get the money back.
Transaction https://explorer.testnet.chain.robinhood.com/tx/0x2a927023701c…--password locks the link so the recipient needs both halves; send the
password another way. --unlock 1h refuses claims for the first hour;
--expiry never makes a snail that can never be taken back.
Two links, and they are not interchangeable. The claim link is a bearer instrument: hand it to the recipient and nobody else. The return link is yours, it is the only way to reclaim the value after the window shuts, and it cannot be regenerated from anything.
4. Check
smail status "https://smail.cash/claim#s1.BSc7nv0…"funded on Robinhood Chain testnet, holding 0.01 ETH, memo "invoice 1101".
Claim window shuts 2026-09-27T07:41:48.000Z.
funded 0.01 ETH from a wallet https://explorer.testnet.chain.robinhood.com/tx/0x2a92…status takes a claim link, a return link (the funder's own way to check), a
private link, or --id with the claim key's address. It reads the contract and
every transaction that touched the snail.
5. Receive
On the other side, with its own key configured, the recipient runs:
smail open "https://smail.cash/claim#s1.BSc7nv0…"No flag pays the key's own address; --to <address> pays another. With
SMAIL_RELAYER_URL set, the claim key in the link signs and the relayer
submits, so the opener needs no ETH. A snail releases exactly once, which is
what makes a link safe to send over a channel you do not control.
6. Take it back
smail refund "https://smail.cash/refund#r1.B4jwdCU…~0x7e43…"Only after the claim window has shut, and only with the return link. A sponsored snail goes back to the address that funded it, whoever runs this.
Private links
smail seal --private --amount 0.01
smail open "https://smail.cash/claim#p1.…"A private link (p1.) carries two random secrets instead of a key, and is
sealed into PrivateSnail, a pool of fixed sizes: 0.001, 0.005, 0.01, 0.05 and
0.1 ETH. Sealing costs the amount plus 1%, prepaid for whoever relays the
claim. Opening reads the pool's whole deposit log (never asking the node about
one link), rebuilds the tree, and proves the claim on this machine; it takes a
few seconds. Through a relayer that carries private links the relayer is paid
the prepaid 1%; from your own key, you get it back with the amount.
No window, no memo, no password, and no return link: the sender keeps the same link and takes the money back by opening it, for as long as nobody else has.
What it hides, exactly. The sealing shows the funder, the size and a commitment. The claim shows the recipient, the size and the relayer. Nothing on chain pairs the two, so a claim hides among every unclaimed private link of the same size, which is little in a small pool. The sender can always see when and where it was claimed. PROTOCOL.md §10 has the rest.
Private links need a PrivateSnail recorded for the network (pool and
poolFirstBlock in smail.json, or SMAIL_POOL and SMAIL_POOL_FIRST_BLOCK)
and, to open one, the prover's artifacts (withdraw.wasm,
withdraw_final.zkey). They ship inside smail-sdk, so an installed CLI finds
them on its own; a checkout finds them in circuits/build, and SMAIL_ZK_DIR
points at a copy anywhere else. smail whoami reports both.
The pool's history is kept between commands in ~/.cache/smail (or
SMAIL_CACHE_DIR), starting from a copy shipped in the package, so a private
command reads only the blocks since the last one. The copy is checked against
the pool itself before it is used, and can be deleted at any time.
For programs
One command, two shapes. Read by a person it is prose; piped into anything else
it is JSON, decided by whether stdout is a terminal rather than by remembering a
flag. --json and --human force it either way.
LINK=$(smail seal --amount 0.01 | jq -r .claimLink)
curl -X POST "$WEBHOOK" -d "{\"pay\": \"$LINK\"}"Every command returns ok, and failures carry error on stderr with exit code
1, so a caller can branch without parsing sentences.
smail status "$LINK" | jq -e '.status == "claimed"' && echo paidCommands
| | |
|---|---|
| seal --amount <eth> | Seal a snail. --expiry 24h\|never, --unlock 1h, --memo <text>, --password <text>, --dry-run |
| seal --private --amount <eth> | Seal a private link. --dry-run |
| open <link> | Open a claim link or a private link. --to <address>, --password <text>, --dry-run |
| refund <return link> | Take an expired snail back. --to <address> (not on a sponsored snail), --dry-run |
| status <link> | Read the contract and the receipt. --id for a claim key address |
| whoami | The key, network, contracts, relayer and prover in use |
Global: --json, --human, --env <path>, --verbose.
Environment: SMAIL_PRIVATE_KEY, SMAIL_NETWORK (robinhood,
robinhood-testnet, anvil), SMAIL_RPC, SMAIL_RELAYER_URL, SMAIL_SNAIL,
SMAIL_FIRST_BLOCK, SMAIL_POOL, SMAIL_POOL_FIRST_BLOCK (contract defaults
come from smail.json at build time), SMAIL_APP_ORIGIN, SMAIL_ZK_DIR.
The shielded pool this CLI used to drive (shield, unshield, notes,
open --shield) is retired. Those commands and flags are refused by name.
Or the library
The CLI is a thin wrapper over smail-sdk, the same package
the web app is built on: keys, links, locks, the release signatures, the
private-link tree and prover, and the contract calls. Its README carries the API.
Development
npm run build --workspace smail-cli
npm test --workspace smail-cli # parsers, and the anvil end-to-end runThe end-to-end test deploys a Snail, a SponsoredSmail in front of a stub
relayer network, and (with forge on PATH) a PrivateSnail from
contracts/script/DeployPrivate.s.sol; runs every command in-process against
them; stands up a relayer in a few lines for each; and asserts the chain after
each step. It skips loudly without anvil, and skips the private-link steps
without forge or the artifacts in circuits/build.
