smail-sdk
v0.1.1
Published
Send ETH or USDG as a claim link on Robinhood Chain: keys, links, signatures and contract calls
Maintainers
Readme
smail-sdk
Snail mail for money. Private ETH you can send as a link, on Robinhood Chain. This package is the part of SMAIL that runs anywhere JS runs: in the browser, in a terminal, in an agent.
shield → seal a snail → send a link → claimedThe binding specification is docs/PROTOCOL.md. If
this package and that file disagree, the file is right.
Install
npm install smail-sdk viemESM only. Node 20+ or any browser with WebCrypto; nothing here touches a
server. viem is the chain client; the SDK's readers take a viem
PublicClient and its builders return { to, data, value } for a viem
wallet client, a private key, or a relayer.
The whole thing, in order
import { createPublicClient, createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import {
buildClaimToPool, buildDeposit, buildSealFromPool, buildTree, buildWithdraw,
commitmentOf, createNote, encodeClaimLink, findLeafIndex, generateEnvelopeKey,
proveWithdraw, readDeposits, readEnvelope, robinhood, terminateProver,
} from "smail-sdk";
const client = createPublicClient({ chain: robinhood, transport: http() });
const wallet = createWalletClient({ account: privateKeyToAccount(KEY), chain: robinhood, transport: http() });
const { pool, snail, firstBlock } = deployment; // smail.json
const artifacts = { wasm: "circuits/build/withdraw.wasm", zkey: "circuits/build/withdraw_final.zkey" };
// 1. Shield. Back the note up BEFORE this is sent; it is the only proof the money is yours.
const note = createNote(robinhood.id, parseEther("0.01"));
await wallet.sendTransaction(buildDeposit(pool, commitmentOf(note), note.amount));
// 2. Seal a snail straight out of the pool. The proof hides which deposit paid.
const deposits = await readDeposits(client, pool, firstBlock);
const { call } = await buildSealFromPool(pool, snail, {
note, leafIndex: findLeafIndex(deposits, commitmentOf(note))!, tree: buildTree(deposits),
claimKey: generateEnvelopeKey().address, // keep the private half: it IS the link
relayer: info.relayer, fee: info.minFeeWei, // from RelayerClient.info(), or omit both
}, artifacts);
await relayer.withdraw(proof); // or wallet.sendTransaction(call) yourself
// 3. The recipient reads the snail from the chain, never from the link.
const state = await readEnvelope(client, snail, claimKey.address); // funded, amount, privateOnly, …
// 4. Claim it into the pool as a fresh note, gas paid out of the envelope.
const mine = createNote(robinhood.id, state.amount - info.minFeeWei);
const claim = await buildClaimToPool(snail, { chainId: robinhood.id, snail, claimPrivateKey, commitment: commitmentOf(mine), relayer: info.relayer, fee: info.minFeeWei });
await relayer.release(claim.release);
await terminateProver(); // Node only: snarkjs keeps worker threads alive otherwisesrc/e2e.test.ts runs exactly this against a local anvil, every step
through the SDK and every step asserted on-chain.
Keys, links, locks
A snail is sealed against a fresh secp256k1 key that exists only in the link. The link puts it in the URL fragment, which the browser never sends.
import { encodeClaimLink, encodeRefundLink, generateEnvelopeKey, decodeLinkFragment } from "smail-sdk";
const claim = generateEnvelopeKey(); // { privateKey, address }
const refund = generateEnvelopeKey();
encodeClaimLink("https://smail.cash", claim.privateKey); // https://smail.cash/claim#s1.…
encodeRefundLink("https://smail.cash", refund.privateKey, claim.address); // https://smail.cash/refund#r1.…~0x…
decodeLinkFragment(window.location.hash);
// { kind: "claim", privateKey } | { kind: "locked", salt } | { kind: "refund", privateKey } | nullA locked link carries a salt instead of a key; the password is the other half. Both sides derive the same key, or neither does:
const salt = generateLockSalt();
const privateKey = await deriveLockedKey(salt, "correct horse battery staple");
const claimKey = toClaimKey(privateKey); // fund the envelope under this
encodeClaimLink("https://smail.cash", salt, "locked");Proving
proveWithdraw makes the Groth16 proof SmailPool.withdraw takes, and
checks everything the pool will check first: the note hashes to the leaf at
leafIndex, the fee fits, a fee has a relayer, the public signals came out as
requested. About 300–450 ms on a laptop.
const proof = await proveWithdraw(
{ note, leafIndex, tree, recipient, relayer, fee, hookData }, // relayer/fee/hookData optional
{ wasm, zkey, vkey? }, // vkey: verify locally before returning
prover?, // defaults: window.snarkjs, else import("snarkjs")
);
// { proof: uint256[8] in the contract's limb order, publicSignals: [root, nullifierHash, recipient, amount,
// relayer, fee, extDataHash], root, nullifierHash, extDataHash, recipient, amount, relayer, fee, hookData, proveMs }In the browser the page loads snarkjs as a plain script and passes the artifacts as URLs (or as bytes it already fetched); nothing goes through a bundler:
<script src="/zk/snarkjs.min.js"></script>const proof = await proveWithdraw(
{ note, leafIndex, tree, recipient: snail, relayer, fee, hookData },
{ wasm: "/zk/withdraw.wasm", zkey: "/zk/withdraw_final.zkey" },
window.snarkjs, // found automatically too, when it exists
);encodeHookData({ claimKey, refundKey?, unlockAt?, expiry?, memo? }) is the
payload the pool hands to Snail, bound into the proof as extDataHash;
buildSealFromPool does the proof and the hook data in one call.
encodeMemo("rent") / decodeMemo turn a short string into the bytes32
memo. proofToLimbs / limbsToProof convert between snarkjs's proof object
and the uint256[8]; verifyWithdrawProof(vkey, publicSignals, limbs)
checks one without a chain.
Calls
Every builder returns { to, data, value } and sends nothing.
| | |
|---|---|
| buildDeposit(pool, commitment, amount) | shield amount wei |
| buildWithdraw(pool, proof, params?) | spend a note; params are checked against the proof, never trusted |
| buildSealFromPool(pool, snail, { note, leafIndex, tree, claimKey, refundKey?, unlockAt?, expiry?, memo?, relayer?, fee? }, artifacts, prover?) | prove + withdraw into Snail; returns { call, proof, hookData } |
| buildFundPublic(snail, { claimKey, refundKey?, unlockAt?, expiry?, memo?, amount }) | seal from a wallet, funder visible |
| buildClaimToAddress(snail, { chainId, snail, claimPrivateKey, recipient, relayer?, fee? }) | public snails only |
| buildClaimToPool(snail, { chainId, snail, claimPrivateKey, commitment, relayer?, fee? }) | into the pool as a fresh note worth amount - fee |
| buildRefundToAddress(snail, { chainId, snail, refundPrivateKey, claimKey, recipient, relayer?, fee? }) | after expiry, public snails only |
| buildRefundToPool(snail, { chainId, snail, refundPrivateKey, claimKey, commitment, relayer?, fee? }) | after expiry |
The four release builders sign with signRelease and return the call plus
release, a Release { kind, claimKey, destination, relayer, fee, signature }
that is also the relayer's /release body. authorizeRelease and
encodeRelease are the two halves for anyone who wants them apart.
vectors/release-digest.json pins every builder's signature to the Solidity
byte for byte.
Reads and events
All take a viem PublicClient (or anything with readContract, getLogs,
getBlockNumber, getBlock, getTransaction).
readEnvelope(client, snail, claimKey, now?) // { status, amount, refundKey, unlockAt, expiry, memo, privateOnly, claimable, refundable }
isSpent(client, pool, nullifierHash) // bigint or bytes32
lastRoot(client, pool) // bigint, to compare with tree.root
isKnownRoot(client, pool, root)
nextIndex(client, pool)
releaseDigestOnChain(client, snail, { mode, claimKey, destination, relayer, fee })
readDeposits(client, pool, fromBlock, toBlock?, { window? }) // sorted by leafIndex, gaps refused
buildTree(deposits) // SmailMerkleTree; root === getLastRoot()
findLeafIndex(deposits, commitment, amount?)
readEnvelopeHistory(client, snail, claimKey, fromBlock) // [{ kind, txHash, blockNumber, amount, privateOnly?, mode?, destination?, relayer?, fee? }]
recentEnvelopes(client, snail, fromBlock, limit) // newest first, with submittedBy
watchEnvelope(client, snail, claimKey, settled, stop, timeoutMs?, intervalMs?)
syncNoteStatus(client, pool, notes) // notes with `spent` from the chain
releaseKindOf(mode) // "claimToPool" for MODE_CLAIM_POOL, …eth_getLogs goes out in windows (10k blocks by default); a node that
refuses a range gets the same range at half the size until it accepts.
History and recent-envelope reads walk backwards from the tip, because the
envelope being looked at is nearly always a recent one.
Relayer
const relayer = new RelayerClient("https://relay.smail.cash"); // { fetch } injectable
const info = await relayer.info(); // { chainId, relayer, pool, snail, minFeeWei, gasPriceWei, version }
await relayer.withdraw(proof); // { txHash }; proof must name info.relayer and pay >= minFeeWei
await relayer.release(claim.release); // { txHash }Failures throw RelayerError with status and the relayer's message.
Numbers travel as decimal strings, addresses and hashes as 0x hex
(PROTOCOL.md §10).
Wallet errors
looksRejected(error) says whether the person said no (EIP-1193 4001,
ethers' ACTION_REJECTED, viem's UserRejectedRequestError, or the words),
however deeply the wallet buried it. contractErrorName(error) names the
SMAIL custom error a call reverted with (PrivateOnly, Expired,
NoteAlreadySpent, …) from a viem error, an eth_call, or estimateGas.
walletErrorText flattens any thrown thing to something readable.
Notes, tree, digests, constants
const note = createNote(4663, 10n ** 17n); // { chainId, amount, nullifier, secret }
encodeNote(note) / decodeNote(text) // smail-v1-<chainId>-<wei>-0x<62 hex>-0x<62 hex>
commitmentOf(note); leafOf(note); nullifierHashOf(note)
toBytes32 / fromBytes32; toHex31 / fromHex31
const tree = SmailMerkleTree.fromLeaves(leaves); // or buildTree(deposits)
tree.root; tree.path(index); tree.verify(leaf, path, root)
releaseDigest(params); signRelease(privateKey, params); recoverReleaseSigner(params, signature)
addressToDestination(address); destinationToAddress(bytes32)FIELD_SIZE, ZERO_VALUE, LEVELS, ROOT_HISTORY_SIZE, DOMAIN, MODE,
SECP256K1_ORDER, and the chains robinhood (4663), robinhoodTestnet
(46630) and anvil (31337) as viem Chain objects, with chainById(). The
contract ABIs (smailPoolAbi, snailAbi, groth16VerifierAbi) and creation
bytecode (snailBytecode, groth16VerifierBytecode, poseidonT3Bytecode,
linkSmailPoolBytecode(poseidonT3)) come straight from the Foundry
artifacts, for local deploys.
Development
npm run build --workspace smail-sdk
npm test --workspace smail-sdk # includes the anvil end-to-end test when `anvil` is on PATH
node packages/smail-sdk/scripts/vectors.mjs # regenerate vectors/ from dist/