Skip to content

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:

GroupOptionsRequired
Networknetwork, rpcUrlnetwork always; rpcUrl unless the preset carries one or the chain layer is pre-built
Key sourcemnemonic (+ account, passphrase), signature, nskunless the chain layer derives the key
Chain layerprivateKey, signer, provider + address, readOnly, reader, chainexactly one
Extrasprover, scanner, http, storage, shape, denominations, acceptRelayerFee, syncStrategy, wasm, runtimeno

The wallet's viewing keys and bech32m addresses are derived from the shielded spending key (nsk).

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).
  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 layerSigns depositsDerives nsk without a key source
privateKey — raw hex key, for Node scriptsyesyes, by domain-separated reduction
signer — an EthSigneryesyes, from one EIP-712 signature
provider + address — an EIP-1193 provideryesyes, from one EIP-712 signature
readOnly: true — reads through rpcUrlnono
reader — a pre-built ChainReadernono
chain — a pre-built ChainAdapteryesno

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
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
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
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
});
PropertyDetail
SaltLELANTOS_PRF_SALT from @lelantos-org/sdk/primitives, a fixed 32-byte value; pass it as prf.eval.first
Outputexactly 32 bytes; other lengths raise INVALID_ARGUMENT
Determinismthe same credential always yields the same nsk
Standalone derivationprfOutputToNsk(prf) from @lelantos-org/sdk/primitives reduces a PRF output without running the ceremony

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
// Two chain layers: `privateKey` and `readOnly`.
await 
connect
({
network
: "base",
rpcUrl
,
mnemonic
,
privateKey
, readOnly: true });
Type 'boolean' is not assignable to type '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.

Flagtrue whenOtherwise
provea prover is configured (prover is not "none")spends reject PROVER_UNAVAILABLE
depositthe chain layer signs as an EOAdeposits reject NO_EVM_ACCOUNT
depositAllowancethe adapter supports Permit2 batch allowancessetupDepositAllowance rejects UNSUPPORTED_OPERATION
nativeDepositthe adapter can deposit through NativeAdapterdeposit({ native: true }) rejects UNSUPPORTED_OPERATION
nativeWithdrawa NativeAdapter address is knownwithdraw({ native: true }) rejects UNSUPPORTED_OPERATION
swapprove, a submitter that relays swaps, and a quoter URLquoteSwap and swap reject UNSUPPORTED_OPERATION
registerNameprove, a submitter that relays generic executions, a nameRegistrarAddress, and a chain layer that reads the registrarregisterName rejects UNSUPPORTED_OPERATION

Prover ​

prover accepts a ProverConfig, a pre-built Prover, or "none".

ts
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 fieldDefaultDescription
artifacts@lelantos-org/circuits on Node; none in a browser{ circuit, zkey } URLs or paths
cdn—base URL serving <shape>.wasm and <shape>_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
threadsruntime concurrencyprover 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
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.

Storage ​

storage holds the three persistence backends. Each defaults to memory, which re-downloads and re-scans on every start.

ts
import { 
connect
} from "@lelantos-org/sdk";
const
wallet
= await
connect
({
network
: "base",
rpcUrl
,
privateKey
,
storage
: {
notes
,
tree
,
nullifiers
},
});

See Custom storage and 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
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" } },
}, );
FieldRequired
chainId, treeDepth, relayerAddress, chainalways
fmdUrlunless noteSource is set
relayerUrlunless submitter is set

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 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
import { 
connect
} from "@lelantos-org/sdk";
{ await using
wallet
= await
connect
({
network
: "base",
rpcUrl
,
privateKey
});
await
wallet
.
sync
();
} // disposed here

Next ​