npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

smail-sdk

v0.1.1

Published

Send ETH or USDG as a claim link on Robinhood Chain: keys, links, signatures and contract calls

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  →  claimed

The binding specification is docs/PROTOCOL.md. If this package and that file disagree, the file is right.

Install

npm install smail-sdk viem

ESM 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 otherwise

src/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 } | null

A 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/