# Lelantos SDK — guide (v0.47.0) > Source: https://docs.lelantos.xyz. API reference: https://docs.lelantos.xyz/reference/index.md # Installation > **Tip: If you are an agent** > > You can find these docs as plain markdown: [llms.txt](https://docs.lelantos.xyz/llms.txt) indexes every page, [llms-full.txt](https://docs.lelantos.xyz/llms-full.txt) is the whole guide in one file, and any page is available by appending `.md` to its URL — for example [/guide/quickstart.md](https://docs.lelantos.xyz/guide/quickstart.md). `@lelantos-org/sdk` is published to **GitHub Packages**, not the public npm registry. A plain `npm install` returns a 404. Point the `@lelantos-org` scope at GitHub Packages and provide a token with the `read:packages` scope. ## 1. Configure the registry Add an `.npmrc` to the consuming repository: ``` @lelantos-org:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} ``` The token is read from the environment, so the file can be committed. Do not write the token into it. ## 2. Install ```bash export NODE_AUTH_TOKEN=$(gh auth token) # or a PAT with read:packages npm install @lelantos-org/sdk viem @lelantos-org/circuits ``` | Package | Required | Provides | |---|---|---| | `viem` | **yes** (peer) | the chain adapter, signers, and key derivation from an EOA | | `@lelantos-org/circuits` | no (peer) | prover artifacts, resolved automatically on Node. Browsers pass `prover: { artifacts }` or `prover: { cdn }` instead — see [Browser usage](https://docs.lelantos.xyz/guide/browser.md#prover-artifacts) | | `snarkjs`, `circom_runtime` | no (peer) | the JS witness calculator and fallback prover; imported lazily, only when a proof needs them | ## 3. Configure CI Pass the built-in `GITHUB_TOKEN`: ```yaml - run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Requirements | Requirement | Detail | |---|---| | Runtime | Node 24+, current browsers, or Deno | | Module format | ESM only (`"type": "module"`) | | Platform APIs | Web Crypto and `fetch`; no `node:*` imports in browser-reachable code | | TypeScript | `"moduleResolution": "nodenext"` or `"bundler"` | | Browser CSP | `'wasm-unsafe-eval'` in `script-src` — see [Browser usage](https://docs.lelantos.xyz/guide/browser.md) | The package is exposed through an `exports` map of subpaths. TypeScript's legacy `"node"` resolution cannot read it, and imports such as `@lelantos-org/sdk/advanced` fail to resolve. ## Verify the install ```ts import { VERSION } from "@lelantos-org/sdk"; console.log(VERSION); ``` ## Package layout Most applications import only from the root. Every exported name has exactly one home; see [Package subpaths](https://docs.lelantos.xyz/guide/subpaths.md) for the full map. | Entry point | Use it for | |---|---| | `@lelantos-org/sdk` | `connect()`, the `WalletApi` it returns, amounts, network presets, and every error class | | `@lelantos-org/sdk/watch` | `connectWatch()` for viewing-key wallets | | `@lelantos-org/sdk/advanced`, `/prover`, `/services` | replacing the chain adapter, prover, relayer submitter, stores, or selector | | `@lelantos-org/sdk/protocol`, `/primitives` | fee arithmetic, keys, notes and bundle builders without a wallet | Amounts are branded (`CircuitAmount`, `TokenAmount`); a plain `bigint` amount does not compile. See [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md). ## Versioning The SDK is pre-1.0: minor versions may contain breaking changes. Pin an exact version for reproducible builds, and check the [release notes](https://github.com/lelantos-org/sdk/releases) before upgrading. ## Next - [Quickstart](https://docs.lelantos.xyz/guide/quickstart.md) - [Concepts](https://docs.lelantos.xyz/guide/concepts.md) # Quickstart This page runs a complete round trip in one script: shield, transfer, and unshield. Set `network` and `rpcUrl` for your target chain. ## Prerequisites - [Installation](https://docs.lelantos.xyz/guide/installation.md), including the `.npmrc` token and the `viem` peer - Node 24 or later - An EVM private key funded with the asset to shield, and an RPC endpoint for the chain One EVM private key is enough. It signs on-chain transactions and also derives the shielded spending key (`nsk`), so no second secret is needed. ## Round trip ```ts // setup declare const privateKey: `0x${string}`; declare const peer: string; // example import { connect, formatAmount } from "@lelantos-org/sdk"; // 1. Connect. Resolves the network preset and derives the shielded key. // Nothing is downloaded for proving until the first spend. const wallet = await connect({ network: "base", rpcUrl: "https://base-rpc.example.com", // public presets ship no RPC endpoint privateKey, }); console.log("shielded address:", wallet.address); // 2. Shield 0.5 WETH. Amounts are decimal strings of the token, or values the // SDK returned. The permit and the ERC-20 pull happen in one transaction. const deposit = await wallet.deposit({ asset: "WETH", amount: "0.5" }); // 3. Wait for the relayer to add the note to the tree, then sync. await wallet.awaitDeposit(deposit.escrow); await wallet.sync(); const balance = await wallet.balance("WETH"); console.log(formatAmount(balance.spendable, balance.asset, { symbol: true })); // "0.5 WETH" // 4. Transfer to another shielded address. `autoConsolidate` merges notes and // retries rather than throwing when no single spend can cover the amount. await wallet.transfer({ asset: "WETH", amount: "0.1", recipient: peer, autoConsolidate: true }); // 5. Unshield. `gross` leaves the pool and the protocol fee comes out of it; // pass `net` instead to name what the recipient receives. const out = await wallet.withdraw({ asset: "WETH", gross: "0.2", recipient: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", }); console.log("received", formatAmount(out.net.amount, out.asset), "on ladder:", out.onLadder); // 6. Release workers. Required in any process that rebuilds the wallet. await wallet.dispose(); ``` Every snippet on this site is compiled against the SDK at build time. Hover an identifier to see its type. | Step | Detail | |---|---| | Networks | `mainnet`, `base`, and `arbitrum` are deployed. `rpcUrl` is required for them. See [Networks](https://docs.lelantos.xyz/guide/networks.md). | | Prover artifacts | Resolved from `@lelantos-org/circuits` on Node, on first use. Browsers pass `prover: { artifacts }` — see [Browser usage](https://docs.lelantos.xyz/guide/browser.md). | | Amounts | `"0.5"` is in token units; a plain `bigint` does not compile. See [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md). | | Settlement | A deposit is spendable only after the relayer adds it to the tree and the wallet syncs. See [Deposit](https://docs.lelantos.xyz/guide/deposit.md#the-deposit-lifecycle). | ## Asset and amount inputs `asset` accepts a registry id, an ERC-20 address, or a symbol, and is required on every operation. `amount` names its unit space by its shape: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const peer: string; // example import { circuitAmount } from "@lelantos-org/sdk"; // Equivalent, given a registry where asset 1 is USDC at scale 1: await wallet.transfer({ recipient: peer, asset: "USDC", amount: "12.50" }); // token units await wallet.transfer({ recipient: peer, asset: 1n, amount: { baseUnits: 12_500_000n } }); // base units await wallet.transfer({ recipient: peer, asset: 1n, amount: circuitAmount(12_500_000n) }); // circuit units ``` A symbol shared by two registered assets raises `INVALID_ARGUMENT`. Use the id or token address in that case. See [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md#three-ways-to-name-an-asset). ## Handling errors Every method rejects with a `WalletError` that carries a stable `code` and a `retryable` flag. `isWalletError(err, code)` narrows the error to that code's class, so its fields are typed. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example import { isWalletError } from "@lelantos-org/sdk"; try { await wallet.transfer({ asset: "WETH", amount: "1", recipient }); } catch (err) { if (isWalletError(err, "INSUFFICIENT_COVER")) { // The balance is spread over more notes than one spend can use. console.log("merge these first:", err.consolidate.map((n) => n.id)); } else if (isWalletError(err, "NOTES_HELD") && err.retryable) { // Notes are leased by another spend in flight; retry shortly. } else { throw err; } } ``` Pass `autoConsolidate: true` to merge notes and retry automatically. See [Note management](https://docs.lelantos.xyz/guide/notes.md#consolidating-explicitly) and [Errors](https://docs.lelantos.xyz/guide/errors.md). ## Explicit configuration `connect()` resolves a network preset and fills in defaults. `createWallet(keySource, config)` from `@lelantos-org/sdk/advanced` takes an explicit `WalletConfig` and infers nothing. ```ts import { createWallet } from "@lelantos-org/sdk/advanced"; ``` See [Connecting a wallet](https://docs.lelantos.xyz/guide/wallet.md) and [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md). ## Next - [Concepts](https://docs.lelantos.xyz/guide/concepts.md) — notes, nullifiers, the tree, and sync - [Connecting a wallet](https://docs.lelantos.xyz/guide/wallet.md) — key sources, chain layers, prover and storage options - [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md) — circuit units and conversions # Concepts The SDK hides the cryptography, but four concepts determine how its API behaves. ## Notes The pool does not store balances. It stores **notes**: commitments that each hold one asset, one value, and one owner. - A balance is the sum of unspent notes. - A spend consumes up to four notes and creates new ones. Change returns as a new note. - A balance spread across more than four notes cannot be spent in one transaction. `balance()` is therefore not the maximum spendable amount; use [`spendableMax()`](https://docs.lelantos.xyz/guide/notes.md#what-a-single-spend-can-reach). - When no set of notes fits the input slots, a spend throws `INSUFFICIENT_COVER` instead of sending less. ## Nullifiers Spending a note publishes its **nullifier**, a value derived from the note. The pool rejects a nullifier it has already seen, which prevents double spends. A nullifier does not reveal which note it belongs to. The wallet downloads the full nullifier set and checks it locally. Querying a server for a specific nullifier would reveal a note the wallet owns. ## The Merkle tree Every note commitment is appended to a Merkle tree. A spend proves in zero knowledge that its input notes are in the tree, without revealing their positions. The proof is built against a specific tree root, so the wallet needs a current copy of the tree before it spends. This is why `sync()` defaults to `scope: "full"` on a spending wallet, and why a new deposit is not spendable until the relayer has added it to the tree. See [Deposit](https://docs.lelantos.xyz/guide/deposit.md#the-deposit-lifecycle). ## Note detection (FMD) Notes are encrypted to their recipient, and nothing on chain identifies the recipient. A wallet finds its notes in one of two ways: | Strategy | How it works | Trade-off | |---|---|---| | `full` (default) | download every encrypted note and trial-decrypt locally | no information leaves the wallet; highest bandwidth | | `matches` | register a detection key with the FMD server, which returns probable matches plus false positives | lower bandwidth; the server can detect the wallet's incoming notes | > **Danger: Delegating detection cannot be revoked** > > The detection key registered for `matches` lets the server recover the wallet's root FMD secret. The server can detect incoming notes permanently; rotating the subscription token does not revoke this. See [Sync strategies](https://docs.lelantos.xyz/guide/sync.md#sync-strategies). ## How a transfer uses them 1. Select notes that cover the amount. 2. Prove the notes are in the tree. 3. Publish their nullifiers. 4. Create commitments for the payee, the change, and the relayer fee. 5. Attach FMD clues so recipients can detect their notes. `wallet.transfer()` performs all five steps. The rest of this guide covers configuration, failure handling, and replacing individual components. ## Next - [How it fits together](https://docs.lelantos.xyz/guide/system.md) — the contracts and services the SDK talks to - [Glossary](https://docs.lelantos.xyz/guide/glossary.md) — definitions of keys, units, and protocol terms - [Syncing](https://docs.lelantos.xyz/guide/sync.md) # How it fits together The SDK communicates with **contracts** on an EVM chain and three **backend services** that index and relay transactions. None of them holds keys or can move funds. This page shows which component talks to which, what each request contains, and which requests the SDK never makes. ## The whole system Your application @lelantos-org/sdk NoteSource FmdClient Submitter RelayerClient Quoter quoteSwap ChainAdapter + Prover (local) Backend services fmd-webserver read-only index relayer the only service that writes metaquoter swap routes flushBatch · transfer · withdraw deposit cancel reads EVM chain MASP the pool NativeAdapter ETH wrap / unwrap SwapWrapper atomic swap legs Permit2 canonical, external - **Proving is local.** The prover runs inside the SDK. Secrets — `nsk`, note values, the notes being spent — never leave the process. - **Two parties write to the chain.** Your signer broadcasts deposits and cancellations. The relayer broadcasts all other transactions. - **Reads and writes use different services.** `fmd-webserver` is read-only and never receives transactions. The relayer does not serve note data. ## Who the SDK talks to | Party | SDK entry point | Requests | |---|---|---| | **fmd-webserver** | `FmdClient`, `NoteSource` | encrypted notes, commitment chunks, nullifier chunks, sync positions | | **relayer** | `RelayerClient`, `Submitter`, `DepositStream` | chain registry, fee estimates, swap wrapper address, spend, swap and generic-execution submission, deposit flush events | | **metaquoter** | `quoteSwap`, `fetchSwapQuote` | swap route and `minOut` | | **EVM chain** | `ChainAdapter` | asset registry, fee rates, deposit broadcast, escrow state, handle records | The deployment also runs an explorer indexer, a risk-screening API, and a price feed. The SDK does not contact them. ## Requests the SDK never makes These requests would identify the wallet's notes to the server, so the SDK performs the work locally: | Request not made | Reason | Local alternative | |---|---|---| | "Is nullifier `N` spent?" | a nullifier identifies a note the wallet owns | download the full spent set and check locally | | "Merkle path for leaf `i`" | the leaf index identifies the note being spent | rebuild the tree from the append-only chunk feed | | "Which notes are mine?" | reveals the wallet's notes | download all encrypted notes and trial-decrypt locally | The third can be delegated: the [`matches` sync strategy](https://docs.lelantos.xyz/guide/sync.md#sync-strategies) sends a detection key to the server to reduce bandwidth. The delegation is opt-in and cannot be revoked. ## Deposit flow A deposit is broadcast by your signer. After it is mined, the funds are in escrow until the relayer adds the note to the tree. Wallet (SDK) MASP relayer fmd-webserver sign permit 1 — EIP-2612 / Permit2 witness, bound to this deposit 2 deposit() — funds escrowed DepositEscrowed(id) — mined, not yet spendable 3 flushBatch() 4 SSE: flushed — the note is now in the tree logs indexed 5 sync() — encrypted notes + commitment chunks A new note is spendable only after steps 4 and 5. [`awaitDeposit`](https://docs.lelantos.xyz/guide/deposit.md#waiting-for-the-relayer) waits for both; `DepositStream` observes step 4 directly. The relayer's fee for step 4 is part of the deposit. Every deposit creates two leaves, both bound by the signature in step 1: - the depositor's note; - a fee note addressed to the relayer. The fee note can use a different registered asset (`feeAsset`). The request records it as `feeAssetId`, and the pool transfers both tokens from the payer in the same transaction. The protocol fee is always in the deposited asset. If the relayer charges nothing, the fee leaf is a zero-value note sealed to an address drawn for it, which no one holds, and `feeAssetId` is `0`. ## Spend flow Transfers, withdrawals, and swaps are broadcast by the relayer, never by your signer. The spend proof binds the relayer's address, so the pool rejects the transaction from any other sender. Wallet (SDK) MASP relayer fmd-webserver 1 sync() — notes, commitment chunks, nullifier chunks 2 quoteFee() — what relaying costs select notes Groth16 prove 3 — entirely local; no secret leaves the process 4 submit() — proof + public inputs + shielded fee note 5 transfer() / withdraw() + tree-update SNARK nullifiers + commitments indexed The relayer receives the proof, its public inputs, and its fee note. The spent notes, payee, and amount are private circuit inputs and are not revealed. The relayer does see the submitter's IP address and the submission time. The relayer and the FMD server are defaults, not requirements. `Submitter` and `NoteSource` are interfaces: you can use several relayers, run your own, submit directly from your own account, or use a different indexer. See [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md#custom-submitter). ## Visibility by party | Party | Can see | Cannot see | |---|---|---| | **fmd-webserver** | that a client fetched a page of the public feed | which notes belong to the wallet (with the default `full` strategy) | | **relayer** | IP address, submission time, its fee | spent notes, payee, amount | | **metaquoter** | the token pair and size of a requested route | the requester's identity, and whether the swap is executed | | **the chain** | deposit payer and amount; withdrawal recipient and amount; a handle and the address published under it | the contents of shielded transfers, other than that they occurred; who registered a handle | | **your RPC provider** | which handle a client looks up, and when | which payment, if any, follows a lookup, unless it also serves the relayer | Deposits, withdrawals and handle registrations are publicly visible. Shielded transfers are not. ## What each service can and cannot do No service holds a key, so none can spend. What each can still do, and what the SDK offers against it: | Service | Cannot | Can | Check | |---|---|---|---| | **relayer** | change the recipient, the amount or any output, or the label, address or controller of a handle registration: the proof binds them | quote any fee; refuse to relay; see a registration's label before it lands | `maxFee` on a spend, `acceptRelayerFee` on the wallet — [Fees](https://docs.lelantos.xyz/guide/fees.md#limiting-the-relayer-fee) | | **fmd-webserver** | make a spend land: a proof against a tree the pool does not hold is rejected on chain | withhold a note; serve a well-formed note the pool never inserted | `confirmCommitment` before crediting a payment — [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md#confirming-a-payment-against-the-pool) | | **artifact host** | — | serve another proving key, whose proofs can leak the witness | digest pinning — [Browser usage](https://docs.lelantos.xyz/guide/browser.md#pinning-the-artifacts) | A wallet that only displays a balance needs none of these. One that pays unattended, or releases goods on a payment, should use the first two. ## Next - [Concepts](https://docs.lelantos.xyz/guide/concepts.md) - [Syncing](https://docs.lelantos.xyz/guide/sync.md) — the read path - [Deposit](https://docs.lelantos.xyz/guide/deposit.md) — the deposit path - [Architecture](https://docs.lelantos.xyz/guide/architecture.md) — the SDK's module structure # Glossary Terms used throughout this guide and the [API reference](https://docs.lelantos.xyz/reference/index.md). ## Keys | Term | Definition | |---|---| | `nsk` | Spending key. A field element from which every other key is derived. Holding `nsk` is required to spend. See [Connecting a wallet](https://docs.lelantos.xyz/guide/wallet.md). | | `ivk` | Incoming viewing key. Derived from `nsk`; decrypts notes sent to any address of the account. | | `nk` | Nullifier key. Derived from `nsk`; computes nullifiers. Included in a full viewing key. | | `d` | Diversifier. 16 bytes that select one address of an account. | | Diversifier index | Integer in `[0, 2^32)` naming one address of an account; `addressAt(index)`. Index 0 is `wallet.address`. | | `pk` | Public key of one address. Binds a note commitment to its owner. | | `pk_d` | Diversified public key of one address. The ECDH target that notes are encrypted to. | | `ck_d` | Clue key of one address. Lets a sender attach an FMD clue to a note. | | `dk` | Detection key secret. Tests FMD clues for every address of the account; can be delegated to a server. | | Outgoing key | Derived from `nsk`; seeds the randomness of every output the account sends. With a payee's address, opens what the account sent to that payee. See [Output randomness](https://docs.lelantos.xyz/guide/primitives.md#output-randomness). | | Incoming viewing key (IVK) | Encoded `ivk` (`lelantosivk1…`). Reads incoming notes; cannot tell which are spent. See [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md). | | Full viewing key (FVK) | An incoming viewing key plus `nk` (`lelantosfvk1…`). Reads incoming notes and their spent status. | | Shielded address | Bech32m string with prefix `lelantos` encoding `d`, `pk_d`, `pk`, and `ck_d`. An account has one per diversifier index. See [Addresses](https://docs.lelantos.xyz/guide/addresses.md). | ## Pool and transactions | Term | Definition | |---|---| | MASP | Multi-Asset Shielded Pool. The contract holding shielded funds for every registered asset. | | Note | A shielded value: one asset, one amount, one owner. Balances are sums of unspent notes. | | Commitment (`cm`) | The public hash of a note, appended to the Merkle tree. | | Nullifier (`nf`) | A value derived from a note and `nk`, published when the note is spent. The pool rejects a repeated nullifier. | | Merkle tree | Append-only tree of note commitments. A spend proves its inputs are in the tree. | | Tree depth (`treeDepth`) | Height of the Merkle tree. Must match the deployed contract and circuit. | | Deposit (shield) | Moving tokens from a public balance into the pool. | | Transfer | Moving value between shielded addresses. Publishes no amount. | | Withdraw (unshield) | Moving value from the pool to a public address. | | Swap | Atomic unshield, trade, and re-shield. See [Swap](https://docs.lelantos.xyz/guide/swap.md). | | Credit note | The note created by a swap's re-shield leg when the trade fills; `quote.credit` is its value. | | Refund note | The note a swap re-shields in the input asset when the trade fails or passes its deadline. A handle registration has one too. | | Handle | A short name in the `LelantosNameRegistrar` that publishes one shielded address, served as an ENS subname of each parent the deployment holds. See [Names](https://docs.lelantos.xyz/guide/names.md). | | Published address | The account's address at `PUBLISHED_DIVERSIFIER_INDEX`, the one `registerName` puts under its handle. | | Controller key | The secp256k1 key, derived from `nsk`, whose signature changes a handle's published value. | | Escrow | Holding state of a mined deposit before the relayer adds it to the tree; `DepositResult.escrow` identifies it. | | Flush | The relayer's `flushBatch` transaction, which adds escrowed deposits to the tree. | | `publicIn` | A deposit's public amount, in circuit units. | | `publicOut` | A withdrawal's public gross amount, in circuit units. | | Change | Output notes returning unspent input value to the sender. | | Consolidation | A self-transfer that merges several small notes into fewer notes. | | Cover | A set of notes whose total is at least the target amount. | | Dust | Notes below the selector's dust threshold. | ## Amounts | Term | Definition | |---|---| | Circuit units | The integer amounts used inside the pool (`CircuitAmount`): note values, `publicIn`, `publicOut`. | | Base units | ERC-20 base units (`10 ** decimals` per token; `TokenAmount`). | | Gross / net | A withdrawal's or swap's amount before (`gross`, published) or after (`net`, delivered) the protocol fee. | | `Money` | An amount in both integer spaces, `{ asset, amount, baseUnits }`, as results report it. | | `scale` | Per-asset multiplier from circuit units to token units, set in the registry. | | `index` | Per-asset yield index, scaled by `RAY`. Equals `RAY` when the asset earns no yield. | | `RAY` | `10n ** 27n`, the fixed-point base for `index`. | | `rate` | The pool's `{ gross, supply }` pair for a yield-bearing asset. Used to size payments. | | bps | Basis points; 1 bps = 0.01%. | | `depositBps`, `withdrawBps` | Per-asset protocol fee rates for each direction. See [Fees](https://docs.lelantos.xyz/guide/fees.md). | | Denomination | A fixed withdrawal amount from an asset's ladder. | | Ladder | An asset's ascending list of denominations. See [Denominations](https://docs.lelantos.xyz/guide/denominations.md). | ## Circuit and proving | Term | Definition | |---|---| | Circuit shape | Number of input and output notes a proof supports. The shipped shape is `TRANSACT_4X6`. | | `nIn`, `nOut` | Input and output counts of the circuit shape (4 and 6). | | Groth16 | The zero-knowledge proof system used for spends. | | Witness | The private inputs to a proof, computed before proving. | | zkey | The proving key file (~33 MB for 4x6). | | Prover artifacts | The circuit `.wasm` and the `.zkey` needed to prove. | | Output shuffling | Randomizing the order of output notes so the payee and fee outputs cannot be identified by position. | ## Detection and sync | Term | Definition | |---|---| | Trial decryption | Attempting to decrypt every note with `ivk` to find the wallet's notes. | | FMD | Fuzzy message detection. Clues let a detection key find probable matches with a tunable false-positive rate. | | γ (gamma) | FMD precision parameter; controls the false-positive rate. | | Sync strategy | `full` (download all notes, decrypt locally) or `matches` (server-side detection). See [Syncing](https://docs.lelantos.xyz/guide/sync.md#sync-strategies). | | Subscription token | Bearer credential identifying a `matches` subscription. | | Cursor | Position in the note feed from which the next sync resumes. | | Spent set | The local copy of every published nullifier. | ## Services and selection | Term | Definition | |---|---| | Relayer | Service that submits spends on chain, pays gas, and flushes deposits. Paid by a shielded fee note. | | `fmd-webserver` | Read-only indexer serving encrypted notes, tree chunks, and nullifier chunks. | | Metaquoter | Service returning swap routes and `minOut`. | | `NativeAdapter` | Contract that wraps and unwraps ETH for native deposits and withdrawals. | | `SwapWrapper` | Contract that executes a swap's trade between its two legs. | | Permit2 | Uniswap's token-approval contract, used to authorize deposit transfers. | | SFRT | Smallest-First, Random Tiebreak. The default coin-selection algorithm. | | Spend cooldown | Minimum age in blocks before a received note can be spent. | | Lease | A note selected by a spend in flight, withheld from other spends until it settles. | | `opId` | Correlation id of one operation, on its phases, result, errors, and `state().ops`. | # Connecting a wallet `connect(options)` returns a `WalletApi`: a frozen object of bound methods (`deposit`, `transfer`, `withdraw`, `quoteSwap`, `sync`, `balance`, `state`, …). Its options fall into four independent groups: | Group | Options | Required | |---|---|---| | Network | `network`, `rpcUrl` | `network` always; `rpcUrl` unless the preset carries one or the chain layer is pre-built | | Key source | `mnemonic` (+ `account`, `passphrase`), `signature`, `nsk` | unless the chain layer derives the key | | Chain layer | `privateKey`, `signer`, `provider` + `address`, `readOnly`, `reader`, `chain` | exactly one | | Extras | `prover`, `scanner`, `http`, `storage`, `shape`, `denominations`, `acceptRelayerFee`, `syncStrategy`, `wasm`, `runtime` | no | The wallet's viewing keys and bech32m [addresses](https://docs.lelantos.xyz/guide/addresses.md) are derived from the shielded spending key (`nsk`). > **Warning: Funds belong to `nsk`** > > Notes can be spent only with the `nsk` they were shielded under. Each key source derives `nsk` deterministically; the same input always yields the same key and addresses. Back up the input (mnemonic, private key, or `nsk` itself). ## What `connect` does, in order 1. Validates every option at once. A problem throws `WALLET_CONFIG` listing all of them in `missing`, before any prompt or request. 2. Resolves the network preset. A placeholder preset throws `NETWORK_NOT_DEPLOYED`. 3. Builds the chain layer. 4. Creates the prover handle, without fetching anything (see [Prover](#prover)). 5. Loads wasm and builds the scanner and stores. 6. Derives the key last. With a `signer` or `provider` and no explicit key source, this is the one EIP-712 signature prompt `connect` issues. A declined prompt throws `USER_REJECTED`. Anything `connect` built before a failure is disposed. A `Prover` or `Scanner` instance you passed is left running: it is yours. ## Key source and chain layer | Chain layer | Signs deposits | Derives `nsk` without a key source | |---|---|---| | `privateKey` — raw hex key, for Node scripts | yes | yes, by domain-separated reduction | | `signer` — an `EthSigner` | yes | yes, from one EIP-712 signature | | `provider` + `address` — an EIP-1193 provider | yes | yes, from one EIP-712 signature | | `readOnly: true` — reads through `rpcUrl` | no | no | | `reader` — a pre-built `ChainReader` | no | no | | `chain` — a pre-built `ChainAdapter` | yes | no | Only deposits (and cancelling one, and allowance setup) need an EOA. Transfers, withdrawals, swaps, and handle registrations are authorized by the proof and broadcast by the relayer, so a `readOnly` wallet spends normally; `wallet.capabilities.deposit` is `false` for it. ### `privateKey` The private key signs on-chain transactions and derives `nsk`. ```ts // setup declare const privateKey: `0x${string}`; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl: "https://base-rpc.example.com", privateKey, }); ``` ### `mnemonic` A mnemonic derives `nsk` only (ZIP-32, account `0` by default). Pair it with any chain layer, including `readOnly`. ```ts import { connect, generateMnemonic, isValidMnemonic } from "@lelantos-org/sdk"; const mnemonic = generateMnemonic({ words: 24 }); if (!isValidMnemonic(mnemonic)) throw new Error("bad seed"); const wallet = await connect({ network: "base", rpcUrl: "https://base-rpc.example.com", mnemonic, account: 0, readOnly: true, // spends work; deposits need an EOA }); wallet.capabilities.deposit; // false ``` ### Browser wallets: `provider` + `address` For MetaMask or any EIP-1193 provider, pass the provider and the account to sign as. The user signs one EIP-712 message at the end of `connect` to derive `nsk`; later deposits use the same provider. ```ts // setup declare const window: { ethereum: { request(args: { method: string; params?: unknown[] }): Promise } }; // example import { connect } from "@lelantos-org/sdk"; const [address] = (await window.ethereum.request({ method: "eth_requestAccounts" })) as `0x${string}`[]; if (!address) throw new Error("no account"); const wallet = await connect({ network: "base", rpcUrl: "https://base-rpc.example.com", provider: window.ethereum, address, }); ``` Any library that exposes an EIP-1193 `request` function works the same way, including an ethers `BrowserProvider`'s underlying provider or a WalletConnect provider. The signed message is a fixed EIP-712 structure in the domain `{ name: "Lelantos", version: "1" }`, with no chain id, so the same account derives the same `nsk` on every network. Signature encodings that differ only in `v` or in high/low `s` derive the same key. To derive `nsk` without connecting, use `deriveNskFromSigner(signer)`. `lelantosTypedDataHash()` from `@lelantos-org/sdk/primitives` returns the digest that is signed. ### Passkeys A passkey that supports the WebAuthn PRF extension can derive `nsk`. The SDK does not call WebAuthn itself: the application runs the assertion with `LELANTOS_PRF_SALT` as the PRF input and passes the output to `deriveNskFromPasskey`. Pass the result as `nsk`. ```ts // setup import type { Eip1193ProviderLike } from "@lelantos-org/sdk"; declare function assertWithPrf(salt: Uint8Array): Promise; declare const provider: Eip1193ProviderLike; declare const address: `0x${string}`; declare const rpcUrl: string; // example import { connect, deriveNskFromPasskey } from "@lelantos-org/sdk"; const nsk = await deriveNskFromPasskey({ // Run `navigator.credentials.get` with `extensions: { prf: { eval: { first: salt } } }` // and return the 32 bytes of `getClientExtensionResults().prf.results.first`. evaluatePrf: (salt) => assertWithPrf(salt), }); const wallet = await connect({ network: "base", rpcUrl, nsk, provider, address }); ``` | Property | Detail | |---|---| | Salt | `LELANTOS_PRF_SALT` from `@lelantos-org/sdk/primitives`, a fixed 32-byte value; pass it as `prf.eval.first` | | Output | exactly 32 bytes; other lengths raise `INVALID_ARGUMENT` | | Determinism | the same credential always yields the same `nsk` | | Standalone derivation | `prfOutputToNsk(prf)` from `@lelantos-org/sdk/primitives` reduces a PRF output without running the ceremony | > **Danger: The passkey is the only backup** > > A passkey-derived wallet has no mnemonic. If the user loses the credential, the funds cannot be recovered. Inform users before creating a passkey wallet, or offer an additional backup such as exporting `nsk`. ### Type-checked groups Each group is an exclusive union: two key sources, or two chain layers, do not compile. A `readOnly`, `reader`, or `chain` layer without a key source does not compile either, because it holds no key to derive one from. ```ts // setup import { connect, generateMnemonic } from "@lelantos-org/sdk"; const mnemonic = generateMnemonic({ words: 24 }); const rpcUrl = "https://base-rpc.example.com"; const privateKey: `0x${string}` = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // example // Two chain layers: `privateKey` and `readOnly`. await connect({ network: "base", rpcUrl, mnemonic, privateKey, readOnly: true }); ``` ## Capabilities `wallet.capabilities` is fixed at connect time from the configuration. A method whose flag is `false` stays on the interface and rejects with the code shown. | Flag | `true` when | Otherwise | |---|---|---| | `prove` | a prover is configured (`prover` is not `"none"`) | spends reject `PROVER_UNAVAILABLE` | | `deposit` | the chain layer signs as an EOA | deposits reject `NO_EVM_ACCOUNT` | | `depositAllowance` | the adapter supports Permit2 batch allowances | `setupDepositAllowance` rejects `UNSUPPORTED_OPERATION` | | `nativeDeposit` | the adapter can deposit through `NativeAdapter` | `deposit({ native: true })` rejects `UNSUPPORTED_OPERATION` | | `nativeWithdraw` | a `NativeAdapter` address is known | `withdraw({ native: true })` rejects `UNSUPPORTED_OPERATION` | | `swap` | `prove`, a submitter that relays swaps, and a quoter URL | `quoteSwap` and `swap` reject `UNSUPPORTED_OPERATION` | | `registerName` | `prove`, a submitter that relays generic executions, a `nameRegistrarAddress`, and a chain layer that reads the registrar | `registerName` rejects `UNSUPPORTED_OPERATION` | ## Prover `prover` accepts a `ProverConfig`, a pre-built `Prover`, or `"none"`. ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, prover: { warmup: "lazy", // default: nothing is fetched until the first proof backend: "auto", // wasm, falling back to snarkjs }, }); // Start the ~37 MB artifact download now, for example while the user fills a form. await wallet.warmProver(); ``` | `ProverConfig` field | Default | Description | |---|---|---| | `artifacts` | `@lelantos-org/circuits` on Node; none in a browser | `{ circuit, zkey }` URLs or paths | | `cdn` | — | base URL serving `.wasm` and `_final.zkey` | | `warmup` | `"lazy"` | `"eager"` starts fetching and warming in the background once connected | | `backend` | `"auto"` | `"wasm"` or `"snarkjs"` to force one | | `worker` | — | a factory for a proving Web Worker — see [Browser usage](https://docs.lelantos.xyz/guide/browser.md#keeping-the-main-thread-free) | | `threads` | runtime concurrency | prover thread count | Artifact problems surface at the first proof or `warmProver()`, not at `connect`. An application that only reads balances never downloads the prover. `prover: "none"` makes that explicit: `capabilities.prove` is `false` and spends reject `PROVER_UNAVAILABLE`. ## HTTP options `http` is forwarded to the relayer, FMD, and quoter clients. ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; declare const proxiedFetch: typeof fetch; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, http: { fetch: proxiedFetch, // route every SDK request through your own transport timeoutMs: 10_000, // per attempt, reads and estimates submitTimeoutMs: 60_000, // per attempt, spend submissions retries: 2, headers: { "x-api-key": "…" }, onRetry: ({ service, url, attempt, delayMs }) => console.warn(service, url, attempt, delayMs), }, }); ``` Submissions retry only when no response arrived, or on `429` and `503`, under one idempotency key. See [Timeouts and retries](https://docs.lelantos.xyz/guide/errors.md#timeouts-and-retries). ## Storage `storage` holds the three persistence backends. Each defaults to memory, which re-downloads and re-scans on every start. ```ts // setup import type { NoteStore, NullifierPersistence, TreePersistence } from "@lelantos-org/sdk/advanced"; declare const privateKey: `0x${string}`; declare const rpcUrl: string; declare const notes: NoteStore; declare const tree: TreePersistence; declare const nullifiers: NullifierPersistence; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, storage: { notes, tree, nullifiers }, }); ``` See [Custom storage](https://docs.lelantos.xyz/guide/storage.md) and [Persisting the tree and spent set](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set). ## `createWallet()` — full control `createWallet(source, config)` from `@lelantos-org/sdk/advanced` takes a `KeySource` and an explicit `WalletConfig`. It resolves no network preset and accepts every pluggable, including those `connect` does not expose (`submitter`, `selector`, `noteSource`, `feeBps`). ```ts // setup import type { ChainReader } from "@lelantos-org/sdk/advanced"; declare const chain: ChainReader; // example import { createWallet } from "@lelantos-org/sdk/advanced"; const wallet = await createWallet( // `KeySource` is a discriminated union on `type`. { type: "mnemonic", mnemonic: "…", account: 0 }, { chainId: 8453n, treeDepth: 10, relayerAddress: "0x0000000000000000000000000000000000000002", chain, fmdUrl: "https://fmd.my-deployment.example", relayerUrl: "https://relayer.my-deployment.example", prover: { artifacts: { circuit: "./4x6.wasm", zkey: "./4x6_final.zkey" } }, }, ); ``` | Field | Required | |---|---| | `chainId`, `treeDepth`, `relayerAddress`, `chain` | always | | `fmdUrl` | unless `noteSource` is set | | `relayerUrl` | unless `submitter` is set | > **Warning: `treeDepth` must match the deployment** > > The value must equal the depth of the deployed contract and circuit. A mismatch produces no local error; the proof fails verification on chain. See [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) for every configurable component. ## Disposing a wallet `dispose()` terminates the scanner and prover workers the wallet created. Every other method then rejects with `UNSUPPORTED_OPERATION`; `state()` reports `disposed: true`. Calling it twice is safe. The SDK disposes only what it built. A pool from `scanner: { workers }` and a prover from a `ProverConfig` belong to the wallet. A `Prover` or `Scanner` instance you pass to `connect` or `createWallet` belongs to you: `dispose()` leaves it running, so it can be shared across wallets, and you release it. Storage backends are never closed by the SDK. Call `dispose()` before replacing a wallet, for example on an account or network switch. A worker-pool scanner holds several workers with separate WASM heaps, and they are not released otherwise. The wallet also implements `Symbol.asyncDispose`: ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; { await using wallet = await connect({ network: "base", rpcUrl, privateKey }); await wallet.sync(); } // disposed here ``` ## Next - [Networks](https://docs.lelantos.xyz/guide/networks.md) — built-in presets and custom deployments - [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md) - [Addresses](https://docs.lelantos.xyz/guide/addresses.md) # Networks A network preset contains the deployment details `connect()` needs: chain id, pool and relayer addresses, service URLs, and the circuit's tree depth. Pass a preset name or a `NetworkPreset` object as `network`. | Preset | Chain id | Status | |---|---|---| | `mainnet` | 1 | deployed | | `base` | 8453 | deployed | | `arbitrum` | 42161 | deployed | | `sepolia` | 11155111 | placeholder; not accepted by `connect()` | | `anvil` | 31337 | the SDK's own test preset; its addresses are deploy-dependent | The deployed networks share one relayer and one FMD server. `NETWORKS` exports the table. ## RPC endpoint The public presets ship no RPC endpoint: a shared default would rate-limit and observe every user. Pass your own as `rpcUrl`, which overrides `preset.rpcUrl`: ```ts // setup declare const privateKey: `0x${string}`; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "arbitrum", rpcUrl: "https://arb-rpc.example.com", privateKey }); ``` Without an endpoint, `connect()` throws `WALLET_CONFIG` before any signing prompt. `rpcUrl` is not needed when the chain layer is pre-built (`chain` or `reader`). ## Placeholder presets A placeholder preset has `maspAddress: null` and `relayerAddress: null`. Its name does not compile as `network`, because `DeployedNetworkName` excludes it: ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; await connect({ network: "sepolia", rpcUrl, privateKey }); ``` Called from JavaScript, the same call throws `NETWORK_NOT_DEPLOYED`. An unknown name throws `WALLET_CONFIG`. ## Custom network ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect, type NetworkPreset } from "@lelantos-org/sdk"; const myChain: NetworkPreset = { chainId: 8453n, maspAddress: "0x0000000000000000000000000000000000000001", relayerAddress: "0x0000000000000000000000000000000000000002", relayerUrl: "https://relayer.my-deployment.example", fmdUrl: "https://fmd.my-deployment.example", treeDepth: 10, // Optional: quoterUrl: "https://quote.my-deployment.example", // enables swaps nativeAdapterAddress: "0x0000000000000000000000000000000000000003", // native deposits and withdrawals permit2Address: "0x000000000022D473030F116dDEE9F6B43aC78BA3", // defaults to the canonical deployment submitTimeoutMs: 30_000, }; const wallet = await connect({ network: myChain, rpcUrl, privateKey }); ``` | Field | Effect when absent | |---|---| | `quoterUrl` | `capabilities.swap` is `false` | | `rpcUrl` | `rpcUrl` must be passed to `connect()` | | `nativeAdapterAddress` | `capabilities.nativeDeposit` and `nativeWithdraw` are `false` | | `swapWrapperAddress` | read from the relayer's `/chains` | | `genericCallWrapperAddress` | read from the relayer's `/chains`; with neither, `registerName` rejects `UNSUPPORTED_OPERATION` | | `nameRegistrarAddress` | `capabilities.registerName` is `false` | | `permit2Address` | the canonical `0x000000000022D473030F116dDEE9F6B43aC78BA3` | | `submitTimeoutMs` | 30,000 ms per submit attempt; `http.submitTimeoutMs` overrides it | `relayerAddress` is bound into every proof. It must be the submitter address the relayer publishes on `/chains` — its `Bundler` contract where it bundles, not its signing account. > **Warning: `treeDepth` must match the deployment** > > The value must equal the depth used by the deployed contract and circuit. A mismatch produces no local error; proofs fail verification on chain. ## Next - [Chain adapters](https://docs.lelantos.xyz/guide/chain-adapter.md) - [Browser usage](https://docs.lelantos.xyz/guide/browser.md) # Addresses A shielded address is a **bech32m** string with the human-readable prefix `lelantos` and a 112-byte payload, `d || pk_d || pk || ck_d`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example wallet.address; ``` | Component | Purpose | |---|---| | `d` | diversifier, 16 bytes; selects the base point `g_d` the other keys are built on | | `pk_d` | diversified public key, `ivk · g_d`; the key notes are encrypted to | | `pk` | public key, `Poseidon(TAG_PK, ivk, d)`; lets any sender build a valid note commitment for the recipient | | `ck_d` | clue key, `dk · g_d`; lets a sender attach the FMD clue that makes the note detectable | All four are required to send a detectable note. `ck_d` is the public half of the detection secret: holding an address lets you pay someone, not watch them. ## One account, many addresses An account has one address per integer index in `[0, 2^32)`. `wallet.address` is the address at index 0, and `addressAt(index)` returns any other; an index outside the range rejects `INVALID_ARGUMENT`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const invoice = await wallet.addressAt(7); (await wallet.addressAt(0)) === wallet.address; // true ``` | Property | Detail | |---|---| | Deterministic | an address is a function of the viewing key and the index: the same on every device, and on a [watch-only wallet](https://docs.lelantos.xyz/guide/watch-only.md) of the account | | Nothing to register | every address receives into the same wallet; `sync()` finds a note sent to any of them, and one detection key covers them all | | Unlinkable | without the account's viewing key or detection key, two of its addresses cannot be told to share an owner | Give each payer its own index, and payers cannot recognise a shared payee by comparing addresses. The last index, `PUBLISHED_DIVERSIFIER_INDEX`, is reserved for the address an account publishes under its [handle](https://docs.lelantos.xyz/guide/names.md#the-published-address); `wallet.publishedAddress()` returns it. Do not hand it out for anything else. ## Deriving an address Every address is derived deterministically from `nsk`, through `ivk`. Every [key source](https://docs.lelantos.xyz/guide/wallet.md#key-source-and-chain-layer) yields the same addresses for the same input. To derive an address without a wallet, use `addressFromViewingKey(P, J, viewingKey, index)` from `@lelantos-org/sdk/primitives`; `index` defaults to 0. `ownsAddress(P, J, ivk, decoded)` answers the reverse question: whether a decoded address belongs to a viewing key. ## Validating input | Function | Checks | Cost | |---|---|---| | `shieldedAddress(value)` | prefix and bech32m character set | cheap; suitable for validation on each keystroke | | `parseAddress(value)` | checksum, payload length, and that both points are in the prime-order subgroup and not the identity | async; loads the crypto context once; confirms the address is payable | ```ts // setup declare const entered: string; // example import { isWalletError, parseAddress, shieldedAddress } from "@lelantos-org/sdk"; function looksValid(value: string): boolean { try { shieldedAddress(value); // HRP + charset only return true; } catch { return false; } } try { const { d, pk_d, pk, ck_d } = await parseAddress(entered); // full validation console.log(d, pk_d, pk, ck_d); } catch (err) { if (!isWalletError(err, "INVALID_ARGUMENT")) throw err; console.error("not a payable Lelantos address"); } ``` `transfer()` and `deposit()` validate `recipient` fully themselves. Validating in advance only improves the error shown to the user. `decodeAddress(J, value)` in `@lelantos-org/sdk/primitives` is the synchronous form of `parseAddress`, for code that already holds a Jubjub context. An invalid address raises `INVALID_ARGUMENT`. `parseAddress` and the wallet's operations omit the address from the message, so payee information does not reach application logs. Display the rejected value from your own state if needed. ## Next - [Transfer](https://docs.lelantos.xyz/guide/transfer.md) - [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md) # Amounts and assets A value can be held in three unit spaces. The SDK never guesses which one a number is in: every amount states its space by its shape. | Space | Example | Used for | |---|---|---| | human | `"1.5"` | user input and display | | base units | `1500000000000000000n` (`TokenAmount`) | ERC-20 balances, allowances, pulls, and what a recipient receives | | circuit units | `150000000n` (`CircuitAmount`) | note values and the pool's `publicIn` / `publicOut` | Base and circuit units are related per asset: ``` baseUnits = circuitUnits * asset.scale * asset.index / RAY ``` - `scale` is set per asset in the MASP registry. It cannot be derived from `decimals`. - `index` is the pool's yield index, scaled by `RAY`. For an asset without yield it equals `RAY`, and the formula reduces to `circuitUnits * scale`. ## Resolving an asset `wallet.asset(ref)` returns the chain-verified registry entry, cached for a few seconds; `{ refresh: true }` re-reads it. `symbol` and `decimals` come from the ERC-20 when the chain adapter can read them. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount, minAmount, parseAmount } from "@lelantos-org/sdk"; const weth = await wallet.asset("WETH"); // → { id: 1n, token: "0xC02a…", scale: 10000000000n, symbol: "WETH", decimals: 18, // depositBps: 0n, withdrawBps: 25n, index: RAY, yieldEnabled: false, ladder: [...] } const units = parseAmount("0.25", weth); // 25000000n — human → circuit formatAmount(units, weth, { symbol: true }); // "0.25 WETH" — circuit → human minAmount(weth); // "0.00000001" — the smallest expressible amount ``` `wallet.assets()` lists every registered asset for display. It is the relayer's list and is not verified against the chain; operations always resolve through the verified path. ### Three ways to name an asset Every `asset`, `assetIn`, `assetOut`, and `feeAsset` accepts an `AssetRef`. It is required on every operation; there is no default asset. | Input | Interpreted as | Example | |---|---|---| | `bigint` or numeric string | registry id | `1n`, `"1"` | | 20-byte `0x` string | ERC-20 token address | `"0xC02aaA39…"` | | any other string | symbol | `"WETH"` | A `0x` string of the wrong length is rejected as an invalid address. A symbol that matches more than one registered asset raises `INVALID_ARGUMENT`. ## Stating an amount Every `amount`, `gross`, and `net` accepts an `Amount`: | Form | Space | |---|---| | `"12.5"` | human decimal string of the asset's token | | `circuitAmount(12_500n)`, or any amount the SDK returned | circuit units | | `{ baseUnits: 12_500_000n, round? }` | ERC-20 base units | ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example import { circuitAmount } from "@lelantos-org/sdk"; const usdc = await wallet.asset("USDC"); const { max } = await wallet.spendableMax(usdc.id, { kind: "transfer" }); await wallet.transfer({ recipient, asset: usdc.id, amount: "12.50" }); // human await wallet.transfer({ recipient, asset: usdc.id, amount: { baseUnits: 12_500_000n } }); // base units await wallet.transfer({ recipient, asset: usdc.id, amount: max }); // an SDK-returned CircuitAmount await wallet.transfer({ recipient, asset: usdc.id, amount: circuitAmount(1_250n) }); // explicit circuit units ``` A plain `bigint` does not compile: `100n` could be circuit units or base units, which differ by `scale` — up to 10^12. A `number` does not compile either, and is refused at runtime with `INVALID_ARGUMENT` for JavaScript callers. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example await wallet.transfer({ recipient, asset: "USDC", amount: 12_500_000n }); ``` A zero or negative amount is refused with `INVALID_ARGUMENT` before any request is made. ## Rounding An amount finer than one circuit unit has to be rounded or refused. The default depends on the asset: | Asset | Default | Why | |---|---|---| | plain (`index === RAY`) | `"exact"`: refused with `INVALID_ARGUMENT` | a plain unit is a whole number of base units, so an off-unit amount is a typo | | yield-bearing | `"up"`: the smallest unit count worth at least the amount | a unit is not a whole number of base units, so most human amounts have no exact form | `"up"` on a yield asset is the inverse of `formatAmount`, which rounds down. A formatted balance therefore parses back to exactly the same units, so a "max" button that formats `spendableMax` and parses the text never over-draws: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount, parseAmount } from "@lelantos-org/sdk"; const usdc = await wallet.asset("USDC"); const { max } = await wallet.spendableMax(usdc.id, { kind: "withdraw" }); const shown = formatAmount(max, usdc); // what the input field displays parseAmount(shown, usdc) === max; // true, for plain and yield assets alike ``` To choose the rounding yourself, pass `round`: ```ts // setup import type { AssetInfo } from "@lelantos-org/sdk"; declare const usdc: AssetInfo; // example import { fromBaseUnits, parseAmount, toBaseUnits } from "@lelantos-org/sdk"; parseAmount("0.0000001", usdc, { round: "down" }); // floor: never more than typed fromBaseUnits(1n, usdc, { round: "up" }); // base units → circuit units, ceil toBaseUnits(parseAmount("1", usdc), usdc); // circuit → base units; rounds down by default // As an operation input, round an off-unit base-unit amount explicitly: const amount = { baseUnits: 1_000_001n, round: "down" } as const; ``` | Function | Direction | Default `round` | |---|---|---| | `parseAmount(text, asset)` | human → circuit | `"exact"` plain, `"up"` yield | | `formatAmount(units, asset, { symbol, maxDecimals })` | circuit → human | `"down"` | | `toBaseUnits(units, asset)` | circuit → base | `"down"` | | `fromBaseUnits(base, asset)` | base → circuit | `"exact"` plain, `"up"` yield | | `minAmount(asset)` | — | the human form of one circuit unit | All five take any object with `decimals`, `scale`, and an optional `index` (`AssetUnits`), so they also work on an application's own registry rows. `formatAmount` accepts signed amounts, and `parseAmount` a leading `-`, so differences round-trip too. ## Gross and net A withdrawal and a swap move value out of the pool, and the protocol fee is taken from what leaves. Their amounts state which side of that fee they mean, as exactly one of two fields (`OutAmount`): | Field | Meaning | |---|---| | `gross` | `publicOut`: what leaves the pool, published on chain. The protocol fee comes out of it. | | `net` | what arrives: at the recipient (withdraw) or at the venue (swap), `gross − protocol fee`. | With `net`, the SDK computes the smallest `gross` whose net covers the request. That gross is rarely a [denomination](https://docs.lelantos.xyz/guide/denominations.md), so results report `onLadder`. The relayer's fee is never inside either figure; it is always `fees.relayer`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example // The recipient receives at least 100 USDC; the SDK sizes the gross. const r = await wallet.withdraw({ asset: "USDC", net: "100", recipient }); r.gross; // what left the pool r.net; // what arrived: `baseUnits` is exact r.fees.protocol; // gross − net, or null when the rate is 0 r.onLadder; // false: this gross publishes a distinctive amount ``` Passing both, or neither, does not compile. `outAmount(side, amount)` in `@lelantos-org/sdk/advanced` builds one from a runtime toggle. ## Results carry `Money` Every value in a result or quote is a `Money`: `{ asset, amount, baseUnits }`, the same figure in both integer spaces. Which field is exact follows where the figure lives: | Figure | Exact field | The other | |---|---|---| | shielded: note values, `gross`, a transfer's `amount`, unit-denominated yield fees | `amount` | `baseUnits`, converted and floored | | public: pulls, refunds, `net` received, plain-asset protocol fees | `baseUnits` | `amount`, `fromBaseUnits(…, { round: "down" })`, for display | A fee that was not charged is `null`, never a zero `Money`. ## Yield-bearing assets When the pool routes an asset to a yield venue, its `index` increases over time. A note's circuit-unit value stays fixed while its token value grows. As a result: - A formatted balance changes without any transaction. - Withdrawal denominations are fixed circuit-unit integers, not human amounts. See [Denominations](https://docs.lelantos.xyz/guide/denominations.md). - `wallet.asset()` resolves `index`, so `parseAmount` and `formatAmount` already account for yield. ### Sizing a payment: use `rate`, not `index` `index` is for display. The pool floors it on chain, so a charge computed from it can be lower than what the contract pulls, and a Permit2 amount based on it is rejected. Payments are sized from `rate`, the `{ gross, supply }` pair the pool divides by. `wallet.quoteDeposit()` does this for you and returns the exact pull and the signed ceiling. Without a wallet, `toTokenUnitsAtRate` and `depositTotals` from `@lelantos-org/sdk/protocol` apply the same arithmetic: ```ts // setup import type { AssetInfo, CircuitAmount } from "@lelantos-org/sdk"; declare const usdc: AssetInfo; declare const units: CircuitAmount; // example import { toTokenUnitsAtRate } from "@lelantos-org/sdk/protocol"; if (usdc.yieldEnabled && usdc.rate === undefined) { // Not a precision problem: this asset cannot be quoted at all. throw new Error("pool has not priced this asset yet"); } toTokenUnitsAtRate(units, usdc.scale, usdc.rate, { round: "up" }); ``` | Asset | `rate` | Convert a payment with | |---|---|---| | no yield | absent | `scale` | | yield, priced | present | `rate` | | yield, not priced | absent | cannot be quoted — do not fall back to `scale` | ## When `decimals` is unknown `parseAmount` and `formatAmount` throw `INVALID_ARGUMENT` when the asset has no `decimals`, which happens only with a chain adapter that cannot read ERC-20 metadata. `toBaseUnits` and `fromBaseUnits` do not need it. `hasTokenMeta(asset)` and `requireTokenMeta(asset)` from `@lelantos-org/sdk/advanced` narrow or assert: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; import { hasTokenMeta } from "@lelantos-org/sdk/advanced"; const { asset, total } = await wallet.balance(1n); const label = hasTokenMeta(asset) ? formatAmount(total, asset, { symbol: true }) : `${total} units`; ``` ## Branded types Values returned by the SDK are branded — `AssetId`, `CircuitAmount`, `TokenAmount`, `Hex32`, `EvmAddress`, `ShieldedAddress` — so the compiler rejects a base-unit amount where a circuit amount is expected. Create branded values with the constructors, which validate their input: ```ts import { assetId, circuitAmount, evmAddress, hex32, tokenAmount } from "@lelantos-org/sdk"; const id = assetId(1n); // throws if negative or beyond uint64 const units = circuitAmount(500n); // throws if negative const base = tokenAmount(500_000_000n); const token = evmAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); const cm = hex32("0x0000000000000000000000000000000000000000000000000000000000000001"); ``` Explicit unit arithmetic without an `AssetInfo` — `toCircuitUnits`, `toTokenUnits`, `parseUnits`, `formatUnits`, `RAY` — is in `@lelantos-org/sdk/protocol`. ## Next - [Addresses](https://docs.lelantos.xyz/guide/addresses.md) - [Transfer](https://docs.lelantos.xyz/guide/transfer.md) - [Denominations](https://docs.lelantos.xyz/guide/denominations.md) # Deposit A deposit moves tokens from a public ERC-20 balance into the shielded pool. It is the only operation broadcast by your own account, so it needs a chain layer that signs (`capabilities.deposit`) and you pay its gas. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const friend: string; // example const result = await wallet.deposit({ asset: "USDC", // id, token address or symbol amount: "100", // the new note's value; fees are charged on top feeAsset: "USDC", // optional — asset the relayer's fee note is paid in. Default `asset` recipient: friend, // optional — shielded owner of the note. Default this wallet deadline: BigInt(Math.floor(Date.now() / 1000) + 600), // optional — permit and escrow deadline onPhase: (phase, { opId, txHash }) => console.log(opId, phase, txHash), }); result.escrow; ``` | Option | Default | Description | |---|---|---| | `asset` | — | registry id, token address, or symbol | | `amount` | — | the principal that becomes the note (`publicIn`); the protocol fee and relayer fee are pulled on top | | `feeAsset` | `asset` | asset for the relayer fee note — see [A deposit's relayer fee](https://docs.lelantos.xyz/guide/fees.md#a-deposit-s-relayer-fee) | | `recipient` | this wallet | shielded owner of the new note | | `native` | `false` | send the native coin through `NativeAdapter`; `asset` must be the wrapped coin | | `deadline` | now + 1 h | Permit2 signature and escrow deadline, unix seconds | | `signal`, `onPhase`, `opId` | — | cancellation, progress, and a correlation id — see [Errors](https://docs.lelantos.xyz/guide/errors.md#operation-ids-and-cancellation) | Phases, in order: `preparing`, `signing` (only when a Permit2 signature is needed), `submitting`, `broadcast` (the transaction hash is known), `confirmed` (mined). ## Quoting first `quoteDeposit` runs the same resolution and validation as `deposit`, reads balances and allowances, and returns what the deposit would pull and which path it would take — without signing anything. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; const quote = await wallet.quoteDeposit({ asset: "USDC", amount: "100" }); for (const pull of quote.pulls) { // One entry per ERC-20 the pool pulls: principal + protocol fee, and the relayer's note. console.log(pull.token, pull.amount, "balance", pull.balance, "covered", pull.allowance?.covers); } quote.fees.protocol; // Money in USDC, or null quote.fees.relayer; // Money in the fee asset, or null quote.strategy; // "native" | "allowance" | "witness" quote.sufficientBalance; // false → the deposit would revert for lack of funds quote.allowanceSetupAvailable; // true → setupDepositAllowance would remove the per-deposit signature ``` | Field | Meaning | |---|---| | `amount` | the new note's value | | `principal` | `amount` plus the protocol fee, in base units of `asset` | | `pulls[]` | per token: `amount` (exact pull), `ceiling` (what is signed; adds yield headroom on a yield asset), `balance`, `allowance` | | `separateFee` | the relayer's note is pulled on its own, in another token | | `strategy` | the path `deposit` would take now | | `quotedAt` | unix seconds; yield figures drift, so re-quote rather than cache | A quote rejects exactly as the deposit would before signing: `FEE_ASSET_NOT_QUOTED`, `INVALID_ARGUMENT` for a refused fee asset or bad amount, `UNSUPPORTED_OPERATION` for `native` without an adapter, `NO_EVM_ACCOUNT` without a signing chain layer. ## Deposit strategies The token transfer and the deposit execute in one transaction; no separate `approve` per deposit is needed. The wallet picks a path from the adapter's capabilities and the account's Permit2 state: | Strategy | When | Prompts | |---|---|---| | `native` | `native: true` | one transaction | | `allowance` | every pull is covered by a Permit2 allowance with time to spare | one transaction | | `witness` | otherwise | one Permit2 signature, then one transaction | ### One-time allowance setup `setupDepositAllowance` authorizes the pool through Permit2 once, so later deposits take the `allowance` path with no signature. It approves Permit2 on each token that needs it, then signs one batch permit and sends one `permit` transaction for all of them. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const quote = await wallet.quoteDeposit({ asset: "USDC", amount: "100", feeAsset: "WETH" }); if (quote.allowanceSetupAvailable && wallet.capabilities.depositAllowance) { await wallet.setupDepositAllowance({ assets: quote.pulls.map((p) => p.asset.id), onProgress: (p) => { if (p.step === "approving") console.log(`approve ${p.index}/${p.total}`, p.token, p.status); else console.log(p.step, p.status, p.txHash); }, }); } ``` | Option | Default | |---|---| | `cap` | `2^160 − 1` base units, which Permit2 treats as unlimited | | `expiration` | now + 90 days | | `deadline` | signature deadline, now + 30 minutes | ## The deposit lifecycle | State | Meaning | Observed with | |---|---|---| | **confirmed** | the transaction is mined; funds are in escrow | `deposit()` resolves | | **flushed** | the relayer has added the note to the Merkle tree | `awaitDeposit(result.escrow)` | | **synced** | the wallet has the note and the tree containing it | `sync()` | The note is spendable only after it is synced. Between confirmed and flushed it has no tree position, so no proof can be built for it. ## Waiting for the relayer `awaitDeposit(escrow)` syncs until the note's commitment appears. It resolves a status rather than throwing on timeout: a slow indexer after a mined deposit is not a failure. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const { escrow } = await wallet.deposit({ asset: "USDC", amount: "100" }); const seen = await wallet.awaitDeposit(escrow, { timeoutMs: 300_000, pollMs: 3_000 }); if (seen.status !== "seen") console.warn("not flushed yet:", seen.status); ``` The default timeout is 120 seconds, polling every 2 seconds. Pass `throwOnTimeout: true` to reject with `FMD_TIMEOUT` instead. A deposit to another `recipient` never appears in this wallet; watch for it from the recipient's side. ### Push notification with `DepositStream` The relayer also publishes flushes on a server-sent events stream. `DepositStream` from `@lelantos-org/sdk/services` matches them by `depositId`: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const signal: AbortSignal; // example import { DepositStream } from "@lelantos-org/sdk/services"; // Open the stream before depositing: the relayer does not replay past events. const stream = new DepositStream("https://relayer.lelantos.xyz", 8453n); const { escrow } = await wallet.deposit({ asset: "USDC", amount: "100" }); const wait = await stream.awaitFlush(escrow.depositId, { signal }); if (wait.kind === "flushed") console.log("flushed in", wait.txHash, wait.blockNumber); stream.close(); ``` `awaitFlush` never rejects. It resolves `"flushed"`, `"aborted"` (the signal fired), or `"closed"` (the stream closed); the last two mean the flush was not observed, not that the deposit failed. `wait.txHash` is the relayer's `flushBatch` transaction, not the deposit's. `EventSource` is not available in Node. Pass `eventSourceFactory` in the options; without it the constructor throws `ENVIRONMENT`. On Node, `awaitDeposit` is usually simpler. ## Reading the result | Field | Meaning | |---|---| | `amount` | the new note's value (`Money`) | | `fees.protocol`, `fees.relayer` | `Money` in the deposited asset and the fee asset, or `null` when not charged | | `pulled` | what the pool pulled per asset, exact base units: the deposited asset first, then the fee asset when pulled separately | | `strategy`, `native` | the path taken | | `recipient` | owner of the new note | | `escrow` | `{ depositId, native, asset, commitment, cancelInputs, cancellableAtBlock }` | | `txHash`, `opId` | the deposit transaction and this call's correlation id | | `ownCommitments` | the new note's commitment, when `recipient` is this wallet | `escrow` is plain data. Persist it whole, with a bigint-aware serializer, to await or cancel after a reload. ## Cancelling a deposit If the relayer does not flush a deposit, the payer can reclaim it once `cancellableAtBlock` is reached. The refund always goes to the original payer. ```ts // setup import type { DepositEscrow, WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const escrow: DepositEscrow; // example const tip = await wallet.chain.blockNumber?.(); if (tip !== undefined && tip >= escrow.cancellableAtBlock) { const r = await wallet.cancelDeposit(escrow); r.refunded; // Money in the deposited asset: principal, protocol fee, and a same-asset relayer fee r.feeRefunded; // Money in the fee asset when it was pulled separately, else null } ``` `cancelDeposit` accepts the `escrow` a deposit returned, whose `cancelInputs` are used as-is, or `{ depositId, fromBlock? }`, in which case the inputs are rebuilt from the pool's `DepositEscrowed` log. The log is searched from a recent window by default; pass `fromBlock` for an older escrow. Native escrows are routed through `NativeAdapter` automatically. `cancelInputs` is the escrow as the pool published it in `DepositEscrowed`. The pool keeps only a digest of those fields, so a cancel hands every one back unchanged: an altered field reverts with `DigestMismatch`, and a missing one (a stored escrow from an older SDK, or a serializer that names bigint fields and omits `pulled`) is rejected as `INVALID_ARGUMENT` before any request. Do not fill a field in by hand; cancel with `{ depositId }` instead, which rereads the inputs from the log. `cancelInputs.pulled` is the escrow's refund cap: for a yield-bearing asset, the base units pulled in the deposited asset's token, which `refunded` never exceeds; for a plain asset, exactly `0n`. It is not `DepositResult.pulled`, which lists the deposit's pulls per asset. | Failure | Code | |---|---| | already flushed or cancelled | `INVALID_ARGUMENT`, `argument: "depositId"`, before any transaction | | `cancelInputs` incomplete | `INVALID_ARGUMENT`, `argument: "cancelInputs"`, before any request | | no signing account | `NO_EVM_ACCOUNT` | | cancelled before `cancellableAtBlock`, or with altered `cancelInputs` | `RPC_FAILED` with `retryable: false` (the pool reverted the call), or `TX_REVERTED` if it was mined | > **Warning: Block numbers on rollups** > > `cancellableAtBlock` is in the EVM's `block.number` space. On Arbitrum that is the L1 block number, while logs and the default search window use L2 blocks. When cancelling by `depositId` on a rollup, pass `fromBlock`. ## Next - [Transfer](https://docs.lelantos.xyz/guide/transfer.md) - [Fees](https://docs.lelantos.xyz/guide/fees.md) - [Syncing](https://docs.lelantos.xyz/guide/sync.md) # Transfer A transfer moves value between shielded addresses. It spends the sender's notes and creates new notes for the payee, the change, and the relayer fee if one is charged. No public balance changes, and no amount is published. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const peer: string; // example const tx = await wallet.transfer({ asset: "USDC", // id, token address or symbol amount: "25", // the recipient note's value — see Amounts recipient: peer, // bech32m `lelantos1…` address memo: "INV-2026-00418", // optional — text only the recipient can read feeAsset: "USDC", // optional — pay the relayer in another asset autoConsolidate: true, // optional — merge notes and retry when no cover exists selection: { dustThreshold: 10n }, // optional — coin-selection rules onPhase: (phase) => console.log(phase), // "preparing" | "consolidating" | "proving" | "submitting" | "confirmed" }); tx.recipientCommitment; ``` | Option | Description | |---|---| | `asset` | registry id, token address, or symbol | | `amount` | the recipient note's value: a decimal string, an SDK-returned amount, or `{ baseUnits }` | | `recipient` | recipient shielded address | | `memo` | text for the recipient, encrypted with the note — see [Memo](#memo) | | `feeAsset` | pay the relayer fee in another asset — see [Fees](https://docs.lelantos.xyz/guide/fees.md#paying-the-fee-in-a-different-asset) | | `maxFee` | cap on the relayer fee, in the fee asset — see [Fees](https://docs.lelantos.xyz/guide/fees.md#limiting-the-relayer-fee) | | `autoConsolidate` | on `INSUFFICIENT_COVER`, merge notes with a self-transfer and retry once. Default `false` | | `selection` | coin-selection rules (`dustThreshold`, `cooldownBlocks`, `maxInputs`, `only`, …) — see [Note management](https://docs.lelantos.xyz/guide/notes.md#selection-rules) | | `deadline` | unix seconds; checked before submitting, else `DEADLINE_PASSED` | | `signal`, `onPhase`, `opId` | cancellation, progress, and a correlation id — see [Errors](https://docs.lelantos.xyz/guide/errors.md#operation-ids-and-cancellation) | `"proving"` is CPU-bound and takes seconds. Show it as a distinct state in a UI. See [Benchmarks](https://docs.lelantos.xyz/guide/benchmarks.md) for timings. `"confirmed"` arrives when the relayer answers, which it does once the transaction is mined. ## Reading the result | Field | Meaning | |---|---| | `amount` | the payee note's value (`Money`) | | `recipient` | the payee | | `recipientCommitment` | the payee note's commitment | | `fees.relayer` | the relayer fee note (`Money` in the fee asset), or `null` for a free relay | | `fees.protocol` | always `null`: transfers pay no protocol fee | | `ownCommitments` | outputs this wallet will recover: change, plus the payee note on a self-transfer | | `nonZeroCommitments` | outputs with a non-zero value; the rest are padding | | `spent` | ids of the consumed notes, across both assets when the fee was cross-asset | | `change` | change left in `asset`, circuit units | | `txHash`, `operation` | the relayer's transaction, and where this operation sits in it when bundled | > **Warning: Use `recipientCommitment`, not `commitments[0]`** > > Output order is randomized so observers cannot tell which output is the payment. `commitments[0]` is not the payee's note. Sharing `recipientCommitment` with the payee reveals nothing new; the payee finds the same note when syncing, and can wait for it with `awaitCommitments([cm])`. ## Memo `memo` attaches a short text to the payee's note. It is encrypted with the note, so only the recipient reads it: the relayer, the chain and observers see ciphertext of the same length whether a memo was sent or not. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const peer: string; // example await wallet.transfer({ asset: "USDC", amount: "25", recipient: peer, memo: "INV-2026-00418" }); // The recipient, after syncing: for (const note of await wallet.notes({ spent: false })) { if (note.memo !== undefined) console.log(note.value, note.memo); } ``` - **Size.** At most 128 bytes as UTF-8: 128 ASCII characters, about 64 Cyrillic or Arabic, about 42 Chinese or Japanese. `MEMO_BYTES` from `@lelantos-org/sdk/primitives` is the limit. A longer text, or one containing U+0000, throws `INVALID_ARGUMENT` before any note is selected. - **The sender keeps no copy.** The wallet does not store the memo and cannot read it back from the chain. Keep it with the transfer's result: a [payment proof](#proving-a-payment) needs the exact text, and shows it to whoever verifies the proof. - **It is the sender's text.** The recipient's wallet does not check it. Show it as plain text, and do not treat a link or an instruction in it as coming from anyone the recipient trusts. - **It lasts as long as the note.** [`compact()`](https://docs.lelantos.xyz/guide/notes.md) removes a spent note together with its memo. ## Proving a payment A transfer is private to everyone but its two parties, so a sender in a dispute has nothing to show. `paymentProof` produces the secret behind one output; with the payee's address, its holder reads that output's asset, value and memo from the chain. A proof opens one output and nothing else: not the spend's other outputs or inputs, not other payments by either party, and it neither spends the note nor shows when it is spent. The payee cannot forge one. ### Making a proof ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example const paid = await wallet.transfer({ asset: "USDC", amount: "25", recipient }); const proof = await wallet.paymentProof({ txHash: paid.txHash, commitment: paid.recipientCommitment, recipient: paid.recipient, asset: paid.amount.asset, amount: paid.amount.amount, // circuit units // memo: the transfer's `memo`, exactly as given, when it had one }); // `proof` is plain JSON: send it to whoever asked. ``` Every field of the target but `memo` comes from the transfer's result. The secret is recomputed from the target, the wallet's key, and the spend's published nullifiers, so nothing but the memo has to be stored: a wallet restored elsewhere produces the same proof from the same target. | `paymentProof` rejects | When | |---|---| | `INVALID_ARGUMENT` | the transaction did not publish `commitment`, or the node holds no receipt for it; or the wallet's key with `recipient`, `asset`, and `amount` does not reproduce the output, because another wallet made it, or it paid a different recipient, asset, or amount, or carried a different memo | | `UNSUPPORTED_OPERATION` | the chain layer lacks `fetchNotePayload` or `txReceiptLogs` — see [Optional reads](https://docs.lelantos.xyz/guide/chain-adapter.md#optional-reads) | ### Verifying a proof The verifier needs no key, only the payee's address and an RPC: ```ts // setup import type { PaymentProof } from "@lelantos-org/sdk"; import type { ChainReader } from "@lelantos-org/sdk/advanced"; declare const proof: PaymentProof; declare const payeeAddress: string; declare const reader: ChainReader; // example import { verifyPaymentProof } from "@lelantos-org/sdk/protocol"; // `reader` is any chain layer that reads logs, such as a wallet's `chain`. const result = await verifyPaymentProof({ proof, recipient: payeeAddress, reader }); if (result.ok) console.log(result.asset, result.value, result.memo); // circuit units else console.log(result.reason); ``` `result.memo` is the output's memo, absent when it has none. `recipient` is the exact address that was paid. A payee's other [addresses](https://docs.lelantos.xyz/guide/addresses.md#one-account-many-addresses) do not verify: the proof binds one address, and nothing in it links that address to the rest of the account. | `reason` | Meaning | |---|---| | `malformed` | not a version-3 proof | | `wrong-chain` | made for another chain than the reader's | | `not-published` | the pool published no such commitment in that transaction, or the reader found no such transaction | | `wrong-ephemeral` | the secret is not that output's | | `not-for-recipient` | the output was not encrypted to this address | | `no-value` | the output carries value 0 or names asset 0; the payee's wallet discards such a note, so it credited nothing | | `commitment-mismatch` | it opens, but not to the note the pool committed to | | `wrong-clue` | the published clue is not the one the output's secret gives for this address | ## Claim-link keys To pay someone who has no wallet yet, fund a fresh account and hand over its key. `claimLinkKey(index)` derives that account from the wallet's own key, so an unclaimed link can be recomputed and swept back from any device: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const index: number; // example const link = await wallet.claimLinkKey(index); await wallet.transfer({ asset: "USDC", amount: "25", recipient: link.address }); // Hand `link.nsk` to the payee: `connect({ nsk })` over it spends the note. ``` > **Danger: One index, one link** > > Two links from one index share a key, so the holder of the first can take the second. Use the first index whose account has never held a note, and check that against the chain rather than a local counter: another device on the same seed may be ahead. The key is specific to the chain. Indices are unlinkable to each other and to the wallet's address. ## When the notes do not fit A spend consumes at most `nIn` notes (four with the default circuit). The four funding errors say why a spend could not be funded, and each has its own remedy: | Code | Cause | Remedy | |---|---|---| | `INSUFFICIENT_BALANCE` | the unspent notes do not add up to the amount plus a same-asset fee | send less, or top up | | `NOTES_HELD` | they would, but some are reserved by another spend, cooling down, or dust | `retryable: true` means waiting frees enough | | `INSUFFICIENT_COVER` | they add up, but no combination fits the input slots | consolidate | | `FEE_ASSET_NOT_QUOTED` | the relayer does not accept `feeAsset` | pick one of `accepted` | | `FEE_ABOVE_LIMIT` | the relayer's quote exceeds `maxFee` or `acceptRelayerFee` | retry, or raise the limit | ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example import { isWalletError } from "@lelantos-org/sdk"; try { await wallet.transfer({ asset: "USDC", amount: "25", recipient }); } catch (err) { if (isWalletError(err, "INSUFFICIENT_COVER")) { // `err.consolidate` names the notes to merge; `consolidationAttempted` says whether // `autoConsolidate` already tried. console.log(err.consolidate.map((n) => n.id), err.consolidateSum, err.consolidationAttempted); } else if (isWalletError(err, "INSUFFICIENT_BALANCE")) { console.log("have", err.available, "need", err.required); } else { throw err; } } ``` To avoid `INSUFFICIENT_COVER`: | Approach | Behaviour | |---|---| | `autoConsolidate: true` | the wallet merges the notes named by the selector, then retries. Costs one extra proof and transaction. Phases report `"consolidating"`. | | manual consolidation | catch the error and merge the notes yourself, to show progress in a UI. See [Consolidating explicitly](https://docs.lelantos.xyz/guide/notes.md#consolidating-explicitly). | | `spendableMax()` | size the transfer to what one spend can reach, so the error does not occur. See [What a single spend can reach](https://docs.lelantos.xyz/guide/notes.md#what-a-single-spend-can-reach). | ## Concurrent spends Each spend leases the notes it selects until it settles, so two spends started at once never select the same note. The second one selects from what is left, or rejects `NOTES_HELD` (retryable) when the leased notes were needed. ## Next - [Withdraw](https://docs.lelantos.xyz/guide/withdraw.md) - [Fees](https://docs.lelantos.xyz/guide/fees.md) - [Note management](https://docs.lelantos.xyz/guide/notes.md) # Withdraw A withdrawal spends shielded notes and pays a public EVM address. The recipient and the gross amount are visible on chain. ## To an ERC-20 balance ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const tx = await wallet.withdraw({ asset: "USDC", gross: "1000", // what leaves the pool; or `net: "…"` for what arrives recipient: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", autoConsolidate: true, onPhase: (phase) => console.log(phase), // "preparing" | "consolidating" | "proving" | "submitting" | "confirmed" }); tx.onLadder; ``` | Option | Description | |---|---| | `asset` | registry id, token address, or symbol | | `gross` **or** `net` | exactly one — see [Gross or net](#gross-or-net) | | `recipient` | EVM address receiving the tokens | | `native` | unwrap to the native coin — see [To native ETH](#to-native-eth) | | `feeAsset`, `maxFee`, `selection`, `autoConsolidate`, `deadline`, `signal`, `onPhase`, `opId` | as for [Transfer](https://docs.lelantos.xyz/guide/transfer.md) | Change is returned as new notes. When the asset has a withdrawal ladder, change is split into ladder denominations — see [Denominations](https://docs.lelantos.xyz/guide/denominations.md#change-lands-on-the-ladder-too). ## Gross or net The protocol fee is deducted from what leaves the pool. Name the side you mean: | Field | You name | The SDK computes | |---|---|---| | `gross` | `publicOut`, the amount published on chain | what the recipient receives: `gross − fee` | | `net` | what the recipient receives | the smallest `gross` that delivers at least that | ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example import { formatAmount } from "@lelantos-org/sdk"; // A payment of exactly 250 USDC to an exchange deposit address. const r = await wallet.withdraw({ asset: "USDC", net: { baseUnits: 250_000_000n }, recipient }); formatAmount(r.gross.amount, r.asset); // e.g. "250.625" — published on chain r.net.baseUnits; // ≥ 250_000_000n, exact r.fees.protocol; // Money in USDC, or null at 0 bps r.fees.relayer; // Money in the fee asset, or null — never inside gross or net ``` A `net` withdrawal rarely lands on a denomination, so it publishes a distinctive amount; `r.onLadder` is then `false`. Surface that to the user. See [Denominations](https://docs.lelantos.xyz/guide/denominations.md). > **Danger: Relayer fee is separate** > > Neither `gross` nor `net` includes the relayer's fee. It is paid from notes, in `feeAsset`, and reported as `fees.relayer`. ## Previewing `previewWithdraw` computes what a withdrawal would publish, cost, and deliver. It does no proving or submitting, so it can run on every keystroke. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const p = await wallet.previewWithdraw({ asset: "USDC", gross: "1000" }); p.publicOut; // gross in circuit units — what the chain sees p.netFormatted; // "997.5" — what the recipient gets p.onLadder; // whether this gross blends with other users' withdrawals p.suggestion; // nearest denomination, when it does not ``` Without a wallet, `withdrawNetFor(publicOut, asset)` and `grossForNet` from `@lelantos-org/sdk/protocol` apply the same arithmetic. Do not reimplement it: yield-bearing and plain assets round at different steps, and the results differ by up to one unit. ## Reading the result | Field | Meaning | |---|---| | `gross` | `publicOut` (`Money`, `amount` exact) | | `net` | delivered to `recipient` (`Money`, `baseUnits` exact) | | `fees.protocol`, `fees.relayer` | `Money` or `null` | | `onLadder` | whether `gross` is a denomination; `false` makes the withdrawal linkable | | `native` | unwrapped to the native coin | | `recipient`, `spent`, `change`, `txHash`, `opId` | as for a transfer | ## To native ETH ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example if (wallet.capabilities.nativeWithdraw) { await wallet.withdraw({ asset: "WETH", // must be the chain's registered wrapped coin gross: "0.5", recipient, native: true, feeAsset: "USDC", // any accepted fee asset works on the native path too }); } ``` `native: true` unshields WETH and unwraps it in the same transaction through the `NativeAdapter` contract. It needs no EOA of your own — the relayer broadcasts it — but it needs a known `NativeAdapter` address. Without one it rejects `UNSUPPORTED_OPERATION` before any fee is quoted. ## Withdrawing the maximum `balance().total` is not the maximum withdrawable amount: notes can be reserved, dust, or recently received, and one spend can use only `nIn` notes. `spendableMax` applies the spend's own rules, and with `kind` it also reserves the relayer fee: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example import { circuitAmount } from "@lelantos-org/sdk"; import { largestAtMost } from "@lelantos-org/sdk/protocol"; const usdc = await wallet.asset("USDC"); const { max } = await wallet.spendableMax(usdc.id, { kind: "withdraw" }); // `max` is the largest `gross` one spend can cover, after the fee. Either withdraw it all… if (max > 0n) await wallet.withdraw({ asset: usdc.id, gross: max, recipient }); // …or the largest denomination at or below it, which blends in where `max` rarely does. const denomination = largestAtMost(max, usdc.ladder); if (denomination !== undefined) { await wallet.withdraw({ asset: usdc.id, gross: circuitAmount(denomination), recipient }); } ``` Pass the same `feeAsset`, `native`, and `selection` to `spendableMax` that the withdrawal will use. For a denomination picker, use `wallet.withdrawDenominations()`. ## Next - [Denominations](https://docs.lelantos.xyz/guide/denominations.md) - [Swap](https://docs.lelantos.xyz/guide/swap.md) - [Fees](https://docs.lelantos.xyz/guide/fees.md) # Swap A swap exchanges one shielded asset for another in a single atomic operation: 1. **Leg 1** unshields the input asset to the `SwapWrapper` contract, which executes the trade. 2. **Leg 2** re-shields the output as a new note for the recipient. Both legs are submitted together, so the value is never held unshielded between transactions. A `withdraw` followed by a `deposit` does not provide this guarantee. If the trade fails or passes its deadline, the wrapper re-shields the input as a refund note instead. A swap is two calls: `quoteSwap` prices it, and `swap` executes that quote. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; const quote = await wallet.quoteSwap({ assetIn: "WETH", assetOut: "USDC", gross: "0.5", // what leaves the pool; or `net` for what reaches the venue slippageBps: 50, }); // What the output note will hold if the swap fills. Show this, not `expectedOut`. console.log("you receive", formatAmount(quote.credit.amount, quote.assetOut, { symbol: true })); const result = await wallet.swap({ quote }); ``` Swaps need `capabilities.swap`: a prover, a relayer that relays swaps, and a network with a quoter URL. The `SwapWrapper` address comes from the network preset or the relayer's `/chains`; without one, `quoteSwap` rejects `UNSUPPORTED_OPERATION`. ## Quoting | `quoteSwap` option | Description | |---|---| | `assetIn`, `assetOut` | input and output assets | | `gross` **or** `net` | exactly one: `gross` leaves the pool, `net` is what reaches the venue (`gross − protocol fee`) — see [Gross and net](https://docs.lelantos.xyz/guide/amounts.md#gross-and-net) | | `slippageBps` | maximum slippage, `0`–`10000` | | `feeAsset` | asset `fees.relayer` is quoted in; default `assetIn` | | `signal` | cancels the quote request | | `SwapQuote` field | Meaning | |---|---| | `gross`, `net` | leg 1 in `assetIn`; `net.baseUnits` is the venue's `amountIn` | | `onLadder` | whether `gross` is a denomination of `assetIn` (it is published) | | `expectedOut`, `minOut` | the venue's expected output and the slippage floor, base units of `assetOut` | | `credit` | exactly what the output note will hold if the swap fills | | `refundCredit` | what the refund note would hold, in `assetIn` | | `fees.protocol` | leg 1's withdraw fee, in `assetIn`, taken out of `gross` | | `fees.relayer` | the relayer's spend fee, in the fee asset; paid from notes, not inside `gross` | | `fees.flush`, `fees.outProtocol` | leg 2's relayer flush fee and deposit fee, in `assetOut`; already subtracted from `credit` | | `venue`, `quotedAt` | venue label and the unix second it was queried | `credit` is not `minOut / scale`: leg 2's fees come out of the output, and any fill above `minOut` goes to the treasury. The quote computes it with the same code the swap binds into the proof. A quote is plain frozen data and survives `structuredClone`, so it can live in a UI cache. Do not construct or edit one. ## Executing | `swap` option | Description | |---|---| | `quote` | from `quoteSwap`; its assets, amount, route, and `minOut` are what the proof binds | | `recipient` | shielded owner of the output note; default this wallet | | `refundAddress` | EVM account bound into the intent for refunds; default this wallet's EOA, else the relayer's | | `deadline` | unix seconds after which the wrapper refunds instead of swapping | | `feeAsset`, `selection`, `autoConsolidate`, `signal`, `onPhase`, `opId` | as for [Transfer](https://docs.lelantos.xyz/guide/transfer.md) | `swap` does not trust the quote object. It re-resolves both assets and recomputes `gross`, `net`, `credit`, and `refundCredit` at current rates: | Outcome | Code | What to do | |---|---|---| | figures reproduce | — | the swap runs | | a yield index or the relayer's flush fee moved since quoting | `QUOTE_STALE`, `retryable: true`, `fields` names the moved figures | re-quote, show the new figures, and run the new quote | | the quote was altered or is malformed | `INVALID_ARGUMENT`, `argument: "quote"` | a bug in the caller | ```ts // setup import type { QuoteSwapOptions, SwapResult, WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare function confirmWithUser(q: Awaited>): Promise; // example import { isWalletError } from "@lelantos-org/sdk"; async function swapWithFreshQuote(args: QuoteSwapOptions): Promise { for (let attempt = 0; attempt < 3; attempt++) { const quote = await wallet.quoteSwap(args); if (!(await confirmWithUser(quote))) return undefined; try { return await wallet.swap({ quote }); } catch (err) { if (!isWalletError(err, "QUOTE_STALE")) throw err; console.info("quote moved:", err.fields); // e.g. ["credit"] } } throw new Error("rates keep moving; try again later"); } ``` Beyond that check, staleness is your policy: `quotedAt` says how old the venue price is, and the chain enforces `minOut`. ## Reading the result | Field | Meaning | |---|---| | `asset`, `assetOut` | input and output assets | | `gross`, `net`, `onLadder` | leg 1, as quoted | | `expectedCredit` | the output note's value if the swap fills: the quote's `credit` | | `refundCredit` | the refund note's value if it does not | | `creditCommitment`, `refundCommitment` | exactly one of the two lands; await it with `awaitCommitments` | | `deadline` | unix seconds after which the wrapper refunds | | `fees` | the quote's four fees, with `relayer` as actually paid | | `spent`, `change`, `txHash`, `opId` | as for a transfer | Exactly one of the two notes lands, so wait for whichever appears first: ```ts // setup import type { SwapResult, WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const result: SwapResult; // example async function swapOutcome(r: SwapResult): Promise<"filled" | "refunded"> { const credit = r.creditCommitment.toLowerCase(); const refund = r.refundCommitment.toLowerCase(); for (;;) { await wallet.sync({ scope: "notes" }); for (const note of await wallet.notes()) { const cm = note.cm.toLowerCase(); if (cm === credit) return "filled"; if (cm === refund) return "refunded"; } await new Promise((resolve) => setTimeout(resolve, 2_000)); } } console.log(await swapOutcome(result)); ``` With a `recipient` other than this wallet, the credit note lands in the recipient's wallet instead. Without a wallet, `fetchSwapQuote` (`@lelantos-org/sdk/services`) and `sizeBNote` (`@lelantos-org/sdk/protocol`) are the underlying venue client and output sizing. ## Next - [Syncing](https://docs.lelantos.xyz/guide/sync.md) - [Fees](https://docs.lelantos.xyz/guide/fees.md) # Names A handle is a short name, such as `alice`, that publishes one of an account's shielded addresses. A payer who knows the handle looks the address up instead of copying 195 characters. Handles live in the `LelantosNameRegistrar` contract. Each ENS parent name the deployment holds serves them as subnames, so `alice` is also `alice.lelantos.xyz` in any ENS client. The SDK reads the registrar directly and never resolves through ENS. Registration is a spend: it is paid from shielded funds and broadcast by the relayer, so it needs no EVM account and names none. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const result = await wallet.registerName({ label: "alice" }); if (result.registered) { console.log(result.label, "now resolves to", result.address); } ``` Registering needs `capabilities.registerName`: a prover, a relayer that relays `GenericCallWrapper` executions, a `nameRegistrarAddress` in the network preset, and a chain layer that can read the registrar. > **Warning: A handle is public and permanent** > > The handle, the address published under it, and the time of registration are on chain for everyone, and stay in chain history after the record is cleared. A handle cannot be transferred or released. Ask the user to confirm before calling `registerName`. ## Which names are genuine A handle is genuine only under the parent names the deployment lists (`nameParents` in the registry's `/v1/chains`), and its address is the text record `xyz.lelantos.address`. `lelantos.eth` is **not** one of those parents: that ENS name belongs to a third party, who decides what `anything.lelantos.eth` resolves to. Do not accept it, and do not accept a name under any parent you were not configured with. `parseHandle` enforces this: ```ts import { parseHandle } from "@lelantos-org/sdk/protocol"; const parents = ["lelantos.xyz"]; // from the registry, never from user input parseHandle("alice", parents); // { label: "alice", parent: undefined } parseHandle("@Alice", parents); // { label: "alice", parent: undefined } parseHandle("alice.lelantos.xyz", parents); // { label: "alice", parent: "lelantos.xyz" } try { parseHandle("alice.lelantos.eth", parents); } catch (err) { // INVALID_ARGUMENT, details.reason "parent" } ``` A label is 3 to 32 characters of `a-z`, `0-9` and single hyphens, and does not start or end with a hyphen. `isNameLabel` checks one; case is folded by `parseHandle` and by `registerName`. ## The published address `registerName` publishes `wallet.publishedAddress()`: the account's address at `PUBLISHED_DIVERSIFIER_INDEX`, the last index. It is not `wallet.address`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { PUBLISHED_DIVERSIFIER_INDEX } from "@lelantos-org/sdk/primitives"; const published = await wallet.publishedAddress(); published === (await wallet.addressAt(PUBLISHED_DIVERSIFIER_INDEX)); // true published === wallet.address; // false ``` The index is reserved so that no address the account hands out elsewhere can be matched to the one it publishes. Keep giving payers other indices, as [Addresses](https://docs.lelantos.xyz/guide/addresses.md#one-account-many-addresses) describes. Notes sent to the published address arrive in the same wallet and need nothing extra to be found. ## Registering | `registerName` option | Description | |---|---| | `label` | the handle; a name under a parent is refused, pass the bare label | | `asset` | the plain (not yield-bearing) pool asset of the registrar's fee token, unshielded to pay the fee. Looked up from the token by default, which needs an asset list the wallet may not have: pass it when the wallet rejects `INVALID_ARGUMENT` on `asset`, and where registration is free | | `deadline` | unix seconds after which the wrapper refunds instead of registering | | `feeAsset`, `maxFee`, `selection`, `autoConsolidate`, `signal`, `onPhase`, `opId` | as for [Transfer](https://docs.lelantos.xyz/guide/transfer.md) | The label is checked against the registrar before anything is proven: | Outcome | Code | What to do | |---|---|---| | the label is malformed | `INVALID_ARGUMENT`, `argument: "name"` | show the label rule | | the label is already registered | `INVALID_ARGUMENT`, `argument: "label"`, `details.reason: "taken"` | ask for another | | the label is free | — | the registration runs | The operation unshields the registrar's fee plus the smallest change the pool will re-shield. From the wrapper's single-use clone it approves the fee and calls `register`, then re-shields the change. Three fees are paid: | Fee | Paid to | Where it shows | |---|---|---| | registration | the registrar's treasury | `registrationFee` | | relayer | the relayer, for the transaction | `fees.relayer`; quote it beforehand with `quoteFee("registerName")` | | protocol | the pool, on the unshield | `fees.protocol` | ## Reading the result A registration that landed did not necessarily register. If another registration of the same label lands first, or the registrar's fee rose after the wallet built the intent, the calls fail and the wrapper re-shields the input as a refund note. Only the relayer's and the pool's fees are spent. | Field | Meaning | |---|---| | `registered` | `true`: the handle is claimed. `false`: the calls failed and the input was refunded. `undefined`: the transaction's receipt could not be read | | `label`, `address` | the handle, case-folded, and the address published under it | | `controller` | the address of the key that controls the handle | | `registrationFee` | what the registrar charged; `null` where registration is free | | `changeCommitment`, `refundCommitment` | exactly one of the two lands; await it with `awaitCommitments` | | `deadline`, `fees`, `spent`, `change`, `txHash`, `opId` | as for a swap | ```ts // setup import type { RegisterNameResult, WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const result: RegisterNameResult; // example if (result.registered === false) { // The funds are on their way back as the refund note. await wallet.awaitCommitments([result.refundCommitment]); } ``` When `registered` is `undefined`, look the handle up: it is registered by this call if its `controller` is `result.controller`. ## Looking a handle up `readNameRecord` reads a handle with any viem client. It needs no wallet. ```ts import { parseAddress } from "@lelantos-org/sdk"; import { readNameRecord } from "@lelantos-org/sdk/advanced"; import { parseHandle } from "@lelantos-org/sdk/protocol"; import { createPublicClient, http } from "viem"; declare const registrar: string; // nameRegistrarAddress, from the registry declare const parents: string[]; // nameParents, from the registry const client = createPublicClient({ transport: http("https://rpc.example.com") }); const { label } = parseHandle("alice.lelantos.xyz", parents); const record = await readNameRecord(client, registrar, label); if (record.registered && record.value !== "") { await parseAddress(record.value); // rejects unless it is a payable shielded address } ``` | `NameRecord` field | Meaning | |---|---| | `registered` | whether the label is taken | | `value` | the published address; empty when the holder cleared it | | `controller` | the address whose signature changes the value | | `nonce` | how many times the value has changed | The registrar stores the value without parsing it. Validate it with [`parseAddress`](https://docs.lelantos.xyz/guide/addresses.md#validating-input) before paying it, and treat a value that fails as no address. A lookup is a request that names the payee. Whoever serves the RPC learns which handle was read, and can match a lookup to a payment that follows it. Look a handle up when a contact is saved, not each time it is paid. ## The controller key A handle belongs to a key, not to an account on chain. `wallet.nameControllerKey()` returns it: a secp256k1 key derived from the account's seed, the same on every device. | The key | Detail | |---|---| | Authorises | replacing the published value, or clearing it, with a signed `setValue` on the registrar | | Receives | the refund of a registration escrow that is cancelled instead of flushed, and any unused dust, at its address | | Is linked to | nothing: its address is the handle's public `controller`, and no other account of the user appears in a registration | Treat the private key as the handle. Whoever holds it can redirect payments made to the handle by publishing another address. ## Next - [Addresses](https://docs.lelantos.xyz/guide/addresses.md) - [Privacy checklist](https://docs.lelantos.xyz/guide/privacy.md) - [Fees](https://docs.lelantos.xyz/guide/fees.md) # Fees An operation can incur three separate costs, each charged by a different party. | Fee | Charged by | Paid in | Public | |---|---|---|---| | **Gas** | the network | native ETH | yes | | **Protocol fee** | the MASP contract | the moved asset: added to a deposit, deducted from a withdrawal | yes | | **Relayer fee** | the relayer | any asset the relayer accepts, as a shielded note (for a deposit, a second leaf) | no | You pay gas only for transactions your own account broadcasts: deposits, cancellations, and allowance setup. For relayed transactions the relayer pays gas and recovers it through the relayer fee. Every result and quote reports the last two as `fees: { protocol, relayer }`, each a `Money` or `null` when not charged. ## Protocol fee Rates are set **per asset and per direction** and are returned on `AssetInfo`. There is no pool-wide rate. | Rate | Applies to | Calculation | |---|---|---| | `depositBps` | `deposit`, and the re-shield leg of a swap | **added to** the principal | | `withdrawBps` | `withdraw`, and the unshield leg of a swap | **deducted from** the gross amount | ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { parseAmount } from "@lelantos-org/sdk"; import { withdrawNetFor } from "@lelantos-org/sdk/protocol"; const usdc = await wallet.asset("USDC"); usdc.depositBps; // shield rate, in basis points usdc.withdrawBps; // unshield rate // What a 1000 USDC gross withdrawal delivers, in ERC-20 base units. const { net, fee } = withdrawNetFor(parseAmount("1000", usdc), usdc); ``` `withdrawNetFor` takes the whole asset because it needs `withdrawBps`, `scale`, `index`, and `yieldEnabled`. A withdrawal names either side of this fee with `gross` or `net`; see [Gross or net](https://docs.lelantos.xyz/guide/withdraw.md#gross-or-net). ### Deposits of yield-bearing assets For a yield-bearing asset, the deposit total is converted to token units once using the pool's `rate`, not `index`. - **An asset without `rate` cannot be deposited.** The SDK raises `INVALID_ARGUMENT`. `scale` is not used as a fallback because it underestimates what the pool pulls. - **The signed amount is a ceiling, not the quote.** The pool's rate increases every block, so the SDK signs the exact pull plus a small headroom: `quoteDeposit().pulls[].ceiling`. Permit2 transfers only what the pool requests (`pulls[].amount`), and `NativeAdapter` refunds unused ETH. Plain assets sign the exact pull. Re-quote before submitting if the user takes time to confirm. ### Overriding rates `feeBps` on `createWallet`'s `WalletConfig` replaces the rates the pool reports for every asset. It is not a `connect()` option: against a live pool it produces wrong quotes as soon as the owner changes a rate. Use it only when the real rates are unavailable, such as on a fork, in tests, or before the registry is deployed. ## Relayer fee A relayer can charge for relaying. The fee is a shielded output note addressed to the relayer, created inside the transaction it pays for, so no public transfer links the payer to the transaction. `quoteFee(kind)` returns the fee before the transaction is built, and which assets the wallet can pay it in: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; const quote = await wallet.quoteFee("transfer"); if (!quote.charged) { console.log("this relayer relays transfers for free"); } else { for (const option of quote.options) { console.log( option.asset.symbol ?? option.asset.id, formatAmount(option.amount, option.asset), option.affordable ? "affordable" : "insufficient balance", ); } } ``` | `kind` | Estimate | |---|---| | `"transfer"` | spend estimate | | `"withdraw"` | spend estimate; `{ native: true }` prices the unwrap path | | `"swap"` | swap estimate; gas covers both legs and the trade | | `"deposit"` | the relayer's later `flushBatch`; `balance` and `affordable` are `undefined`, because a deposit's fee is funded from the public wallet | | `"registerName"` | a [handle registration](https://docs.lelantos.xyz/guide/names.md#registering): the wrapper's gas plus the gas its calls are forwarded | | `FeeOption` field | Meaning | |---|---| | `asset` | an accepted asset, resolved in the registry | | `amount`, `baseUnits` | the fee note's value in circuit units (exact) and base units | | `balance` | this wallet's unspent shielded balance of the asset | | `affordable` | `balance >= amount` | > **Warning: `affordable` does not guarantee the spend succeeds** > > `affordable` compares the fee with the unspent balance of that asset. The notes must also fit the circuit's input slots, alongside the notes being spent. Use `spendableMax(asset, { kind, feeAsset })` for a figure that accounts for both. ## Limiting the relayer fee The fee is the relayer's own quote, and the wallet pays it as quoted. Nothing else bounds it, so set a limit wherever no person reviews the fee before it is paid: agents, scripts, and [x402 payers](https://docs.lelantos.xyz/guide/x402.md#a-budget-is-required), whose `budget` covers servers only. `maxFee` limits one spend. It is stated in the fee asset, and a dearer quote rejects with `FEE_ABOVE_LIMIT` before anything is proven: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example await wallet.transfer({ asset: "USDC", amount: "25", recipient, maxFee: "0.10" }); ``` `acceptRelayerFee` covers every quote the wallet pays, including deposits and the self-spends that `autoConsolidate` and `redenominate` run, which `maxFee` does not reach: ```ts // setup declare const rpcUrl: string; declare const mnemonic: string; declare const USDC_ID: bigint; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, mnemonic, readOnly: true, // `asset` is the fee asset's id, `amount` its circuit units. acceptRelayerFee: ({ asset, amount }) => asset === USDC_ID && amount <= 100_000n, }); ``` Either rejection is `FEE_ABOVE_LIMIT`, and `retryable`: a quote follows gas and may fall back under the limit. Its fields are listed under [Errors](https://docs.lelantos.xyz/guide/errors.md#funds). ## Paying the fee in a different asset By default the relayer fee is paid in the moved asset. Set `feeAsset` to pay in another: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example const { max } = await wallet.spendableMax("WETH", { kind: "withdraw", feeAsset: "USDC" }); await wallet.withdraw({ asset: "WETH", gross: max, recipient, feeAsset: "USDC" }); ``` For spends: - The fee asset needs its own input note and change slot. The published 4×6 circuit has room for both. - The relayer must accept the asset. An asset it does not quote is rejected with `FEE_ASSET_NOT_QUOTED` before proving; `err.accepted` lists the ones it takes. - `spendableMax` with the same `feeAsset` reserves the fee note's input slot, so the maximum and the spend agree. A spend that uses every slot for the moved asset fails with `INSUFFICIENT_COVER` and `reason: "fee-slot"`. ### A deposit's relayer fee A deposit also pays the relayer with a note, funded from the payer's **public** balance. `feeAsset` selects the asset and defaults to `asset`. The protocol fee is always charged in the deposited asset. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example // Shield USDC, pay the relayer in WETH. const quote = await wallet.quoteDeposit({ asset: "USDC", amount: "100", feeAsset: "WETH" }); quote.separateFee; // true: two token pulls quote.pulls; // [{ token: USDC, amount: principal + protocol fee }, { token: WETH, amount: relayer fee }] const result = await wallet.deposit({ asset: "USDC", amount: "100", feeAsset: "WETH" }); result.pulled; // what was actually pulled, per asset ``` When `feeAsset` differs from `asset`, the deposit makes **two token transfers**: the principal and protocol fee in the deposited token, and the relayer fee in the fee token. - The signature strategy signs both amounts in one Permit2 batch permit. - The allowance strategy is used only when an active Permit2 allowance covers both tokens. - If the relayer charges nothing, the fee note has zero value and names asset 0, and the deposit makes one transfer regardless of `feeAsset`. Cancelling such a deposit refunds each token separately: `refunded` in the deposited asset and `feeRefunded` in the fee asset. See [Cancelling a deposit](https://docs.lelantos.xyz/guide/deposit.md#cancelling-a-deposit). #### Unsupported fee assets The SDK rejects these combinations with `INVALID_ARGUMENT` on `feeAsset` before signing: | Combination | Reason | |---|---| | `native: true` with a different fee asset | `NativeAdapter` transfers only the wrapped native token | | a yield-bearing fee asset different from the deposited asset | the pool rejects it with `FeeAssetUnsupported` | A fee asset the relayer does not accept is refused with `FEE_ASSET_NOT_QUOTED`, because the relayer would not flush the deposit and it would stay in escrow until cancelled. To filter a fee-asset picker without a quote per option, `depositFeeAssetRefusal` from `@lelantos-org/sdk/protocol` returns the reason without throwing: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { depositFeeAssetRefusal } from "@lelantos-org/sdk/protocol"; const usdc = await wallet.asset("USDC"); const { options } = await wallet.quoteFee("deposit"); const payable = options.filter((o) => depositFeeAssetRefusal(usdc, o.asset, false) === undefined); ``` #### Computing pulls without a wallet `depositTotals` and `depositPulls` from `@lelantos-org/sdk/protocol` are the arithmetic `quoteDeposit` runs: ```ts import { depositPulls, depositTotals } from "@lelantos-org/sdk/protocol"; // 100 USDC principal (scale 1e4), relayer quoted 42 WETH circuit units (scale 1e10). const { principal, relayer } = depositTotals({ publicIn: 10_000n, feeIn: 42n, depositBps: 20n, scale: 10_000n, publicAssetId: 1n, // USDC feeAssetId: 2n, // WETH; pass 1n when the fee is in the deposited asset feeScale: 10_000_000_000n, // read only when the note is pulled separately }); const usdc = { id: 1n, token: "0x…a1", yieldEnabled: false }; const weth = { id: 2n, token: "0x…e7", yieldEnabled: false }; const { separateFee, byToken } = depositPulls({ deposited: usdc, feeAsset: weth, principal, relayer }); // byToken: [{ asset: usdc, amount: principal }, { asset: weth, amount: relayer }] ``` | `depositPulls` field | Contents | |---|---| | `byAsset` | one entry per transfer the pool requests (the batch permit entries) | | `byToken` | amounts summed per ERC-20 token; a plain asset and a yield asset can share one token, balance, and Permit2 allowance | | `separateFee` | the fee asset when its note is pulled separately, else `undefined` | Pass `publicAssetId` and `feeAssetId` to `depositTotals`. Without them, the presence of `feeScale` alone means a separate fee transfer, and passing it for a fee in the deposited asset produces an incorrect split. ## Stale fee quotes A spend is priced against the relayer's estimate at submission time. A relayer that refuses the fee answers with `RELAYER_REJECTED` and a `reason`: | `reason` | Retryable | Meaning | |---|---|---| | `stale-estimate` | yes | the estimate moved; retrying re-quotes | | `fee-too-low`, `fee-missing` | no | the fee note does not satisfy this relayer | | `fee-asset-rejected` | no | the relayer does not take this fee asset | See [Errors](https://docs.lelantos.xyz/guide/errors.md#relayer-rejections). ## Next - [Errors](https://docs.lelantos.xyz/guide/errors.md) - [Building transactions manually](https://docs.lelantos.xyz/guide/primitives.md) # Syncing A note is spendable only when the wallet has three things locally: the note, the Merkle tree containing it, and the nullifier set showing whether it is spent. `sync()` fetches them. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const report = await wallet.sync(); ``` `sync()` trial-decrypts the encrypted note feed with the wallet's viewing key, stores matches in the note store, pages the nullifier and commitment feeds, and marks notes whose nullifiers appear in the spent set. A second call while one is running queues behind it. The tree and the nullifier set are downloaded in full, because asking a server for a specific path or nullifier would reveal which notes the wallet owns. To avoid downloading them on every start, [persist them](#persisting-the-tree-and-spent-set). ## Scope | `scope` | Fetches | Use when | |---|---|---| | `"full"` (default on a spending wallet) | notes, spent set, and Merkle tree | before spending, or to warm the tree | | `"notes"` | notes and spent set | displaying balances; a spend syncs the tree itself | A watch-only wallet always syncs `"notes"`: it never builds a proof, so it has no tree. An incoming-viewing-key wallet also skips the spent set (`report.nullifiers` is absent). ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example await wallet.sync({ scope: "notes" }); // cheap refresh for a balance screen await wallet.sync({ reload: true }); // re-read the note store first, after another tab wrote to it ``` ## Progress and cancellation `onProgress` reports each stream separately. `signal` stops paging at the next page boundary; progress made before the abort is saved, the call rejects with `signal.reason`, and the next sync resumes. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const report = await wallet.sync({ signal: AbortSignal.timeout(30_000), pageSize: 1000, onProgress: (p) => { if (p.stream === "notes") console.log("notes", p.phase, p.fetched, p.hits); else console.log(p.stream, "chunk", p.chunkId, p.syncedCount); }, }); if (report.notes.stoppedBy !== "exhausted") { console.warn("the note feed did not run to its end:", report.notes.stoppedBy); } ``` | `SyncReport` field | Contents | |---|---| | `notes` | `fetched`, `hits`, `added`, `skipped`, `pages`, `cursor`, `stoppedBy` | | `tree` | tree chunk summary; present for `scope: "full"` | | `nullifiers` | spent-set chunk summary; absent without a full viewing key | | `syncedAt` | when this sync finished | | `notes.stoppedBy` | Meaning | |---|---| | `"exhausted"` | the feed was read to the end; the wallet is up to date | | `"cursorStalled"` | the feed returned a page without advancing the cursor; the note source is misbehaving | | `"pageCap"` | the per-sync page limit was reached; the note source is misbehaving | A failed sync rejects with its error and records it in `state().sync.lastError` until the next success. See [Balances and state](https://docs.lelantos.xyz/guide/state.md). ## Waiting for a transaction to be indexed After a transaction lands, the indexer must process it before `sync()` returns its notes. `awaitCommitments` syncs (scope `"notes"`) until the given commitments are in the local store. It resolves a status instead of throwing on timeout. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example const tx = await wallet.transfer({ asset: "USDC", amount: "10", recipient }); // Only this wallet's own outputs will ever land in its own store. const seen = await wallet.awaitCommitments(tx.ownCommitments, { timeoutMs: 60_000, pollMs: 2_000 }); if (seen.status !== "seen") console.warn(seen.status, seen.missing.length, "missing"); ``` | Option | Default | |---|---| | `timeoutMs` | 120,000 ms | | `pollMs` | 2,000 ms | | `pageSize` | the feed's page size | | `throwOnTimeout` | `false`; `true` rejects with `FMD_TIMEOUT` | | `signal` | resolves `"aborted"` when fired | Pass `ownCommitments`: only outputs the wallet can decrypt ever reach its store. For a deposit, `awaitDeposit(result.escrow)` is the same call on the escrow's commitment. ## Checking for new data `FmdClient.fetchHead()` from `@lelantos-org/sdk/services` returns the indexer's latest positions in one inexpensive request. Poll it and sync only when the head has advanced. ```ts import { FmdClient } from "@lelantos-org/sdk/services"; const fmd = new FmdClient("https://fmd.lelantos.xyz", 8453n); const head = await fmd.fetchHead(); ``` ## Persisting the tree and spent set By default the tree and nullifier set are kept in memory and rebuilt on every start. Pass `storage.tree` and `storage.nullifiers` to restore them at startup and save them after each sync. | Interface | Methods | |---|---| | `TreePersistence` | `load`, `save`, `clear` | | `NullifierPersistence` | `load`, `save` | `clear` lets the wallet discard a corrupt tree and rebuild it. The nullifier set only grows, so it has no `clear`. ```ts // setup declare function idbGet(k: string): Promise; declare function idbSet(k: string, v: string): Promise; declare function idbDel(k: string): Promise; // example import type { NullifierPersistence, NullifierStoreState, TreePersistence, TreeStoreState, } from "@lelantos-org/sdk/advanced"; // bigint is not JSON-serialisable, so encode explicitly rather than relying // on a replacer somewhere up the call stack. const enc = (v: unknown) => JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? `${x}n` : x)); const dec = (s: string) => JSON.parse(s, (_k, x) => (typeof x === "string" && /^\d+n$/.test(x) ? BigInt(x.slice(0, -1)) : x)); export class IdbTreePersistence implements TreePersistence { async load(): Promise { const raw = await idbGet("lelantos-tree"); return raw ? (dec(raw) as TreeStoreState) : null; } async save(state: TreeStoreState): Promise { await idbSet("lelantos-tree", enc(state)); } // Required. The wallet repairs a bad tree by discarding and rebuilding it; a backend // that cannot forget would restore the discarded tree on the next `load()`. async clear(): Promise { await idbDel("lelantos-tree"); } } export class IdbNullifierPersistence implements NullifierPersistence { async load(): Promise { const raw = await idbGet("lelantos-nullifiers"); return raw ? (dec(raw) as NullifierStoreState) : null; } async save(state: NullifierStoreState): Promise { await idbSet("lelantos-nullifiers", enc(state)); } } ``` Pass them to `connect()`: ```ts // setup import type { NullifierPersistence, TreePersistence } from "@lelantos-org/sdk/advanced"; declare const IdbTreePersistence: new () => TreePersistence; declare const IdbNullifierPersistence: new () => NullifierPersistence; declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, storage: { tree: new IdbTreePersistence(), nullifiers: new IdbNullifierPersistence(), }, }); ``` > **Tip: Persist `nodes` as well as `leaves`** > > `TreeStoreState.nodes` contains cached internal Merkle nodes. A state saved without them still loads, but the first proof rebuilds them (about 350K hashes). Serialize the full state passed to `save`. Pre-built `TreeStore` and `NullifierStore` instances, for example to share a pre-seeded cache, are `createWallet` options in `@lelantos-org/sdk/advanced`. When either is set, the corresponding persistence option is ignored. ## Sync strategies `syncStrategy` selects how the wallet finds its notes. | Strategy | Endpoint | Detection | Privacy | Bandwidth | |---|---|---|---|---| | `{ kind: "full" }` (default) | `/v1/notes` | local trial decryption | no key material leaves the wallet | every encrypted note | | `{ kind: "matches", token }` | `/v1/matches` | server-side, via a registered subscription | the server learns which notes match | matches and false positives only | > **Danger: Delegating detection cannot be revoked** > > The registered detection scalars are `x_i = dk + h_i`, where `h_i` is public. The server can therefore recover the root FMD secret `dk` and detect the wallet's incoming notes permanently, at any false-positive rate. > > Rotating the subscription token does not revoke this. Only a new `nsk` does. ### Registering a subscription ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const privateKey: `0x${string}`; declare const rpcUrl: string; declare const myAppConfig: { subscriptionEpoch?: number }; // example import { connect, decodeViewingKey } from "@lelantos-org/sdk"; import { cryptoContext, deriveSubscriptionToken, detectionKey, detectionKeyToHex, FMD_DEFAULT_GAMMA, subscriptionTokenToHex, } from "@lelantos-org/sdk/primitives"; import { FmdClient } from "@lelantos-org/sdk/services"; const { P } = await cryptoContext(); const viewingKey = decodeViewingKey(P, wallet.keys.viewingKey); // `epoch` is 0 until you rotate. const epoch = BigInt(myAppConfig.subscriptionEpoch ?? 0); const tokenHex = subscriptionTokenToHex(deriveSubscriptionToken(P, viewingKey.ivk, epoch)); const detectionKeyHex = detectionKeyToHex(await detectionKey(viewingKey, FMD_DEFAULT_GAMMA)); const fmd = new FmdClient("https://fmd.lelantos.xyz", 8453n); await fmd.createSubscription({ detectionKeyHex, gamma: FMD_DEFAULT_GAMMA, tokenHex }); const matching = await connect({ network: "base", rpcUrl, privateKey, syncStrategy: { kind: "matches", token: tokenHex }, }); ``` > **Warning: Derive the token from `ivk`** > > Do not derive the token from `dk` or the detection key. The server receiving the detection scalars can recover `dk`, and could forge a token derived from it. At epoch 0 nothing needs to be stored: `deriveSubscriptionToken(P, ivk)` regenerates the same token, and registering again returns the existing subscription (`created: false`). ### Rotating the token The token is a bearer credential sent in the `Authorization` header of every poll. It is stable across sessions and devices, so it identifies the wallet to the server. Rotate it by passing a new `epoch`. After rotating, **store the epoch**. It cannot be derived. ## Next - [Balances and state](https://docs.lelantos.xyz/guide/state.md) - [Note management](https://docs.lelantos.xyz/guide/notes.md) - [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md) # Balances and state A wallet exposes what it knows two ways: `balance(asset)`, an async read that splits one asset's value by what a spend can reach, and `state()`, a synchronous immutable snapshot for rendering without polling. ## Balance ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; await wallet.sync({ scope: "notes" }); const b = await wallet.balance("USDC"); formatAmount(b.total, b.asset, { symbol: true }); // every unspent note formatAmount(b.spendable, b.asset); // what one spend can cover, with no fee reserved b.withheld; // { reserved, cooldown, dust, slots } b.syncedAt; // undefined before the first sync ``` The figures always add up: ``` total = spendable + withheld.reserved + withheld.cooldown + withheld.dust + withheld.slots ``` | `withheld` | Cause | Resolves | |---|---|---| | `reserved` | leased by a spend in flight, or held after a submission with an unknown outcome | when the spend settles, or the reservation expires | | `cooldown` | received too recently | after the spend cooldown, as blocks arrive | | `dust` | below the dust threshold | no; excluded by configuration | | `slots` | beyond the circuit's input arity | only by [consolidating](https://docs.lelantos.xyz/guide/notes.md#consolidating-explicitly) | `spendable` reserves nothing for the relayer fee. For the maximum of a specific operation, call `spendableMax(asset, { kind, feeAsset })`; see [What a single spend can reach](https://docs.lelantos.xyz/guide/notes.md#what-a-single-spend-can-reach). `balance` resolves the asset against the chain, so a watch-only wallet needs `rpcUrl` or a `reader` to call it. On a wallet whose viewing key cannot see spends (`spentKnown: false`), `total` is everything ever received. ## State snapshot `state()` returns a `WalletState`: an immutable object that stays the same instance until something changes. | Field | Contents | |---|---| | `version` | increments on every change | | `sync` | `status` (`"idle"` \| `"syncing"`), `syncedAt`, `lastReport`, `lastError` | | `notes` | `count` (spent included), `unspent`, `pendingSpend` | | `balances` | `ReadonlyMap`: unspent total per asset, without the async split | | `ops` | operations in flight, oldest first: `{ opId, op, phase, startedAt }` | | `disposed` | `true` after `dispose()` | `state().balances` needs no RPC, so it also works on a watch-only wallet without a reader. Format it with an `AssetInfo` you already hold. ## Subscribing `subscribe(listener)` calls the listener with each new snapshot after a change commits: a sync starting or finishing, notes added, spent, reserved or compacted, an operation starting, changing phase or settling, and dispose. Changes within one microtask are coalesced into one call. It is not called on subscribe, and it returns an idempotent unsubscribe. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const unsubscribe = wallet.subscribe((state) => { if (state.sync.lastError) console.warn("sync failing:", state.sync.lastError.code); for (const op of state.ops) console.log(op.op, op.opId, op.phase); }); // later unsubscribe(); ``` A listener that throws is logged and swallowed. ### React Methods are bound and `state()` returns a stable snapshot, so both plug directly into `useSyncExternalStore`: ```ts // setup declare function useSyncExternalStore(subscribe: (onChange: () => void) => () => void, getSnapshot: () => T): T; declare function useMemo(factory: () => T, deps: readonly unknown[]): T; // example import type { AssetId, ReadOnlyWalletApi, WalletState } from "@lelantos-org/sdk"; export function useWalletState(wallet: ReadOnlyWalletApi): WalletState { return useSyncExternalStore(wallet.subscribe, wallet.state); } export function useUnspent(wallet: ReadOnlyWalletApi, asset: AssetId): bigint { const state = useWalletState(wallet); return useMemo(() => state.balances.get(asset) ?? 0n, [state, asset]); } ``` `useSyncExternalStore(wallet.subscribe, wallet.state)` never loops: `state()` returns the same object between changes. > **Tip: Rendering progress** > > `state().ops` lists every operation in flight with its latest phase, including ones started elsewhere in the app. A global activity indicator can read it instead of threading `onPhase` callbacks through components. Match an entry to a call with the `opId` you passed. ## Next - [Note management](https://docs.lelantos.xyz/guide/notes.md) - [Syncing](https://docs.lelantos.xyz/guide/sync.md) - [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md) # Note management The pool stores notes, not balances. This page covers reading the local note cache, computing the maximum amount one spend can send, the selection rules, and consolidating notes. ## Reading notes ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const all = await wallet.notes(); // every note, every asset const unspentUsdc = await wallet.notes({ asset: 1n, spent: false }); // both fields optional ``` `notes()` reads the local cache as of the last sync; it makes no request. For totals, use [`balance(asset)`](https://docs.lelantos.xyz/guide/state.md#balance) or `state().balances`. `WalletNote` is the public note view: `id`, `asset`, `value`, `spent`, `cm`, `firstSeenBlock`, `discoveredAt`, and `memo` when the sender attached [one](https://docs.lelantos.xyz/guide/transfer.md#memo). For the fields custom proofs need, `note.notePayload()` returns `{ asset, value, rho, rcm, d }`; `d` is the diversifier of the [address](https://docs.lelantos.xyz/guide/addresses.md#one-account-many-addresses) the note was sent to. ## What a single spend can reach `balance().total` is not the maximum sendable amount. A "max" button based on it can fail with `INSUFFICIENT_COVER` or `NOTES_HELD`. `spendableMax(asset, options)` applies the same rules as the spend and reports why the rest of the balance is excluded: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; const weth = await wallet.asset("WETH"); const { max, withheld } = await wallet.spendableMax(weth.id, { kind: "transfer" }); console.log("send at most", formatAmount(max, weth, { symbol: true })); console.log(withheld); // { reserved, cooldown, dust, slots } ``` | Option | Effect | |---|---| | `kind` | reserve the relayer fee for this operation: subtracted from `max` when paid in `asset`, one input slot when paid in another. Omitted: nothing is reserved | | `feeAsset` | with `kind`: the asset the spend will pay the fee in; default `asset` | | `native` | with `kind: "withdraw"`: price the native-unwrap estimate | | `selection` | the same selection rules the spend will use | `max` is the largest transfer `amount`, or withdrawal or swap `gross`, that one spend can cover. Pass the same options the operation will use, and the two agree. ## Selection rules Every spend accepts `selection`, and `spendableMax` accepts the same object, so a predicted maximum and the spend agree. | Field | Default | Effect | |---|---|---| | `dustThreshold` | `0n` | exclude notes below this value; roughly twice the marginal fee is a good start | | `cooldownBlocks` | `1` | minimum note age in blocks; needs a chain layer that reports `blockNumber` | | `maxInputs` | the circuit's `nIn` | notes one spend may consume; values above `nIn` are capped | | `bucketPct` | `0.05` | tiebreak shuffle width | | `only` | — | restrict candidates to these note ids | The SDK supplies the fee threshold and the chain tip itself, so they are not options. The default selector is **SFRT** (Smallest-First, Random Tiebreak). Compared with largest-first selection, it avoids a balance-ordering pattern that links spends, and it consumes dust over time. `DenominationCoinSelector` wraps SFRT and prefers exact covers, which produce no change; pass it as `selector` to `createWallet`. See [Denominations](https://docs.lelantos.xyz/guide/denominations.md#selecting-for-zero-change). ### Spend cooldown `cooldownBlocks` excludes notes received within that many blocks. It prevents spending change in the same block that created it, which would link the two transactions. It applies only when the chain layer implements `blockNumber` (the built-in viem adapter does) and notes have a `firstSeenBlock`. ### Concurrent spends A spend leases the notes it selects until it settles. Two spends started together select disjoint notes, and a spend whose notes are all leased rejects `NOTES_HELD` with `retryable: true`. Leases are released when a spend fails definitively. After a submission whose outcome is unknown, its notes stay reserved until the spent set confirms them or the reservation expires; see [`SPEND_OUTCOME_UNKNOWN`](https://docs.lelantos.xyz/guide/errors.md#spend-outcome-unknown). ## Consolidating explicitly `autoConsolidate: true` merges notes automatically during a spend. To consolidate manually — to show progress, or ahead of time — self-transfer the notes named by `INSUFFICIENT_COVER`, pinning them with `selection.only`: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; // example import { isWalletError } from "@lelantos-org/sdk"; try { await wallet.transfer({ asset: "USDC", amount: "500", recipient }); } catch (err) { if (!isWalletError(err, "INSUFFICIENT_COVER") || err.reason !== "arity") throw err; const merge = await wallet.transfer({ asset: err.asset, amount: err.consolidateSum, // the notes' total, so no change is left over recipient: wallet.address, selection: { only: err.consolidate.map((n) => n.id) }, }); // The merged note is not spendable until it is indexed and in the tree. await wallet.awaitCommitments(merge.ownCommitments); await wallet.transfer({ asset: "USDC", amount: "500", recipient }); } ``` > **Warning: Pin the notes with `only`** > > A self-transfer of `consolidateSum` without `only` may select a different, single note of similar value instead of the intended small notes. The consolidation then changes nothing and the retry fails again. The self-transfer pays the relayer fee from the same notes when the fee is in the same asset, so `consolidateSum` may need to be reduced by the fee. Pass `feeAsset` to pay it from another asset instead. ## Maintenance ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example await wallet.sync({ reload: true }); // re-read the note store after an external mutation const { removed } = await wallet.compact(); // drop spent notes; balances do not change const rounds = await wallet.redenominate("USDC", { maxRounds: 4 }); // re-split change onto the ladder await wallet.dispose(); // release scanner and prover workers ``` | Method | Effect | |---|---| | `sync({ reload: true })` | reloads the note store after an external change, then syncs | | `compact()` | removes notes already marked spent | | `redenominate(asset)` | self-transfers off-ladder notes onto denominations — see [Denominations](https://docs.lelantos.xyz/guide/denominations.md#change-lands-on-the-ladder-too) | | `dispose()` | releases workers — see [Disposing a wallet](https://docs.lelantos.xyz/guide/wallet.md#disposing-a-wallet) | Recovery tools that bypass the wallet's own bookkeeping — `markSpent`, direct access to the note store, selector, and tree — are on `walletInternals(wallet)` in `@lelantos-org/sdk/internal`, which carries no stability guarantee. ## Next - [Custom storage](https://docs.lelantos.xyz/guide/storage.md) - [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) - [Denominations](https://docs.lelantos.xyz/guide/denominations.md) # Watch-only wallets A viewing key gives read access to a shielded account without the ability to spend. A watch-only wallet created from one supports sync, notes, balances, and state; spending methods are absent from its type. Use cases include balance displays on devices that must not hold spending keys, access for accountants and auditors, and monitoring services. ## Key hierarchy The spending key `nsk` derives every other key: ``` nsk ─┬─ ivk ─┬─ d diversifier of each address, one per index │ ├─ pk binds the note commitment, per address │ ├─ pk_d ECDH target of incoming notes, per address │ └─ dk FMD detection secret, one for the account └─ nk derives nullifiers ``` `ivk` covers every [address](https://docs.lelantos.xyz/guide/addresses.md#one-account-many-addresses) of the account, so a viewing key reads notes sent to all of them. | Key | Reads incoming notes | Knows which notes are spent | Can spend | |---|---|---|---| | **Incoming viewing key** (`lelantosivk1…`) | yes | no | no | | **Full viewing key** (`lelantosfvk1…`) | yes | yes | no | | Spending key | yes | yes | yes | A full viewing key adds `nk`, which computes nullifiers. `nk` derives from `nsk`, not `ivk`, so an incoming viewing key cannot be upgraded to a full viewing key. > **Warning: An incoming viewing key reports received totals** > > Without `nk` the wallet cannot tell spent notes from unspent ones. With an incoming viewing key, balances are the total ever received, not the current balance. Check `spentKnown`. > **Danger: Viewing keys cannot be revoked** > > A viewing key is fixed by `nsk`. Its holder can decrypt every past and future note received by the account. The only way to end that access is to move funds to a new account. ## Exporting a key A spending wallet exposes both encodings on `keys`. Neither contains `nsk`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const ivk = wallet.keys.viewingKey; // "lelantosivk1…": incoming notes only const fvk = wallet.keys.fullViewingKey; // "lelantosfvk1…": also settles which notes are spent ``` Both are bech32m; the prefix identifies the tier. To encode a raw key object instead, use `encodeViewingKey` or `encodeFullViewingKey` (both root); `isFullViewingKey` narrows what `decodeViewingKey` returns. ## Creating a watch-only wallet `connectWatch` lives on its own subpath, so a viewer's bundle never reaches the prover, the relayer submitter, or the coin selector. ```ts // setup declare const fvk: string; // example import { formatAmount } from "@lelantos-org/sdk"; import { connectWatch } from "@lelantos-org/sdk/watch"; await using watch = await connectWatch({ network: "base", rpcUrl: "https://base-rpc.example.com", // for asset metadata; omit to read notes only viewingKey: fvk, }); await watch.sync(); watch.address; // the watched account's address at index 0, derived from the key await watch.addressAt(3); // any other index; the same address the spending wallet returns watch.spentKnown; // true for a full viewing key, false for an incoming one watch.keys.tier; // "full" | "incoming" const usdc = await watch.balance("USDC"); console.log(formatAmount(usdc.total, usdc.asset, { symbol: true })); ``` `connectWatch` returns a `ReadOnlyWalletApi` (exported from the root). It accepts an encoded key of either tier, or a decoded `ViewingKey` / `FullViewingKey`; the tier is detected from the key. | Option | Description | |---|---| | `network`, `rpcUrl` | as for `connect`; `rpcUrl` is optional here | | `viewingKey` | either tier, encoded or decoded | | `reader` | a pre-built `ChainReader` for asset metadata, instead of `rpcUrl` | | `scanner`, `http`, `denominations`, `wasm`, `runtime` | as for `connect` | | `storage` | `{ notes?, nullifiers? }`; a watch wallet has no tree | | `syncStrategy` | default `full`; `matches` also requires `allowDetectionKeyRelease: true` | Without `rpcUrl` or `reader`, no RPC is contacted: `sync`, `notes`, and `state` work, while `asset`, `assets`, and `balance` reject `WALLET_CONFIG`. `state().balances` still reports unspent totals per asset id. For explicit configuration, `createWatchWallet(key, config)` in `@lelantos-org/sdk/advanced` is the counterpart of `createWallet`. ## Confirming a payment against the pool Notes come from the indexer, and anyone who knows an address can encrypt a well-formed note to it. A note in `notes()` shows what the indexer served, not that the pool holds it. Before crediting a payment, ask the pool: ```ts // setup import type { ReadOnlyWalletApi } from "@lelantos-org/sdk"; declare const watch: ReadOnlyWalletApi; declare const receipt: { commitment: string; txHash: string }; // example const paid = await watch.confirmCommitment(receipt.commitment, receipt.txHash); ``` `confirmCommitment` reads the transaction's receipt over the wallet's own RPC and resolves `true` only if the pool published that commitment in it. The note gives the asset and value; this gives its existence. A deposit's escrow resolves `false` until flushed, since it can still be cancelled. It never resolves `false` for a transaction it could not read. With the default reader, a hash the node holds no receipt for is waited on for 15 seconds and then rejects `TX_MINING`, which is retryable. Without `rpcUrl` or `reader` it rejects `UNSUPPORTED_OPERATION`. ## Differences from a spending wallet | | Spending wallet | Watch-only wallet | |---|---|---| | Entry point | `connect` from `@lelantos-org/sdk` | `connectWatch` from `@lelantos-org/sdk/watch` | | Returns | `WalletApi` | `ReadOnlyWalletApi` | | Spend and quote methods | present | absent from the type (compile-time error) | | Prover, submitter, coin selector | loaded on demand | not in the module graph | | Merkle tree | synced with `scope: "full"` | never synced; every sync is `"notes"` | | Chain access | required | optional; needed only for asset metadata and `balance` | | `matches` sync strategy | allowed | requires `allowDetectionKeyRelease: true` | A watch-only wallet requires `allowDetectionKeyRelease: true` for the `matches` strategy because registering a detection key permanently exposes the **owner's** incoming notes to the server. That decision belongs to the account owner, not the viewer. See [Sync strategies](https://docs.lelantos.xyz/guide/sync.md#sync-strategies). ## Next - [Syncing](https://docs.lelantos.xyz/guide/sync.md) - [Balances and state](https://docs.lelantos.xyz/guide/state.md) - [Addresses](https://docs.lelantos.xyz/guide/addresses.md) # Denominations A withdrawal publishes its gross amount, `publicOut`. If a user deposits 3,417 units and later withdraws 3,417 units, the two public amounts link the transactions. Withdrawing a value from a fixed **ladder** of denominations publishes an amount that many other withdrawals share. A ladder is an ascending list of fixed circuit-unit amounts. It does not change with the yield index; only the token value of each denomination does. ## What the ladder prevents A round trip publishes two amounts: the deposit's `publicIn`, attributed to the payer, and the withdrawal's `publicOut`, attributed to the recipient. Internal transfers publish no amount. On a yield-bearing asset almost every amount is distinctive. Circuit units are normalized by a moving index, so a round token amount converts to a different unit count every block. A withdrawal of the same integer as a deposit links the two, and reveals the holding period and the yield earned. A denomination is a fixed integer, so its anonymity set is every withdrawal of that size in the pool's history. > **Danger: Do not compute denominations at runtime** > > A denomination computed from a human amount (`n = human * RAY / (scale * index)`) changes with the index and produces the distinctive amounts the ladder removes. Use the ladder's integers. Their human labels are for display: once yield accrues, `1_000_000_000` may display as ~1050 USDC instead of 1000. Only `publicOut` needs a denomination. Deposit amounts are public and attributed to the payer regardless, and transfers publish no amount. ## The default ladder Every asset gets a `{1, 2, 5} × 10^e` series in circuit units, derived from its `scale` and `decimals`. No per-token configuration is needed. | Property | Behaviour | |---|---| | spacing | steps of 2× and 2.5×; any amount is within ~20% of a sum of two or three denominations | | lower bound | 1e5 circuit units (0.001 WETH, $0.10 USDC at typical scales); bounds leftover dust | | upper bound | a single cap in circuit units for all assets; the highest denominations are rarely used, so their anonymity sets are small | | `decimals` | used only to move the range for assets registered at an unusual `scale` | The ladder is resolved when the `AssetInfo` is built and is available as `asset.ladder`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { circuitAmount, formatAmount } from "@lelantos-org/sdk"; const usdc = await wallet.asset("USDC"); usdc.ladder.length > 0; // does this asset have a ladder at all? for (const d of usdc.ladder) { console.log(formatAmount(circuitAmount(d), usdc, { symbol: true })); // "10 USDC", "20 USDC", … } ``` ## Building a picker `wallet.withdrawDenominations()` returns each denomination with its current value and the net amount after the protocol fee. Both labels change with the yield index; recompute them instead of caching. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example for (const choice of await wallet.withdrawDenominations("USDC")) { console.log(choice.label, "→", choice.netLabel); } ``` A `net` withdrawal is sized to its recipient and is almost never a denomination; offer denominations as `gross` amounts. For a typed amount, `previewWithdraw` reports whether it is a denomination and suggests the nearest one. An off-ladder amount is accepted, but it publishes a distinctive value; show this to the user. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const p = await wallet.previewWithdraw({ asset: "USDC", gross: "1234" }); p.hasLadder; // false → the asset has none, and `onLadder` means nothing p.onLadder; // false → this amount is not one of them p.suggestion; // the nearest denomination, ties going to the smaller ``` The following example warns on an off-ladder amount and withdraws what the user entered: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; const recipient = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; declare const entered: string; // example import { formatAmount } from "@lelantos-org/sdk"; const usdc = await wallet.asset("USDC"); const p = await wallet.previewWithdraw({ asset: usdc.id, gross: entered }); if (p.hasLadder && !p.onLadder && p.suggestion !== undefined) { // Offer the nearest denomination. It can be above or below what was typed; // ties go to the smaller. console.warn(`off-ladder; nearest denomination is ${formatAmount(p.suggestion, usdc)}`); } await wallet.withdraw({ recipient, asset: usdc.id, gross: p.publicOut }); ``` Offer the suggestion; do not substitute it automatically. Only the user knows whether a smaller amount is acceptable. ## Change lands on the ladder too Change is split into ladder denominations, largest first, with any remainder in one final note: ``` decompose(4900n, ladder, 4) → pieces [2000, 2000, 500], dust 400 ``` The remainder can be re-split later by a self-transfer, which publishes no amount. `redenominate` runs those self-transfers in a loop: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example const rounds = await wallet.redenominate("USDC", { maxRounds: 4 }); ``` - Each round creates `nOut - 1` denomination notes and one remainder note, until the remainder is below the smallest denomination. - It is idempotent and safe to run on a schedule. - A round that cannot find cover ends the loop without throwing. - The return value is the number of rounds run. `0` means the asset has no ladder or no notes needed splitting. ## Selecting for zero change `DenominationCoinSelector` wraps the default SFRT selector and prefers note sets that pay the target exactly. An exact cover produces no change note. The selector is a `createWallet` option in `@lelantos-org/sdk/advanced`: ```ts // setup import type { KeySource, WalletConfig } from "@lelantos-org/sdk/advanced"; declare const keySource: KeySource; declare const config: WalletConfig; // example import { createWallet, DenominationCoinSelector } from "@lelantos-org/sdk/advanced"; const wallet = await createWallet(keySource, { ...config, selector: new DenominationCoinSelector() }); ``` All other SFRT behaviour — dust thresholds, spend cooldown, reservations, consolidation — is unchanged. The default selector is plain SFRT. ## What the ladder does not hide The ladder removes the amount as a link. Other withdrawal data remains public: | Still public | Implication | |---|---| | recipient address | withdrawals to one address are linked to each other and to that address's identity | | number and timing of withdrawals | a large balance leaves as several withdrawals; spread them across blocks and recipients | | deposit amounts | a rare deposit amount still narrows the candidate set | | network metadata | the relayer sees timing and IP address unless requests are routed elsewhere via `http.fetch` | - **Net amount.** The recipient receives the denomination minus `withdrawBps`, computed on chain, so all withdrawals of one denomination at one time receive the same net. Choosing `publicOut` to produce a round net amount — which is what `withdraw({ net })` does — makes `publicOut` distinctive again. - **Off-ladder amounts.** An amount no one else withdraws links to its funding deposit and marks the wallet as not using the ladder. - **Actual anonymity set.** The set is the number of users who withdrew that denomination, not the number who could have. The smallest and largest denominations, and low-traffic pools, provide less cover. Note selection and FMD delegation are separate privacy considerations; see [Note management](https://docs.lelantos.xyz/guide/notes.md) and [Syncing](https://docs.lelantos.xyz/guide/sync.md#sync-strategies). ## Disabling ladders `denominations` on `connect()`, `connectWatch()`, and `WalletConfig` enables or disables ladders: ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; await connect({ network: "base", rpcUrl, privateKey, denominations: false }); ``` | Value | Effect | |---|---| | `true` (default) | every asset has a derived ladder | | `false` | change is split evenly, `previewWithdraw` reports no ladder, `redenominate` does nothing | > **Warning: The ladder is shared across SDK versions** > > Every wallet on the same SDK version derives the same denominations for an asset, which is what forms a shared anonymity set. Versions with different ladders split that set, so the ladder changes only through a coordinated migration. ## Next - [Privacy checklist](https://docs.lelantos.xyz/guide/privacy.md) - [Withdraw](https://docs.lelantos.xyz/guide/withdraw.md) - [Note management](https://docs.lelantos.xyz/guide/notes.md) # Privacy checklist The pool hides which notes a transaction spends, who receives a transfer, and transfer amounts. Other data remains observable, and some SDK options reduce privacy. This page lists what an application should configure and communicate to users. ## What is public | Data | Visible to | Details | |---|---|---| | Deposit payer and amount | everyone (on chain) | [Denominations](https://docs.lelantos.xyz/guide/denominations.md#what-the-ladder-prevents) | | Withdrawal recipient and gross amount | everyone (on chain) | [Withdraw](https://docs.lelantos.xyz/guide/withdraw.md) | | That a shielded transfer occurred, and when | everyone (on chain) | [How it fits together](https://docs.lelantos.xyz/guide/system.md#visibility-by-party) | | Submitter IP address and timing | relayer | [How it fits together](https://docs.lelantos.xyz/guide/system.md#spend-flow) | | Requested token pair and size | metaquoter | [Swap](https://docs.lelantos.xyz/guide/swap.md) | | A handle, the address published under it, and when it was registered | everyone (on chain) | [Names](https://docs.lelantos.xyz/guide/names.md) | | Which handle is looked up, and when | the RPC serving the lookup | [Names](https://docs.lelantos.xyz/guide/names.md#looking-a-handle-up) | | RPC reads, including your deposit account | your RPC provider | [Networks](https://docs.lelantos.xyz/guide/networks.md#rpc-endpoint) | | Incoming notes (with `matches` strategy) | FMD server | [Syncing](https://docs.lelantos.xyz/guide/sync.md#sync-strategies) | ## Checklist ### Amounts - **Withdraw denominations.** Use `previewWithdraw` and show `onLadder: false` to the user; offer `suggestion` instead of changing the amount. A `net` withdrawal is almost never on the ladder. See [Denominations](https://docs.lelantos.xyz/guide/denominations.md#building-a-picker). - **Keep ladders enabled** (`denominations: true`, the default). - **Do not round the net amount.** Choosing `publicOut` so the recipient receives a round figure makes `publicOut` distinctive. - **Avoid distinctive deposit amounts** where the user can choose, since deposits are public. - **Consider `DenominationCoinSelector`** (via `createWallet`) to reduce change, and run `redenominate` to keep notes on the ladder. ### Timing and linkage - **Spread large withdrawals** across blocks and recipients; several withdrawals to one address are linked. - **Keep the spend cooldown** (`selection.cooldownBlocks`, default 1) and use a chain adapter that implements `blockNumber`, which the cooldown requires. See [Spend cooldown](https://docs.lelantos.xyz/guide/notes.md#spend-cooldown). - **Keep the default SFRT selector.** Largest-first selection creates a pattern that links spends. See [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md#custom-coin-selector). - **Use `recipientCommitment`**, not an output index, to identify the payee note; outputs are shuffled. See [Transfer](https://docs.lelantos.xyz/guide/transfer.md#reading-the-result). - **Give each payer its own address** with `addressAt(index)`. Payers comparing addresses then cannot tell they pay the same account. See [Addresses](https://docs.lelantos.xyz/guide/addresses.md#one-account-many-addresses). - **Publish only the reserved address under a handle**, which `registerName` does, and hand it out nowhere else. A registration re-shields its change in the same public transaction as that address, so the change is kept to one unit. - **Look a handle up once, when a contact is saved**, not before each payment. A lookup followed by a spend lets whoever served the lookup pair them. See [Names](https://docs.lelantos.xyz/guide/names.md#looking-a-handle-up). - **Accept only names under the parents your deployment lists.** `lelantos.eth` is not one of them. See [Names](https://docs.lelantos.xyz/guide/names.md#which-names-are-genuine). ### Detection and keys - **Keep the `full` sync strategy** unless bandwidth requires `matches`. Delegating detection cannot be revoked. See [Sync strategies](https://docs.lelantos.xyz/guide/sync.md#sync-strategies). - **In a watch-only wallet, leave `allowDetectionKeyRelease` unset.** The decision belongs to the account owner. See [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md#differences-from-a-spending-wallet). - **Treat viewing keys as permanent disclosure.** They cannot be revoked. See [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md). - **Treat a payment proof as disclosure of one payment.** With the payee's address, its holder reads that output's asset, value and memo; it reveals nothing else. Send it only to whoever should learn it. See [Proving a payment](https://docs.lelantos.xyz/guide/transfer.md#proving-a-payment). - **Treat a handle's controller key as the handle.** Its holder can publish another address under it. Clearing a record stops it resolving but does not erase it from chain history. See [Names](https://docs.lelantos.xyz/guide/names.md#the-controller-key). - **Treat a claim-link key as the funds.** Whoever holds it can spend what the link holds. See [Claim-link keys](https://docs.lelantos.xyz/guide/transfer.md#claim-link-keys). - **Derive subscription tokens from `ivk`**, and store the epoch after rotating. See [Rotating the token](https://docs.lelantos.xyz/guide/sync.md#rotating-the-token). ### Network - **Route traffic if IP privacy matters.** Pass `http: { fetch }` to send all SDK service requests through a proxy, and point `rpcUrl` at an endpoint you trust. See [HTTP options](https://docs.lelantos.xyz/guide/wallet.md#http-options). - **Do not query servers per note.** Custom `NoteSource` implementations must not request Merkle paths or nullifier status for specific notes. See [Custom note source](https://docs.lelantos.xyz/guide/interfaces.md#custom-note-source). ### Local data - **Encrypt the notes file at rest.** It links its holder to every commitment the wallet owns. See [Custom storage](https://docs.lelantos.xyz/guide/storage.md#contents). - **Do not log user input verbatim.** SDK error messages omit rejected addresses and values; keep application logs consistent with that. ## Next - [Denominations](https://docs.lelantos.xyz/guide/denominations.md) - [How it fits together](https://docs.lelantos.xyz/guide/system.md) # Package subpaths `@lelantos-org/sdk` publishes eleven typed entry points. Every exported name has exactly **one** home: the SDK's CI fails if a name is published from two subpaths, so there is never a choice to make about where to import something from. Most applications import only from the root. | Import | Contents | Stability | |---|---|---| | `@lelantos-org/sdk` | `connect`, the `WalletApi` / `ReadOnlyWalletApi` types and every option, quote, and result type; amounts (`parseAmount`, `formatAmount`, `toBaseUnits`, `fromBaseUnits`) and brands; `NETWORKS`; logging; `configureWasm`; key helpers (`generateMnemonic`, `deriveNskFromSigner`, `deriveNskFromPasskey`, viewing-key codec, `parseAddress`); every error class and `isWalletError` | semver | | `@lelantos-org/sdk/watch` | `connectWatch` | semver | | `@lelantos-org/sdk/x402` | x402 payers: `x402`, `shieldedExact`, `unshieldedExact`, budgets | semver | | `@lelantos-org/sdk/advanced` | `createWallet`, `createWatchWallet`, `WalletConfig`; the `ChainReader` / `ChainAdapter` ports, the viem adapter and reader, signers, `supports*` guards; `Submitter` / `HttpRelayerSubmitter`; note stores and sources; tree and nullifier persistence; coin selectors; scanners; `hasTokenMeta`, `outAmount` | semver, integrator tier | | `@lelantos-org/sdk/prover` | `Prover`, `WasmProver`, `SnarkjsProver`, `WorkerProver`, `browserWorkerProver`; artifact resolution and caching; `configureProverThreads` | semver, integrator tier | | `@lelantos-org/sdk/protocol` | fees (`depositTotals`, `withdrawNet`, `withdrawNetFor`, `grossForNet`, `depositFeeAssetRefusal`), deposit pulls, denominations, units (`RAY`, `toCircuitUnits`, `toTokenUnitsAtRate`), swap sizing, circuit shapes, relayer wire types, bundle builders, Permit2 signing | semver, integrator tier | | `@lelantos-org/sdk/primitives` | hex, bytes, field, randomness; Poseidon and Jubjub; keys, diversifiers and addresses; note encryption and output-secret derivation; FMD | semver, integrator tier | | `@lelantos-org/sdk/services` | `RelayerClient`, `DepositStream`, `FmdClient`, `fetchSwapQuote`, the shared HTTP client | semver, integrator tier | | `@lelantos-org/sdk/workers/prover` | the prover worker's entry module, for `new Worker(new URL(…))` | semver | | `@lelantos-org/sdk/workers/scanner` | the scanner worker's entry module | semver | | `@lelantos-org/sdk/internal` | `walletInternals`, circuit internals, the stored-note codec, sync engine pieces, test hooks | **unstable** | The raw wasm-pack modules are also exported as `@lelantos-org/sdk/wasm/{prover,jubjub,poseidon}` and `…/wasm` for bundler setups that resolve wasm assets themselves. ## Stability tiers - **semver** — the application surface. Breaking changes follow the pre-1.0 policy in [Versioning](https://docs.lelantos.xyz/guide/installation.md#versioning). - **semver, integrator tier** — covered too, but shaped by the SDK's internals, so expect wider changes than on the root. - **unstable** — `@lelantos-org/sdk/internal` can change in any release, including patches. Pin an exact version and the `@lelantos-org/circuits` version when relying on it. ## Where to find things | You want to | Import from | |---|---| | connect, move value, read balances, format amounts, handle errors | `@lelantos-org/sdk` | | show a balance from a viewing key | `@lelantos-org/sdk/watch` | | pay an HTTP 402 from the pool | `@lelantos-org/sdk/x402` | | build a wallet with a custom submitter, selector, note source, or fee override | `@lelantos-org/sdk/advanced` | | implement a chain adapter, note store, or persistence backend | `@lelantos-org/sdk/advanced` | | run proving in a worker you construct, or manage the artifact cache | `@lelantos-org/sdk/prover` | | compute fees, pulls, or denominations without a wallet | `@lelantos-org/sdk/protocol` | | derive keys, decode addresses, register an FMD subscription | `@lelantos-org/sdk/primitives` | | talk to the relayer, FMD server, or quoter directly | `@lelantos-org/sdk/services` | | reach a wallet's note store, prover, or `markSpent` | `@lelantos-org/sdk/internal` | ```ts import { connect, formatAmount, isWalletError, type WalletApi } from "@lelantos-org/sdk"; import { connectWatch } from "@lelantos-org/sdk/watch"; import { createWallet, ViemChainAdapter } from "@lelantos-org/sdk/advanced"; import { browserWorkerProver } from "@lelantos-org/sdk/prover"; import { depositTotals, RAY } from "@lelantos-org/sdk/protocol"; import { cryptoContext, decodeAddress } from "@lelantos-org/sdk/primitives"; import { DepositStream, FmdClient } from "@lelantos-org/sdk/services"; import { x402 } from "@lelantos-org/sdk/x402"; ``` ## Bundle size The root entry is lazy: importing `connect` does not bundle the spend path, the prover, or the deposit family until they are first used. `@lelantos-org/sdk/watch` never reaches the prover or the relayer submitter. Each entry has a size budget checked in the SDK's CI, so importing from the root is not a bundle-size penalty. ## x402 payments `x402(wallet, options)` wraps `fetch` so that an HTTP `402 Payment Required` is paid from the shielded pool and the request retried once. A budget is required. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { isWalletError } from "@lelantos-org/sdk"; import { x402 } from "@lelantos-org/sdk/x402"; await wallet.sync(); const pay = x402(wallet, { budget: { total: "5", perRequest: "0.5" }, // human units, per asset allowHosts: ["api.example.com"], }); try { const data = await pay("https://api.example.com/premium").then((r) => r.json()); } catch (err) { if (isWalletError(err, "X402_PAYMENT")) console.warn("not paid:", err.reason); else throw err; } ``` To send the wrapped requests through your own `fetch` (a proxy, instrumentation), pass `http: { fetch }`, as with `connect`. Payments are shielded transfers by default. `allowUnshielded: true` also pays servers that accept only standard EVM `exact`, by unshielding into a throwaway address; it is off by default. Budgets, offer selection, the unshielded mechanism and every refusal reason are covered in [x402 payments](https://docs.lelantos.xyz/guide/x402.md). ## Next - [x402 payments](https://docs.lelantos.xyz/guide/x402.md) - [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) # x402 payments [x402](https://x402.org) uses HTTP `402 Payment Required` as a protocol: the server answers an unpaid request with the terms it accepts, the client pays, and the request is repeated with a receipt attached. No account and no API key are involved. `@lelantos-org/sdk/x402` implements the payer. `x402(wallet, options)` wraps `fetch` so a 402 is settled from the shielded pool and the request retried once. The result is an ordinary `fetch`, accepted anywhere one is. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { x402 } from "@lelantos-org/sdk/x402"; await wallet.sync(); const pay = x402(wallet, { budget: { total: "5" } }); const data = await pay("https://api.example.com/premium").then((r) => r.json()); ``` `x402()` returns synchronously and makes no request until the wrapped `fetch` is first called. Any response other than a 402 is passed through untouched. The server sees a payment rather than a customer: a shielded transfer carries no sender identity, and on-chain observers see a MASP transaction. The request itself is ordinary HTTPS and the server reads it — see [Privacy checklist](https://docs.lelantos.xyz/guide/privacy.md). ## A budget is required An agent chooses its own purchases, so the exposure is a long run of individually valid payments. `budget.total` is required; omitting it throws before the wrapper is built. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { x402 } from "@lelantos-org/sdk/x402"; const pay = x402(wallet, { budget: { total: "5", perRequest: "0.5" }, // human units, per asset allowHosts: ["api.example.com"], }); ``` | Field | Meaning | |---|---| | `budget.total` | cumulative ceiling for the lifetime of this payer. Required | | `budget.perRequest` | ceiling for any single payment. Defaults to `total` | | `allowHosts` | hostnames this payer may pay. Unset means any host | Limits are human decimal units applied **per asset**: `{ total: "5" }` is five of each asset, not five across all of them, because assets are not comparable without a price oracle. The ledger belongs to the `x402()` call rather than the process, so two payers built from one wallet hold independent budgets. The budget counts what is paid to servers, not the relayer fee on each shielded payment. Cap that with `acceptRelayerFee` on the wallet the payer runs on. See [Limiting the relayer fee](https://docs.lelantos.xyz/guide/fees.md#limiting-the-relayer-fee). A payment being minted is reserved against the budget until it settles. Proving takes seconds; without the hold, concurrent payments would each pass the check before any was recorded. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { formatAmount } from "@lelantos-org/sdk"; import { x402 } from "@lelantos-org/sdk/x402"; const pay = x402(wallet, { budget: { total: "5" } }); // … calls … for (const [asset, amount] of pay.spent()) { console.log(formatAmount(amount, await wallet.asset(asset), { symbol: true })); } ``` `spent()` returns circuit units keyed by MASP asset id. See [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md). ## What happens on a 402 | Step | | |---|---| | 1 | the wrapped `fetch` sends the request; the server answers `402` with a `PAYMENT-REQUIRED` header | | 2 | the host is checked against `allowHosts` | | 3 | one `accepts[]` entry is selected and priced, and the amount reserved against the budget | | 4 | the mechanism mints the payment — for a shielded one, `wallet.transfer` and its Groth16 proof | | 5 | the reservation is committed and the identical request retried with a `PAYMENT-SIGNATURE` header | | 6 | `onPayment` fires with the settlement receipt, and the response is returned | > **Warning: A payment is attached to at most one retry** > > A paid request that still answers `402` throws `payment-rejected` and is not paid again: the funds are spent and a blind retry would spend more. The wrapper also sits outside the SDK's 5xx retry logic, so a transient failure on the retry never re-runs a payment. A failure while minting releases the reservation. Nothing is spent and the budget is unchanged. ## Choosing among the server's offers A 402 may carry several offers. Selection walks them shielded networks first, then in the server's own order, and settles the first this wallet can satisfy. An unsatisfiable offer is skipped rather than fatal: | Skipped when | | |---|---| | no mechanism is registered for the scheme and network | an `eip155:*` offer with `allowUnshielded` off | | the offer settles on another chain, or names another pool | no bridging | | a shielded offer's asset is not covered | asked as the spend asks it, so the relayer fee counts | | an unshielded offer cannot be funded | neither the payer address nor a top-up from the pool covers it | | `maxTimeoutSeconds` is below `DEFAULT_MIN_TIMEOUT_SECONDS` (20) | the window would close before the proof was ready | Payability is what supports multi-asset pricing: a server may offer one resource in USDC and in WETH, and the payer settles whichever it can pay. Only the mechanism can judge this — the wallet's notes for a shielded offer, a throwaway address's ERC-20 balance for an unshielded one — so selection asks it rather than pricing offers itself. Both answer before anything moves, which is what makes an offer this wallet cannot pay a skip rather than a failed request. A budget breach aborts instead of falling through — dropping to a cheaper offer would hide that the ceiling was reached. When nothing is payable, `no-acceptable-requirements` names each offer tried and why it was skipped. ## Shielded payments `shieldedExact` implements `scheme: "exact"` on `network: "shielded:"`. A shielded transfer has `exact` semantics, so this is a network family under the existing scheme rather than a new scheme. `extra.pool` is the only pool-specific field; this SDK produces and accepts `LELANTOS_POOL`. The flow is upfront: the transfer is submitted before the resource is served, and the payload is its receipt — pool, transaction hash, and the recipient note's commitment. x402's `authorization` flow would require a facilitator able to relay a Lelantos bundle and is not supported. Upfront payment exposes the payer to a server that takes payment without answering, which is the second reason `budget` is required. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { x402 } from "@lelantos-org/sdk/x402"; const pay = x402(wallet, { budget: { total: "5" }, shielded: { onPhase: (phase) => console.log(phase), // "proving" takes seconds autoConsolidate: true, // merge notes when no single spend covers the price minTimeoutSeconds: 30, }, }); ``` The commitment the server verifies comes from the receipt's `recipientCommitment`, not a fixed output slot: output order is randomized, and the payer's change commitment would not verify. See [Transfer](https://docs.lelantos.xyz/guide/transfer.md#reading-the-result). ## Unshielded payments Many x402 servers accept only EIP-3009 on a public chain. `allowUnshielded: true` registers `unshieldedExact`, which pays them by unshielding into a throwaway address. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { x402 } from "@lelantos-org/sdk/x402"; const pay = x402(wallet, { budget: { total: "5" }, allowUnshielded: true, unshielded: { topUpMultiple: 10n, // unshield up to ten payments' worth per proof onTopUp: ({ payer, asset, amount }) => console.log("funded", payer, asset, amount), }, }); ``` It is off by default because it trades privacy for reach. The observable link becomes "a Lelantos withdrawal funded this address" rather than "this account paid this API": payer keys are derived from `nsk`, so they are unlinkable to the wallet's signer address and recoverable from the same seed on any machine. Each resource host gets its own payer slot by default, since one shared address would let servers link payments by comparing `from`. | | | |---|---| | gas | none: EIP-3009 is a signed authorization the server's facilitator submits | | tokens | EIP-3009-capable only (USDC and similar); most ERC-20s are not | | chains | the chain the MASP is deployed on. An offer on another chain is refused, not bridged | | top-ups | not counted against the budget: value moves to an address the caller controls, and counting it would double-count every payment made from it. `onTopUp` makes them observable | | routing | an offer the payer address and the pool together cannot fund is skipped, like any other offer this wallet cannot pay, before an authorization is signed | | `topUpMultiple` | an amortisation, not a requirement: a pool holding less than the multiple still funds the payment itself, rather than the offer being refused | ## When a payment is refused Every refusal below means no funds moved: the checks run before `wallet.transfer` or `wallet.withdraw` is called. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const pay: (url: string) => Promise; // example import { isWalletError } from "@lelantos-org/sdk"; try { await pay("https://api.example.com/premium"); } catch (err) { if (isWalletError(err, "X402_PAYMENT")) { console.warn("not paid:", err.reason, err.resource); } else { throw err; } } ``` | `reason` | Meaning | Response | |---|---|---| | `budget-exceeded` | cumulative spend for this asset would pass `budget.total` | a policy stop; surface it to the operator | | `per-request-limit` | this payment is over `budget.perRequest` | the resource costs more than the agent may spend | | `host-not-allowed` | the host is not in `allowHosts` | correct the allowlist, or do not call it | | `no-acceptable-requirements` | nothing in `accepts[]` is payable by this wallet | check the asset, chain and balance named in the message | | `unsupported-requirements` | malformed, or another chain or pool | usually a server-side fault | | `payment-rejected` | the server answered 402 again after payment | do not retry; the funds are spent | `resource` is a field rather than part of `context`, which error reporters serialise in full: the URL identifies the paid APIs this wallet calls. See [Errors](https://docs.lelantos.xyz/guide/errors.md). ## Auditing what was paid ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; // example import { x402 } from "@lelantos-org/sdk/x402"; const pay = x402(wallet, { budget: { total: "5" }, onPayment: (record) => { record.url; record.requirements.amount; // the accepts[] entry that was satisfied record.unshielded; // true when this payment left the pool record.settlement?.transaction; // the server's receipt, when it sent one }, }); ``` `onPayment` fires after each successful payment and its errors are swallowed, so an audit sink that throws cannot fail the request. ## Using an existing x402 client `x402()` covers the common case. With an existing `x402Client` — to combine with another chain's mechanism, or to use `@x402/mcp` — register the mechanisms directly; both are structurally `SchemeNetworkClient`. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; import type { SchemeNetworkClient } from "@lelantos-org/sdk/x402"; declare const wallet: WalletApi; declare const client: { register(network: string, mechanism: SchemeNetworkClient): void }; // example import { shieldedExact, shieldedNetwork } from "@lelantos-org/sdk/x402"; const chainId = await wallet.chain.chainId(); client.register(shieldedNetwork(chainId), shieldedExact(wallet)); ``` Registering directly opts out of the budget, host allowlist and spend accounting in `x402()`; the mechanisms enforce none of them. Verifying a presented receipt — the payee side — uses a watch-only wallet instead of this module: the decrypted note gives the asset and value, and `confirmCommitment` establishes that the pool holds it. See [Confirming a payment against the pool](https://docs.lelantos.xyz/guide/watch-only.md#confirming-a-payment-against-the-pool). ## Next - [Package subpaths](https://docs.lelantos.xyz/guide/subpaths.md) - [Privacy checklist](https://docs.lelantos.xyz/guide/privacy.md) - [Errors](https://docs.lelantos.xyz/guide/errors.md) # Browser usage The SDK runs in browsers with the same API as on Node. Browser applications need a few additional steps: 1. Allow WebAssembly in the Content Security Policy, and serve the page cross-origin isolated. 2. Keep the bundler from rewriting the SDK's wasm glue. 3. Provide the prover artifact URLs. 4. Run proving and trial decryption in Web Workers. ## Content Security Policy and isolation The WASM prover requires `'wasm-unsafe-eval'` in `script-src`. Neither `eval` nor `new Function` is used: ``` script-src 'self' 'wasm-unsafe-eval'; ``` Multi-threaded proving and the scanner pool use `SharedArrayBuffer`, which browsers gate behind cross-origin isolation. Set these headers on the document: ``` Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` Without cross-origin isolation, the SDK proves with snarkjs instead of single-threaded WASM, which is faster in that case, and logs a warning on `lelantos:wallet:prover`. ## Bundler configuration Bundlers that pre-bundle the SDK rewrite the wasm-pack glue's `new URL("_bg.wasm", import.meta.url)` into a path that does not exist at runtime. The SDK catches that and falls back to slower JS, so the symptom is not an error but a wallet roughly ten times slower at sync. In Vite: ```ts // setup declare function defineConfig(config: { optimizeDeps?: { exclude?: string[] }; worker?: { format?: "es" | "iife" }; }): unknown; // example export default defineConfig({ optimizeDeps: { exclude: ["@lelantos-org/sdk"] }, worker: { format: "es" }, }); ``` Every such degradation is logged, and SDK logging is off until you install a sink, so enable it at least in development — see [Logging](https://docs.lelantos.xyz/guide/logging.md). If excluding the package is not an option, pass pre-resolved module URLs as `wasm` to `connect()`. ## Prover artifacts On Node, the prover loads artifacts from `@lelantos-org/circuits`. In a browser there is no default: host the circuit and proving key and pass their URLs. ```ts // setup import type { Eip1193ProviderLike } from "@lelantos-org/sdk"; declare const provider: Eip1193ProviderLike; declare const address: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, provider, address, prover: { artifacts: { circuit: "https://cdn.example.com/4x6.wasm", zkey: "https://cdn.example.com/4x6_final.zkey", }, }, }); ``` Alternatively, set `prover: { cdn }` to a base URL that serves `.wasm` and `_final.zkey`; the SDK derives both file names from the circuit shape. ### Pinning the artifacts A proving key is trusted input: a substituted one can make its proofs leak the witness, which holds the spending key. Artifacts the SDK locates itself (`prover.cdn`, and `@lelantos-org/circuits` on Node) are checked against the SHA-256 of the published release before parsing and refused with `PROVER_ARTIFACTS_FAILED` on a mismatch, so a `cdn` must serve the release's files unchanged. Explicit `artifacts` are taken as given, since they may be a build of your own. To pin the published files served from your own URLs, pass their digests: ```ts import type { ProverConfig } from "@lelantos-org/sdk"; import { PROVER_ARTIFACT_SHA256 } from "@lelantos-org/sdk/prover"; const prover: ProverConfig = { artifacts: { circuit: "https://cdn.example.com/4x6.wasm", zkey: "https://cdn.example.com/4x6_final.zkey", sha256: PROVER_ARTIFACT_SHA256["4x6"], }, }; ``` The check also covers the artifact cache: a cached entry that no longer matches is discarded and downloaded again. Nothing is downloaded at `connect()`. With the default `warmup: "lazy"`, artifacts are fetched at the first proof; call `wallet.warmProver()` when the user opens a send form, or set `warmup: "eager"` to start in the background right after connecting. A missing artifact configuration surfaces then, as `PROVER_ARTIFACTS_MISSING`. ### Circuit shape The SDK ships one circuit, `TRANSACT_4X6`: four inputs and six outputs, with a ~33 MB zkey and a ~4 MB witness circuit. Six outputs fit the payment, change, a relayer fee in a second asset, and that asset's change in one transaction. > **Warning: The pool's verifier must match the circuit** > > A pool deployed with a verifier for a different shape cannot be used. The SDK cannot detect the verifier at `connect()`; the mismatch appears as a rejected proof at submission. ## Keeping the main thread free Proving and trial decryption are CPU-bound and block the thread they run on. Run both in Web Workers by passing worker factories to `connect()`. The SDK publishes the worker entry points as `@lelantos-org/sdk/workers/prover` and `@lelantos-org/sdk/workers/scanner`. ```ts // setup import type { Eip1193ProviderLike } from "@lelantos-org/sdk"; declare const provider: Eip1193ProviderLike; declare const address: `0x${string}`; declare const rpcUrl: string; declare const circuit: string; declare const zkey: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, provider, address, prover: { artifacts: { circuit, zkey }, // The `new Worker(new URL(...))` expression must sit at your own call site // so the bundler emits a worker chunk for it. worker: () => new Worker(new URL("@lelantos-org/sdk/workers/prover", import.meta.url), { type: "module", }), }, scanner: { workers: () => new Worker(new URL("@lelantos-org/sdk/workers/scanner", import.meta.url), { type: "module", }), size: 4, // default 2–8, by hardware concurrency }, }); // Each worker owns a WASM heap. Release them when tearing the wallet down. await wallet.dispose(); ``` A DOM `Worker` satisfies the SDK's `WorkerLike` type directly; no cast or adapter is needed. > **Warning: Write `new Worker(new URL(..., import.meta.url))` at the call site** > > Both options take a factory function, not a worker or a URL. Bundlers emit a worker chunk only when this exact expression appears in your source. Passing the URL through a helper function hides it from the bundler; Vite then inlines the worker as a `data:` URL and its imports fail at runtime. > > The factory also lets the scanner pool restart a crashed worker. Bundler-specific forms work too, for example `import ProverWorker from "…?worker"` with `() => new ProverWorker()`. A proof blocks its calling thread even with multi-threading enabled, so a worker prover is required to keep the UI responsive. ### Pre-built workers For settings the `ProverConfig` shorthand does not expose, build the worker prover yourself with `browserWorkerProver` from `@lelantos-org/sdk/prover` and pass it as `prover`. The same applies to `browserWorkerScanner` from `@lelantos-org/sdk/advanced`. A `Prover` or `Scanner` instance you pass is yours: `wallet.dispose()`, and a `connect` that fails, leave it running. That lets one prover serve every wallet in a tab; release it yourself when you are done with it. Workers the SDK builds from `prover: { worker }` or `scanner: { workers }` are the wallet's and go with `dispose()`. ```ts // setup import type { Eip1193ProviderLike } from "@lelantos-org/sdk"; declare const provider: Eip1193ProviderLike; declare const address: `0x${string}`; declare const rpcUrl: string; declare const circuit: string; declare const zkey: string; // example import { connect } from "@lelantos-org/sdk"; import { browserWorkerScanner } from "@lelantos-org/sdk/advanced"; import { browserWorkerProver } from "@lelantos-org/sdk/prover"; const prover = browserWorkerProver({ worker: () => new Worker(new URL("@lelantos-org/sdk/workers/prover", import.meta.url), { type: "module", }), artifacts: { circuit, zkey }, threads: 4, cacheArtifacts: false, // skip the worker's Cache API copy }); const scanner = browserWorkerScanner({ worker: () => new Worker(new URL("@lelantos-org/sdk/workers/scanner", import.meta.url), { type: "module", }), size: 4, }); const wallet = await connect({ network: "base", rpcUrl, provider, address, prover, scanner }); // Teardown: the wallet does not own the instances it was given. await wallet.dispose(); await scanner.dispose(); prover.dispose(); ``` ## Threads and timing Proving has two phases, both logged at `debug` on `lelantos:prover:wasm`: | Phase | Threads | |---|---| | witness generation | single-threaded | | Groth16 proof | parallelized; accounts for the difference between thread counts | Set the thread count with `prover: { threads }`, `configureProverThreads(n)` from `@lelantos-org/sdk/prover`, or `threads` on `browserWorkerProver`. See [Benchmarks](https://docs.lelantos.xyz/guide/benchmarks.md) for measured timings. ## Artifact caching Downloaded artifacts are stored in the **Cache API** automatically when the browser supports it. The Cache API is shared across the origin, so the cache serves both page reloads and the prover worker. > **Danger: The URL is the cache key** > > Unpinned artifacts are not revalidated: publish new proving keys under a new URL. Pinned artifacts are checked against their digest on every load, so a stale entry is discarded and fetched again. See [Pinning the artifacts](#pinning-the-artifacts). ```ts // setup import type { ArtifactCache } from "@lelantos-org/sdk/prover"; declare const myCache: ArtifactCache; // example import { requestPersistentStorage } from "@lelantos-org/sdk/advanced"; import { clearArtifactCache, configureArtifactCache } from "@lelantos-org/sdk/prover"; // Recommended once at startup: WebKit evicts Cache API storage after ~7 days // without a visit, which silently restores the cold start. This covers every // store the origin owns, so a persisted note or tree store benefits too. await requestPersistentStorage(); await clearArtifactCache(); // reclaim ~37 MB, or force a re-download configureArtifactCache(false); // opt out entirely configureArtifactCache(myCache); // or store them in IndexedDB / OPFS / disk ``` A custom cache implements `ArtifactCache`: `get(url)` and `put(url, bytes)`. Neither method may throw; on a storage failure the SDK fetches from the network. > **Warning: Configure caching inside the worker** > > `configureArtifactCache` on the main thread does not affect a worker prover, which runs in a separate module context. Pass `cacheArtifacts: false` to `browserWorkerProver` to disable caching in the worker. A custom `ArtifactCache` cannot be passed to the worker; install it in the worker's own code. ## Persisting state across page loads Without persistence, a browser wallet downloads the note feed, the Merkle tree, and the nullifier set on every page load. Configure all three under `storage`: | Option | Persists | Guide | |---|---|---| | `storage.notes` | notes and the sync cursor | [Custom storage](https://docs.lelantos.xyz/guide/storage.md) | | `storage.tree` | Merkle tree | [Syncing](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set) | | `storage.nullifiers` | spent-nullifier set | [Syncing](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set) | ## Switching accounts and networks Call `wallet.dispose()` before replacing a wallet — on disconnect, account switch, or network switch. Workers are live threads and are not released when the wallet goes out of scope. `dispose()` releases only workers the SDK built; a `Prover` or `Scanner` instance you passed stays running until you dispose it (see [Pre-built workers](#pre-built-workers)). For UI bindings, see [Balances and state](https://docs.lelantos.xyz/guide/state.md#react). ## Next - [Node usage](https://docs.lelantos.xyz/guide/node.md) - [Benchmarks](https://docs.lelantos.xyz/guide/benchmarks.md) - [Custom storage](https://docs.lelantos.xyz/guide/storage.md) # Node usage `connect()` detects Node and configures the prover, scanner, and HTTP clients for it. This page covers the Node-specific settings: prover artifacts, threads, persistent storage, deposit events, and shutdown. Node 24 or later is required. It provides the global `fetch` and Web Crypto APIs the SDK uses. ## Prover artifacts The prover needs the circuit (`.wasm`) and proving key (`_final.zkey`); the default shape is `4x6`. They are resolved in this order: | Order | Source | |---|---| | 1 | `prover: { artifacts }` on `connect()` | | 2 | `LELANTOS_PROVER_ARTIFACTS_DIR` environment variable (used only if both files exist) | | 3 | the `@lelantos-org/circuits` package | | 4 | `prover: { cdn }` | Resolution happens at the first proof or `wallet.warmProver()`, not at `connect()`. If none resolves, that call throws `PROVER_ARTIFACTS_MISSING`; its `tried` field lists every location checked. ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, prover: { artifacts: { circuit: "./artifacts/4x6.wasm", zkey: "./artifacts/4x6_final.zkey" }, warmup: "eager", // a long-running service pays the load once, at startup }, }); ``` `@lelantos-org/circuits` is published to GitHub Packages; install it with the same registry setup as the SDK. See [Installation](https://docs.lelantos.xyz/guide/installation.md). A script that only reads balances can pass `prover: "none"`: nothing is loaded, and spends reject `PROVER_UNAVAILABLE`. ## Prover threads The WASM prover parallelizes Groth16 across a thread pool built on `node:worker_threads`. | Setting | Precedence | |---|---| | `prover: { threads }` on `connect()`, or `configureProverThreads(n)` from `@lelantos-org/sdk/prover` | highest; applied before the first proof | | `LELANTOS_PROVER_THREADS` | used when neither is set | | default | `os.availableParallelism()`, clamped to 2–32 | `0` or `1` selects single-threaded proving. ```ts import { configureProverThreads } from "@lelantos-org/sdk/prover"; configureProverThreads(8); ``` A proof blocks the calling thread while it runs, even with a thread pool. In a server that handles other requests concurrently, run wallet operations in a dedicated worker thread or process. ## Persistent storage The SDK ships only in-memory stores. A Node process that restarts re-scans the note feed and rebuilds the tree unless you provide persistent implementations under `storage`: | Option | Interface | Guide | |---|---|---| | `storage.notes` | `NoteStore` | [Custom storage](https://docs.lelantos.xyz/guide/storage.md) | | `storage.tree` | `TreePersistence` | [Syncing](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set) | | `storage.nullifiers` | `NullifierPersistence` | [Syncing](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set) | A file-backed store implements `load()` and `save()` over a JSON file. Write to a temporary file and rename it so that a crash cannot leave a partially written file. ## Deposit events `wallet.awaitDeposit(result.escrow)` polls the indexer until a deposit is flushed and works on Node without extra setup. See [Waiting for the relayer](https://docs.lelantos.xyz/guide/deposit.md#waiting-for-the-relayer). `DepositStream` from `@lelantos-org/sdk/services` pushes flush events instead, but uses `EventSource`, which Node does not provide. Pass an `eventSourceFactory` that returns a compatible object; without it the constructor throws `ENVIRONMENT`. ## Shutdown The wallet implements `Symbol.asyncDispose`, so `await using` disposes it at the end of the scope: ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; await using wallet = await connect({ network: "base", rpcUrl, privateKey }); await wallet.sync(); // `wallet.dispose()` runs when the scope exits. ``` | Component | Keeps the process alive | |---|---| | default prover thread pool | no; its threads do not hold the event loop | | worker threads you create for a custom `Prover` or `Scanner` | yes, until you dispose that instance (`wallet.dispose()` does not) | Call `dispose()` (or use `await using`) in scripts and before replacing a wallet in long-running processes. ## Logging Configure logging from environment variables in scripts: ```bash LELANTOS_LOG='lelantos:*' LELANTOS_LOG_LEVEL=debug node ./script.mjs ``` The variables take effect only when the script applies them with `loggingFromEnv()`. See [Logging](https://docs.lelantos.xyz/guide/logging.md#configuration-from-environment-variables). ## Next - [Custom storage](https://docs.lelantos.xyz/guide/storage.md) - [Browser usage](https://docs.lelantos.xyz/guide/browser.md) # Custom storage The wallet persists three kinds of state, each through its own interface: | State | Interface | `connect` option | |---|---|---| | decrypted notes and sync cursor | `NoteStore` | `storage.notes` | | Merkle tree | `TreePersistence` | `storage.tree` | | spent-nullifier set | `NullifierPersistence` | `storage.nullifiers` | All three default to memory. A wallet without persistent storage re-downloads and re-scans the note feed on every start. The interfaces are in `@lelantos-org/sdk/advanced`. This page covers `NoteStore`. For the tree and nullifier set, see [Persisting the tree and spent set](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set). ## Implementing `NoteStore` `NoteStore` has two methods: `load()` returns the full notes file, and `save()` replaces it. ```ts // setup declare function idbGet(k: string): Promise; declare function idbSet(k: string, v: string): Promise; // example import type { NoteStore, NotesFile } from "@lelantos-org/sdk/advanced"; export class IndexedDbNoteStore implements NoteStore { async load(): Promise { const json = (await idbGet("lelantos-notes")) ?? '{"version":1,"notes":[]}'; return JSON.parse(json) as NotesFile; } async save(file: NotesFile): Promise { await idbSet("lelantos-notes", JSON.stringify(file)); } } ``` `StoredNote` encodes `bigint` fields as decimal strings, so `NotesFile` serializes with plain `JSON.stringify`. > **Danger: Persist `cursor`** > > `NotesFile.cursor` is the sync resume position. A store that drops it — for example by saving only `notes` — causes every sync to re-scan from the beginning, with no error. Save the whole object passed to `save()`. Pass the store when connecting: ```ts // setup import type { NoteStore } from "@lelantos-org/sdk/advanced"; declare const IndexedDbNoteStore: new () => NoteStore; declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; const wallet = await connect({ network: "base", rpcUrl, privateKey, storage: { notes: new IndexedDbNoteStore() }, }); ``` `createWallet` takes the same store as `noteStore`, and `connectWatch` as `storage.notes`. `InMemoryNoteStore` is the default implementation. ## Implementation requirements | Requirement | Reason | |---|---| | Save the entire `NotesFile`, including `cursor` and `version` | a missing `cursor` forces a full re-scan; a missing `version` makes the wallet refuse to open | | Make `save()` atomic (write to a temporary key, then rename; or use a transactional store) | a partially written file that fails to parse forces a full re-scan | | Call `wallet.sync({ reload: true })` after modifying the store outside the SDK, such as from another tab | the wallet does not detect external changes | | Encrypt the stored file | see [Contents](#contents) | The SDK serializes its own reads and writes to the store, so `save()` is never called concurrently by one wallet. ## Schema version The current `NotesFile` version is 3. Each stored note records `d`, the diversifier of the address it was received at, and is spendable only under that address's `pk`. The SDK does not upgrade files: opening a wallet whose store holds another version rejects `WALLET_CONFIG`. Clear the store (or have `load()` return an empty version-3 file) and the next sync re-scans the feed. ## Contents The notes file contains the commitments the wallet owns, their values, the [memos](https://docs.lelantos.xyz/guide/transfer.md#memo) received with them as plain text, and the sync cursor. It does **not** contain `nsk` or nullifiers; nullifiers are computed in memory and never written. > **Danger: The notes file is sensitive** > > The file cannot be used to spend, but it links its holder to every commitment the wallet owns on chain. Encrypt it at rest. ## Reducing file size `wallet.compact()` removes notes already marked spent and returns the number removed. Balances are unchanged. ## Next - [Syncing](https://docs.lelantos.xyz/guide/sync.md) - [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) # Pluggable interfaces Every external dependency of a wallet is an interface with a working default. `connect()` exposes the ones applications commonly swap; `createWallet(keySource, config)` from `@lelantos-org/sdk/advanced` accepts all of them. | Interface | Default | `connect` option | `WalletConfig` field | Common replacements | |---|---|---|---|---| | `ChainReader` / `ChainAdapter` | `ViemChainReader` / `ViemChainAdapter` | `reader` / `chain` | `chain` (required) | another client library, hardware-wallet signing | | `Prover` | `WasmProver`, snarkjs fallback, loaded lazily | `prover` | `prover` | a Web Worker prover, a remote prover, a mock | | `Scanner` | `LocalScanner` | `scanner` | `scanner` | `WorkerPoolScanner` for trial decryption off the main thread | | `NoteStore` | `InMemoryNoteStore` | `storage.notes` | `noteStore` | file, IndexedDB, encrypted key-value store | | `TreePersistence`, `NullifierPersistence` | memory | `storage.tree`, `storage.nullifiers` | `treePersistence`, `nullifierPersistence` | IndexedDB, disk | | `NoteSource` | `FmdNoteSource` over the FMD server | — | `noteSource` | alternative indexer, test fixture | | `Submitter` | `HttpRelayerSubmitter` | — | `submitter` | several relayers, direct submission, test mock | | `CoinSelector` | `SfrtCoinSelector` | — | `selector` | `DenominationCoinSelector`, deterministic test stub | | `TreeStore`, `NullifierStore` | built from the chunk feeds | — | `treeStore`, `nullifierStore` | a pre-seeded or shared cache | Related guides: - `ChainReader`, `ChainAdapter` — [Chain adapters](https://docs.lelantos.xyz/guide/chain-adapter.md) - `NoteStore` — [Custom storage](https://docs.lelantos.xyz/guide/storage.md) - `TreePersistence`, `NullifierPersistence` — [Syncing](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set) - `Prover`, `Scanner` — [Browser usage](https://docs.lelantos.xyz/guide/browser.md#keeping-the-main-thread-free) - `DenominationCoinSelector` — [Denominations](https://docs.lelantos.xyz/guide/denominations.md#selecting-for-zero-change) ## Injecting implementations ```ts // setup import type { CoinSelector, KeySource, NoteStore, Submitter, WalletConfig } from "@lelantos-org/sdk/advanced"; declare const keySource: KeySource; declare const config: WalletConfig; declare const myStore: NoteStore; declare const mySubmitter: Submitter; declare const mySelector: CoinSelector; // example import { createWallet } from "@lelantos-org/sdk/advanced"; const wallet = await createWallet(keySource, { ...config, noteStore: myStore, submitter: mySubmitter, selector: mySelector, }); ``` `createWallet` returns the same `WalletApi` as `connect`, with the same capabilities and errors. ## Custom coin selector ```ts import { circuitAmount, type AssetId, type CircuitAmount } from "@lelantos-org/sdk"; import type { CoinSelector, SelectionResult, SelectOpts, StoredNote } from "@lelantos-org/sdk/advanced"; export class LargestFirstSelector implements CoinSelector { select( all: readonly StoredNote[], asset: AssetId, target: CircuitAmount, opts?: SelectOpts, ): SelectionResult { const mine = all .filter((n) => BigInt(n.asset) === asset && !n.spent) .sort((a, b) => (BigInt(b.value) > BigInt(a.value) ? 1 : -1)); const picked = mine.slice(0, opts?.maxInputs ?? 4); const sum = picked.reduce((acc, n) => acc + BigInt(n.value), 0n); const needed = target + (opts?.fee ?? 0n); if (sum >= needed) { return { plan: "direct", notes: picked, sum: circuitAmount(sum) }; } const consolidate = mine.slice(-2); return { plan: "consolidate-first", consolidate, consolidateSum: circuitAmount(consolidate.reduce((a, n) => a + BigInt(n.value), 0n)), targetWithFee: circuitAmount(needed), }; } } ``` > **Danger: Largest-first selection weakens privacy** > > This example shows the interface only. Largest-first selection creates a balance-ordering pattern that has been used to link spends (Tramèr et al., USENIX '24). The default `SfrtCoinSelector` randomizes selection to prevent it. The wallet removes notes leased by in-flight spends before calling a selector, and passes the spend's selection rules in `opts` together with the quoted fee and the chain tip. Honouring `dustThreshold`, `cooldownBlocks`, `maxInputs`, and `only` is the selector's job; wrapping `SfrtCoinSelector` keeps them. Return values must use the SDK's branded types; create them with `circuitAmount`, `assetId`, and `hex32`, which validate their input. ## Custom note source A `NoteSource` returns pages of encrypted notes after a cursor. It does not provide Merkle paths or spent status, because requesting either for a specific note would identify that note to the server. ```ts import type { ListNotesOpts, NotePage, NoteSource, ScanInput } from "@lelantos-org/sdk/advanced"; /** Replays a fixed set of notes. Enough to drive a wallet in a unit test. */ export class StaticNoteSource implements NoteSource { constructor(private readonly rows: ScanInput[]) {} async listNotes(opts?: ListNotesOpts): Promise { const after = opts?.after ?? 0; const page = this.rows.slice(after, after + (opts?.limit ?? 100)); const next = after + page.length; return { inputs: page, nextAfter: next, resumeAfter: next }; } } ``` Each row is a `ScanInput`: the wire `ciphertext`, the packed `epk` and `clueR` points as published with the note, and its `cm`, `leafIndex`, and `blockNumber`. The scanner keeps a note only when its `cm`, `epk`, and clue are the ones its plaintext determines, so pass all of them through unchanged. | Field | Meaning | |---|---| | `nextAfter` | cursor for the next page within the current sync; always past every returned row | | `resumeAfter` | highest cursor that is safe to **persist** | > **Warning: Do not persist `nextAfter`** > > On a feed that is still backfilling history, `resumeAfter` is behind `nextAfter`. Persisting `nextAfter` skips rows inserted later by the backfill, and they are never synced. On an append-only feed the two values are equal. ## Custom submitter `Submitter` has one required method, `submit`, and five optional ones. ```ts import type { ChainToken, EstimateResponse, RelayerSubmitResponse, SubmitTransactPayload } from "@lelantos-org/sdk/protocol"; import { type EstimateKind, HttpRelayerSubmitter, type Submitter } from "@lelantos-org/sdk/advanced"; /** Races two relayers and takes whichever answers first. */ export class RacingSubmitter implements Submitter { constructor( private readonly primary: HttpRelayerSubmitter, private readonly others: HttpRelayerSubmitter[], ) {} submit(payload: SubmitTransactPayload): Promise { return Promise.any([this.primary, ...this.others].map((p) => p.submit(payload))); } // Delegate fee quoting and the asset list to one peer. Omitting `estimate` // makes the wallet build no fee slot at all — correct only for a relayer // that subsidises gas, and a refusal at submit time otherwise. estimate(chainId: bigint, kind: EstimateKind): Promise { return this.primary.estimate(chainId, kind); } assets(chainId: bigint): Promise { return this.primary.assets(chainId); } } ``` | Method | Required | Without it | |---|---|---| | `submit` | yes | — | | `estimate` | no | the wallet adds no fee output; a relayer that charges fees rejects the submission | | `assets` | no | the wallet resolves assets by numeric id from the chain registry | | `submitSwap` | no | `capabilities.swap` is `false` | | `refundAddress` | no | no relayer-provided refund account for swaps from wallets without an EVM account | | `swapWrapperAddress` | no | the wrapper must come from the network preset | A spend's `relayerAddress` is bound into its proof, so every relayer a custom submitter uses must share the address the wallet was configured with. ## Mocking the wallet Code that uses a wallet, rather than configuring one, can depend on the `WalletApi` or `ReadOnlyWalletApi` interface. Tests can then supply a stub with no chain, relayer, or prover. ```ts import type { AssetRef, ReadOnlyWalletApi } from "@lelantos-org/sdk"; export async function totalUnspent(wallet: ReadOnlyWalletApi, assets: AssetRef[]): Promise { let sum = 0n; for (const asset of assets) sum += (await wallet.balance(asset)).total; return sum; } // In a test, stub only what the code under test calls: const stub = { balance: async () => ({ total: 5n }), } as unknown as ReadOnlyWalletApi; await totalUnspent(stub, ["USDC", "WETH"]); // 10n ``` ## Next - [Chain adapters](https://docs.lelantos.xyz/guide/chain-adapter.md) - [Custom storage](https://docs.lelantos.xyz/guide/storage.md) # Chain adapters The chain port is the SDK's only interface to the chain. `connect()` builds the viem implementation from `rpcUrl` and the chain-layer option; implement the port yourself to use another client library, a custom signer, or a transport `connect` does not cover. Everything on this page is in `@lelantos-org/sdk/advanced`. ## Two halves | Interface | Holds | Used by | |---|---|---| | `ChainReader` | everything a chain can answer without a key: registry, tree roots, token state, receipts | every wallet: sync, spends, quotes, `asset()` | | `ChainAdapter` | `ChainReader` plus signing as the user's EOA and the deposit, cancel, and Permit2 writes | deposits, `cancelDeposit`, `setupDepositAllowance` | A spend is authorized by its proof and broadcast by the relayer, so the spend path only ever reads. A wallet built on a `ChainReader` (`reader`, or `readOnly: true`) transfers, withdraws, and swaps normally; only its deposit methods reject `NO_EVM_ACCOUNT`. An adapter's behaviour must depend only on its constructor arguments, not on global state. ## Implementing a reader `chainId`, `maspAddress`, and `fetchAsset` are the required members; every other read is optional. ```ts // setup declare function readRegistry(id: bigint): Promise<{ token: `0x${string}`; scale: bigint; disabled: boolean; depositBps: bigint; withdrawBps: bigint; index: bigint; yieldEnabled: boolean }>; // example import { evmAddress, type AssetId, type EvmAddress } from "@lelantos-org/sdk"; import type { AssetEntry, ChainReader } from "@lelantos-org/sdk/advanced"; export class MyChainReader implements ChainReader { constructor( private readonly id: bigint, private readonly pool: EvmAddress, ) {} async chainId(): Promise { return this.id; } async maspAddress(): Promise { return this.pool; } async fetchAsset(id: AssetId): Promise { const entry = await readRegistry(id); return { ...entry, token: evmAddress(entry.token) }; } } ``` Addresses are branded `EvmAddress` values: build them with `evmAddress()`, which validates and checksums. ### `AssetEntry` | Field | Notes | |---|---| | `token`, `scale`, `disabled` | the registry entry | | `depositBps`, `withdrawBps` | per-asset fee rates, read with the entry; there is no separate fee method | | `index`, `yieldEnabled` | the pool's yield index (RAY-scaled; `RAY` for a plain asset) and whether the asset yields; both required | | `rate` | `{ gross, supply }`; **required** for yield-bearing assets | > **Warning: Provide `rate` for yield-bearing assets** > > `index` is floored on chain, so a deposit sized from it can be lower than the amount the contract pulls, and the Permit2 transfer fails. Without `rate`, deposits of a yield-bearing asset fail. `scale` is not a substitute. ### Optional reads When an optional member is missing, the dependent feature is unavailable; nothing else fails. | Member | Without it | |---|---| | `tokenMeta` | no `symbol` / `decimals`; human-unit conversion throws | | `blockNumber` | the spend cooldown is inactive, and `cancellableAtBlock` cannot be compared with the tip | | `isKnownRoot` | root validity is determined from the commitment feed only | | `nativeAdapterAddress` | no native deposits or withdrawals | | `tokenBalanceOf`, `nativeBalance`, `tokenAllowance`, `permit2Allowance` | `quoteDeposit` reports `balance` / `allowance` as `undefined` | | `getEscrowed`, `fetchDepositEscrowed`, `cancelDelay` | no cancel by `depositId`; `cancellableAtBlock` uses a conservative bound | | `waitTxReceipt`, `txReceiptLogs` | no confirmation wait after broadcast; results carry no `operation`; `confirmCommitment` and `paymentProof` reject `UNSUPPORTED_OPERATION` | | `fetchNotePayload` | `paymentProof` rejects `UNSUPPORTED_OPERATION`, and the layer cannot serve `verifyPaymentProof` | ## Implementing an adapter A `ChainAdapter` adds `payerAddress` and `signPermit2`, both required, plus the writes for the deposit paths your chain supports. | Member | Enables | |---|---| | `submitDeposit` | the `witness` strategy (a Permit2 signature per deposit) | | `submitDepositAuthorized`, `permit2Allowance`, `permit2PermitAllowance`, `signPermit2Allowance` | the `allowance` strategy | | `signPermit2AllowanceBatch`, `permit2PermitAllowanceBatch`, `tokenApprove`, `tokenAllowance` | `setupDepositAllowance` | | `submitDepositNative` + `nativeAdapterAddress` | native deposits | | `cancelDeposit`, `cancelDepositNative` | reclaiming escrows | Each deposit write resolves once mined with `{ txHash, depositId, blockNumber, escrowed }`, where `escrowed` is the pool's decoded `DepositEscrowed` log. A signing failure from the user should reject with `UserRejectedError`; the viem adapter maps EIP-1193 code `4001` for you. `escrowed` includes the log's last field, `pulled`, the escrow's refund cap (`0n` for a plain asset). Read it from the log rather than computing it, and return the same record from `fetchDepositEscrowed`. The wallet builds the `inputs` of `cancelDeposit` and `cancelDepositNative` from that record; forward them unchanged, with `inputs.pulled` as the last argument of the contract call. The pool digests every field, so any other value reverts with `DigestMismatch`. Type guards narrow a reader to a capability: ```ts // setup import type { ChainReader } from "@lelantos-org/sdk/advanced"; declare const chain: ChainReader; // example import { supportsAllowanceBatch, supportsAllowanceTransfer, supportsNativeEth, supportsSigning, } from "@lelantos-org/sdk/advanced"; if (supportsSigning(chain)) { // `chain` is a ChainAdapter here. await chain.payerAddress(); } if (supportsNativeEth(chain)) { chain.nativeAdapterAddress(); // non-optional here } const canSetUpAllowance = supportsAllowanceTransfer(chain); const canBatchAllowances = supportsAllowanceBatch(chain); // strictly narrower ``` Code holding a wallet can read `wallet.capabilities` instead, which answers the same questions from the same checks. ## Adapter-specific reads `wallet.chain` is typed as `ChainReader`. Narrow it with a guard, or check a member, to call an optional read: ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const depositId: bigint; // example const record = await wallet.chain.fetchDepositEscrowed?.(depositId); ``` ## Constructing the viem adapter `ViemChainAdapter` takes an `EthSigner`: `PrivateKeySigner` for a raw key, or `Eip1193Signer` for a browser provider. `ViemChainReader` is the read-only half. Build one directly to set options `connect` does not expose, such as `cacheTimeMs`, then pass it as `chain` (or `reader`) with an explicit key source. ```ts // setup declare const rpcUrl: string; declare const nsk: bigint; declare const privateKey: `0x${string}`; // example import { connect, NETWORKS } from "@lelantos-org/sdk"; import { PrivateKeySigner, ViemChainAdapter } from "@lelantos-org/sdk/advanced"; const base = NETWORKS.base; const chain = new ViemChainAdapter({ rpcUrl, signer: new PrivateKeySigner(privateKey, rpcUrl, base.chainId), // the signer pins its chain id maspAddress: base.maspAddress, chainId: base.chainId, cacheTimeMs: 0, // default: every blockNumber() reads the chain }); // A pre-built adapter exposes no key to derive from, so the shielded key source is explicit. const wallet = await connect({ network: "base", chain, nsk }); ``` ## Next - [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) - [Networks](https://docs.lelantos.xyz/guide/networks.md) # Errors Every wallet method rejects with a `WalletError`, or with the `reason` of an `AbortSignal` you passed. Each error has a stable `code`, a `retryable` flag, and typed fields for the facts of that failure. Handle errors by `code`; message text is not part of the API. `isWalletError(err, code)` is the recommended check. It narrows `err` to the class for that code, so its fields are typed, and it checks structure rather than class identity, so it also works when a bundle contains two copies of the SDK. ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: string; declare function retryLater(): void; // example import { isWalletError } from "@lelantos-org/sdk"; try { await wallet.transfer({ asset: "USDC", amount: "25", recipient }); } catch (err) { if (isWalletError(err, "INSUFFICIENT_COVER")) { await wallet.transfer({ asset: "USDC", amount: "25", recipient, autoConsolidate: true }); } else if (isWalletError(err, "RELAYER_REJECTED")) { console.error(err.reason, err.status); // typed: RelayerRejectReason, number if (err.retryable) retryLater(); } else if (isWalletError(err)) { switch (err.code) { case "RELAYER_TIMEOUT": case "RELAYER_FAILED": console.error(err.url, err.attempts); break; default: throw err; } } else { throw err; // your own AbortSignal's reason } } ``` `WALLET_ERROR_CODES` lists every code at runtime. `AnyWalletError` is the union of all error classes, discriminated by `code`; `WalletErrorOf` is the class for one code. New codes may be added in minor releases, so keep a `default` branch. ## Common fields | Field | Meaning | |---|---| | `code` | the stable discriminator | | `retryable` | the same call, unchanged, may succeed later without user action | | `context` | `{ opId, op, url, method }`: the operation the failure belongs to — `op` names nested steps such as `"transfer:consolidate"` | | `details` | optional diagnostic scalars; never secrets | | `cause` | the underlying error, where there is one | Amounts, asset ids, and addresses are typed fields, never part of the message, which reaches application logs verbatim. A non-SDK failure escaping a wallet method, such as a bug in a custom plugin, is wrapped as `INTERNAL` with the original as `cause`. ## Error codes ### Configuration and arguments Fix the call or the wiring. None is retryable. | Code | Class | Fields | Raised when | |---|---|---|---| | `WALLET_CONFIG` | `WalletConfigError` | `missing[]` | `connect` options are missing or invalid; every problem is listed at once | | `NETWORK_NOT_DEPLOYED` | `NetworkNotDeployedError` | `network` | a placeholder preset was passed from JavaScript | | `ENVIRONMENT` | `EnvironmentError` | | a platform capability is missing: Web Crypto, workers, `EventSource` | | `INVALID_ARGUMENT` | `InvalidArgumentError` | `argument` | an argument the SDK cannot act on: a bad amount, address, asset, option, or an altered quote | | `NO_EVM_ACCOUNT` | `NoEvmAccountError` | `operation` | a deposit, cancel, or allowance setup on a wallet without a signing chain layer | | `UNSUPPORTED_OPERATION` | `UnsupportedOperationError` | `operation`, `missing[]` | the adapter, submitter, or network lacks what the operation needs; also any call on a disposed wallet | ### Funds | Code | Class | Fields | Retryable | |---|---|---|---| | `INSUFFICIENT_BALANCE` | `InsufficientBalanceError` | `asset`, `available`, `required` | no: top up or send less | | `NOTES_HELD` | `NotesHeldError` | `asset`, `required`, `spendable`, `held.{reserved,cooldown,dust}` (`value`, `count`), `reservedUntil?` | when reserved and cooling-down notes alone cover the shortfall; not when only dust would | | `INSUFFICIENT_COVER` | `InsufficientCoverError` | `asset`, `target`, `consolidate[]` (`id`, `value`), `consolidateSum`, `consolidationAttempted`, `reason` (`"arity"` \| `"fee-slot"`) | no: consolidate, or pass `autoConsolidate: true` | | `FEE_ASSET_NOT_QUOTED` | `FeeAssetNotQuotedError` | `asset`, `kind`, `accepted[]` | no: pick an accepted asset | | `FEE_ABOVE_LIMIT` | `FeeAboveLimitError` | `asset`, `quoted`, `limit?`, `source` (`"maxFee"` \| `"acceptRelayerFee"`), `kind` | yes: the quote follows gas; otherwise raise the limit | ### Submission | Code | Class | Fields | Retryable | |---|---|---|---| | `DEADLINE_PASSED` | `DeadlinePassedError` | `deadline` | no; nothing was sent | | `QUOTE_STALE` | `QuoteStaleError` | `fields[]` | yes: re-quote and run the new quote — see [Swap](https://docs.lelantos.xyz/guide/swap.md#executing) | | `USER_REJECTED` | `UserRejectedError` | `action` (`"derive-key"` \| `"sign-permit"` \| `"send-tx"`) | no; nothing was signed or sent | | `RELAYER_REJECTED` | `RelayerRejectedError` | `status`, `reason`, `body`, `reservedNoteIds`, `reservedUntil?` | per `reason` — see [Relayer rejections](#relayer-rejections) | | `SPEND_OUTCOME_UNKNOWN` | `SpendOutcomeUnknownError` | `reservedNoteIds`, `reservedUntil`, `txHash?` | no — see [below](#spend-outcome-unknown) | ### Transport | Code | Class | Fields | Retryable | |---|---|---|---| | `RELAYER_TIMEOUT`, `FMD_TIMEOUT`, `QUOTER_TIMEOUT` | `NetworkError` | `url`, `status?`, `body?`, `attempts[]` | yes | | `RELAYER_FAILED`, `FMD_FAILED`, `QUOTER_FAILED` | `NetworkError` | `url`, `status?`, `body?`, `attempts[]` | no response, 408, 429, and 5xx | | `WIRE_FORMAT` | `WireFormatError` | `path` | no: a server response broke the documented contract | `status` and `body` describe the final attempt only; `attempts` holds every attempt, oldest first. An attempt without `status` got no response. ### Chain | Code | Class | Fields | Retryable | |---|---|---|---| | `RPC_FAILED` | `ChainRpcError` | `method` | yes, unless the contract reverted the call | | `TX_REVERTED` | `TxRevertedError` | `txHash`, `reason?` | no | | `TX_MINING` | `TxMiningError` | `txHash?` | yes: the receipt did not arrive in time | | `TREE_OUT_OF_SYNC` | `TreeOutOfSyncError` | `localRoot`, `mirrorRoot` | yes: the commitment feed disagrees with the pool even after a rebuild; nothing was spent | ### Prover and workers | Code | Class | Fields | Retryable | |---|---|---|---| | `PROVER_FAILED` | `ProverError` | | no | | `PROVER_UNAVAILABLE` | `ProverUnavailableError` | | no: `prover: "none"`, or the optional peer the backend needs is not installed | | `PROVER_ARTIFACTS_MISSING` | `ProverArtifactsMissingError` | `tried[]`, `shape` | no — see [Browser usage](https://docs.lelantos.xyz/guide/browser.md#prover-artifacts) | | `PROVER_ARTIFACTS_FAILED` | `ProverArtifactsFailedError` | `source` | per HTTP status: not for 4xx, and never for an artifact that fails its pinned digest | | `WORKER_TIMEOUT` | `WorkerRpcError` | `method?` | yes | | `WORKER_CRASHED`, `WORKER_FAILED` | `WorkerRpcError` | `method?` | no | ### Other | Code | Class | Fields | Retryable | |---|---|---|---| | `X402_PAYMENT` | `X402PaymentError` | `reason`, `resource?` | no | | `INTERNAL` | `InternalError` | `cause` | no: an SDK bug or a plugin's untyped throw; report it | ## Relayer rejections `RELAYER_REJECTED` is a definite refusal. `reason` is parsed from the relayer's response; `body` is its text, kept off the message because it can echo the payload. | `reason` | Retryable | Meaning | |---|---|---| | `nullifier-spent` | no | a note this spend consumes is already spent; the wallet resyncs its spent set before throwing | | `nullifier-in-flight` | yes | another submission of the same note is pending at the relayer | | `idempotency-key-reused` | yes | the request key collided; a retry draws a fresh one | | `stale-estimate` | yes | the fee estimate moved; a retry re-quotes | | `fee-missing`, `fee-too-low`, `fee-asset-rejected` | no | the fee note is absent, short, or in an asset the relayer does not take | | `contract-rejected` | no | a pool guard refused the payload in pre-flight; no gas was spent | | `reverted` | no | the transaction reached the chain and reverted | | `bad-request` | no | the payload is malformed | | `unknown-chain` | no | the relayer does not serve this chain | | `unavailable`, `internal` | yes | the relayer cannot serve right now | | `unknown` | no | a response this SDK version does not recognise | On `nullifier-*` and `stale-estimate`, the spend's notes are reserved (`reservedNoteIds`) until `reservedUntil`, so a retry does not reselect them. Calling `RelayerClient` from `@lelantos-org/sdk/services` directly, a fee refusal is an HTTP `402` `NetworkError`; detect it with `isShieldedFeeRejection(err)`. ## Spend outcome unknown `SPEND_OUTCOME_UNKNOWN` means a spend was handed to the relayer and no definite answer came back: a timeout, a dropped connection, or a broadcast without a receipt. **It may have landed.** The wallet reserves the spend's notes until the spent set shows them spent or `reservedUntil` passes. A blind retry therefore selects other notes — which would send the payment twice if the first attempt landed. ```ts // setup import type { TransferOptions, WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const args: TransferOptions; declare function askUser(message: string): Promise; // example import { isWalletError } from "@lelantos-org/sdk"; try { await wallet.transfer(args); } catch (err) { if (!isWalletError(err, "SPEND_OUTCOME_UNKNOWN")) throw err; // Give the indexer time, then check whether the reserved notes were spent. await new Promise((resolve) => setTimeout(resolve, 30_000)); await wallet.sync({ scope: "notes" }); const spentIds = new Set((await wallet.notes({ spent: true })).map((n) => n.id)); const landed = err.reservedNoteIds.every((id) => spentIds.has(id)); if (!landed && (await askUser(`Not confirmed yet (tx ${err.txHash ?? "unknown"}). Send again?`))) { await wallet.transfer(args); } } ``` Guidance: - Do not retry automatically. Show the operation as pending, with `txHash` when the relayer reported one. - Sync after a while and check the reserved notes, as above, or look up `txHash` on chain. - If the notes are still unspent after `reservedUntil`, the spend did not land; they return to selection on their own. - `balance().withheld.reserved` and `state().notes.pendingSpend` include these notes in the meantime. ## Operation ids and cancellation Every operation accepts `opId`, `signal`, and `onPhase`: | Option | Behaviour | |---|---| | `opId` | a correlation id (1–64 of `[A-Za-z0-9_:.-]`), minted when omitted. It appears on phases, the result, `err.context.opId`, logs, and `state().ops`. It is never sent to a service. | | `signal` | checked between every step. The operation rejects with `signal.reason` as-is and releases its note leases. An abort after the spend reached the relayer does not recall it: the SDK waits for the answer or reports `SPEND_OUTCOME_UNKNOWN`. | | `onPhase(phase, { opId, txHash })` | progress; a throwing callback is logged and swallowed | ```ts // setup import type { WalletApi } from "@lelantos-org/sdk"; declare const wallet: WalletApi; declare const recipient: `0x${string}`; // example import { isWalletError } from "@lelantos-org/sdk"; const controller = new AbortController(); const opId = `withdraw:${crypto.randomUUID()}`; try { await wallet.withdraw({ asset: "USDC", gross: "10", recipient, opId, signal: controller.signal }); } catch (err) { if (err === controller.signal.reason) console.log("cancelled by the user"); else if (isWalletError(err)) console.error(err.context.opId === opId, err.code); else throw err; } ``` ## Timeouts and retries The relayer, FMD, and quoter clients share one transport, configured with `http` on `connect()`: | Setting | Default | |---|---| | `retries` | 3, after the first attempt | | backoff | 250 ms, doubling, with jitter | | `timeoutMs` (reads and estimates) | 15,000 ms per attempt | | `submitTimeoutMs` (spend submissions) | 30,000 ms per attempt, or the preset's value (90,000 ms on `mainnet`) | | quoter timeout | 5,000 ms per attempt | | Retried: reads and estimates | no response, timeouts, 408, 429, 5xx | | Retried: submissions | no response, timeouts, 429, 503 — under one `Idempotency-Key` | | Never retried | a submission answered with 500 or 502, which may follow a broadcast | ```ts // setup declare const privateKey: `0x${string}`; declare const rpcUrl: string; // example import { connect } from "@lelantos-org/sdk"; // A poll on a latency budget: fail fast rather than retry for a minute. const wallet = await connect({ network: "base", rpcUrl, privateKey, http: { timeoutMs: 3_000, retries: 1, onRetry: (i) => console.warn("retry", i.service, i.attempt) }, }); ``` > **Warning: `timeoutMs` applies per attempt** > > With retries, a request can take several times `timeoutMs`. At the defaults, a failing read takes over a minute to throw. For latency-sensitive calls such as polling, set both `timeoutMs` and `retries`. Clients constructed directly (`RelayerClient`, `FmdClient`, `HttpRelayerSubmitter`) take `HttpClientOptions`, which also accepts `backoffMs`. ## Next - [Troubleshooting](https://docs.lelantos.xyz/guide/troubleshooting.md) — symptoms and fixes - [Logging](https://docs.lelantos.xyz/guide/logging.md) # Logging The SDK emits no log output by default. Logging requires both a level and a sink. ## Enabling logging ```ts import { configureLogging, consoleSink } from "@lelantos-org/sdk"; configureLogging({ level: "debug", sink: consoleSink() }); ``` Call `configureLogging` once, before `connect()`. The configuration is global and applies to all loggers immediately. | Field | Description | |---|---| | `level` | most verbose level to emit; default `"silent"` | | `sink` | destination for records; **without a sink nothing is emitted** | | `namespaces` | glob or list of namespaces to include; default all | Levels, from least to most verbose: `silent`, `error`, `warn`, `info`, `debug`, `trace`. ## Filtering by namespace A full sync at `debug` produces thousands of records. Namespaces are colon-delimited and accept globs: ```ts import { configureLogging, consoleSink } from "@lelantos-org/sdk"; configureLogging({ level: "debug", sink: consoleSink(), namespaces: ["lelantos:prover:*", "lelantos:http"], }); ``` | Namespace | Records | |---|---| | `lelantos:http` | HTTP requests, status codes, retries | | `lelantos:wallet:sync` | sync progress: pages, matches, checkpoints | | `lelantos:wallet:connect` / `:prover` | connect steps, prover backend selection and fallbacks | | `lelantos:callback` / `lelantos:wallet:state` | a throwing `onPhase`, `onProgress`, or `subscribe` listener, logged and swallowed | | `lelantos:wallet:chunks` | tree and nullifier chunk feeds | | `lelantos:sync:scan` / `lelantos:sync:pool` | trial decryption and the worker pool | | `lelantos:wallet:selection` | selected and excluded notes | | `lelantos:wallet:spend` / `:deposit` / `:notes` | spend, deposit, and note store operations | | `lelantos:prover:wasm` | witness and Groth16 timings, thread count | | `lelantos:prover:artifacts` / `:cache` | artifact loading and Cache API hits | | `lelantos:prover:worker` / `lelantos:worker:rpc` | worker prover and worker RPC | | `lelantos:relayer:deposits` | deposit event stream | | `lelantos:storage` | note store reads and writes | | `lelantos:crypto:poseidon` / `lelantos:wasm:rayon` | hash backend selection and thread pool setup | ## Custom sinks A sink is a function that receives a `LogRecord`: ```ts // setup declare const pino: { error: (o: unknown) => void; warn: (o: unknown) => void; info: (o: unknown) => void; debug: (o: unknown) => void; trace: (o: unknown) => void }; // example import { configureLogging, type LogSink } from "@lelantos-org/sdk"; const sink: LogSink = (r) => { pino[r.level]({ ns: r.ns, msg: r.msg, t: r.t, ...r.fields }); }; configureLogging({ level: "info", sink }); ``` `consoleSink()` prefixes each line with the time since the previous record (`+Nms`). Options: `{ timestamps: false }` removes the prefix; `{ console }` sets the output target. ## Configuration from environment variables `loggingFromEnv()` reads `LELANTOS_LOG` (namespaces) and `LELANTOS_LOG_LEVEL` (level; defaults to `debug` when only `LELANTOS_LOG` is set). It returns `null` when neither variable is set. `loggingFromEnv()` returns a configuration without a sink and does not apply it. Add a sink and pass the result to `configureLogging`: ```ts import { configureLogging, consoleSink } from "@lelantos-org/sdk"; import { loggingFromEnv } from "@lelantos-org/sdk/advanced"; const fromEnv = loggingFromEnv(); if (fromEnv) configureLogging({ ...fromEnv, sink: consoleSink() }); ``` ```bash LELANTOS_LOG='lelantos:sync:*' LELANTOS_LOG_LEVEL=trace node ./my-script.mjs ``` ## Logging in Web Workers `configureLogging` on the main thread does not apply inside workers. The prover and scanner workers receive the level and namespace filter that is active when they are created, so configure logging **before** `connect()`. Records from workers are forwarded to the main-thread sink without being filtered again. ## Environment variables | Variable | Effect | Programmatic equivalent | |---|---|---| | `LELANTOS_LOG` | namespace filter | `configureLogging({ namespaces })` | | `LELANTOS_LOG_LEVEL` | log level | `configureLogging({ level })` | | `LELANTOS_PROVER_THREADS` | WASM prover thread count | `prover: { threads }` or `configureProverThreads(n)` | | `LELANTOS_PROVER_ARTIFACTS_DIR` | directory containing `.wasm` and `_final.zkey` | `prover: { artifacts }` on `connect()` | Programmatic settings take precedence over environment variables. ## Next - [Troubleshooting](https://docs.lelantos.xyz/guide/troubleshooting.md) - [Errors](https://docs.lelantos.xyz/guide/errors.md) # Troubleshooting Common symptoms, their causes, and fixes. Enable [logging](https://docs.lelantos.xyz/guide/logging.md) for the relevant namespace before investigating, and check the error's `code` — see [Errors](https://docs.lelantos.xyz/guide/errors.md). ## Connecting ### `WALLET_CONFIG` from `connect()` `err.missing` lists every problem at once. The most common: | Entry | Fix | |---|---| | `` `rpcUrl` `` | public presets ship no RPC endpoint; pass your own. See [Networks](https://docs.lelantos.xyz/guide/networks.md#rpc-endpoint). | | `a key source` | a `readOnly`, `reader`, or `chain` layer cannot derive `nsk`; add `mnemonic`, `signature`, or `nsk`. | | `one chain layer` | pass exactly one of `privateKey`, `signer`, `provider` + `address`, `readOnly`, `reader`, `chain`. | ### `USER_REJECTED` with `action: "derive-key"` The user declined the EIP-712 key-derivation signature at the end of `connect()`. Nothing was built that needs cleaning up; offer to retry. ### `NETWORK_NOT_DEPLOYED` The preset has no deployed contracts (for example `sepolia`). Use a deployed preset or a custom `NetworkPreset`. See [Networks](https://docs.lelantos.xyz/guide/networks.md). ### TypeScript cannot resolve `@lelantos-org/sdk/...` Set `"moduleResolution"` to `"nodenext"` or `"bundler"`. If the subpath does not exist, see [Subpaths](https://docs.lelantos.xyz/guide/subpaths.md) for the ones the SDK exports. ### `npm install` returns 404 The `@lelantos-org` scope is not configured for GitHub Packages, or `NODE_AUTH_TOKEN` is missing. See [Installation](https://docs.lelantos.xyz/guide/installation.md#_1-configure-the-registry). ## Balance and sync ### A deposit does not appear in the balance | Cause | Fix | |---|---| | The relayer has not flushed the deposit | Wait with `awaitDeposit(result.escrow)`; it syncs until the note appears. See [Deposit lifecycle](https://docs.lelantos.xyz/guide/deposit.md#the-deposit-lifecycle). | | The deposit went to a different address | Check `recipient` in the deposit call; the default is the wallet's own address. | | The relayer never flushed it | Once `escrow.cancellableAtBlock` is reached, cancel and reclaim the funds. See [Cancelling a deposit](https://docs.lelantos.xyz/guide/deposit.md#cancelling-a-deposit). | ### Sync returns no notes | Cause | Fix | |---|---| | Different `nsk` than the one that received the notes | Confirm the key source. A mnemonic, a private key, and a signature derive different keys. See [Connecting a wallet](https://docs.lelantos.xyz/guide/wallet.md). | | Wrong network | Check the preset's `chainId` and service URLs. See [Networks](https://docs.lelantos.xyz/guide/networks.md). | | Sync did not complete | Check `report.notes.stoppedBy`. `"cursorStalled"` or `"pageCap"` indicates a faulty note source. See [Progress and cancellation](https://docs.lelantos.xyz/guide/sync.md#progress-and-cancellation). | | `matches` strategy with an unregistered or rotated token | Re-register, or use the stored epoch. See [Registering a subscription](https://docs.lelantos.xyz/guide/sync.md#registering-a-subscription). | Enable `lelantos:wallet:sync` and `lelantos:sync:scan` logging to see pages fetched and matches found. ### Every sync re-scans from the beginning A custom `NoteStore` is not persisting `NotesFile.cursor`, or `save()` fails silently. Save the entire object passed to `save()`. See [Custom storage](https://docs.lelantos.xyz/guide/storage.md#implementation-requirements). ### Every startup downloads the tree and nullifier set `storage.tree` and `storage.nullifiers` are not configured. See [Persisting the tree and spent set](https://docs.lelantos.xyz/guide/sync.md#persisting-the-tree-and-spent-set). ### `balance()` rejects `WALLET_CONFIG` on a watch-only wallet `balance`, `asset`, and `assets` read asset metadata from the chain. Pass `rpcUrl` or `reader` to `connectWatch`, or use `state().balances`. See [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md#creating-a-watch-only-wallet). ### An incoming-viewing-key wallet shows too high a balance An incoming viewing key cannot detect spent notes, so balances are the total received. Use a full viewing key. See [Watch-only wallets](https://docs.lelantos.xyz/guide/watch-only.md#key-hierarchy). ## Spending ### `INSUFFICIENT_COVER` with enough balance The balance is spread across more notes than one spend can use. Check `spendableMax()` and its `withheld.slots`, then consolidate or pass `autoConsolidate: true`. See [What a single spend can reach](https://docs.lelantos.xyz/guide/notes.md#what-a-single-spend-can-reach). ### `NOTES_HELD` Notes are reserved by another spend, cooling down, or dust. If `err.retryable` is `true`, retry after the other spend settles or a block arrives; `err.held` says which bucket. If it is `false`, only dust would close the gap: lower `selection.dustThreshold` or send less. ### Consolidation runs but the retry fails the same way The consolidating self-transfer selected different notes. Pin the ids from the error with `selection.only`. See [Consolidating explicitly](https://docs.lelantos.xyz/guide/notes.md#consolidating-explicitly). ### `SPEND_OUTCOME_UNKNOWN` The submission may have landed. Do not resend blindly; see [Spend outcome unknown](https://docs.lelantos.xyz/guide/errors.md#spend-outcome-unknown). ### The proof is rejected at submission | Cause | Fix | |---|---| | `treeDepth` does not match the deployment | Use the preset's value, or the depth of the deployed contract. See [Networks](https://docs.lelantos.xyz/guide/networks.md#custom-network). | | The pool's verifier is for a different circuit shape | Use a pool deployed for `TRANSACT_4X6`. See [Circuit shape](https://docs.lelantos.xyz/guide/browser.md#circuit-shape). | | `relayerAddress` is not the relayer's published submitter | Use the address from the relayer's `/chains` (its `Bundler`). | ### `RELAYER_REJECTED` with a fee reason `stale-estimate` is retryable as-is. `fee-too-low` and `fee-missing` usually mean a custom `Submitter` without `estimate`. `fee-asset-rejected` means the relayer does not take `feeAsset`. See [Relayer rejections](https://docs.lelantos.xyz/guide/errors.md#relayer-rejections). ### `FEE_ASSET_NOT_QUOTED` The relayer does not accept the asset named as `feeAsset`. `err.accepted` lists the ones it does; `quoteFee(kind).options` lists them with amounts. ### `FEE_ABOVE_LIMIT` The relayer quoted more than `maxFee` or `acceptRelayerFee` allows; nothing was proven or sent. Retry later, raise the limit, or pay in another asset. See [Limiting the relayer fee](https://docs.lelantos.xyz/guide/fees.md#limiting-the-relayer-fee). ### Cross-asset fee raises `INSUFFICIENT_COVER` with `reason: "fee-slot"` The spend uses every input slot, leaving none for the fee note. Size it with `spendableMax(asset, { kind, feeAsset })`, consolidate the asset being moved, or pay the fee in it. See [Paying the fee in a different asset](https://docs.lelantos.xyz/guide/fees.md#paying-the-fee-in-a-different-asset). ### `QUOTE_STALE` from `swap` A yield index or the relayer's flush fee moved since `quoteSwap`. Re-quote and run the new quote. See [Swap](https://docs.lelantos.xyz/guide/swap.md#executing). ## Deposits ### `NO_EVM_ACCOUNT` on deposit The wallet's chain layer cannot sign (`readOnly`, `reader`, or a passkey-only setup). Connect with `privateKey`, `signer`, or `provider` + `address`. Spends still work without one. ### `UNSUPPORTED_OPERATION` on deposit The chain adapter lacks the method a deposit path requires (`err.missing` names it): `submitDeposit`, `submitDepositNative` with `nativeAdapterAddress`, or the Permit2 allowance methods. See [Implementing an adapter](https://docs.lelantos.xyz/guide/chain-adapter.md#implementing-an-adapter). ### Yield-bearing asset deposit raises `INVALID_ARGUMENT` The asset has no `rate`. A custom chain adapter must return `rate` for yield-bearing assets. See [`AssetEntry`](https://docs.lelantos.xyz/guide/chain-adapter.md#assetentry). ### `awaitDeposit` resolves `"timeout"` The relayer has not flushed the deposit yet, or the indexer is behind. The deposit itself is mined; wait longer, or cancel once cancellable. ### `cancelDeposit` raises `INVALID_ARGUMENT` on a stored escrow `err.argument` is `"cancelInputs"`: a field of the stored escrow is missing or lost its type, such as a `bigint` restored as a string. Cancel with `{ depositId }` instead, which rereads the inputs from the pool's log. See [Cancelling a deposit](https://docs.lelantos.xyz/guide/deposit.md#cancelling-a-deposit). ## Proving and performance ### `PROVER_ARTIFACTS_MISSING` at the first spend On Node, install `@lelantos-org/circuits` or set `LELANTOS_PROVER_ARTIFACTS_DIR`. In a browser, pass `prover: { artifacts }` or `prover: { cdn }`. The error's `tried` field lists the locations checked. Call `warmProver()` early to surface this before a user action. See [Node usage](https://docs.lelantos.xyz/guide/node.md#prover-artifacts) and [Browser usage](https://docs.lelantos.xyz/guide/browser.md#prover-artifacts). ### `PROVER_UNAVAILABLE` The wallet was connected with `prover: "none"`, or the optional peer the backend needs (`snarkjs`, `circom_runtime`) is not installed. ### Proving is much slower than the benchmarks Check the thread count in the `effective` field of `lelantos:prover:wasm` logs. A value of 1 in a browser means the page is not cross-origin isolated. See [Content Security Policy and isolation](https://docs.lelantos.xyz/guide/browser.md#content-security-policy-and-isolation). ### Sync is much slower in the browser than on Node The bundler rewrote the SDK's wasm glue and the SDK fell back to JS. Exclude the package from pre-bundling. See [Bundler configuration](https://docs.lelantos.xyz/guide/browser.md#bundler-configuration). ### The UI freezes during a transaction Proving and trial decryption are running on the main thread. Pass `prover.worker` and `scanner.workers` factories. See [Keeping the main thread free](https://docs.lelantos.xyz/guide/browser.md#keeping-the-main-thread-free). ### The worker fails to load in production builds The `new Worker(new URL(..., import.meta.url))` expression is not written directly at the call site, so the bundler did not emit a worker chunk. See the warning in [Keeping the main thread free](https://docs.lelantos.xyz/guide/browser.md#keeping-the-main-thread-free). ### Memory grows after switching accounts Previous wallets were not disposed. Call `dispose()` before replacing a wallet. See [Disposing a wallet](https://docs.lelantos.xyz/guide/wallet.md#disposing-a-wallet). ## Next - [Errors](https://docs.lelantos.xyz/guide/errors.md) - [Logging](https://docs.lelantos.xyz/guide/logging.md) # Benchmarks Proof generation is the most expensive client-side operation. These measurements use the `bench` harness, which runs the same `WorkerProver` and prover worker entry point (`@lelantos-org/sdk/workers/prover`) as a wallet. | Parameter | Value | |---|---| | Date | 2026-10-07 | | Circuit | `TRANSACT_4X6`, Merkle depth 11, ~33 MB zkey | | Network | HTTPS over LAN | | Method | one warm-up run, then median of 5 timed runs from `lelantos:prover:wasm` logs | ## WASM prover (browser) | Device | Threads | Total | Witness | Groth16 | Prepare | |---|---|---|---|---|---| | macOS · Chrome 154 | 16 | **233 ms** | 76 ms | 156 ms | 292 ms · cold | | iPhone · Safari 18.5 | 4 | 1021 ms | 75 ms | 946 ms | 3039 ms · cold | | iPhone · Safari 18.5 | 4 | 1008 ms | 78 ms | 931 ms | 173 ms · warm | - **Threads** is the size of the thread pool actually created. - **Prepare** is artifact download and parsing, measured outside the proof timing. *Warm* means the zkey was already in the Cache API; *cold* means it was downloaded. - The Mac has 16 cores and 32 GB of memory; the iPhone has 4 cores. Observations: - Witness generation is single-threaded and takes 75–78 ms on both devices. - Groth16 is 67% of total time at 16 threads and 93% at 4 threads. - A page without cross-origin isolation gets no thread pool, and the SDK uses snarkjs instead. That configuration is not measured here. - On the iPhone, cold artifact preparation (3.0 s) exceeds proving time; with the artifacts in the Cache API it takes 173 ms. It occurs once per session; `wallet.warmProver()` (or `prover: { warmup: "eager" }`) and the Cache API move it out of the first transaction. Over the public internet, download time depends on bandwidth. ## Native prover (relayer) The relayer proves natively on a larger circuit. Only the cost breakdown has been measured: - Multi-scalar multiplications: **86%** of proving time. The six 2^17 FFTs: **14%**. - `taceo-groth16` is ~**1.7×** faster than `ark-groth16`, both at 113k constraints on 16 threads and on the 4x6 zkey. These figures are not comparable with the WASM table: the circuit and arithmetic backend differ. ## Measuring in your application Enable `debug` logging for the prover namespaces to record the same phases: ```ts import { configureLogging, consoleSink } from "@lelantos-org/sdk"; configureLogging({ level: "debug", sink: consoleSink(), namespaces: ["lelantos:prover:*"], }); ``` The `effective` field on `lelantos:prover:wasm` records the thread count. If timings are far above the table, check it first: a failed thread-pool initialization is the usual cause. ## Next - [Browser usage](https://docs.lelantos.xyz/guide/browser.md) - [Logging](https://docs.lelantos.xyz/guide/logging.md) # Architecture The SDK is organized in tiers. A module may import only from lower tiers. `scripts/check-layers.mjs` enforces this in CI. | Tier | Modules | Role | |---|---|---| | 0 | `core`, `errors`, `log`, `runtime` | primitives, the error taxonomy, logging, worker RPC and wasm loading; no protocol knowledge | | 1 | `crypto` | field arithmetic, Poseidon, Jubjub | | 2 | `fmd`, `keys`, `notes` | key derivation, detection, note encryption | | 3 | `protocol`, `circuit` | wire formats, fee and denomination policy, circuit shapes and witness | | 4 | `permit2`, `chain`, `prover`, `services` | the chain port and viem adapter, provers, HTTP clients | | 5 | `bundle`, `sync` | transaction assembly, note synchronization, tree and nullifier mirrors | | 6 | `wallet` | `connect`, the `WalletApi` object, operations | | 7 | `x402`, `entry` | x402 payments, and the published subpath barrels | ## Enforced rules 1. **Imports go downward only.** A module imports from its own tier or below, never above, so the dependency graph has no cycles. 2. **No `export *`.** Every re-export is named. A symbol is public if and only if an `entry/*` barrel forwards it by name, and no name is published from two subpaths. 3. **Errors are leaves.** `errors/` imports only `core/`, so every tier can throw typed errors. 4. **Operations stay independent.** An operation in `wallet/ops/` never imports another, and the watch-only wallet never reaches the spend path. ## Implications for consumers - **The root entry loads lazily.** `connect` and the wallet object are in the root, but the spend path, the prover, and the deposit family load on first use, so a balance-only page does not bundle them. `@lelantos-org/sdk/watch` reaches neither the prover nor the submitter at all. Bundle budgets for each entry are enforced in CI. - **Branded types are defined in tier 0.** `AssetId`, `CircuitAmount`, and `Hex32` are declared in `core`, so every tier can use them. See [Amounts and assets](https://docs.lelantos.xyz/guide/amounts.md#branded-types). - **Subpaths are homes, not layers.** Each exported name has one subpath; see [Package subpaths](https://docs.lelantos.xyz/guide/subpaths.md). ## Next - [Pluggable interfaces](https://docs.lelantos.xyz/guide/interfaces.md) - [API reference](https://docs.lelantos.xyz/reference/index.md) # Building transactions manually The [wallet API](https://docs.lelantos.xyz/guide/wallet.md) covers every operation on this page. Use these primitives, from `@lelantos-org/sdk/protocol`, `@lelantos-org/sdk/primitives`, and `@lelantos-org/sdk/services`, only to build a transaction shape the wallet does not provide. ## Relayer fee output A relayer fee is a shielded output note addressed to the relayer, included in the transaction it pays for. The relayer's `GET /chains` response indicates whether it charges. **If `shieldedFee` is present**, every spend and swap on that chain must include a fee output; submissions without one are rejected with `402`. ```ts // setup declare const asset: bigint; // example import { cryptoContext } from "@lelantos-org/sdk/primitives"; import { feeOutputFromEstimate } from "@lelantos-org/sdk/protocol"; import { RelayerClient } from "@lelantos-org/sdk/services"; const { J } = await cryptoContext(); const relayer = new RelayerClient("https://relayer.lelantos.xyz"); const estimate = await relayer.estimateSpend(8453n, "transfer"); // null when this relayer charges nothing; throws FEE_ASSET_NOT_QUOTED when it // charges but cannot take `asset` — that spend cannot be relayed at all. const fee = feeOutputFromEstimate({ J, estimate, asset }); ``` ### Deposit fee note A deposit has no proof and no output slots. Its fee is a second leaf next to the depositor's note, built by `buildDeposit` from its `fee` argument. The pool and the flush circuit enforce these rules: | Rule | Detail | |---|---| | Asset | `fee.asset`, defaulting to the deposit asset. The note and its commitment use that asset, and the request sets `feeAssetId` to it. A different asset must be non-yield-bearing and accepted by the relayer. | | Zero-value fee | a zero-value note naming asset 0, with `feeAssetId = 0`. The circuit hashes `feeAssetId` into the leaf, so the note must carry the same id; the pool reverts with `FeeAssetMustBeZero` for any other id on a zero-value leaf. | | Permit | determined by `isSameFeeAsset(feeIn, feeAssetId, publicAssetId)`. If true, sign a single-token `PermitWitnessTransferFrom` with `maxFee = 0`. If false, pass `feeToken` and `maxFee` to `signPermit2Witness`, which signs a `PermitBatchWitnessTransferFrom` over `[deposit token, fee token]` in that order. | `isSameFeeAsset` compares asset ids, not token addresses: a plain asset and a yield asset can share one ERC-20 and still require two transfers. `depositTotals` computes the two amounts, `{ principal, relayer }`. See [Computing pulls without a wallet](https://docs.lelantos.xyz/guide/fees.md#computing-pulls-without-a-wallet). ## Output slot order `buildSpend` takes one `outputs` array with an entry per slot: `{ asset, value, recipient, memo? }`, where `recipient` is a decoded shielded address and `memo` is the 128-byte field `encodeMemo` from `@lelantos-org/sdk/primitives` builds from a text. The fee output's index must be random: a fee note always in the last slot would identify relayed transactions. Build the list, then shuffle it once: ```ts // setup import type { FeeOutput, OutputRecipient } from "@lelantos-org/sdk/protocol"; declare const fee: FeeOutput | null; declare const asset: bigint; declare const sendValue: bigint; declare const changeValues: bigint[]; declare const payee: OutputRecipient; declare const own: OutputRecipient; // example import { shuffled } from "@lelantos-org/sdk/primitives"; import type { OutputSpec } from "@lelantos-org/sdk/protocol"; // A fee output is an OutputSpec like any other, so one shuffle places it. const outputs: OutputSpec[] = shuffled([ { asset, value: sendValue, recipient: payee }, ...changeValues.map((value) => ({ asset, value, recipient: own })), ...(fee ? [fee] : []), ]); ``` Shuffle before calling `buildSpend`, and do not reorder afterwards: each output's `rho` is derived from its final index. The wallet does the same internally, and also records the positions of its own outputs from the same permutation, which is how results report `ownCommitments`. ## Output randomness `buildSpend` and `buildDeposit` draw no randomness for an output. Both take `outgoingKey`, from `deriveOutgoingKey(nsk)` in `@lelantos-org/sdk/primitives`, and derive every output's commitment blinder, ECDH ephemeral, and clue blinder from it, the output's own fields, the recipient's address, its memo, and, in a spend, the spend's nullifiers. The sender can therefore recompute them later, which is what a [payment proof](https://docs.lelantos.xyz/guide/transfer.md#proving-a-payment) does. | Builder | `rho` of each output | |---|---| | `buildSpend` | `Poseidon(TAG_RHO, nullifiers[0], index)`; nothing to supply | | `buildDeposit` | `deriveDepositRho(outgoingKey, rhoNonce)`; supply one `rhoNonce` per leaf, 32 random bytes, fresh per deposit and distinct between the two leaves | > **Danger: The outgoing key is a viewing key for what was sent** > > With a payee's address, its holder can open every note the account sent to that payee. It grants no spend authority and reveals nothing about incoming notes. `sealOutput`, in `@lelantos-org/sdk/protocol`, seals a single output the same way, for code that assembles a bundle itself. ## Next - [Fees](https://docs.lelantos.xyz/guide/fees.md) - [Errors](https://docs.lelantos.xyz/guide/errors.md) - [API reference](https://docs.lelantos.xyz/reference/index.md)