Skip to content

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
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
;
OptionDefaultDescription
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
feeAssetassetasset for the relayer fee note — see A deposit's relayer fee
recipientthis walletshielded owner of the new note
nativefalsesend the native coin through NativeAdapter; asset must be the wrapped coin
deadlinenow + 1 hPermit2 signature and escrow deadline, unix seconds
signal, onPhase, opId—cancellation, progress, and a correlation id — see Errors

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
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
FieldMeaning
amountthe new note's value
principalamount 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
separateFeethe relayer's note is pulled on its own, in another token
strategythe path deposit would take now
quotedAtunix 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:

StrategyWhenPrompts
nativenative: trueone transaction
allowanceevery pull is covered by a Permit2 allowance with time to spareone transaction
witnessotherwiseone 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
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
);
}, }); }
OptionDefault
cap2^160 − 1 base units, which Permit2 treats as unlimited
expirationnow + 90 days
deadlinesignature deadline, now + 30 minutes

The deposit lifecycle ​

StateMeaningObserved with
confirmedthe transaction is mined; funds are in escrowdeposit() resolves
flushedthe relayer has added the note to the Merkle treeawaitDeposit(result.escrow)
syncedthe wallet has the note and the tree containing itsync()

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
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
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 ​

FieldMeaning
amountthe new note's value (Money)
fees.protocol, fees.relayerMoney in the deposited asset and the fee asset, or null when not charged
pulledwhat the pool pulled per asset, exact base units: the deposited asset first, then the fee asset when pulled separately
strategy, nativethe path taken
recipientowner of the new note
escrow{ depositId, native, asset, commitment, cancelInputs, cancellableAtBlock }
txHash, opIdthe deposit transaction and this call's correlation id
ownCommitmentsthe 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
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.

FailureCode
already flushed or cancelledINVALID_ARGUMENT, argument: "depositId", before any transaction
cancelInputs incompleteINVALID_ARGUMENT, argument: "cancelInputs", before any request
no signing accountNO_EVM_ACCOUNT
cancelled before cancellableAtBlock, or with altered cancelInputsRPC_FAILED with retryable: false (the pool reverted the call), or TX_REVERTED if it was mined

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 ​