Error handling conventions: This module uses the canonicalTheSodaxError<LeverageYieldErrorCode>shape (same family as the swap, bridge, and money market modules). Discriminate onresult.error.code(e.g.'INTENT_CREATION_FAILED','LOOKUP_FAILED'); structured details live onresult.error.context(srcChainKey,action,method,phase,field). See the Error Handling section below for the full per-method code table.
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 theasset / borrowToken peg.
The loop
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 LTVL, 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:
targetLTV 85%:
The risk: the spread can invert
The multiplier cuts both ways. The same5.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
netAprgoes negative (e.g. supply 3% / borrow 5% at 85% LTV →netApr ≈ −8.3%). The SDK returnsnetAprRayas a signed bigint precisely so this case is representable. - Because the position holds real debt, a depeg or price move between
assetandborrowTokencan erode the health factor.getPosition()exposes the livehealthFactor,ltv,collateral, anddebtso a UI can warn before liquidation territory. - A
targetLTV ≥ 100%would imply infinite leverage and is rejected bygetApr()withVALIDATION_FAILED.
The APR fromgetApr()is steady-state, not realised APY. It assumes AAVE rates stay constant and the vault holds continuously attargetLTV. 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 — thelsoda* token (e.g. lsodaWEETH). Two consequences:
- The share token address is the vault proxy address.
vaultand thelsoda*token are the same address. - Standard ERC-4626 views (
previewDeposit,previewWithdraw,previewRedeem,maxWithdraw,totalAssets) work, plus a non-standardgetPositionDetails()that returns the live leveraged-position snapshot.
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, thelsoda* 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 — notswaps.partnerFee, which never applies to vault flows. Precedence is:
per-intent partnerFee → leverageYield.partnerFee → global fee → no fee.
Set it per feature, or globally:
partnerFee to deposit() or withdraw() — it rides on the returned payload as VaultSwapActionParams.partnerFee — or pass it directly to vaultSwap() / createVaultIntent():
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
SizeminOutputAmount with sodax.leverageYield.getQuote(), which deducts the same effective leverage-yield fee the intent will charge:
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.
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
srcChainKeyis the hub, where the spoke tx already is the hub tx — then callnotifySolverso 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, carryingoperation: 'deposit' | 'withdraw'), which relays and post-executes server-side, then pollgetSubmitTxStatusuntilsolved. On any non-success — submission rejected, a 200 the backend did not accept, terminalfailed/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 sameVaultSwapResponseeither 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.
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.
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 theLeverageYieldSwapPayload 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 theLeverageYieldSwapPayload 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’screateIntent, 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’spostExecution. 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 underlyingasset 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’sasset 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 itsasset 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’sasset(). All return Promise<Result<bigint, LeverageYieldLookupError>> (getAsset returns Result<Address, …>).
listVaults / getVault / getVaultByAddress
Registry lookups (synchronous, noResult). 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 returnPromise<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— partitionsLOOKUP_FAILEDacross the read methods ('getApr','getPosition','getMaxWithdrawForUser','getShareBalance', …) and'resolveDeadline'for thedeposit/withdrawdefault-deadline read.context.field— set onVALIDATION_FAILED('inputAmount','vault','inputToken','outputToken','amount','targetLtvBps').context.phase—'intentCreation' | 'approve' | 'allowanceCheck' | 'lookup' | 'validate' | 'verify' | 'relay' | 'postExecution'.
Guards
Use the exported guards instead ofinstanceof SodaxError (bundle-safe):
isLeverageYieldError(e)— broad guard for any leverage-yield error.isLeverageYieldCreateIntentError(e)—createVaultIntentand the create-intent arm ofdeposit/withdraw(whose default-deadline read can instead yield aLOOKUP_FAILEDcaught byisLeverageYieldLookupError).isLeverageYieldSwapError(e)—vaultSwap.isLeverageYieldApproveError(e)—approve.isLeverageYieldAllowanceCheckError(e)—isAllowanceValid.isLeverageYieldLookupError(e)— read methods.
Discrimination example
Best practices
- Discriminate on
error.code, noterror.message. Messages are human-readable and may change. - Partition reads via
context.method. All read methods shareLeverageYieldLookupError;context.methodtells you which read failed. - Use
error.causefor forensics. Every wrapped error preserves the original oncause; loggers walk it automatically. - Use
JSON.stringify(error)for logging.toJSON()handles bigint coercion + cause-chain truncation safely. - Type-guard, don’t
as-cast. Use theisLeverageYield*Errorguards 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.