Skip to content

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
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<C> is the class for one code. New codes may be added in minor releases, so keep a default branch.

Common fields ​

FieldMeaning
codethe stable discriminator
retryablethe 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"
detailsoptional diagnostic scalars; never secrets
causethe 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.

CodeClassFieldsRaised when
WALLET_CONFIGWalletConfigErrormissing[]connect options are missing or invalid; every problem is listed at once
NETWORK_NOT_DEPLOYEDNetworkNotDeployedErrornetworka placeholder preset was passed from JavaScript
ENVIRONMENTEnvironmentErrora platform capability is missing: Web Crypto, workers, EventSource
INVALID_ARGUMENTInvalidArgumentErrorargumentan argument the SDK cannot act on: a bad amount, address, asset, option, or an altered quote
NO_EVM_ACCOUNTNoEvmAccountErroroperationa deposit, cancel, or allowance setup on a wallet without a signing chain layer
UNSUPPORTED_OPERATIONUnsupportedOperationErroroperation, missing[]the adapter, submitter, or network lacks what the operation needs; also any call on a disposed wallet

Funds ​

CodeClassFieldsRetryable
INSUFFICIENT_BALANCEInsufficientBalanceErrorasset, available, requiredno: top up or send less
NOTES_HELDNotesHeldErrorasset, 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_COVERInsufficientCoverErrorasset, target, consolidate[] (id, value), consolidateSum, consolidationAttempted, reason ("arity" | "fee-slot")no: consolidate, or pass autoConsolidate: true
FEE_ASSET_NOT_QUOTEDFeeAssetNotQuotedErrorasset, kind, accepted[]no: pick an accepted asset
FEE_ABOVE_LIMITFeeAboveLimitErrorasset, quoted, limit?, source ("maxFee" | "acceptRelayerFee"), kindyes: the quote follows gas; otherwise raise the limit

Submission ​

CodeClassFieldsRetryable
DEADLINE_PASSEDDeadlinePassedErrordeadlineno; nothing was sent
QUOTE_STALEQuoteStaleErrorfields[]yes: re-quote and run the new quote — see Swap
USER_REJECTEDUserRejectedErroraction ("derive-key" | "sign-permit" | "send-tx")no; nothing was signed or sent
RELAYER_REJECTEDRelayerRejectedErrorstatus, reason, body, reservedNoteIds, reservedUntil?per reason — see Relayer rejections
SPEND_OUTCOME_UNKNOWNSpendOutcomeUnknownErrorreservedNoteIds, reservedUntil, txHash?no — see below

Transport ​

CodeClassFieldsRetryable
RELAYER_TIMEOUT, FMD_TIMEOUT, QUOTER_TIMEOUTNetworkErrorurl, status?, body?, attempts[]yes
RELAYER_FAILED, FMD_FAILED, QUOTER_FAILEDNetworkErrorurl, status?, body?, attempts[]no response, 408, 429, and 5xx
WIRE_FORMATWireFormatErrorpathno: 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 ​

CodeClassFieldsRetryable
RPC_FAILEDChainRpcErrormethodyes, unless the contract reverted the call
TX_REVERTEDTxRevertedErrortxHash, reason?no
TX_MININGTxMiningErrortxHash?yes: the receipt did not arrive in time
TREE_OUT_OF_SYNCTreeOutOfSyncErrorlocalRoot, mirrorRootyes: the commitment feed disagrees with the pool even after a rebuild; nothing was spent

Prover and workers ​

CodeClassFieldsRetryable
PROVER_FAILEDProverErrorno
PROVER_UNAVAILABLEProverUnavailableErrorno: prover: "none", or the optional peer the backend needs is not installed
PROVER_ARTIFACTS_MISSINGProverArtifactsMissingErrortried[], shapeno — see Browser usage
PROVER_ARTIFACTS_FAILEDProverArtifactsFailedErrorsourceper HTTP status: not for 4xx, and never for an artifact that fails its pinned digest
WORKER_TIMEOUTWorkerRpcErrormethod?yes
WORKER_CRASHED, WORKER_FAILEDWorkerRpcErrormethod?no

Other ​

CodeClassFieldsRetryable
X402_PAYMENTX402PaymentErrorreason, resource?no
INTERNALInternalErrorcauseno: 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.

reasonRetryableMeaning
nullifier-spentnoa note this spend consumes is already spent; the wallet resyncs its spent set before throwing
nullifier-in-flightyesanother submission of the same note is pending at the relayer
idempotency-key-reusedyesthe request key collided; a retry draws a fresh one
stale-estimateyesthe fee estimate moved; a retry re-quotes
fee-missing, fee-too-low, fee-asset-rejectednothe fee note is absent, short, or in an asset the relayer does not take
contract-rejectednoa pool guard refused the payload in pre-flight; no gas was spent
revertednothe transaction reached the chain and reverted
bad-requestnothe payload is malformed
unknown-chainnothe relayer does not serve this chain
unavailable, internalyesthe relayer cannot serve right now
unknownnoa 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
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:

OptionBehaviour
opIda 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.
signalchecked 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
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():

SettingDefault
retries3, after the first attempt
backoff250 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 timeout5,000 ms per attempt
Retried: reads and estimatesno response, timeouts, 408, 429, 5xx
Retried: submissionsno response, timeouts, 429, 503 — under one Idempotency-Key
Never retrieda submission answered with 500 or 502, which may follow a broadcast
ts
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
) },
});

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 ​