Skip to content

feat(sdk): generic balance check with native fee + rent coverage#372

Merged
chybisov merged 2 commits intomainfrom
feat/emb-334-generic-balance-check
Apr 15, 2026
Merged

feat(sdk): generic balance check with native fee + rent coverage#372
chybisov merged 2 commits intomainfrom
feat/emb-334-generic-balance-check

Conversation

@chybisov
Copy link
Copy Markdown
Member

Which Linear task is linked to this PR?

EMB-334

Why was it implemented this way?

The SDK's checkBalance only validated the source-token amount. It never verified the wallet had enough of the chain's native gas token to pay fees, and on Solana it didn't check ATA rent. Wallets with sufficient swap-token balance but insufficient native gas slipped past the SDK and hit cryptic "transaction will fail" warnings during wallet simulation (Slack-reported case: ~0.001 SOL when ~0.002 SOL of rent was required).

A second bug surfaced alongside: every provider's balance reader silently treated RPC failures as zero balance, so a flaky RPC on a well-funded wallet produced false "Insufficient balance" errors. Fixed in the same change.

Approach

Core (packages/sdk) — one chain-agnostic helper:

  1. Derive per-token requirements from the step: source amount + gas costs + non-included fee costs, filtered to the source chain.
  2. Read all required balances in one batched provider.getBalance call.
  3. Validate every requirement against the wallet balance.
  4. Retry within a bounded budget — 6 attempts, exponential backoff (150 * 2^attempt ≈ 4.65s total sleep), wrapped in a 10s outer withTimeout. Slippage applies to the source-token portion only and only on the final attempt; overhead (gas/fees) is never trimmed.

Providers — each balance reader now distinguishes:

  • Known zero — RPC succeeded, wallet genuinely holds none. amount: 0n.
  • Unknown — RPC failed (or partially failed). amount: undefined.

This drives the core helper's retry / error decisions:

  • EVM: multicall sub-call failure → undefined (was blindly cast to bigint).
  • Solana: only report 0n for SPL mints when both Token and Token2022 program queries succeeded (handles PYUSD-on-Token2022 when only Token responded).
  • Bitcoin: Promise.allSettled instead of Promise.all — an RPC flake no longer sinks the read.
  • Sui: distinguish getAllBalances rejection from "wallet holds no coin of this type".

withTimeout consolidation — moved from sdk-provider-solana to @lifi/sdk, re-exported. Shared by the new core retry wrapper and the Solana sign-and-execute path.

Decisions locked with the user

  • Unknown signal: TokenAmount.amount === undefined ⇒ unknown; 0n ⇒ known zero. No public-type change; fixes the prior ?? 0n bug at callsites.
  • Read-fail error: reuse BalanceError with distinct message "Could not read wallet balance." vs "The balance is too low.".
  • Retry budget: 6 attempts, exponential backoff, ~4.65s total sleep, 10s outer timeout.

Out of scope (follow-ups)

  • Thread AbortSignal through provider RPC calls so timed-out reads actually cancel instead of running detached.
  • Opt-in lenient mode for overhead tokens (proceed with a warning when an overhead token is unreadable).
  • Cancellation of an in-flight balance check when the user cancels execution.
  • Per-provider unit coverage for Solana/Bitcoin/Sui (the EVM provider got new unit tests; the others are still covered by their .int.spec.ts against live RPCs).

Visual showcase (Screenshots or Videos)

N/A — backend logic change.

Checklist before requesting a review

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the SDK API or adds new features that require documentation, I have updated the documentation in the public-docs repository.

Test plan

  • pnpm check — green
  • pnpm check:types — green
  • pnpm test:unit — 313 passing, 5 skipped (no regressions); includes 18 new checkBalance tests, 4 new withTimeout tests, 2 new EVM multicall tests
  • Run per-provider *.int.spec.ts against live RPCs to verify the "known zero" changes don't regress live reads
  • Manual repro (Solana, Slack case): wallet with ~0.001 SOL + sufficient swap token, quote requires ATA creation → expect "insufficient SOL for fees" BalanceError before wallet simulation. Top up to ≥0.005 SOL → flow proceeds. Kill RPC mid-check → expect "Could not read wallet balance." (not a false "balance too low")
  • Manual sanity (EVM): USDC swap on a wallet with USDC but effectively zero ETH → clear "insufficient ETH for gas" error before wallet simulation

EMB-334

Rewrite checkBalance to verify the wallet holds enough of every token
required to execute the step on its source chain: source amount, gas
costs, and non-included fee costs. Reads all balances in one batched
provider call and retries within a bounded budget (6 attempts,
exponential backoff ≈4.65s, 10s outer timeout) to absorb transient
RPC failures and post-confirmation propagation lag. Slippage is applied
to the source-token portion only and never trims the overhead reserve.

Providers now distinguish "known zero" (RPC succeeded, amount is 0n)
from "unknown" (RPC failed, amount is undefined) so the retry loop can
tell a flaky RPC apart from a genuinely empty wallet. Applies across
EVM multicall sub-calls, Solana Token/Token2022 partial failures,
Bitcoin RPC failures, and Sui getAllBalances rejections.

Move withTimeout from sdk-provider-solana to @lifi/sdk and re-export
it so both the new checkBalance retry wrapper and the Solana sign-and-
execute task use the shared utility.

Delete dead execution.unit.spec.ts and its handlers (describe.skip'd
suite with commented-out assertions and a vi.mock targeting a non-
existent path).
Align Solana and Sui int tests with the new provider semantics:
a mint/coin-type the wallet doesn't hold resolves to amount: 0n
when the underlying RPC call succeeds (previously the tests asserted
amount: undefined, which conflated "wallet has none" with "RPC failed").

The inline comments already said "invalid tokens should be returned
with balance 0" — the assertions now match the comments.

EMB-334
@chybisov chybisov merged commit 89a87c3 into main Apr 15, 2026
2 checks passed
@chybisov chybisov deleted the feat/emb-334-generic-balance-check branch April 15, 2026 07:36
chybisov added a commit that referenced this pull request Apr 15, 2026
EMB-334

Backport of #372 (v4 main) to v3.

Rewrite checkBalance to verify the wallet holds enough of every token
required to execute the step on its source chain: source amount, gas
costs, and non-included fee costs. Reads all balances in one batched
provider call and retries within a bounded budget (6 attempts,
exponential backoff, 10s outer timeout). Slippage is applied to the
source-token portion only and never trims the overhead reserve.

Providers now distinguish "known zero" (amount: 0n) from "unknown"
(amount: undefined) so the retry loop can tell a flaky RPC apart from
a genuinely empty wallet:
- EVM: multicall sub-call failures → undefined
- Solana: per-program ok flags; only report 0n when both Token and
  Token2022 queries succeeded
- UTXO: Promise.allSettled; omit amount on RPC failure
- Sui: distinguish getAllBalances rejection from "no coins"

Update Solana/Sui int tests to assert 0n for not-held tokens (was
toBeUndefined — contradicted the inline comments).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant