Skip to content

Pluggable interfaces

Nine injection points on WalletConfig. Only chain is required; every other has a working default, so replace one when you need behaviour the default cannot give you — a different indexer, a test double, an off-main-thread scanner.

InterfaceDefaultReplace it for
ChainAdapter— (required)ethers / web3.js / hardware-wallet signing
NoteSourceFmdNoteSource (over FmdClient)alt indexer, P2P feed, unit-test mock
NoteStoreInMemoryNoteStorefile, IndexedDB, encrypted KV
TreeStorebuilt from the commitment chunk feedpre-seeded tree, shared cache
NullifierStorebuilt from the nullifier chunk feedpre-seeded spent set, shared cache
SubmitterHttpRelayerSubmittermulti-relayer race, direct on-chain submit, test mock
ProverWasmProver (snarkjs fallback; useWasmProver: false opts out)Web Worker prover, mock
CoinSelectorSfrtCoinSelectorlargest-first, Penumbra planner, deterministic test stub
ScannerLocalScannerWorkerPoolScanner for off-main-thread trial decryption

TreeStore and NullifierStore are usually configured through treePersistence / nullifierPersistence rather than replaced outright — see Syncing.

WalletApi is itself an interface, so upstream tests can mock the whole wallet rather than its parts.

Where to inject

connect() accepts every pluggable directly, alongside the network preset and key source. Reach for Wallet.create() only when you want no defaults resolved at all.

ts
import { 
connect
} from "@lelantos-org/sdk";
const
wallet
= await
connect
({
privateKey
: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
network
: "anvil",
rpcUrl
: "http://localhost:8545",
noteStore
:
myStore
,
submitter
:
mySubmitter
,
selector
:
mySelector
,
});

Custom coin selector

ts
import { 
circuitAmount
} from "@lelantos-org/sdk";
import type {
AssetId
,
CircuitAmount
, StoredNote } from "@lelantos-org/sdk";
import type { CoinSelector,
SelectionResult
, SelectOpts } from "@lelantos-org/sdk/wallet";
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, 2);
const
sum
=
picked
.
reduce
((
acc
,
n
) =>
acc
+
BigInt
(
n
.
value
), 0n);
if (
sum
>=
target
) {
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
:
target
,
}; } }

Largest-first is shown as an example, not a recommendation

Selecting by descending value leaves a balance-ordering fingerprint that has been used to link spends (Tramèr et al., USENIX '24). The default SfrtCoinSelector randomizes the tiebreak specifically to remove it. Replace the selector for testing or for a different privacy analysis — not for convenience.

Branded values

Implementing an SDK interface means producing its branded types. The constructors — circuitAmount, assetId, hex32 — validate as they brand, so an invalid value fails at the boundary rather than deep inside the circuit.

Custom note source

A NoteSource answers one question: give me a page of encrypted notes after this cursor. Merkle paths and the spent set are deliberately not part of it — both would name a specific note to the server.

ts
import type { ListNotesOpts, NotePage, NoteSource } from "@lelantos-org/sdk";
import type { ScanInput } from "@lelantos-org/sdk/sync";

/** Replays a fixed set of notes. Enough to drive a wallet in a unit test. */
class 
StaticNoteSource
implements NoteSource {
constructor(private readonly
rows
: ScanInput[]) {}
async
listNotes
(
opts
?: ListNotesOpts):
Promise
<NotePage> {
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
};
} }

nextAfter and resumeAfter are not always the same

nextAfter drives the loop within one sync and always advances past everything just returned. resumeAfter is the highest cursor safe to persist, and on a feed still backfilling history it lags behind. Persisting nextAfter there steps over rows the backfill has not inserted yet, losing them permanently. On a strictly append-only feed the two are equal.

Custom submitter

Submitter has one required method and three optional ones. The optional three are optional so that a submitter written before a feature existed keeps working.

ts
import type { Submitter } from "@lelantos-org/sdk";
import { 
HttpRelayerSubmitter
} from "@lelantos-org/sdk";
import type { RelayerSubmitResponse, SubmitTransactPayload } from "@lelantos-org/sdk/protocol"; // `EstimateKind` is not exported on its own; derive it from the interface. type
EstimateKind
=
Parameters
<
NonNullable
<Submitter["estimate"]>>[1];
/** Races two relayers and takes whichever answers first. */ class
RacingSubmitter
implements Submitter {
constructor(private readonly
peers
:
HttpRelayerSubmitter
[]) {}
submit
(
payload
: SubmitTransactPayload):
Promise
<RelayerSubmitResponse> {
return
Promise
.
any
(this.
peers
.
map
((
p
) =>
p
.
submit
(
payload
)));
} // Delegate fee quoting and the asset registry to the first peer. Omitting // `estimate` would make the wallet build no fee slot at all — correct only // for a relayer that subsidises gas, and a 402 at submit time otherwise.
estimate
(
chainId
: bigint,
kind
:
EstimateKind
) {
return this.
peers
[0]!.
estimate
(
chainId
,
kind
);
}
assets
(
chainId
: bigint) {
return this.
peers
[0]!.
assets
(
chainId
);
} }

Mocking the whole wallet

For code that consumes a wallet rather than configures one, WalletApi is the seam. Depend on the interface and a test needs no chain, relayer, or prover at all.

ts
declare function 
makeStubWallet
(): WalletApi;
async function
totalBalance
(
wallet
: WalletApi,
assets
: bigint[]):
Promise
<bigint> {
return
assets
.
reduce
((
sum
,
id
) =>
sum
+
wallet
.
balance
(
id
), 0n);
} await
totalBalance
(
makeStubWallet
(), [1n, 2n]);

Next