@odysseyfi/loopr-sdk
v0.2.0
Published
Public SDK for Odyssey Loopr leveraged strategies. Embed Loopr into your own frontend with plain EOA wallets.
Maintainers
Readme
@odysseyfi/loopr-sdk
Public SDK for Odyssey Loopr leveraged strategies. Embed Loopr into your own frontend with a plain EOA wallet — no smart-contract-account deployment required.
Status: beta. The method surface is stable; the underlying partner API routes land incrementally. Methods that are not yet wired up throw
LooprSDKErrorwith codeNOT_IMPLEMENTED.
Contents
- Install
- Distribution
- Quick start
- Initializing the SDK
- Strategies
- Positions
- Data shapes
- Quotes
- Borrow / Repay
- Live previews
- Calculators
- Building transactions
- Executing transactions
- Simulating step sequences
- Smart-contract account derivation
- Recipes
- Error handling
- Partner identification
- Versioning & status
- Exports
- License
Install
npm install @odysseyfi/loopr-sdk viemviem >= 2.46.3 is an optional peerDependency. Partners who already
use viem (typically via wagmi) won't end up with two copies in their
dependency tree; partners on other stacks (ethers, web3.js, plain JavaScript)
can install with no peer-dep warning and skip viem entirely. The SDK only
references viem for TypeScript types — dist/*.js has zero runtime
imports from viem — so every method except executeAll works without viem
at runtime. executeAll accepts any object that satisfies
{ sendTransaction, account }; a viem WalletClient is the canonical fit,
but a wagmi or ethers wrapper exposing the same shape works too.
Distribution
The SDK ships as ESM-only ("type": "module", NodeNext module resolution).
Runtime requirements: Node.js ≥ 20 and a modern bundler (Next.js ≥ 13, Vite,
webpack ≥ 5, Rollup, esbuild). A dual CJS/ESM build can be added later if a
partner reports an interop constraint; file an issue with your toolchain
details if that happens.
Quick start
Loopr is currently deployed on Ethereum mainnet (1), Optimism (10),
Base (8453), Plasma (9745), and Hemi (43111) — Arbitrum is not a
supported network. Use one of those chain IDs anywhere the SDK takes
chainId.
sdk.strategies.list / .get and sdk.positions.list / .get return
bound objects — Strategy carries quote.open, build.open, and
project.open; Position carries quote.{close,adjust},
build.{close,push,pull}, and project.{borrow,push,pull,close}. Routing
fields (chainId, strategyId, deposit/borrow tokens, position address)
flow through the bound object, so partners only supply the user-specific
bits.
import { createLooprSDK } from '@odysseyfi/loopr-sdk'
import { createPublicClient, createWalletClient, custom, http } from 'viem'
import { mainnet } from 'viem/chains'
const loopr = createLooprSDK({ partnerId: 'your-partner-id' })
// 1. Discover strategies (filtered by your partner registration scope).
const strategies = await loopr.strategies.list({ chainId: mainnet.id })
const strategy = strategies[0]
// 2. Quote — `quote.open` is user-agnostic; the strategy supplies chainId,
// strategyId, depositToken, and borrowToken.
const { quote, expiresAt } = await strategy.quote.open({
amount: 1_000_000_000n, // 1000 of strategy.collaterals.asset (e.g. USDC)
leverage: 3,
slippage: 0.01,
})
// 3. Build the unsigned steps. Same shape as the quote, plus build-only
// knobs (`approvalMode`, optional `tokenIn` for zap-ins, etc.).
const { steps } = await strategy.build.open({
owner: '0xYourUserEOA...',
amount: 1_000_000_000n,
leverage: 3,
slippage: 0.01,
approvalMode: 'max',
})
// 4. Execute sequentially with the user's wallet. `waitForReceipt: true`
// requires a public client so the SDK can poll for confirmations.
const wallet = createWalletClient({
account: '0xYourUserEOA...',
chain: mainnet,
transport: custom(window.ethereum),
})
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
})
const results = await loopr.executeAll(wallet, steps, {
publicClient,
waitForReceipt: true,
onStep: result => {
console.log(`step ${result.index} (${result.step.label}): ${result.hash}`)
},
})Closing or adjusting a position uses the same shape — fetch the bound
Position and call its methods:
const { positions } = await loopr.positions.list({
owner: '0xYourUserEOA...',
chainId: mainnet.id,
})
const position = positions[0]
// Close the entire position.
const { steps } = await position.build.close({
owner: '0xYourUserEOA...',
slippage: 0.005,
})
// Push more collateral (re-leverages to the new target).
await position.build.push({
owner: '0xYourUserEOA...',
amount: 500_000_000n,
leverage: 3,
slippage: 0.01,
})
// Pull collateral out.
await position.build.pull({
owner: '0xYourUserEOA...',
tokenOut: position.depositToken.address,
withdrawAll: true,
})Initializing the SDK
import { createLooprSDK } from '@odysseyfi/loopr-sdk'
const loopr = createLooprSDK({
partnerId: 'your-partner-id',
// baseUrl: 'https://staging.odyssey.finance/api/partner/v1/loopr',
// apiKey: 'reserved-for-future-key-auth',
// fetch: customFetch,
})LooprSDKOptions
| Field | Type | Required | Default | Notes |
| ----------- | -------------- | -------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| partnerId | string | yes | — | Issued during partner onboarding. Forwarded as X-Partner-Id. |
| baseUrl | string | no | https://app.odyssey.finance/api/partner/v1/loopr | Override for staging or self-hosted partner gateways. |
| apiKey | string | no | — | Forwarded as X-Api-Key. Reserved for the planned key-auth rollout — see Partner identification. |
| fetch | typeof fetch | no | globalThis.fetch | Inject a custom fetch (logging, server-side adapters, retries on top of the SDK's). |
Server-side use
The SDK is browser-first but works fine in Node ≥ 20 — pass a fetch
implementation if your runtime doesn't expose globalThis.fetch, and skip
executeAll (server-side broadcast is the partner's responsibility):
const loopr = createLooprSDK({
partnerId: 'your-partner-id',
fetch: globalThis.fetch ?? (await import('undici')).fetch,
})
const strategies = await loopr.strategies.list({ chainId: 1 })
const { steps } = await strategies[0].build.open({ ...params })
// Hand `steps` to your own wallet broadcaster (ethers, web3, etc.).Strategies
strategies.list(query?) → Strategy[]
Returns enriched strategies filtered by the partner's registration scope and, optionally, the runtime query.
const strategies = await loopr.strategies.list({
chainId: 1, // single-chain
// chainIds: [1, 8453, 9745], // multi-chain
// assets: ['0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'], // filter by asset (deposit OR borrow side)
// protocols: ['euler', 'morpho'], // protocol families
})StrategyListQuery
| Field | Type | Notes |
| ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| chainId | number? | Single-chain filter. Mutually exclusive with chainIds (server uses whichever is set). |
| chainIds | number[]? | Multi-chain filter. Comma-joined on the wire. |
| assets | Address[]? | Filter to strategies where the deposit OR borrow token is in the list (EIP-55 checksummed addresses are recommended). |
| protocols | string[]? | Protocol families. Aligned with allowedProtocols from your partner scope. Currently: aave, compound, euler, morpho, synth. |
The runtime query intersects with your registration scope — narrowing only, never widening. Strategies outside your scope are never returned, regardless of the query.
strategies.get(key) → Strategy
const strategy = await loopr.strategies.get({ chainId: 1, strategyId: 42 })StrategyKey
| Field | Type | Notes |
| -------------- | ---------- | ------------------------------------------------------------------------------------------------ |
| chainId | number | Required. |
| strategyId | number | Required. Numeric on-chain id (not the string id). |
| depositToken | Address? | Optional disambiguator for cases where one (chainId, strategyId) maps to multiple token pairs. |
| borrowToken | Address? | Same. |
Note:
Strategy.idis a string from the admin API and not a stable client-side key. Always match by the(chainId, strategyId, depositToken, borrowToken)tuple.
Positions
positions.list(query) → PositionsResponse
const { positions, errors, summary } = await loopr.positions.list({
owner: '0xYourUserEOA...',
chainId: 1,
// assets: [USDC], // optional: only positions whose depositToken matches
})PositionListQuery
| Field | Type | Notes |
| --------- | ------------ | -------------------------------------------------------------------------------------------- |
| owner | Address | Required. The user's EOA — the SDK calls accounts.deriveSca server-side to find their SCA. |
| chainId | number | Required. |
| assets | Address[]? | Filter to positions whose depositToken.address is in the list. |
PositionsResponse
type PositionsResponse = {
positions: Position[] // bound objects
errors: { positionAddress: string; error: string }[] // per-position enrichment failures
summary: PositionsSummary // list-level aggregates
}errors lists positions that exist on-chain but the partner enrichment
pipeline couldn't assemble (e.g. a missing strategy match) — partners can
surface these or ignore them.
positions.get(eoa, chainId) → PositionsResponse
const result = await loopr.positions.get('0xUserEoa...', 1)Same return shape as positions.list so dashboards can use one render
path for both detail and list views. Like positions.list, partners pass
the EOA: the partner route derives the user's deterministic SCA and
queries on-chain positions by SCA, so the EOA-first contract holds end to
end.
Data shapes
The partner API does the math the first-party Loopr UI does, so dashboards get the same numbers without re-deriving them. Net APY, peg ratios, scaled amounts, and list-level summaries all come back pre-computed.
Strategy (returned by strategies.list / strategies.get)
| Field | Type | Notes |
| -------------------------------------------- | ---------------- | --------------------------------------------------------------------------- |
| chainId, strategyId | number | Routing keys. Use them for (chainId, strategyId) matching. |
| id | string | Opaque admin-side id. Not a stable client-side key. |
| name | string | e.g. "vaETH / msETH". |
| enabled | boolean | Disabled strategies are filtered out of list by default. |
| market, yieldSource | { icon, name } | Lending market (Aave V3, Euler V2, Morpho, Synth) and yield source. |
| borrowAsset, collaterals.asset | StrategyToken | usdPrice is a decimal string; apy is a percentage. |
| collaterals.naked, collaterals.unwrapped | StrategyToken? | Alternate forms (e.g. unwrapped wstETH → stETH). |
| interestRate | number? | Borrow rate at the lending market (percentage, e.g. 1.0). |
| estimatedAPY.{min,max} | number | Looped strategy APY before fees, percentage. |
| fees.{depositFee,withdrawFee} | number | Percentages. |
| fees.performanceFee | number? | Percentage (e.g. 2 for 2%). |
| rewardsApr, hasRewards | number?, bool? | Merkl reward APR (percentage) and presence flag. |
| liquidity.currentUtilization | number? | 0..1 fraction, not 0..100. |
| liquidity.maxUtilization | number? | 0..1 fraction. |
| liquidity.totalLiquidity | string? | Raw bigint string in borrowAsset.decimals. Prefer availableLiquidity. |
| liquidity.optimalLiquidity | string? | Raw bigint string. Lending-market specific. |
| liquidity.marketLiquidity | string? | Raw bigint string. Pass to build.open only when the market needs it. |
| collateralFactor | number? | Percentage (0..100). See maxLeverage for the leverage cap it implies. |
| maxLoop | number | Max iterations of the leverage loop the SDK will use. |
| flashBorrowToken, swapper | Address?, str? | Routing internals. Partners rarely need these directly. |
| pendleExpiry, isPendleExpired | string?, bool? | ISO timestamp; only present for Pendle PT strategies. |
| minBorrowAmount | string? | Raw bigint string. Lending-market minimum borrow size. |
| netApy | number | What the UI shows. maxStrategyApy * (1 - fee/100) + rewardsApr. |
| maxStrategyApy | number | max(estimatedAPY.min, estimatedAPY.max). |
| debtPeg / collateralPeg | string? | Decimal string ratio of the two usdPrice values, 4dp. |
| availableLiquidity.{raw,scaled,symbol} | object? | Raw bigint and the scaled borrowAsset-unit amount. |
| maxLeverage | number? | Theoretical safe max leverage from collateralFactor. |
Position (returned by positions.list / positions.get)
| Field | Type | Notes |
| --------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| address, chainId, strategyId | — | The position's SCA-owned address and routing keys. |
| strategyName | string | e.g. "vaUSDC / msUSD". |
| market | { icon, name }? | Mirrors the strategy's market so list views render without joining. |
| depositToken, borrowToken | PositionToken | balance is the user EOA's wallet balance (raw bigint string). |
| depositedAmount, borrowedAmount, debt | string | Raw bigint strings. Prefer *Scaled for display. |
| interestRate | number | Borrow rate at the lending market, percentage (e.g. 5.2 for 5.2%). Normalized server-side from the e18 raw on-chain rate. |
| collateralFactor | number | 0..1 fraction. See liquidationLtv for the percentage form. |
| leverage | number | Effective leverage, e.g. 2.99. |
| loopedApy | number | Strategy APY before performance fee + rewards (percentage). |
| health | string? | Categorical: 'good' \| 'fair' \| 'risky' \| 'liquidation'. |
| healthFactor | number | Below 1 is liquidatable; clamped to [1, 10] for display. |
| ltv | number? | Current LTV percentage (0..100). |
| liquidationPrice | string? | Decimal price string of depositToken in borrowToken. |
| hasRewards | boolean? | True if the position has Merkl rewards available. |
| pnl | object? | null if PnL is not yet computable (no opening price etc.). |
| pnl.totalPnl, pnl.netReturn | number | USD amounts. netReturn is post-fee. |
| pnl.totalPnlPercent | number | Percentage. |
| pnl.protocolFees, pnl.merklRewards | number | USD amounts. |
| pnl.entryPeg | number? | Borrow-token amount per 1 unit of deposit at open time. |
| pnl.openTimestamp | number | Unix seconds. |
| netApy | number | What the UI shows. Includes the strategy's performance fee + rewards. |
| performanceFee, rewardsApr, collateralApy | number? | Denormalized from the joined strategy — no manual join required. |
| liquidationLtv | number? | collateralFactor * 100. |
| depositedScaled / borrowedScaled / debtScaled | string? | Pre-scaled by token decimals. |
| valueUsd / borrowedValueUsd | string? | Decimal USD strings. |
| pnl.netReturnInDepositToken | number? | Yield in deposit-token units (yield-earned-tokens row in the UI). |
PositionsResponse.summary
positions.list and positions.get return a summary block alongside the
list — the same aggregates the first-party Loopr dashboard shows in its top
cards:
| Field | Type | Source |
| ------------------- | -------- | ----------------------------------------------------- |
| positionsValueUsd | string | Sum of valueUsd across the returned positions. |
| activeCount | number | Count where health !== 'liquidation'. |
| avgApy | number | Mean of loopedApy across positions that report one. |
Bold fields are derived during enrichment; the rest mirror what the lending market and on-chain state report.
Quotes
Quotes are server-computed previews backed by an oracle quote and the
strategy's pricing — the source of truth for what the eventual build.*
will produce. Use these at review time before the user signs. For
slider-tick previews, use project.* instead.
All quote.* methods return a QuoteEnvelope<Q>:
type QuoteEnvelope<Q> = {
quote: Q // operation-specific body — see tables below
expiresAt: number // unix ms; quote validity window
quoteId: string // correlation id (logging today; quote-pinning later)
}If the user takes longer than the validity window to act, partners should
re-quote before calling build.*. The default window today is several
minutes but the SDK doesn't pin a value — read expiresAt and respect it.
strategy.quote.open(params) → QuoteEnvelope<OpenQuote>
const { quote, expiresAt } = await strategy.quote.open({
owner: userEoa,
amount: 1_000_000_000n, // raw bigint in strategy.collaterals.asset.decimals
leverage: 3,
slippage: 0.01, // 1% (decimal). Optional; SDK default is 1%.
})OpenQuote
| Field | Type | Notes |
| -------------------- | -------- | ------------------------------------------------------------------------------ |
| expectedCollateral | bigint | Final collateral after the leverage loop, in deposit-token units. |
| expectedDebt | bigint | Final debt, in deposit-token units. |
| healthFactor | number | Post-open health factor (e18 scale). |
| liquidationPrice | string | Decimal price of 1 unit of deposit token in borrow-token terms at liquidation. |
| slippageApplied | bigint | Slippage tolerance the quote was computed with, in 1e18 units. Echo of input. |
position.quote.close(params?) → QuoteEnvelope<CloseQuote>
const { quote } = await position.quote.close({
slippage: 0.005, // optional
})CloseQuote
All amounts are raw bigints in the indicated token's decimals.
| Field | Type | Notes |
| ---------------------- | --------- | -------------------------------------------------------------------------- |
| borrowedAmount | bigint | Total borrow-token debt being repaid. |
| borrowToken | Address | Borrow token address. |
| depositToken | Address | Deposit token address. |
| depositedAmount | bigint | Total deposit-token collateral being unwound. |
| flashBorrowToken | Address | Token used to flash-loan the unwind (often = borrowToken). |
| flashLoanAmount | bigint | Flash-loan principal. |
| flashLoanRepayAmount | bigint | Flash-loan principal + fee. |
| transferOutAmount | bigint | Amount of depositToken transferred from position to user during close. |
| estimatedReceiving | bigint | What the user actually pockets after the flash loan repays and fees clear. |
| slippageApplied | bigint | Echo of input, 1e18 units. |
position.quote.adjust(params) → QuoteEnvelope<AdjustQuote>
const { quote } = await position.quote.adjust({
delta: 100_000_000n, // positive = push more collateral, negative = pull
slippage: 0.01,
})AdjustQuote
| Field | Type | Notes |
| ------------------ | -------- | -------------------------------------------------- |
| delta | bigint | Positive = push, negative = pull (echo of input). |
| healthFactor | number | Post-adjust health factor. |
| liquidationPrice | string | Decimal price string at the new liquidation point. |
| slippageApplied | bigint | Echo of input, 1e18 units. |
Borrow / Repay
Borrow takes more debt against the existing position; repay reduces debt.
Both come in quote and build flavors.
Flat-only. Borrow and repay live on
sdk.quote.*/sdk.build.*— they're not bound onPosition. PasspositionAddress+strategyId+chainIdexplicitly. (The reason is route-level: borrow needs an optional MorphomarketIdand repay's two modes are wire-shaped differently, so the bound flat-vs-bound symmetry breaks down. May be revisited once the surface stabilizes.)
sdk.quote.borrow(params) → QuoteEnvelope<BorrowQuote>
const { quote } = await loopr.quote.borrow({
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
borrowAmount: 500_000_000n, // 500 msUSD (6dp)
// marketId: '0x…', // optional — only for Morpho
})BorrowQuoteParams
| Field | Type | Notes |
| ----------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| chainId | number | Required. |
| strategyId | number | Required. |
| positionAddress | Address | The SCA-owned position contract address. |
| borrowAmount | bigint | Raw bigint in borrowToken.decimals. Must be non-zero. |
| marketId | Hex? | 32-byte hex. Required when the strategy is Morpho-backed and the borrow may exceed current market liquidity — the build route uses it to prepend a public-allocator reallocation tx. Skip for non-Morpho. |
BorrowQuote
| Field | Type | Notes |
| ------------------ | -------- | -------------------------------------------------- |
| borrowed | bigint | Echo of borrowAmount. |
| healthFactor | number | Post-borrow health factor. |
| liquidationPrice | string | Decimal price string at the new liquidation point. |
Borrow has no
slippageApplied— the operation doesn't touch the swapper.
sdk.quote.repay(params) → QuoteEnvelope<RepayQuote>
Two modes:
auto— flash-loan deleverage. The SCA borrows the repay amount (plus any optional collateral withdraw) via flash loan, swaps through the swapper, and repays the position's debt. Goes through the swapper, so partners passslippage.manual— caller funds the repay with externalborrowToken. The SCA pullsborrowTokenfrom the EOA and repays directly. No flash loan, no swap, no slippage. Requires an EOA-direct approve before the build (the route inserts the approve step automatically).
// Auto: deleverage with a 1% swap tolerance
const auto = await loopr.quote.repay({
mode: 'auto',
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
repayAmount: 500_000_000n,
withdrawAmount: 50_000_000n, // optional bundled collateral pull
slippage: 0.01,
})
// Manual: caller pays the repay from their own wallet
const manual = await loopr.quote.repay({
mode: 'manual',
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
repayAmount: 500_000_000n,
})RepayQuoteParams (discriminated union on mode)
| Field | Type | Mode | Notes |
| ----------------- | ---------- | --------- | -------------------------------------------------------------- |
| mode | 'auto' | both | Discriminator. |
| mode | 'manual' | both | " |
| chainId | number | both | Required. |
| strategyId | number | both | Required. |
| positionAddress | Address | both | Required. |
| repayAmount | bigint | both | Raw bigint in borrowToken.decimals. Must be non-zero. |
| withdrawAmount | bigint? | auto only | Optional collateral withdraw bundled into the same flash-loop. |
| slippage | number | auto only | Decimal (e.g. 0.01 = 1%). Required for auto. |
RepayQuote
| Field | Type | Mode | Notes |
| ---------------------- | ---------- | --------- | ------------------------------------------------------- |
| mode | 'auto' | — | Echo discriminator. |
| mode | 'manual' | — | " |
| repaid | bigint | both | Echo of repayAmount. |
| healthFactor | number | both | Post-repay health factor. |
| liquidationPrice | string | both | New liquidation point. |
| withdrawn | bigint | auto only | Echo of withdrawAmount (or 0n). |
| flashLoanAmount | bigint | auto only | Flash-loan principal. |
| flashLoanRepayAmount | bigint | auto only | Flash-loan principal + fee. |
| transferOutAmount | bigint | auto only | Amount transferred from the position during the unwind. |
| slippageApplied | bigint | auto only | Echo of input slippage in 1e18 units. |
sdk.build.borrow(params) → BuildResult
Same param shape as quote.borrow, plus owner: Address (the EOA — SCA
derived server-side).
const { steps } = await loopr.build.borrow({
owner: '0xYourUserEOA...',
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
borrowAmount: 500_000_000n,
})sdk.build.repay(params) → BuildResult
Discriminated by mode like quote.repay. Auto takes slippage, manual
takes approvalMode.
// Auto
await loopr.build.repay({
mode: 'auto',
owner: userEoa,
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
repayAmount: 500_000_000n,
withdrawAmount: 50_000_000n,
slippage: 0.01,
})
// Manual
await loopr.build.repay({
mode: 'manual',
owner: userEoa,
chainId: 1,
strategyId: 42,
positionAddress: '0xPosition...',
repayAmount: 500_000_000n,
approvalMode: 'max', // optional — see Approval mode
})Auto vs manual repay: when to use which
| Situation | Recommended mode |
| ---------------------------------------------------------- | --------------------------------------------------- |
| User has no borrowToken in wallet, just leveraged equity | auto (deleverages from collateral via swap) |
| User wants to bundle a partial collateral withdraw | auto with withdrawAmount set |
| User holds idle borrowToken outside Loopr | manual (cheaper — no swap, no flash-loan fee) |
| Closing a position completely | Use build.close instead — it's the optimized path |
Live previews
For UIs with leverage sliders or borrow-amount inputs, partners need the
projected APY / Health / LTV / liquidation price to update on every tick —
without firing a server quote per slider movement. The bound project.*
methods are pure-client what-if helpers: same formulas as the first-party
Loopr form (useEstimatedApy + usePositionHealth), zero network
round-trips.
// On every slider tick:
const preview = strategy.project.open({
amount: 1_000_000_000n, // 1000 USDC (6dp)
leverage: 3,
})
// preview.projectedNetApy → 12.74 (percentage)
// preview.projectedHealth → 'good' ('good' | 'fair' | 'risky' | 'liquidation')
// preview.projectedHealthFactor → 1.35 (clamped to [1, 10] like the UI)
// preview.projectedLtv → 66.67 (percentage)
// preview.projectedLiquidationPrice → 0.7424 (borrow-token price in deposit-token units)
// preview.projectedCollateral → 3000000000n (raw bigint, deposit decimals)
// preview.projectedDebt → 2000000000n
// preview.projectedBorrowedAmount → ~2004400000n (borrow decimals, peg-approximated)
// Position-side previews — covers every adjust operation with a UI surface.
position.project.borrow({ amount: 500_000_000n }) // borrow more (debt up)
position.project.push({ amount: 500_000_000n, leverage: 3 }) // add collateral, re-leverage
position.project.pull({ withdrawAmount: 100_000_000n }) // pull collateral
position.project.pull({ withdrawAll: true }) // pull all unlocked
position.project.close() // estimated receiving on closeMethod reference
| Method | Returns | Notes |
| --------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------- |
| strategy.project.open({ amount, leverage }) | Projection | Open-position preview from scratch. |
| position.project.borrow({ amount }) | Projection | Borrow more from the existing position. amount is in borrowToken.decimals. |
| position.project.push({ amount, leverage }) | Projection | Add collateral and re-leverage. amount is in depositToken.decimals. |
| position.project.pull({ withdrawAll? withdrawAmount? }) | Projection | Pull collateral, debt unchanged. Pass exactly one of the two flags. |
| position.project.close() | CloseProjection | Approximated proceeds if the position were closed now. See CloseProjection. |
Projection
| Field | Type | Notes |
| --------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------ |
| projectedCollateral | bigint | Deposit-token units (raw bigint). |
| projectedDebt | bigint | Deposit-token units. Peg-approximated. |
| projectedBorrowedAmount | bigint | Borrow-token units. Peg-approximated. |
| projectedLeverage | number | Effective leverage after the operation. Echo of input for projectOpen; computed for projectBorrow. |
| projectedNetApy | number | estimateLeveragedApy(...) * (1 - fee/100) + rewardsApr. Percentage. |
| projectedHealthFactor | number | Clamped to [1, 10] to mirror the UI tooltip. |
| projectedHealth | PositionHealth | 'good' \| 'fair' \| 'risky' \| 'liquidation'. |
| projectedLtv | number | 0..100 percentage. |
| projectedLiquidationPrice | number? | Per-unit borrow-token price (in deposit-token terms) at liquidation. |
CloseProjection
| Field | Type | Notes |
| ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------- |
| projectedReceiving | bigint | Approximated receive amount in position.depositToken.decimals, gross of swap slippage. Equals depositedAmount - debt. |
| projectedReceivingUsd | string? | USD equivalent (decimal string), if depositToken.usdPrice is available. |
For the oracle-exact close numbers (flash-loan amounts, exact receive after
slippage, etc.), use position.quote.close — that's a server round-trip.
When to use quote.* vs project.*
| Scenario | Method |
| ---------------------------------- | ----------------------------------------------------------------------- |
| Slider tick / live form preview | strategy.project.open, position.project.{borrow,push,pull} |
| "If I close now, what do I get?" | position.project.close (approximate) → position.quote.close (exact) |
| Review screen / "are you sure" CTA | strategy.quote.open, position.quote.{close,adjust} |
| Pre-build.* validation | strategy.quote.open etc. |
The project.* numbers use a peg approximation (USD prices on the
strategy / position) and are typically sub-bp off the eventual on-chain
result for stable-collateralized strategies. They're correct enough for a
slider preview — not correct enough to gate a transaction. Use quote.* at
review time for the oracle-exact numbers the resulting build will use.
Plain-function exports
import {
projectOpen,
projectBorrow,
projectPush,
projectPull,
projectClose,
estimateLeveragedApy,
calculateNetApy,
} from '@odysseyfi/loopr-sdk'Useful for partners building their own projection abstractions or memoizing slider input to a custom shape.
Calculators
Where project.* answers "given these inputs, what's the resulting
state?", calc.* answers the inverse: "given a target state, what input
gets me there?" Pure-client like project.* (no network, no kernel deps),
just complementary direction.
const strategies = await loopr.strategies.list({ chainId: mainnet.id })
const strategy = strategies[0]
// "I want $3000 of total leveraged exposure at 3x — how much do I deposit?"
const required = strategy.calc.requiredDeposit({
targetCollateral: 3_000_000_000n, // 3000 USDC (6dp)
targetLeverage: 3,
})
// → 1_000_000_000n (1000 USDC)
// "Is this Pendle PT close to expiry?"
const days = strategy.calc.pendleDaysUntilExpiry()
// → 14.2 (days), or undefined for non-Pendle strategies, or negative if past
const { positions } = await loopr.positions.list({ owner, chainId })
const position = positions[0]
// "Max borrow" button on a borrow-more modal
const max = position.calc.maxBorrowable({ minHealthFactor: 1.05 })
// → bigint in borrowToken decimals
// "Max withdraw" button on a pull modal
const withdrawable = position.calc.maxWithdrawable()
// → bigint in depositToken decimals
// Risk badge: "liquidates if vaUSDC drops 12% vs msUSD"
const distance = position.calc.distanceToLiquidationPct()
// → number (percentage), or undefined if prices missingMethod reference
| Method | Returns |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| strategy.calc.requiredDeposit({ targetCollateral, targetLeverage }) | bigint (deposit-token decimals; 0n for invalid leverage) |
| strategy.calc.pendleDaysUntilExpiry(now?) | number? — days until expiry; undefined for non-Pendle |
| position.calc.maxBorrowable({ minHealthFactor? }) | bigint (borrow-token decimals; 0n past threshold or missing prices) |
| position.calc.maxWithdrawable({ minHealthFactor? }) | bigint (deposit-token decimals; full deposit when debt === 0) |
| position.calc.distanceToLiquidationPct() | number? — percentage drop in deposit/borrow peg to reach liquidation; undefined if prices/liquidationPrice missing |
Defaults
minHealthFactor defaults to 1.05 — the "fair" bucket boundary in
HEALTH_THRESHOLDS. Lower = more aggressive (closer to liquidation),
higher = more conservative (more headroom for price moves). A common
partner pattern is to expose two presets: "max safe" at 1.05, "low
risk" at 1.5 or higher.
maxWithdrawable and pending fees
The Loopr position contract accrues protocol fees on-chain (via
getUpdatedPendingFees()) independently of debt. maxWithdrawable does
not subtract those fees — Position doesn't surface them today. For
non-zero debt the minHealthFactor=1.05 default leaves ~5% headroom that
absorbs typical accrued fees in practice. The zero-debt branch has no
such buffer: a long-open position with accrued fees would revert if a
partner pipes the value straight into build.pull({ withdrawAmount }).
Use maxWithdrawable for "estimate / show in UI / pre-fill the input."
Use withdrawAll: true on build.pull for "actually pull everything" —
the contract resolves the precise fee-adjusted amount on-chain.
Plain-function exports
import {
calculateRequiredDeposit,
calculatePendleDaysUntilExpiry,
calculateMaxBorrowable,
calculateMaxWithdrawable,
calculateDistanceToLiquidationPct,
} from '@odysseyfi/loopr-sdk'Useful when you don't have a bound Strategy / Position in hand (e.g.
custom data-fetching layer that hands you raw fields).
Building transactions
Build methods take user-specific inputs and return an array of unsigned
transaction Steps the partner client signs and broadcasts as the EOA —
no @zerodev/* import on the partner side, no kernel knowledge.
The server derives the SCA from owner, checks if it needs deployment,
encodes the kernel batch, and converts decimal leverage / slippage
to 1e18 wire units before returning steps.
All build methods return:
type BuildResult = {
steps: Step[] // ordered: approve (if needed) → sca-deploy (if needed) → execute
estimatedGas: bigint[] // per-step gas estimate
expiresAt: number // unix ms — re-build past this point
}
type Step = {
kind: StepKind // 'sca-deploy' | 'approve' | 'execute'
to: Address // contract to call
data: Hex // calldata
value: bigint // ETH value (sums inner values for the batched execute step)
chainId: number // step's chain
label: string // human-readable label, e.g. "Approve USDC"
}Step kinds
| Kind | to | data | When emitted |
| ------------ | ------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| approve | token contract | approve(spender, amount) | Once, before SCA-bound steps, when the EOA's allowance to the SCA is below amount. |
| sca-deploy | kernel factory | createAccount(...) (factory call) | Once, only when the user's SCA has no on-chain code yet. Deploys the SCA at its deterministic address. |
| execute | SCA | kernel-encoded batch of inner calls | Always, exactly once per build. Wraps every Loopr operation (and installModule if it's the first interaction) into a single tx. |
Step.label is suitable for surfacing in a transaction-progress UI.
Step.kind lets partners render approve and deploy steps differently
(e.g. a separate "setting up your account" milestone for the first-ever
open). For most users the sca-deploy step is absent — it's emitted
only on the user's very first Loopr interaction across all chains.
Why does the partner client never see kernel internals?
Kernel encoding (encodeCalls) and factory-args resolution
(getFactoryArgs) run server-side. Partners get raw
{ to, data, value, chainId } tuples that any wallet stack can sign:
viem walletClient.sendTransaction, wagmi's useSendTransaction,
ethers' wallet.sendTransaction, etc. Future kernel migrations
(V3.2, EIP-7702 path, eventual Safe swap) ship server-side without
partner-side changes.
strategy.build.open(params) → BuildResult
const { steps } = await strategy.build.open({
owner: userEoa,
amount: 1_000_000_000n,
leverage: 3,
slippage: 0.01,
approvalMode: 'max', // optional, default 'exact'
// tokenIn: WETH, // optional zap-in (see below)
})StrategyBuildOpenParams
| Field | Type | Required | Notes |
| ----------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------ |
| owner | Address | yes | EOA that signs and owns the resulting position. SCA derived internally. |
| amount | bigint | yes | Raw bigint in strategy.collaterals.asset.decimals (or tokenIn.decimals for zap-ins). |
| leverage | number | yes | Decimal (e.g. 3 = 3x). >= 1, <= strategy.maxLeverage. |
| slippage | number? | no | Decimal (e.g. 0.01 = 1%). Default 1%. Throws INVALID_OPTIONS on negative / non-finite. |
| approvalMode | 'exact' \| 'max'? | no | See below. Default 'exact'. |
| tokenIn | Address? | no | Zap-in: open with a different token than the strategy's deposit asset. Server-side swap. |
| tokenInSwapper | string? | no | Override the swapper used when tokenIn is set. |
| marketLiquidity | bigint? | no | Pre-fetched market liquidity hint. Skip unless the partner has a specific use case. |
position.build.close(params) → BuildResult
const { steps } = await position.build.close({
owner: userEoa,
slippage: 0.005,
// receiveToken: WETH, // optional: receive a different token than depositToken
})PositionBuildCloseParams
| Field | Type | Required | Notes |
| -------------- | ---------- | -------- | ------------------------------------------------------ |
| owner | Address | yes | EOA signing the close. |
| slippage | number? | no | Decimal. Default 1%. |
| receiveToken | Address? | no | Receive proceeds in a token other than depositToken. |
position.build.push(params) → BuildResult
Adds collateral to an existing position and re-leverages.
const { steps } = await position.build.push({
owner: userEoa,
amount: 500_000_000n,
leverage: 3,
slippage: 0.01,
approvalMode: 'max',
})PositionBuildPushParams
| Field | Type | Required | Notes |
| ---------------- | ------------------- | -------- | ----------------------------------------------------------------------- |
| owner | Address | yes | EOA signing the push. |
| amount | bigint | yes | Raw bigint in position.depositToken.decimals (or tokenIn.decimals). |
| leverage | number | yes | New target leverage after the push. |
| slippage | number? | no | Default 1%. |
| approvalMode | 'exact' \| 'max'? | no | See below. |
| tokenIn | Address? | no | Zap-in token. |
| tokenInSwapper | string? | no | Override swapper. |
position.build.pull(params) → BuildResult
Withdraws collateral. No re-leverage; the existing leverage scales with the new collateral level.
const { steps } = await position.build.pull({
owner: userEoa,
tokenOut: position.depositToken.address,
withdrawAll: true,
// withdrawAmount: 100_000_000n,
})PositionBuildPullParams
| Field | Type | Required | Notes |
| ---------------- | ---------- | -------- | ---------------------------------------------------------------------------------- |
| owner | Address | yes | EOA signing the pull. |
| tokenOut | Address | yes | Token the user receives. Often position.depositToken.address. |
| withdrawAll | boolean? | no | Withdraw the entire unlocked collateral. Mutually exclusive with withdrawAmount. |
| withdrawAmount | bigint? | no | Specific amount to withdraw, in position.depositToken.decimals. |
Approval mode ('exact' | 'max')
When the SDK needs to insert an approve step before the operation:
'exact'(default): approve onlyamount. Safer, but the user signs one approve per operation. Best for one-off opens.'max': approve2^256 - 1. The user signs once, then subsequent opens / pushes against the same token + spender skip the approve step. Best for partners running active management UIs.
Choose based on your UX: a single-shot open page should default to
'exact'; a position-management dashboard should default to 'max'.
Zap-in (tokenIn)
To open or push with a different token than the strategy's deposit asset (e.g. open a vaUSDC strategy from WETH):
await strategy.build.open({
owner: userEoa,
amount: parseEther('1'), // 1 WETH
leverage: 3,
slippage: 0.01,
tokenIn: WETH_ADDRESS, // <- triggers server-side WETH → vaUSDC swap
})The SDK adds a swap step before the leverage loop. Slippage applies to the
swap as well as the loop. Set tokenInSwapper only if the partner has a
specific routing requirement; otherwise the server picks the best swapper.
Step ordering and idempotency
steps are returned in execution order. kind === 'sca-deploy' is
deterministic (same calldata for the same EOA + chain) and idempotent on
the contract side, so re-running it after a partial failure is safe.
approve steps are also safe to re-run if the prior approval transaction
succeeded but the SDK didn't see the receipt.
Executing transactions
executeAll signs and broadcasts each step in order, optionally waiting for
each receipt before continuing. The SDK is wallet-agnostic — any object
that exposes sendTransaction and account works (viem WalletClient,
wagmi config wrapper, custom ethers adapter, etc.).
const results = await loopr.executeAll(walletClient, steps, {
publicClient,
waitForReceipt: true,
onStep: result => {
console.log(`✓ step ${result.index} (${result.step.label}): ${result.hash}`)
},
receiptTimeoutMs: 180_000,
})ExecuteAllOptions
| Field | Type | Default | Notes |
| ------------------ | ----------------------------- | --------- | --------------------------------------------------------------------------------------------- |
| waitForReceipt | boolean? | true | When true, requires publicClient and waits for each receipt before sending the next step. |
| publicClient | PublicClient? | — | Required when waitForReceipt !== false. Used for waitForTransactionReceipt. |
| onStep | (r: ExecuteResult) => void? | — | Invoked after each successful step, in order. |
| receiptTimeoutMs | number? | 120_000 | Per-step max wait for waitForTransactionReceipt. On expiry → RECEIPT_TIMEOUT error. |
Don't combine
waitForReceipt: falsewith builds that include ansca-deploystep. The kernel batch step that follows the deploy targets the user's SCA address. Without waiting for the deploy receipt, the EOA's tx to the SCA gets broadcast before the SCA exists on-chain and the EVM treats it as a value-only transfer — silent success, no events, no position.executeAllrefuses this combination at the SDK boundary withLooprSDKError(INVALID_OPTIONS). If you need fire-and-forget for performance, build separately and submit only whensteps.every(s => s.kind !== 'sca-deploy')(or just leavewaitForReceiptat its safe default).
ExecuteResult
type ExecuteResult = {
index: number // 0-based step index
step: Step // the step that was executed
hash: Hex // tx hash
receipt?: TransactionReceipt // present when waitForReceipt !== false
}Wallet client requirements
type ExecuteAllWalletClient = {
sendTransaction: (args: { account, to, data, value, chain }) => Promise<Hex>
account: { address: Address, ... }
}A viem WalletClient satisfies this directly. Wagmi's useWalletClient hook
returns a client that does too. For ethers, wrap your Wallet to expose the
matching shape. The SDK reads account to enforce that all steps target the
same wallet.
Chain enforcement
Before each step, executeAll reads the wallet's current chain (via
getChainId() if exposed, falling back to wallet.chain.id) and throws
WRONG_CHAIN if it doesn't match step.chainId. Switch the user's wallet
before calling executeAll, or split the build into per-chain batches.
Error semantics and partial state
When a step fails partway through, executeAll throws a LooprSDKError
with partialResults attached — the steps that did succeed before the
failure, in order:
try {
const results = await loopr.executeAll(wallet, steps, { publicClient })
} catch (err) {
if (err instanceof LooprSDKError) {
const partial = (err as any).partialResults as ExecuteResult[] | undefined
// partial[0..n-1] succeeded; the (n)-th step in `steps` is where it broke.
// For approve / SCA-deploy steps, you can usually retry from index n;
// for execute steps, re-quote and re-build first.
}
}| Code | When |
| ----------------------- | -------------------------------------------------------------------------------------------- |
| MISSING_ACCOUNT | walletClient.account is unset. |
| MISSING_PUBLIC_CLIENT | waitForReceipt: true (the default) but no publicClient passed. |
| WRONG_CHAIN | Wallet's chain doesn't match the step's chainId. |
| RECEIPT_TIMEOUT | waitForTransactionReceipt exceeded receiptTimeoutMs. details.hash is the pending hash. |
| STEP_REVERTED | The transaction was mined but reverted. details.{hash, index}. |
| STEP_FAILED | The wallet rejected, network error, or any other thrown exception during send. |
Simulating step sequences
simulate(steps, { from }) dry-runs a built step sequence as a specific EOA
without submitting transactions. Useful for pre-flight validation before
prompting the user to sign.
const result = await loopr.simulate(steps, { from: userEoa })
if (!result.ok) {
console.error(
`simulate failed at step ${result.firstFailure.stepIndex}:`,
result.firstFailure.revertReason ?? result.firstFailure.decoded,
)
// Show "this transaction will fail" to the user before they sign.
}SimulateResult
type SimulateResult =
| { ok: true; results: unknown[] } // every step would succeed
| {
ok: false
results: unknown[] // per-step partial output
firstFailure: {
stepIndex: number // which step would revert
revertReason?: string // raw revert string if available
decoded?: { name: string; args?: unknown[] } // decoded custom error
}
}from is required — Loopr operations are sender-context-sensitive (SCA
ownership, approvals, etc.), so the simulation needs to know who's calling.
Smart-contract account derivation
Loopr positions are owned by a deterministic smart-contract account (SCA)
derived from the EOA. You don't need to manage this — every build.*
method on Strategy and Position derives the SCA internally from the
owner (EOA) you pass in.
accounts.deriveSca({ eoa }) → Address
const sca = await loopr.accounts.deriveSca({ eoa: userEoa })
// "0x...abc" — the deterministic SCA owned by userEoaUse this when:
- The UI displays the SCA address before opening a position (e.g. "your account: 0xabc...").
- The user wants to pre-fund the SCA (send tokens directly to the SCA before opening, instead of approving the EOA).
- A backend job needs the SCA for indexing or analytics.
Caching
Resolutions are deterministic per-EOA (kernel index 0, kernel v3.1, EntryPoint 0.7) and identical across chains. The SDK caches results in-memory for the lifetime of the SDK instance, keyed by lowercased EOA so different casings of the same address share a cache entry. There's no TTL — the mapping never changes.
If the underlying HTTP call fails, the cache entry is evicted so the next call retries cleanly.
Recipes
Auto-preview pattern (live + oracle-exact)
The pattern every adjust / open page wants: render a pure-client live projection on every keystroke, then fetch the oracle-exact quote and the build in parallel after a debounce. Sign button stays disabled until the build matches the latest inputs, so a stale build is never signed.
const PREVIEW_DEBOUNCE_MS = 400
const useOpenPreview = (
strategy: Strategy | null,
owner?: Address,
amount?: bigint,
leverage?: number,
slippage?: number,
) => {
const [envelope, setEnvelope] = useState<QuoteEnvelope<OpenQuote> | null>(
null,
)
const [build, setBuild] = useState<BuildResult | null>(null)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Live, sub-bp accurate, no network.
const live = useMemo(
() =>
strategy && amount && leverage
? strategy.project.open({ amount, leverage })
: null,
[strategy, amount, leverage],
)
useEffect(() => {
if (!strategy || !owner || !amount || !leverage) return undefined
let cancelled = false
setPreviewing(true)
const t = setTimeout(async () => {
try {
const [q, b] = await Promise.all([
strategy.quote.open({ amount, leverage, slippage }),
strategy.build.open({ amount, leverage, owner, slippage }),
])
if (!cancelled) {
setEnvelope(q)
setBuild(b)
setError(null)
}
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err))
} finally {
if (!cancelled) setPreviewing(false)
}
}, PREVIEW_DEBOUNCE_MS)
return () => {
cancelled = true
clearTimeout(t)
}
}, [strategy, owner, amount, lever