Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,18 @@ const assets = await client.getBalancesForBridge();
// {
// symbol: 'USDC',
// name: 'USDC',
// balance: '1250.50', // Total across all chains
// balance: '1250.50', // Deprecated alias for usableBalance
// totalBalance: '1250.50', // Total before native-token reservations
// usableBalance: '1250.50', // Available after reservations
// value: '1250.50', // USD value (string)
// decimals: 6,
// logo: 'https://...',
// currencyId: 1,
// chainBalances: [ // Per-chain balances
// {
// balance: '500.00',
// balance: '500.00', // Deprecated alias for usableBalance
// totalBalance: '500.00',
// usableBalance: '500.00',
// value: '500.00',
// symbol: 'USDC',
// chain: { id: 1, name: 'Ethereum', logo: '...' },
Expand Down Expand Up @@ -330,15 +334,21 @@ type TokenBalance = {
name: string; // Display label (e.g. "USDC/USDM")
symbol: string; // Majority symbol by chain count
logo: string; // Token logo URL
balance: string; // Total balance (human-readable)
/** @deprecated Use usableBalance instead. */
balance: string; // Compatibility alias for usableBalance
totalBalance: string; // Before native-token reservations
usableBalance: string; // Available after reservations
value: string; // USD value (string for precision)
decimals: number;
currencyId?: number; // Required on BridgeTokenBalance
chainBalances: ChainBalance[];
};

type ChainBalance = {
balance: string;
/** @deprecated Use usableBalance instead. */
balance: string; // Compatibility alias for usableBalance
totalBalance: string; // Before native-token reservations
usableBalance: string; // Available after reservations
value: string; // USD value (string)
symbol: string;
chain: { id: number; name: string; logo: string };
Expand All @@ -348,6 +358,10 @@ type ChainBalance = {
};
```

For ERC-20 balances and bridge balances, `totalBalance` and `usableBalance` currently match. Swap
native-token balances can differ because `totalBalance` preserves the middleware balance while
`usableBalance` excludes the gas reserve used by source selection.

---

### Bridge Operations
Expand Down
14 changes: 8 additions & 6 deletions src/bridge/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,15 @@ createBridgeIntent(provider='mayan') → createMayanBridgeIntent: # try/catch
# Step 1: source inventory — keep only sources where source chain AND token are mayanEnabled
# depositFee looked up with 'depositMayan' → match.depositMayanFeeToken (not depositFeeToken)
# Step 2: per-leg floor = $1.10 USD (×2 for native ETH → Ethereum mainnet)
# keep sources with usableUsd ≥ $1.10 ∧ usable ≥ minimumAmount; sort by usableUsd DESC
# keep sources with usableUsd ≥ $1.10 ∧ usable ≥ minimumAmount;
# sort Ethereum last, then usableUsd DESC within each group
# Step 3: gas drop — capped per chain (ETH .05, BSC .02, Polygon .2, Avax .1, Arb .01);
# native destination + gas drop → throw; modelled INSIDE the Mayan route, not an RFF dest

# Steps 4-7: quote once, then trim ONE leg — Mayan quotes are EXACT-IN, RFF is EXACT-OUT.
# Step 4: ONE batched getMayanQuotes with every eligible leg at its FULL usable amount,
# gas drop on the largest leg (index 0 after the usableUsd-desc sort). maxOut[i] = minReceived.
# Step 5: commit the largest legs in full until Σ maxOut ≥ amount; if even all legs maxed are short
# gas drop on the first priority-ordered leg. maxOut[i] = minReceived.
# Step 5: commit priority-ordered legs in full until Σ maxOut ≥ amount; if even all legs maxed are short
# → throw Insufficient balance (detected in ONE round, not three).
# Step 6: the last committed leg is the SWING. Keep the others at full; trim the swing to the
# residual needFromSwing = amount − Σ(other committed maxOut):
Expand Down Expand Up @@ -374,13 +375,14 @@ the quote source list and use an internal fee of `0`. The public `total` is `caG
- **Mayan per‑leg floor `$1.10`** (×2 for native ETH → mainnet); sources below it are dropped before
selection.
- **Mayan convergence is quote‑once + swing‑leg.** One batched `getMayanQuotes` prices every eligible
leg at full usable; the largest legs are committed in full and only the **last (swing) leg** is
trimmed (≤ `MAYAN_SWING_MAX_QUOTES` re‑quotes) to the residual output. Convergence is guaranteed
leg at full usable; legs are ordered with Ethereum last, then usable USD descending within each
group. They are committed in that order and only the **last (swing) leg** is trimmed
(≤ `MAYAN_SWING_MAX_QUOTES` re‑quotes) to the residual output. Convergence is guaranteed
(the swing at full usable already covers the residual), insufficiency is detected in **one** round
(`Σ all‑max < amount`), and any overshoot is confined to the swing leg and bounded by one per‑leg
minimum (accepted by design). Replaces the old ≤3‑round proportional‑rescale loop that could miss
the target and fall back to Nexus.
- **Mayan gas drop** is chain‑capped and rides the **largest** leg only; it lives in the route
- **Mayan gas drop** is chain‑capped and rides the **first priority-ordered** leg only; it lives in the route
payload, never as an RFF destination. Native destination + gas drop is rejected.
- **Exact‑out source selection** (Nexus) is greedy over `usable = balance − depositFee`, Ethereum
ordered last; a leftover `remainingPayable` throws `Insufficient balance`.
Expand Down
14 changes: 9 additions & 5 deletions src/bridge/intent/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,13 +419,18 @@ const createMayanBridgeIntent = async (
.filter((source) => {
return source.usableUsd.gte(minimumPerLegUsd) && source.usable.gte(source.minimumAmount);
})
.sort((a, b) => Decimal.sub(b.usableUsd, a.usableUsd).toNumber());
.sort((a, b) => {
const aIsEth = a.chain.id === 1 ? 1 : 0;
const bIsEth = b.chain.id === 1 ? 1 : 0;
if (aIsEth !== bIsEth) return aIsEth - bIsEth;
return Decimal.sub(b.usableUsd, a.usableUsd).toNumber();
});
if (allowedSources.length === 0) {
throw Errors.invalidInput('intent must include at least one allowed source');
}

// Step 3: Mayan quotes are exact-in while the SDK bridge request is exact-out. We quote
// every eligible leg once at its full usable amount, commit the largest legs in full,
// every eligible leg once at its full usable amount, commit priority-ordered legs in full,
// and trim only the last (swing) leg to the residual output we still need. The swing leg
// may overshoot by up to one per-leg minimum, which is accepted.
let finalAmountOut = new Decimal(0);
Expand Down Expand Up @@ -487,8 +492,7 @@ const createMayanBridgeIntent = async (
};

// Step 4: quote every eligible leg once at its full usable amount. The destination gas
// drop rides the largest leg (index 0 after the usableUsd-desc sort) so a single quote
// pays for it.
// drop rides the first priority-ordered leg so a single quote pays for it.
const maxQuotes = await quoteMayanLegs(middlewareClient, {
legs: allowedSources.map((source, index) => ({
chainId: source.chain.id,
Expand All @@ -505,7 +509,7 @@ const createMayanBridgeIntent = async (
out: maxQuotes[index].minReceived,
}));

// Step 5: commit the largest legs in full until their summed output covers the
// Step 5: commit the priority-ordered legs in full until their summed output covers the
// requested amount.
const committedLegs: typeof maxLegs = [];
let cumulativeOut = new Decimal(0);
Expand Down
10 changes: 10 additions & 0 deletions src/domain/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,12 @@ export type UnifiedBalanceResponseData = {
};

export type ChainBalance = {
/** @deprecated Use `usableBalance` instead. */
balance: string;
/** Balance before native-token reservations are deducted. */
totalBalance: string;
/** Balance available for SDK operations after reservations are deducted. */
usableBalance: string;
value: string;
symbol: string;
chain: {
Expand All @@ -642,7 +647,12 @@ export type ChainBalance = {
};

export type TokenBalance = {
/** @deprecated Use `usableBalance` instead. */
balance: string;
/** Total balance across chains before native-token reservations are deducted. */
totalBalance: string;
/** Balance available across chains after reservations are deducted. */
usableBalance: string;
value: string;
chainBalances: ChainBalance[];
currencyId?: number;
Expand Down
52 changes: 43 additions & 9 deletions src/services/balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,21 @@ import type { FlatBalance } from '../swap/types';
import type { MiddlewareBridgeBalanceClient, MiddlewareSwapBalanceClient } from '../transport';
import { convertAddressByUniverse } from './addresses';
import { estimateRepresentativeDepositTxFee } from './deposit-fee-estimation';
import { createPublicClientWithFallback } from './evm';
import { divDecimals } from './math';
import { equalFold } from './strings';
import { estimateRepresentativeSwapNativeReserveFee } from './swap-native-reserve-fee';

const logger = getLogger();

const USD_VALUE_DECIMALS = 2;
const MONAD_CHAIN_ID = 143;
const MONAD_DELEGATED_EOA_RESERVE = new Decimal(10);
const EIP7702_DELEGATION_PREFIX = '0xef0100';

type TokenBalanceGroup = {
balance: Decimal;
totalBalance: Decimal;
value: Decimal;
currencyId?: number;
chainBalances: ChainBalance[];
Expand All @@ -55,6 +60,8 @@ const finalizeTokenBalance = (group: TokenBalanceGroup): TokenBalance => {

return {
balance: group.balance.toFixed(),
totalBalance: group.totalBalance.toFixed(),
usableBalance: group.balance.toFixed(),
value: toUsdValueString(group.value),
chainBalances: orderBy(
group.chainBalances,
Expand Down Expand Up @@ -85,13 +92,15 @@ const addChainBalanceToGroup = (
groups.get(groupKey) ??
({
balance: new Decimal(0),
totalBalance: new Decimal(0),
value: new Decimal(0),
currencyId: input.currencyId,
chainBalances: [] as ChainBalance[],
symbolMeta: new Map(),
} satisfies TokenBalanceGroup);

group.balance = Decimal.add(group.balance, chainBalance.balance);
group.totalBalance = Decimal.add(group.totalBalance, chainBalance.totalBalance);
group.value = Decimal.add(group.value, input.value);
group.chainBalances.push(chainBalance);

Expand Down Expand Up @@ -150,7 +159,7 @@ export const getBalancesForSwap = async (input: {
const adjusted =
input.deductNativeReserve === false
? swapSupported
: await deductSwapNativeReserveFees(input.chainList, swapSupported);
: await deductSwapNativeReserveFees(input.chainList, swapSupported, input.evmAddress);
return flatBalancesToAssets(input.chainList, adjusted);
};

Expand All @@ -159,7 +168,8 @@ export const getBalancesForSwap = async (input: {
// every-swap-supported-chain to 0-2 chains.
export const deductSwapNativeReserveFees = async (
chainList: ChainListType,
balances: FlatBalance[]
balances: FlatBalance[],
evmAddress: Hex
): Promise<FlatBalance[]> => {
const nativeChainIds = new Set(
balances
Expand All @@ -171,12 +181,31 @@ export const deductSwapNativeReserveFees = async (
}

const chainsNeedingFees = chainList.chains.filter((c) => nativeChainIds.has(c.id));
const feeByChain = new Map<number, Decimal>();
const reserveByChain = new Map<number, Decimal>();
await Promise.all(
chainsNeedingFees.map(async (chain) => {
try {
const fee = await estimateRepresentativeSwapNativeReserveFee({ chain });
feeByChain.set(chain.id, divDecimals(fee, chain.nativeCurrency.decimals));
const publicClient =
chain.id === MONAD_CHAIN_ID ? createPublicClientWithFallback(chain) : undefined;
const [fee, isDelegatedOnMonad] = await Promise.all([
estimateRepresentativeSwapNativeReserveFee({ chain, publicClient }),
publicClient
? publicClient
.getCode({ address: evmAddress })
.then((code) => code?.toLowerCase().startsWith(EIP7702_DELEGATION_PREFIX) === true)
.catch((error) => {
logger.error('swap.balance.monad_delegation_check.failed', error, {
chainId: chain.id,
});
return false;
})
: Promise.resolve(false),
]);
const feeReserve = divDecimals(fee, chain.nativeCurrency.decimals);
reserveByChain.set(
chain.id,
isDelegatedOnMonad ? feeReserve.plus(MONAD_DELEGATED_EOA_RESERVE) : feeReserve
);
} catch (error) {
logger.error('swap-balance-fee-estimate', error, { chainID: chain.id });
}
Expand All @@ -185,10 +214,10 @@ export const deductSwapNativeReserveFees = async (

return balances.map((b) => {
if (!equalFold(b.tokenAddress, EADDRESS)) return b;
const fee = feeByChain.get(b.chainID);
if (!fee) return b;
const reserve = reserveByChain.get(b.chainID);
if (!reserve) return b;
const amount = new Decimal(b.amount);
const remaining = amount.sub(fee);
const remaining = amount.sub(reserve);
// Actual amount removed from this native balance — the reserve fee, or the whole balance
// when the fee exceeds it.
const deducted = amount.sub(Decimal.max(remaining, 0));
Expand All @@ -197,12 +226,13 @@ export const deductSwapNativeReserveFees = async (
deductedNativeAmount: `${deducted.toString()} ${b.symbol}`,
});
if (remaining.lte(0)) {
return { ...b, amount: '0', value: 0 };
return { ...b, amount: '0', totalAmount: b.totalAmount ?? b.amount, value: 0 };
}
const ratio = amount.gt(0) ? remaining.div(amount) : new Decimal(0);
return {
...b,
amount: remaining.toFixed(b.decimals, Decimal.ROUND_FLOOR),
totalAmount: b.totalAmount ?? b.amount,
value: ratio.mul(b.value).toNumber(),
};
});
Expand Down Expand Up @@ -237,6 +267,8 @@ export const aggregateBalancesByCurrency = (
const value = new Decimal(currency.value);
const chainBalance: ChainBalance = {
balance: normalizedBalance.toFixed(),
totalBalance: normalizedBalance.toFixed(),
usableBalance: normalizedBalance.toFixed(),
value: toUsdValueString(value),
symbol: token.symbol,
chain: {
Expand Down Expand Up @@ -286,6 +318,8 @@ export const flatBalancesToAssets = (
const value = new Decimal(balance.value);
const chainBalance: ChainBalance = {
balance: balance.amount,
totalBalance: balance.totalAmount ?? balance.amount,
usableBalance: balance.amount,
value: toUsdValueString(value),
symbol: balance.symbol,
chain: {
Expand Down
6 changes: 5 additions & 1 deletion src/swap/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ export const buildSwapPreflight = async (
// router never sizes a swap against native it needs to execute. Applied here — the single swap
// source-sizing chokepoint — regardless of whether balances were preloaded (composite flow
// passes raw, keeping actual values for its own destination-gas shortfall) or freshly fetched.
const reserved = await deductSwapNativeReserveFees(options.chainList, rawBalances);
const reserved = await deductSwapNativeReserveFees(
options.chainList,
rawBalances,
options.eoaAddress
);
const balances = selectSwapSources(reserved, input.data.toChainId, input.data.toTokenAddress);

const candidateChainIds = getCandidateChainIds(input, balances);
Expand Down
5 changes: 5 additions & 0 deletions src/swap/swap.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ smaller of 0.5% or $0.25 for stable-only conversions, and the smaller of 2% or $
non-stable conversion is required. Provider selection happens only for route-relevant remote value;
direct destination routes do not request a bridge provider.

Before source selection, native balances reserve the representative execution fee. On Monad
(chain 143), a positive MON balance also starts an EIP-7702 delegation-code read in parallel with
that fee estimate; a delegated EOA reserves an additional 10 MON. The usable balance is clamped at
zero, while public balance results preserve the pre-reservation amount as `totalBalance`.

An Exact Out same-token bridge can include a positive native-gas requirement in the bridge intent.
Routing accounts for that gas in the selected source amount, omits a destination gas swap, and sets
the bridge receiver to the EOA.
Expand Down
1 change: 1 addition & 0 deletions src/swap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export type SwapData =

export type FlatBalance = {
amount: string; // human decimal string
totalAmount?: string; // human decimal string before native-token reservations
chainID: number;
decimals: number;
logo: string;
Expand Down
Loading