Leverage Yield
Error handling conventions: This module uses the canonical
SodaxError<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.
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
┌───────────────────────────────────────────────┐
│ │
▼ │
deposit asset ──▶ supply as collateral ──▶ borrow borrowToken
(e.g. sodaWEETH) (earns supply APR) (pays borrow APR)
│
▼
re-supply ◀── swap back into asset
(borrowToken → asset)
│ ▲
└───────────────── repeat until targetLTV ──────┘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:
Quantity
Formula
Example at L = 85%
Total collateral supplied
1 / (1 − L)
6.67× principal
Total borrowed
L / (1 − L)
5.67× principal
Leverage multiplier (leverageMultiplierWad)
L / (1 − L)
5.67×
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
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 from
getApr()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 — the lsoda* 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.
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:
name
Lookup key — the lsoda* share-token symbol
'lsodaWEETH'
vault
Deployed vault proxy on Sonic — also the lsoda* token address
0xD09d…701D
asset
Underlying collateral (a Sodax vault token)
sodaWEETH
borrowToken
Token borrowed against asset
sodaETH
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
Because entering and exiting a position are ordinary intent-based swaps, vaultSwap() inherits the global partner fee configured on the Sodax instance by default. Set config.swaps.partnerFee and every leverage-vault deposit (and withdraw) deducts it from the input amount, exactly like any other swap:
To charge a fee only on leverage-vault deposits (or a different fee than the global one), pass partnerFee to deposit(). It rides on the returned payload as the per-intent fee override (VaultSwapActionParams.partnerFee) and takes precedence over config.swaps.partnerFee for that intent only:
The fee is taken inside createVaultIntent() (which vaultSwap() delegates to): the effective fee (per-intent override, falling back to the global config) 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. LeverageYieldService.deposit() builds the CreateIntentParams with data: '0x'; the fee data is then constructed at intent-creation time, so the deposit needs no fee plumbing of its own. When neither fee is set, no fee is taken (the historical "fee not charged" case).
Flows
Deposit (any token → lsoda*)
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.swaps.getQuote with the vault address as the destination token (token_dst) — lsoda* shares are solver-tradeable — then subtract your slippage tolerance.
Withdraw (lsoda* → any token)
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.swaps.getQuote with the vault address as the source token (token_src) — then subtract slippage.
To size a full exit, read the withdrawable balance with getMaxWithdrawForUser(vault, srcChainKey, srcAddress) (already dust-buffered) or the raw share balance with getShareBalanceForUser(...).
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
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 swap layer's per-intent fee override. 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. Synchronous. Returns: Result<LeverageYieldSwapPayload, LeverageYieldCreateIntentError>. 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 → 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 Erc20Service.approve. With raw: true returns unsigned tx data and does not broadcast. Returns: Promise<Result<TxReturnType<HubChainKey, R> | EvmReturnType<true>, LeverageYieldApproveError>>. context.action is 'approve'.
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. 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
deposit, withdraw
LeverageYieldCreateIntentError | LeverageYieldLookupError
VALIDATION_FAILED, INTENT_CREATION_FAILED, LOOKUP_FAILED, UNKNOWN
createVaultIntent
LeverageYieldCreateIntentError
VALIDATION_FAILED, INTENT_CREATION_FAILED, UNKNOWN
vaultSwap
LeverageYieldSwapError
create-intent codes plus TX_VERIFICATION_FAILED, TX_SUBMIT_FAILED, RELAY_TIMEOUT, RELAY_FAILED, EXECUTION_FAILED, EXTERNAL_API_ERROR
approve
LeverageYieldApproveError
VALIDATION_FAILED, APPROVE_FAILED, UNKNOWN
isAllowanceValid
LeverageYieldAllowanceCheckError
VALIDATION_FAILED, ALLOWANCE_CHECK_FAILED, UNKNOWN
all read methods
LeverageYieldLookupError
VALIDATION_FAILED, LOOKUP_FAILED, UNKNOWN
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 of instanceof 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.
Last updated