Skip to main content
Error handling conventions: This module uses the canonical SodaxError<LeverageYieldErrorCode> shape (same family as the swap, bridge, and money market modules). Discriminate on result.error.code (e.g. 'INTENT_CREATION_FAILED', 'LOOKUP_FAILED'); structured details live on result.error.context (srcChainKey, action, method, phase, field). See the Error Handling section below for the full per-method code table.
The LeverageYieldService class, reachable via sodax.leverageYield, exposes the SODAX leverage-yield vaults — leveraged-yield strategy vaults deployed on the Sonic hub. This page explains what a leverage-yield vault is, how the strategy works on-chain, and how the SDK lets you enter and exit a position from any spoke chain.

How the leverage-yield vault works

A leverage-yield vault automates a leveraged-lending loop to turn a small yield spread into a larger one. It takes a yield-bearing deposit, uses it as collateral to borrow a correlated asset, swaps the borrowed amount back into more of the deposit asset, and re-supplies — repeating until it reaches a target leverage. The position is a leveraged long on the asset / borrowToken peg.

The loop

Each pass adds more collateral (and more debt). The loop stops at the vault’s target loan-to-value (targetLTV) — the fraction of collateral value the vault is willing to borrow against. The geometric series of repeated borrows converges to a fixed multiple of the original deposit.

Leverage multiplier and where the yield comes from

At a target LTV L, the steady-state amounts (as a multiple of your principal) are: Your net yield is the base supply rate on your principal plus the leverage multiplier applied to the spread between the supply and borrow rates:
Worked example — supply 5%, borrow 3%, targetLTV 85%:
A 2-point raw spread becomes a ~16% headline APR. That amplification is the entire point of the vault — and also its risk.

The risk: the spread can invert

The multiplier cuts both ways. The same 5.67× that amplifies a positive spread amplifies a negative one:
  • If the borrow rate rises above the supply rate, every loop becomes a net cost and netApr goes negative (e.g. supply 3% / borrow 5% at 85% LTV → netApr ≈ −8.3%). The SDK returns netAprRay as a signed bigint precisely so this case is representable.
  • Because the position holds real debt, a depeg or price move between asset and borrowToken can erode the health factor. getPosition() exposes the live healthFactor, ltv, collateral, and debt so a UI can warn before liquidation territory.
  • A targetLTV ≥ 100% would imply infinite leverage and is rejected by getApr() with VALIDATION_FAILED.
The APR from getApr() is steady-state, not realised APY. It assumes AAVE rates stay constant and the vault holds continuously at targetLTV. Real returns depend on rate volatility and rebalance cadence.

The vault on-chain

Each vault is a deployed contract on the Sonic hub that follows the ERC-4626 standard. A depositor’s position is represented by the vault’s ERC-4626 share token — the lsoda* token (e.g. lsodaWEETH). Two consequences:
  • The share token address is the vault proxy address. vault and the lsoda* token are the same address.
  • Standard ERC-4626 views (previewDeposit, previewWithdraw, previewRedeem, maxWithdraw, totalAssets) work, plus a non-standard getPositionDetails() that returns the live leveraged-position snapshot.
The lending pool is a SODAX fork of AAVE; the vault reads its reserve rates to compute APR and manages collateral/debt through it.

A vault’s descriptor

Each registered vault carries four static fields: The registry lives in @sodax/types (leverageYieldConfig) and derives every address from the canonical LsodaTokens / SodaTokens registries, so a deployment-address change lives in exactly one place. Look vaults up with listVaults(), getVault(name), or getVaultByAddress(address).

The SDK model: shares as solver-tradeable tokens

The service does not expose bespoke “deposit into vault” / “redeem from vault” calls. Instead, the lsoda* share token is registered as an ordinary solver-tradeable token (it is spread into the swap-supported tokens for Sonic). So entering and exiting a leveraged position are just intent-based swaps, executed by the service’s own vaultSwap():
  • Enter a position = swap any token → lsoda* shares.
  • Exit a position = swap lsoda* shares → any token.
LeverageYieldService’s job is to build the correct swap payload (the CreateIntentParams plus any execution flags) via deposit() / withdraw(), then execute it via vaultSwap() (or createVaultIntent() for manual relay control); the solver (plus the vault’s ERC-4626 mechanics) does the rest. This is why deposits and withdrawals are cross-chain by default and require no vault-specific approvals on the spoke side. createVaultIntent / vaultSwap are leverage-yield copies of the swap domain’s createIntent / swap() — duplicated deliberately so the vault-specific execution modifiers (hubWalletSwap, per-intent partnerFee) live on the leverage-yield action wrapper (VaultSwapActionParams) and never leak into the generic swap surface.

Partner fee

Entering and exiting a position are intent-based swaps, but they are priced off the leverage-yield partner fee — not swaps.partnerFee, which never applies to vault flows. Precedence is: per-intent partnerFeeleverageYield.partnerFee → global fee → no fee. Set it per feature, or globally:
Both directions are charged. To override the configured fee for a single intent, pass partnerFee to deposit() or withdraw() — it rides on the returned payload as VaultSwapActionParams.partnerFee — or pass it directly to vaultSwap() / createVaultIntent():
The fee is taken inside createVaultIntent() (which vaultSwap() delegates to): the effective fee is deducted from inputAmount and encoded into the intent’s data as the IntentDataType.FEE envelope, so the intents contract routes it to the partner address on the hub. deposit() / withdraw() build the CreateIntentParams with data: '0x'; the fee data is constructed at intent-creation time, so the builders need no fee plumbing of their own. When no fee is set at any level, none is taken. Because the fee comes out of inputAmount, its denomination differs by direction: a deposit’s input is the token being paid in, while a withdraw’s input is the vault itself — so a withdraw fee is taken in lsoda* shares and the receiver accrues vault shares rather than the output token. Both are hub-side ERC20s and both surface in sodax.partners.feeClaim.

Quoting

Size minOutputAmount with sodax.leverageYield.getQuote(), which deducts the same effective leverage-yield fee the intent will charge:
Do not use sodax.swaps.getQuote() for vault flows: it deducts the effective swap fee, so once the two feature fees differ the quote and the intent disagree — and when the leverage-yield fee is the larger one, the minOutputAmount derived from that quote exceeds what the intent can deliver and it never fills. Whichever quote method you use, keep the fee consistent across both calls: pass the same partnerFee to the quote and to the builder, or omit it on both.

Flows

Deposit (any token → lsoda*)

deposit() builds the LeverageYieldSwapPayload{ params: CreateIntentParams } — for swapping any solver-supported inputToken on a spoke chain into the vault’s lsoda* share token. The output is delivered to the user’s hub wallet on Sonic (not back to the spoke) so a later withdraw() can spend it from there. The deadline defaults to the hub (Sonic) block timestamp + 5 minutes — anchored to on-chain time rather than the client clock; solver defaults to 0x0 (any solver). To size minOutputAmount, quote via sodax.leverageYield.getQuote with the vault address as the destination token (token_dst) — lsoda* shares are solver-tradeable — then subtract your slippage tolerance. An optional partnerFee overrides the configured leverage-yield fee for this intent; pass the same value to the quote.

Withdraw (lsoda* → any token)

withdraw() builds the LeverageYieldSwapPayload for swapping the vault’s lsoda* shares — which sit in the user’s hub wallet — back into any solver-supported token on any chain. The payload carries hubWalletSwap: true: vaultSwap() then authorises the hub wallet to spend the shares via a Connection.sendMessage the user signs on srcChainKey, instead of a spoke-side asset-manager deposit. withdraw() is async (it reads the hub block timestamp for the default deadline) and returns a Result for a call shape uniform with deposit(). Size minOutputAmount the same way as a deposit — sodax.leverageYield.getQuote with the vault address as the source token (token_src) — then subtract slippage. withdraw() also accepts an optional partnerFee; withdrawals are charged, and that fee is taken in lsoda* shares.
To size a full exit, read the withdrawable balance with getMaxWithdrawForUser(vault, srcChainKey, srcAddress) (already dust-buffered) or the raw share balance with getShareBalanceForUser(...).

Completion paths and timeout

vaultSwap() completes through one of two paths, each bounded by its own timeout budget:
  • Client-side (the default): verify the broadcast intent tx landed on-chain, relay it to the hub (Sonic) — skipped when srcChainKey is the hub, where the spoke tx already is the hub tx — then call notifySolver so the solver fills the intent.
  • Backend 2-step (opt in with leverageYield.useBackendSubmitTx: true): hand the broadcast tx to the Leverage Yield API (sodax.api.leverageYield.submitTx, carrying operation: 'deposit' | 'withdraw'), which relays and post-executes server-side, then poll getSubmitTxStatus until solved. On any non-success — submission rejected, a 200 the backend did not accept, terminal failed/abandoned, a rejected API key, or the poll running out — it falls back to the client-side path so the vault swap still completes, returning the same VaultSwapResponse either way. That is safe because re-relaying / re-posting an already-processed vault swap is idempotent (the relay dedups and the solver re-affirms the intent — no double-fill), and it matters in practice: the backend keeps processing at its own pace after the SDK gives up, so the two relays can race.
This is the leverage-yield counterpart of swaps.useBackendSubmitTx, but it defaults off while the backend path beds in. Read the effective value on sodax.config.leverageYieldUseBackendSubmitTx. timeout is a per-attempt budget, not an end-to-end one: the backend attempt (the POST plus its status poll) gets it, and if that attempt does not complete the client-side relay wait gets a fresh one starting after verification — so neither a stalled backend nor a slow source-chain confirmation can shorten it, and raising timeout grows both. Verification runs on the client-side path only (the backend runs its own, so verifying first would delay every backend success). Worst-case wall-clock is createVaultIntent + timeout + verification + max(timeout, RELAY_FALLBACK_FLOOR_MS) + notifySolver, where verification is bounded by the source chain’s pollingConfig.maxTimeoutMs and the first and last terms are not bounded by timeout at all — the same model SWAPS.md documents for swaps.
When the backend attempt does not complete, its own error is logged and discarded — the fallback runs and its outcome is what you receive, so the code on the Result always describes the client-side attempt. Check the logs, not the Result, to tell why the backend path was abandoned. For the HTTP client itself see LEVERAGE_YIELD_API.md.

Direct allowance management (hub-side)

approve() and isAllowanceValid() manage the allowance of the vault’s underlying asset to the vault on Sonic. These are for callers interacting with the vault directly on the hub — the swap-style deposit() flow handles its own approvals, so most integrations never need them.

Methods

getQuote

Solver quote for a vault deposit (token_dst = the vault) or withdraw (token_src = the vault), sized with the effective leverage-yield fee so the quote matches what the intent will charge. Prefer this over sodax.swaps.getQuote for vault flows. An optional partnerFee overrides the configured fee for this quote — pass the same value you pass to the builder. Returns: Promise<Result<SolverIntentQuoteResponse, SolverErrorResponse | LeverageYieldLookupError>> — on failure the error is either the solver’s own SolverErrorResponse ({ detail: { code, message } }) or a SodaxError (VALIDATION_FAILED for a bad amount or a fee that leaves nothing to quote, LOOKUP_FAILED, UNKNOWN); discriminate with isSodaxError(error).

deposit

Builds the LeverageYieldSwapPayload for a deposit (any token → lsoda*, delivered to the hub wallet). An optional partnerFee is forwarded on the payload as the per-intent override of the effective leverage-yield fee. Returns: Promise<Result<LeverageYieldSwapPayload, LeverageYieldCreateIntentError>>. context.action is 'deposit'.

withdraw

Builds the LeverageYieldSwapPayload for a withdraw (lsoda* → any token), with hubWalletSwap: true set on the payload. async — it reads the hub block timestamp for the default deadline. An optional partnerFee is forwarded on the payload as the per-intent override; withdrawals are charged, and the fee is taken in lsoda* shares. Returns: Promise<Result<LeverageYieldSwapPayload, LeverageYieldCreateIntentError | LeverageYieldLookupError>>. context.action is 'withdraw'.

createVaultIntent

Creates the vault swap intent on the user’s source spoke chain without submitting it to the solver — the leverage-yield copy of the swap domain’s createIntent, honouring hubWalletSwap (withdraw routes via a hub-wallet Connection.sendMessage) and the per-intent partnerFee override. With raw: true returns unsigned tx data. Use it directly when you drive the relay yourself (e.g. the backend submit-tx path): relay the returned relayData with the shared relayTxAndWaitPacket helper, then call notifySolver with the hub-side intent tx hash to complete the flow. Returns: Promise<Result<CreateVaultIntentResult<K, Raw>, LeverageYieldCreateIntentError>>tx, the constructed intent (with feeAmount), and relayData.

vaultSwap

Executes the full end-to-end vault swap. createVaultIntent broadcasts the intent on the source spoke chain; completion then runs via one of the two paths in Completion paths and timeout — by default the client-side one: verify the spoke tx → relay to the hub (skipped when the source is Sonic) → notify the solver. Spread a LeverageYieldSwapPayload into it alongside the wallet provider: vaultSwap({ ...payload, walletProvider }). Returns: Promise<Result<VaultSwapResponse, LeverageYieldSwapError>>solverExecutionResponse, intent, and intentDeliveryInfo. context.action is 'vaultSwap'.

notifySolver

Notifies the solver that a vault intent has landed on the hub, triggering it to fill — the leverage-yield copy of the swap domain’s postExecution. vaultSwap calls it automatically; it is public so callers who created the intent with createVaultIntent and relayed it themselves can finish the flow manually. Pass { intent_tx_hash } — the hub-chain (Sonic) tx hash where the intent registered (the relay packet’s dst_tx_hash, or the spoke tx hash for hub-sourced intents). Returns: Promise<Result<SolverExecutionResponse, LeverageYieldPostExecutionError>> — emits only EXECUTION_FAILED / EXTERNAL_API_ERROR / UNKNOWN.

approve

Approves the vault’s underlying asset to the vault on Sonic. Resolves asset() on-chain, then delegates to SpokeService.approve when signing, or to Erc20Service.approve with raw: true, which returns unsigned tx data and does not broadcast. Returns: Promise<Result<TxReturnType<HubChainKey, R> | EvmReturnType<true>, LeverageYieldApproveError>>. context.action is 'approve'. Some tokens take two transactions. A few ERC-20s of the 2017 TetherToken lineage — Ethereum USDT is the only one in the SODAX token list today — reject an allowance change from one non-zero value to another, so a signed approve sends approve(0) first and waits for it to be mined before the real approval. The user signs twice; the returned value is still a single transaction hash, the last one’s. Detection simulates the approval rather than consulting a token list, so a token listed later behaves the same way.

isAllowanceValid

Reads the on-chain allowance of the vault’s asset for owner → vault and returns true when it covers amount. Returns: Promise<Result<boolean, LeverageYieldAllowanceCheckError>>. The error carries phase: 'allowanceCheck'.

getApr

Computes the steady-state APR of a vault from the AAVE supply/borrow rates of its asset and borrowToken, scaled by the vault’s target leverage (see How the leverage-yield vault works above for the formula and caveats). Returns: Promise<Result<LeverageYieldApr, LeverageYieldLookupError>>. Rates are in RAY (1e27); the leverage multiplier is in WAD (1e18). netAprRay can be negative; targetLTV ≥ 100% is rejected with VALIDATION_FAILED.

getLsdApr / getEffectiveApr

getLsdApr fetches the underlying LSD’s staking yield from DefiLlama; getEffectiveApr folds that yield into getApr’s AAVE-only view for the honest leveraged APR. Returns: Promise<Result<LeverageYieldLsdApr | LeverageYieldEffectiveApr, LeverageYieldLookupError>>.
No SDK-level caching. Each call hits the DefiLlama HTTP API fresh — there is no built-in cache or rate limiting. UIs are typically protected by their query layer (the demo uses a 60 s refetchInterval), but server-side aggregators or tight polling loops should add their own caching to avoid DefiLlama rate limits. A failed/timed-out DefiLlama fetch falls back gracefully rather than throwing.

getPosition

Reads the live leveraged-position snapshot (collateral, debt, ltv, healthFactor, idleAsset) via the non-standard getPositionDetails() view. Returns: Promise<Result<LeverageYieldPosition, LeverageYieldLookupError>>.

getMaxWithdraw / getMaxWithdrawForUser

getMaxWithdraw(vault, owner) returns the ERC-4626 maxWithdraw for an owner. getMaxWithdrawForUser(vault, srcChainKey, srcAddress) resolves the user’s hub wallet first, then returns maxWithdraw less a small dust buffer (1 000 wei) — trimming sidesteps an asset-denominated withdraw round-up that asks for one more share than the user holds. Clamps to 0n rather than underflowing when the balance is below the buffer. Returns: Promise<Result<bigint, LeverageYieldLookupError>>.

getShareBalance / getShareBalanceForUser

lsoda* share balance for an address, or for a user via their resolved hub wallet. Returns: Promise<Result<bigint, LeverageYieldLookupError>>.

getTotalAssets / previewDeposit / previewWithdraw / previewRedeem / getAsset

Thin ERC-4626 reads: total assets held by the vault (TVL), share/asset previews, and the vault’s asset(). All return Promise<Result<bigint, LeverageYieldLookupError>> (getAsset returns Result<Address, …>).

listVaults / getVault / getVaultByAddress

Registry lookups (synchronous, no Result). listVaults() returns the registry from the active config; getVault(name) looks up by the lsoda* symbol; getVaultByAddress(address) looks up by the proxy address (case-insensitive). Return undefined for unknown keys.

Types

LeverageYieldVault

LeverageYieldApr

LeverageYieldPosition

Error Handling

All async public methods return Promise<Result<T, SodaxError<NarrowCode>>>. Discriminate on result.error.code (a string literal) — never on result.error.message. Same canonical shape used by swap, bridge, and money market. The service owns the full vault-swap lifecycle: deposit / withdraw build swap payloads, createVaultIntent submits the intent on the source spoke chain, vaultSwap orchestrates create → verify → relay → notify-solver, approve / isAllowanceValid manage the Sonic allowance, and the read methods query on-chain state. Relay/tx-verification codes appear only on vaultSwap, and only on its client-side path (the default, or the backend path’s fallback) — so TX_VERIFICATION_FAILED, TX_SUBMIT_FAILED, RELAY_TIMEOUT and RELAY_FAILED never surface on a vault swap the backend completes. deposit / withdraw can additionally emit LOOKUP_FAILED (method: 'resolveDeadline') when the default-deadline hub-block read fails — an RPC outage, not an intent-build failure. Every other method stays within the create-intent, approve, allowance-check, and lookup subsets.

Per-method error code unions

The broad union type is LeverageYieldError (SodaxError<LeverageYieldErrorCode>).

Discriminators

  • context.action — the user-facing operation ('deposit' | 'withdraw' | 'approve' | 'vaultSwap').
  • context.method — partitions LOOKUP_FAILED across the read methods ('getApr', 'getPosition', 'getMaxWithdrawForUser', 'getShareBalance', …) and 'resolveDeadline' for the deposit / withdraw default-deadline read.
  • context.field — set on VALIDATION_FAILED ('inputAmount', 'vault', 'inputToken', 'outputToken', 'amount', 'targetLtvBps').
  • context.phase'intentCreation' | 'approve' | 'allowanceCheck' | 'lookup' | 'validate' | 'verify' | 'relay' | 'postExecution'.

Guards

Use the exported guards instead of instanceof SodaxError (bundle-safe):
  • isLeverageYieldError(e) — broad guard for any leverage-yield error.
  • isLeverageYieldCreateIntentError(e)createVaultIntent and the create-intent arm of deposit / withdraw (whose default-deadline read can instead yield a LOOKUP_FAILED caught by isLeverageYieldLookupError).
  • isLeverageYieldSwapError(e)vaultSwap.
  • isLeverageYieldApproveError(e)approve.
  • isLeverageYieldAllowanceCheckError(e)isAllowanceValid.
  • isLeverageYieldLookupError(e) — read methods.

Discrimination example

Best practices

  1. Discriminate on error.code, not error.message. Messages are human-readable and may change.
  2. Partition reads via context.method. All read methods share LeverageYieldLookupError; context.method tells you which read failed.
  3. Use error.cause for forensics. Every wrapped error preserves the original on cause; loggers walk it automatically.
  4. Use JSON.stringify(error) for logging. toJSON() handles bigint coercion + cause-chain truncation safely.
  5. Type-guard, don’t as-cast. Use the isLeverageYield*Error guards to narrow.

Chain Keys

Vaults live on the Sonic hub; deposit / withdraw route by the user’s spoke-side chain (srcChainKey) and the output chain (dstChainKey). Use ChainKeys from @sodax/sdk for chain-key constants.