Skip to content

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
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
;
OptionDescription
assetregistry id, token address, or symbol
amountthe recipient note's value: a decimal string, an SDK-returned amount, or { baseUnits }
recipientrecipient shielded address
memotext for the recipient, encrypted with the note — see Memo
feeAssetpay the relayer fee in another asset — see Fees
maxFeecap on the relayer fee, in the fee asset — see Fees
autoConsolidateon INSUFFICIENT_COVER, merge notes with a self-transfer and retry once. Default false
selectioncoin-selection rules (dustThreshold, cooldownBlocks, maxInputs, only, …) — see Note management
deadlineunix seconds; checked before submitting, else DEADLINE_PASSED
signal, onPhase, opIdcancellation, progress, and a correlation id — see Errors

"proving" is CPU-bound and takes seconds. Show it as a distinct state in a UI. See Benchmarks for timings. "confirmed" arrives when the relayer answers, which it does once the transaction is mined.

Reading the result ​

FieldMeaning
amountthe payee note's value (Money)
recipientthe payee
recipientCommitmentthe payee note's commitment
fees.relayerthe relayer fee note (Money in the fee asset), or null for a free relay
fees.protocolalways null: transfers pay no protocol fee
ownCommitmentsoutputs this wallet will recover: change, plus the payee note on a self-transfer
nonZeroCommitmentsoutputs with a non-zero value; the rest are padding
spentids of the consumed notes, across both assets when the fee was cross-asset
changechange left in asset, circuit units
txHash, operationthe relayer's transaction, and where this operation sits in it when bundled

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
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 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() 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
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 rejectsWhen
INVALID_ARGUMENTthe 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_OPERATIONthe chain layer lacks fetchNotePayload or txReceiptLogs — see Optional reads

Verifying a proof ​

The verifier needs no key, only the payee's address and an RPC:

ts
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 do not verify: the proof binds one address, and nothing in it links that address to the rest of the account.

reasonMeaning
malformednot a version-3 proof
wrong-chainmade for another chain than the reader's
not-publishedthe pool published no such commitment in that transaction, or the reader found no such transaction
wrong-ephemeralthe secret is not that output's
not-for-recipientthe output was not encrypted to this address
no-valuethe output carries value 0 or names asset 0; the payee's wallet discards such a note, so it credited nothing
commitment-mismatchit opens, but not to the note the pool committed to
wrong-cluethe published clue is not the one the output's secret gives for this address

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

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:

CodeCauseRemedy
INSUFFICIENT_BALANCEthe unspent notes do not add up to the amount plus a same-asset feesend less, or top up
NOTES_HELDthey would, but some are reserved by another spend, cooling down, or dustretryable: true means waiting frees enough
INSUFFICIENT_COVERthey add up, but no combination fits the input slotsconsolidate
FEE_ASSET_NOT_QUOTEDthe relayer does not accept feeAssetpick one of accepted
FEE_ABOVE_LIMITthe relayer's quote exceeds maxFee or acceptRelayerFeeretry, or raise the limit
ts
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:

ApproachBehaviour
autoConsolidate: truethe wallet merges the notes named by the selector, then retries. Costs one extra proof and transaction. Phases report "consolidating".
manual consolidationcatch the error and merge the notes yourself, to show progress in a UI. See 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.

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 ​