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:
The multiplier is the borrowed multiple, not the exposure. The exposure a depositor holds is total collateral,
1 / (1 − L) = 1 + leverageMultiplier (6.67× here). A UI label that reads “Leverage” should show that sum.
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 — the leverageYieldVaults array (surfaced through leverageYieldConfig) is the source of truth for which vaults exist. It spans ETH-, SOL- and stablecoin-collateral vaults, 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 need no vault-specific approval: a deposit approves its input token the same way a swap does, with sodax.swaps.isAllowanceValid / sodax.swaps.approve, and a withdraw needs no approval at all.
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:
minOutputAmount: 0n or a hand-picked constant. The SDK accepts zero, and the intent then accepts any fill.
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.
deposit() and vaultSwap() do not approve anything themselves. On an EVM spoke the asset manager pulls an ERC-20 input from the user’s wallet, so the deposit reverts without the allowance. On Sonic the spender is the intents contract, and on Stellar the helper sets up a trustline. On other networks nothing needs approving, and the check returns true. The swap-domain helpers resolve all of this for each network, the same as for a swap.
vaultSwapWithApproval() replaces the allowance check, the approval and vaultSwap() with one call. On an EIP-5792 wallet it sends the approval and the deposit as one batch, so the user signs once:
operation, and follows the swap rules for wallet support, allowAccountUpgrade and failures (see Approve and Swap in One Signature). On the two-step path it builds the deposit unsigned before approving, so params that can never deposit fail before the user pays for an approval. A batch that was sent but not confirmed (TX_VERIFICATION_FAILED, context.reason: ATOMIC_BATCH_UNCONFIRMED) may still land, so do not deposit it again. Resume it instead: call vaultSwapWithApproval again with the same payload and wallet, plus resumeBatchId: error.context.batchId and resumeBatchCallCount: error.context.callCount. That sends nothing. It waits for the batch and, once the deposit confirms, relays it client-side, reads the intent back from the hub transaction and notifies the solver, skipping the backend submit-tx. While the batch is pending it returns the same unconfirmed error; one the wallet reports as never included comes back as atomic-batch-failed.
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.
getShareBalanceForUser(vault, srcChainKey, srcAddress). inputAmount is denominated in lsoda* shares. getMaxWithdrawForUser returns the ERC-4626 maxWithdraw, which is denominated in the underlying asset, so it is the wrong unit for inputAmount.
Completion paths and timeout
vaultSwap() completes through one of two paths, each bounded by its own timeout budget:
- Backend 2-step (the default): 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. - Client-side (opt out with
leverageYield.useBackendSubmitTx: false): verify the broadcast intent tx landed on-chain, relay it to the hub (Sonic) — skipped whensrcChainKeyis the hub, where the spoke tx already is the hub tx — then callnotifySolverso the solver fills the intent. This is also the fallback the backend path takes on any non-success.
swaps.useBackendSubmitTx, and it defaults on the same way.
Read the effective value on sodax.config.leverageYieldUseBackendSubmitTx.
POST /leverage-yield/submit-tx declares an API-key scope (swaps:write) where the status read declares
none, and key enforcement is a per-deployment setting. Where it is enforced, a keyless caller spends one
rejected attempt per vault swap before the fallback completes it. Configure apiKey, pass extras.apiKey
per call, or set useBackendSubmitTx: false to skip the attempt. swaps.useBackendSubmitTx declares the
same scope, so this is not specific to leverage yield.
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 does not use them: it approves its input token with the swap-domain sodax.swaps.isAllowanceValid / approve (see Flows), and a withdraw needs no approval.
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 | LeverageYieldLookupError>>. 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 backend 2-step one, falling back to the client-side path (verify the spoke tx → relay to the hub, skipped when the source is Sonic → notify the solver) on any non-success. Pass extras.apiKey to key the backend leg for this call only. 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'.
getApprovalStrategy
Which pathvaultSwapWithApproval would take for a payload: 'not-required' (always for a withdraw), 'atomic-batch' (approval and deposit in one EIP-5792 batch) or 'sequential' (approve, then deposit). It reads the spoke-side deposit allowance, not the hub-side vault allowance that isAllowanceValid reads. Returns: Promise<Result<ApprovalStrategy, ApprovalStrategyError>>.
vaultSwapWithApproval
vaultSwap() with a deposit’s source-token approval folded in — see the Deposit flow above. A withdraw (hubWalletSwap) runs exactly as vaultSwap() does. Returns: Promise<Result<VaultSwapWithApprovalResponse, VaultSwapWithApprovalError>> — vaultSwap()’s response plus approvalStrategy. Errors from the approval step carry context.action: 'vaultSwapWithApproval' and context.approvalStrategy; errors after the deposit is broadcast are vaultSwap()’s own.
getDetailedStatus
Answers “what happened to this vault swap?” from the source-chain tx — the one identifier you always hold aftervaultSwap(). It does not define a new status: it routes to whichever of the two existing
sources can answer, and returns that source’s payload. The backend record comes back unmodified; the
solver’s answer is checked against the durable intent record first (step 2 below).
DetailedLeverageYieldStatus is discriminated on source, so it narrows on its own — no type guards
needed:
DetailedSwapStatus, so swap’s pure summarizeSwapStatus collapses it too when
you want one vocabulary instead of switching on source:
sodax.api.leverageYield.getSubmitTxStatus cannot answer for every vault swap.
Sometimes there is no record — you opted out with useBackendSubmitTx: false, or the submit was rejected
(which a keyless caller hits every time). More often the record exists but is stale: the backend path
POSTs first and only falls back to the client-side relay once that stalls, so a fallback-completed vault
swap leaves behind whatever state the backend last reached, including a record it abandoned outright.
So the method routes:
- Read the backend record; return it while it is still in play.
- Otherwise resolve the hub tx hash — the source tx itself for hub-source vault swaps, else the
delivered relay packet’s
dst_tx_hash— and return the solver’s answer for it. Any answer short ofSOLVEDorFAILEDis checked against the backend’s durable intent record first: a fill that consumed the remainder readsSOLVED(a restarted solver answersNOT_FOUNDfor intents it already filled), and a cancel with nothing filled readsFAILED(expired intents are cancelled on the hub and refunded, and the solver can keep reporting one in flight). Anything else, a cancel after a partial fill included, leaves the solver’s answer standing.
context.status set for isAuthFailure, instead of being
routed on and buried behind a relay or solver error. The swap and bridge routers apply the same rule.
Every other failure is LOOKUP_FAILED, and one of them is budgetable. Branch on error.context.reason:
DETAILED_STATUS_NOT_DELIVERED means the relay has no packet for this source tx, which is ambiguous by
nature — a vault swap still in flight and one whose tx never relayed look identical — so it is the only
failure a caller should bound with a retry budget. Every other one is a dependency failing right now
(relay 5xx, solver down), so keep retrying until it recovers.
Takes an optional second argument, a RequestOverrideConfig. Its apiKey reaches every keyed leg —
the submit-tx record read, the solver read, and the durable-intent read a non-final solver answer falls
back to — so one override keys the whole call. The relay leg is unauthenticated and takes none.
A point-in-time read — poll it yourself, or use @sodax/dapp-kit’s useLeverageYieldDetailedStatus.
getIntentStatus
Polls the solver for the execution status of an intent this service created, keyed on the hub-chain tx hash. Pair it withnotifySolver. Reports the solver verbatim — unchanged contract.
It does not reconcile a NOT_FOUND against the backend’s durable record. A restarted solver answers
NOT_FOUND for intents it already filled, because it keeps intent state in memory; getDetailedStatus
resolves that, and is what to reach for when you hold the source tx hash. Keeping it out of here is
deliberate: the reconcile also turns a NOT_FOUND behind an unreadable backend into an error, which
would change this method’s answers for every caller already using it.
Returns: Promise<Result<SolverIntentStatusResponse, LeverageYieldPostExecutionError>>.
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 backend path’s fallback, or useBackendSubmitTx: false) — 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' | 'vaultSwapWithApproval').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'.context.srcTxHash— set, withsrcChainKey, onvaultSwapfailures after the intent tx was broadcast (TX_VERIFICATION_FAILED, the relay codes, and post-execution failures). Such a failure does not mean the vault swap failed: the tx is on-chain, and with backend submit-tx on the backend keeps working on it aftervaultSwap()gives up. Keep pollinggetDetailedStatuswith it rather than reporting the vault swap as lost. Failures before the broadcast carry none.context.dstTxHash— set, withsrcTxHash, when the solver notification fails (EXECUTION_FAILED/EXTERNAL_API_ERRORwithphase: 'postExecution'): the hub tx that registered the intent. Polling cannot fix this one —getDetailedStatusonly reads, and unless the backend took the vault swap nothing else notifies the solver, so the intent expires unfilled. CallnotifySolver({ intent_tx_hash: error.context.dstTxHash })again (the solver only re-affirms an intent it already has), then keep polling.
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.isVaultSwapWithApprovalError(e)—vaultSwapWithApproval.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.
Leverage Positions
A vault is one shared ERC-4626 position at a singletargetLTV. A leverage position is the unpooled counterpart: one owner-controlled AAVE account per position, cloned by LeveragePositionFactory. Because AAVE allows one eMode category per address, a vault forces every depositor into one category and one leverage tier, while an owner can hold several positions at different tiers simultaneously.
Positions have no static registry — discovery goes through the factory.
Configuration
Position methods need the deployed factory, and it ships as a packaged default —new Sodax() can drive positions with no configuration at all.
Override it only to point somewhere else, such as a fork or a staging deployment:
undefined, so an absent override cannot blank the default. Blanking it deliberately is honoured, and factory-backed methods then return a LOOKUP_FAILED error — they fail closed rather than falling back to a placeholder. Position-only reads (getPositionInfo, getPositionAccount, getPositionCollateralBalance, getPositionPendingState) target the position directly and never need the factory.
Reads
getPositionAccount reads getUserAccountData on the pool, not the position contract, which keeps no accounting of its own. The pool address comes from moneyMarket.lendingPool config rather than the position’s pool() getter — one round trip instead of two, and the deployment address stays sourced from config. Health factor is WAD (1e18); base-currency figures use the pool oracle’s 8-decimal base unit.
Those base-currency figures are for display only. Any amount going into a transaction needs getPositionCollateralBalance: dividing base currency back out by a price lands near the balance, and a decreaseLeverage asking for more collateral than the position holds does not fail on submission — the hook’s transferFrom reverts at fill time, so the intent silently expires. aToken balances only grow, so acting on a slightly stale read leaves interest dust rather than coming up short.
Writes
Use the high-level calls.openPosition, openPositionFromDebtToken and operatePosition only
post the intent — reporting it is a separate notifySolver call, and an unreported intent expires
unfilled, so the owner ends up funded with leverage that never arrives and nothing said so. Three
methods pair the two steps for you, and which one to use is decided by the operation:
withdraw, settle and cancel are synchronous on the hub and need no notification. The split is
not cosmetic: sending a leverage change through the route-only method leaves an intent nothing will
fill, and it expires without a word. The other direction is merely noisy.
The compiler enforces it. buildAddLeverage and buildDecreaseLeverage return
PositionIntentCall, the other three return PositionDirectCall, and the two methods each accept
only their own. The brands are type-only and erased at runtime. operatePosition still takes plain
calls for anyone driving the relay by hand.
Fees: read them from the POSITION, not from config. getEffectivePositionFee() answers what a
position created now would carry, which is what an open needs. An existing position’s fee was fixed
at creation, so config changes and per-call overrides make the two disagree — getPositionInfo
returns that position’s own feeBps and feeReceiver, and an adjust or exit has to charge those. The
fee is given up on top of what the solver is paid, so at a non-zero fee an exit sized at the whole
collateral balance leaves nothing for it and cannot settle. A position fee is capped at
MAX_POSITION_FEE_BPS (100 bps = 1%), mirroring LeveragePosition.MAX_FEE_BPS — the contract reverts
above it, and the SDK rejects a higher configured or per-call fee before anything is signed. The rate
and the receiver must also be set together or not at all: the position rejects a rate with no receiver
(borrowed for, paid to nobody) and a receiver with no rate, so the SDK rejects both pairings too.
ok: true, deliberately. The money has already moved and the intent is
live on the hub; returning a failure would tell the caller nothing happened, and a caller that retries
on failure would open a second position. It is reported on the value, not raised.
The low-level methods remain for callers driving the relay themselves.
Writes (low level)
Positions are driven by direct Sonic transactions rather than solver intents, so these are builders returning anEvmRawTransaction for the caller to sign with a hub wallet provider.
The two create builders return a
Result because they need the factory address; the rest target the position directly and return the transaction unwrapped.
The factory never moves anyone’s tokens. It holds no allowance and pulls from nobody: fund a position by transferring to the address it will be created at — predictPosition(creator, owner, nextPositionIdFor(owner)) — and the clone supplies whatever it finds. So no approval to a shared contract exists anywhere in the flow. Do the transfer and the create in one batch; if the prediction is stale the create reverts on the missing balance and takes the transfer with it rather than stranding the tokens. openPosition / openPositionFromDebtToken build that batch for you.
Do not open two positions for one owner concurrently. The id is per-owner, so the owner’s own second open is the only thing that can advance it between building a payload and executing it. The stale batch’s create lands on a fresh, unfunded address and reverts on the factory’s balanceOf(position) < initialAssets check; on the hub that takes the transfer with it and nothing is lost. Funded from a spoke, the deposit has already landed in the user’s hub wallet by the time the relayed batch reverts, and it stays there — recover it with sodax.recovery (RECOVERY.md).
You can only create a position you own. The factory requires cfg.owner == msg.sender, and the caller there is always the funder’s own hub wallet, so there is nothing to choose — which is why openPosition takes no owner. The binding is what makes the refund address safe: originAddress decides where a cancelled operation’s funds go and past a deadline anyone may trigger that cancel, so a third-party creator picking it was a standing claim on whatever the owner later contributed. It also means nobody else can advance the id your funding address is predicted from. To fund someone else, supply into a position they already own — pool.supply(collateral, amount, position, 0) — which sets no origin and takes nothing away.
Every position write is onlyOwner, and the owner is the user’s hub wallet — so these must execute as that wallet. Building with from: owner is not enough; an address cannot send as another address. The methods in the next section do that for you and are the ones to reach for; the builders are the unit they carry.
Three behaviours worth designing around:
- Leverage changes are asynchronous.
addLeverage/decreaseLeverageonly post an intent; a solver fills it afterwards. PollgetPositionPendingStateinstead of treating the transaction receipt as completion. A position refuses a second operation while one is in flight. - An intent is invisible to the solver until its transaction hash is reported. Pass the hash to
notifySolverafter routing, or the intent simply expires unfilled. - Cancelling returns nothing, because nothing was escrowed. Posting a position intent moves no funds; cancelling revokes the outstanding grant and clears the pending slot. A position carrying no debt also returns its whole collateral balance to the owner.
Sizing the leg — do not skip this
borrowAmount and minCollateralOut are yours to supply, and computing them from oracle prices does not work. A position’s leverage is bought through the solver, and the two legs do not trade at their oracle ratio. LeverageHook supplies whatever the solver actually paid and only then borrows against it, so the pool sees deposit + solver output, never deposit x leverage. Size from parity and the borrow reverts with Aave '36' (COLLATERAL_CANNOT_COVER_NEW_BORROW) — the intent is accepted, the solver fills, and the whole fill unwinds. It has happened twice on mainnet, once at 11.03x where parity predicted 84% LTV against a real 92.06% and a 91% cap.
Use sizeLeverageBorrow and projectLeverageLeg (exported from @sodax/sdk) rather than reimplementing it:
exceedsMaxLtvis a hard gate, not a warning. Post through it and the intent is accepted and then fails at fill.usableMaxLeverageis the real ceiling, and it is below1 / (1 - ltv). Atltv91% with the solver returning 98.77% of parity it is 9.88x, so an 11.03x that looks fine against parity was never available.- Everything is projected from the FLOOR, not the quote — the floor is the worst fill the intent permits, so if the floor is safe every fill is.
haircutandcostUsdare the exception: they are the expected cost of the leg, measured from the quote, which is what a payback period should be measured against. leverageis a multiple of the DEPOSIT, and the open position reports more. The borrow is booked in full while the collateral arrives short by the haircut, leavingdeposit x (1 - (L-1)h)of equity — socollateral / equityreads higher. Always higher, never lower: 2.00x requested at a 4.657% haircut reports 2.0488x. Displayp.exposureLeverage, not the number the user chose — and note it is measured at the floor like everything else here, so it reads above the fill by the slippage tolerance.
side: 'debt' handles it.
Driving a position from any chain
Experimental off the hub. Everything below is exercised end to end on Sonic. From a spoke, the inbound half (deposit, wrap, create, post the intent) is proven only by a fork replay of a real relayed message, and the outbound half — an exit or cancellation delivering the underlying back to the chain it came from — has never run on mainnet. A non-hubPositions live on the hub, but nothing requires the user to. These carry the work there. They post the intent and stop, so prefer the high-level calls above unless you are driving the relay yourself:srcChainKeyis experimental: expect to verify it against your own deployment before putting user funds through it. Bitcoin is refused outright. In TRADING mode the deposit is pulled from the Bound trading wallet, so the hub wallet has to be derived from that address rather than the personal one — which these paths do not do.openPosition,openPositionFromDebtTokenandoperatePositionreturnVALIDATION_FAILED(field: 'srcChainKey') for a Bitcoin source rather than funding the wrong wallet.
The hub is not a special case: there the calls route locally through the wallet router, and from a spoke they are relayed and the promise resolves once the hub side lands.
amount is always in the funding token’s own decimals — wrapping into the 18-decimal money-market reserve happens inside the relayed batch, so the deposit and the position are created together and the funds are never sitting on the hub unattached to a position.
Two details that are easy to get wrong:
- Which hash to report. Both are returned.
srcChainTxHashis what the user signed;dstChainTxHashis the hub transaction where the intent exists, and that is the onenotifySolverneeds. On the hub they are the same transaction; from a spoke they are not. - Who can be paid. The approve spender differs by chain — the user’s own hub wallet on the hub, since the pull happens inside the routed batch, and the spoke asset manager elsewhere.
approvePositionFundingresolves it; a hand-rolled approval against the wrong one leaves the deposit unpullable.
Closing a position
There are two exits, and they differ in which asset the owner ends up holding. Into the collateral. Sell only as much collateral as the debt is worth (buildDecreaseLeverage), wait for the fill, then buildPositionWithdraw the remainder. Two operations, and the withdrawal can pay out to any address. Overshoot the debt slightly — it accrues between quoting and filling, and dust debt left behind blocks the withdrawal entirely.
Into the debt token. Sell the collateral balance in one buildDecreaseLeverage. Net of the position’s own feeBps: the fee is given up on top of the input, so at a non-zero fee the input is balance x 10_000 / (10_000 + feeBps) and the whole balance cannot settle. The solver delivers more debt token than is owed, so the hook repays the debt and leaves the surplus — the owner’s equity, now denominated in the debt asset — in the position. buildSettlePosition sweeps it, and it goes to the position’s owner, not to whoever signed.
A full exit cannot half-happen. The hook repays the debt and then withdraws all the collateral to pay the solver; were the delivered amount short of the debt, that withdrawal would leave debt standing against no collateral and the pool’s health-factor check rejects it — the whole fill reverts and the position is untouched. So quote the full amount and check the floor would cover the debt before posting, rather than posting an intent that can only expire.
Collateral top-ups and debt repayment need no SDK call — AAVE already accepts both against an arbitrary account (pool.supply(collateral, amount, position, 0) and pool.repay(borrowToken, amount, 2, position)), so defending a position never waits on a solver fill.