Skip to content

Fees ​

An operation can incur three separate costs, each charged by a different party.

FeeCharged byPaid inPublic
Gasthe networknative ETHyes
Protocol feethe MASP contractthe moved asset: added to a deposit, deducted from a withdrawalyes
Relayer feethe relayerany asset the relayer accepts, as a shielded note (for a deposit, a second leaf)no

You pay gas only for transactions your own account broadcasts: deposits, cancellations, and allowance setup. For relayed transactions the relayer pays gas and recovers it through the relayer fee.

Every result and quote reports the last two as fees: { protocol, relayer }, each a Money or null when not charged.

Protocol fee ​

Rates are set per asset and per direction and are returned on AssetInfo. There is no pool-wide rate.

RateApplies toCalculation
depositBpsdeposit, and the re-shield leg of a swapadded to the principal
withdrawBpswithdraw, and the unshield leg of a swapdeducted from the gross amount
ts
import { 
parseAmount
} from "@lelantos-org/sdk";
import {
withdrawNetFor
} from "@lelantos-org/sdk/protocol";
const
usdc
= await
wallet
.
asset
("USDC");
usdc
.
depositBps
; // shield rate, in basis points
usdc
.
withdrawBps
; // unshield rate
// What a 1000 USDC gross withdrawal delivers, in ERC-20 base units. const {
net
,
fee
} =
withdrawNetFor
(
parseAmount
("1000",
usdc
),
usdc
);

withdrawNetFor takes the whole asset because it needs withdrawBps, scale, index, and yieldEnabled. A withdrawal names either side of this fee with gross or net; see Gross or net.

Deposits of yield-bearing assets ​

For a yield-bearing asset, the deposit total is converted to token units once using the pool's rate, not index.

  • An asset without rate cannot be deposited. The SDK raises INVALID_ARGUMENT. scale is not used as a fallback because it underestimates what the pool pulls.
  • The signed amount is a ceiling, not the quote. The pool's rate increases every block, so the SDK signs the exact pull plus a small headroom: quoteDeposit().pulls[].ceiling. Permit2 transfers only what the pool requests (pulls[].amount), and NativeAdapter refunds unused ETH. Plain assets sign the exact pull.

Re-quote before submitting if the user takes time to confirm.

Overriding rates ​

feeBps on createWallet's WalletConfig replaces the rates the pool reports for every asset. It is not a connect() option: against a live pool it produces wrong quotes as soon as the owner changes a rate. Use it only when the real rates are unavailable, such as on a fork, in tests, or before the registry is deployed.

Relayer fee ​

A relayer can charge for relaying. The fee is a shielded output note addressed to the relayer, created inside the transaction it pays for, so no public transfer links the payer to the transaction.

quoteFee(kind) returns the fee before the transaction is built, and which assets the wallet can pay it in:

ts
import { 
formatAmount
} from "@lelantos-org/sdk";
const
quote
= await
wallet
.
quoteFee
("transfer");
if (!
quote
.
charged
) {
console
.
log
("this relayer relays transfers for free");
} else { for (const
option
of
quote
.
options
) {
console
.
log
(
option
.
asset
.
symbol
??
option
.
asset
.
id
,
formatAmount
(
option
.
amount
,
option
.
asset
),
option
.
affordable
? "affordable" : "insufficient balance",
); } }
kindEstimate
"transfer"spend estimate
"withdraw"spend estimate; { native: true } prices the unwrap path
"swap"swap estimate; gas covers both legs and the trade
"deposit"the relayer's later flushBatch; balance and affordable are undefined, because a deposit's fee is funded from the public wallet
"registerName"a handle registration: the wrapper's gas plus the gas its calls are forwarded
FeeOption fieldMeaning
assetan accepted asset, resolved in the registry
amount, baseUnitsthe fee note's value in circuit units (exact) and base units
balancethis wallet's unspent shielded balance of the asset
affordablebalance >= amount

affordable does not guarantee the spend succeeds

affordable compares the fee with the unspent balance of that asset. The notes must also fit the circuit's input slots, alongside the notes being spent. Use spendableMax(asset, { kind, feeAsset }) for a figure that accounts for both.

Limiting the relayer fee ​

The fee is the relayer's own quote, and the wallet pays it as quoted. Nothing else bounds it, so set a limit wherever no person reviews the fee before it is paid: agents, scripts, and x402 payers, whose budget covers servers only.

maxFee limits one spend. It is stated in the fee asset, and a dearer quote rejects with FEE_ABOVE_LIMIT before anything is proven:

ts
await 
wallet
.
transfer
({
asset
: "USDC",
amount
: "25",
recipient
,
maxFee
: "0.10" });

acceptRelayerFee covers every quote the wallet pays, including deposits and the self-spends that autoConsolidate and redenominate run, which maxFee does not reach:

ts
import { 
connect
} from "@lelantos-org/sdk";
const
wallet
= await
connect
({
network
: "base",
rpcUrl
,
mnemonic
,
readOnly
: true,
// `asset` is the fee asset's id, `amount` its circuit units.
acceptRelayerFee
: ({
asset
,
amount
}) =>
asset
===
USDC_ID
&&
amount
<= 100_000n,
});

Either rejection is FEE_ABOVE_LIMIT, and retryable: a quote follows gas and may fall back under the limit. Its fields are listed under Errors.

Paying the fee in a different asset ​

By default the relayer fee is paid in the moved asset. Set feeAsset to pay in another:

ts
const { 
max
} = await
wallet
.
spendableMax
("WETH", {
kind
: "withdraw",
feeAsset
: "USDC" });
await
wallet
.
withdraw
({
asset
: "WETH",
gross
:
max
,
recipient
,
feeAsset
: "USDC" });

For spends:

  • The fee asset needs its own input note and change slot. The published 4×6 circuit has room for both.
  • The relayer must accept the asset. An asset it does not quote is rejected with FEE_ASSET_NOT_QUOTED before proving; err.accepted lists the ones it takes.
  • spendableMax with the same feeAsset reserves the fee note's input slot, so the maximum and the spend agree. A spend that uses every slot for the moved asset fails with INSUFFICIENT_COVER and reason: "fee-slot".

A deposit's relayer fee ​

A deposit also pays the relayer with a note, funded from the payer's public balance. feeAsset selects the asset and defaults to asset. The protocol fee is always charged in the deposited asset.

ts
// Shield USDC, pay the relayer in WETH.
const 
quote
= await
wallet
.
quoteDeposit
({
asset
: "USDC",
amount
: "100",
feeAsset
: "WETH" });
quote
.
separateFee
; // true: two token pulls
quote
.
pulls
; // [{ token: USDC, amount: principal + protocol fee }, { token: WETH, amount: relayer fee }]
const
result
= await
wallet
.
deposit
({
asset
: "USDC",
amount
: "100",
feeAsset
: "WETH" });
result
.
pulled
; // what was actually pulled, per asset

When feeAsset differs from asset, the deposit makes two token transfers: the principal and protocol fee in the deposited token, and the relayer fee in the fee token.

  • The signature strategy signs both amounts in one Permit2 batch permit.
  • The allowance strategy is used only when an active Permit2 allowance covers both tokens.
  • If the relayer charges nothing, the fee note has zero value and names asset 0, and the deposit makes one transfer regardless of feeAsset.

Cancelling such a deposit refunds each token separately: refunded in the deposited asset and feeRefunded in the fee asset. See Cancelling a deposit.

Unsupported fee assets ​

The SDK rejects these combinations with INVALID_ARGUMENT on feeAsset before signing:

CombinationReason
native: true with a different fee assetNativeAdapter transfers only the wrapped native token
a yield-bearing fee asset different from the deposited assetthe pool rejects it with FeeAssetUnsupported

A fee asset the relayer does not accept is refused with FEE_ASSET_NOT_QUOTED, because the relayer would not flush the deposit and it would stay in escrow until cancelled.

To filter a fee-asset picker without a quote per option, depositFeeAssetRefusal from @lelantos-org/sdk/protocol returns the reason without throwing:

ts
import { 
depositFeeAssetRefusal
} from "@lelantos-org/sdk/protocol";
const
usdc
= await
wallet
.
asset
("USDC");
const {
options
} = await
wallet
.
quoteFee
("deposit");
const
payable
=
options
.
filter
((
o
) =>
depositFeeAssetRefusal
(
usdc
,
o
.
asset
, false) ===
undefined
);

Computing pulls without a wallet ​

depositTotals and depositPulls from @lelantos-org/sdk/protocol are the arithmetic quoteDeposit runs:

ts
import { 
depositPulls
,
depositTotals
} from "@lelantos-org/sdk/protocol";
// 100 USDC principal (scale 1e4), relayer quoted 42 WETH circuit units (scale 1e10). const {
principal
,
relayer
} =
depositTotals
({
publicIn
: 10_000n,
feeIn
: 42n,
depositBps
: 20n,
scale
: 10_000n,
publicAssetId
: 1n, // USDC
feeAssetId
: 2n, // WETH; pass 1n when the fee is in the deposited asset
feeScale
: 10_000_000_000n, // read only when the note is pulled separately
}); const
usdc
= {
id
: 1n,
token
: "0x…a1",
yieldEnabled
: false };
const
weth
= {
id
: 2n,
token
: "0x…e7",
yieldEnabled
: false };
const {
separateFee
,
byToken
} =
depositPulls
({
deposited
:
usdc
,
feeAsset
:
weth
,
principal
,
relayer
});
// byToken: [{ asset: usdc, amount: principal }, { asset: weth, amount: relayer }]
depositPulls fieldContents
byAssetone entry per transfer the pool requests (the batch permit entries)
byTokenamounts summed per ERC-20 token; a plain asset and a yield asset can share one token, balance, and Permit2 allowance
separateFeethe fee asset when its note is pulled separately, else undefined

Pass publicAssetId and feeAssetId to depositTotals. Without them, the presence of feeScale alone means a separate fee transfer, and passing it for a fee in the deposited asset produces an incorrect split.

Stale fee quotes ​

A spend is priced against the relayer's estimate at submission time. A relayer that refuses the fee answers with RELAYER_REJECTED and a reason:

reasonRetryableMeaning
stale-estimateyesthe estimate moved; retrying re-quotes
fee-too-low, fee-missingnothe fee note does not satisfy this relayer
fee-asset-rejectednothe relayer does not take this fee asset

See Errors.

Next ​