npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@odysseyfi/loopr-sdk

v0.2.0

Published

Public SDK for Odyssey Loopr leveraged strategies. Embed Loopr into your own frontend with plain EOA wallets.

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 LooprSDKError with code NOT_IMPLEMENTED.

Contents

Install

npm install @odysseyfi/loopr-sdk viem

viem >= 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 typesdist/*.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 objectsStrategy 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.id is 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 on Position. Pass positionAddress + strategyId + chainId explicitly. (The reason is route-level: borrow needs an optional Morpho marketId and 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 pass slippage.
  • manual — caller funds the repay with external borrowToken. The SCA pulls borrowToken from 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 close

Method 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 missing

Method 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 only amount. Safer, but the user signs one approve per operation. Best for one-off opens.
  • 'max': approve 2^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: false with builds that include an sca-deploy step. 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. executeAll refuses this combination at the SDK boundary with LooprSDKError(INVALID_OPTIONS). If you need fire-and-forget for performance, build separately and submit only when steps.every(s => s.kind !== 'sca-deploy') (or just leave waitForReceipt at 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 userEoa

Use 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