@zerodev/smart-recipes
v0.8.1
Published
Cross-chain DeFi deposits in one signature. Quote a vault deposit, have the user fund a single address, and a relayer bridges and deposits on the destination chain.
Downloads
572
Readme
@zerodev/smart-recipes
Thin HTTP client for the Smart Recipes server. Express a high-level intent — "deposit into Aave / a Morpho vault / any ERC-4626" or "bridge-and-swap to this token" — as one namespaced call. The call returns a Quote: a Smart Routing Address (SRA) plus a ready-to-sign source-chain transaction (and an equivalent ERC-4337 user op). The user funds the SRA; ZeroDev's relayer bridges to the destination chain (if cross-chain) and runs the pre-baked calls there.
Zero on-chain deps — no viem at runtime, no signer, no bundler. All dynamic
logic (SRA creation, calldata, quoter, RPC, vault discovery, fees) lives
server-side. Friendly → wire resolution (token symbols, decimal scaling)
happens on the server; the SDK returns wire strings verbatim.
Install
pnpm add @zerodev/smart-recipesESM + CJS dual build. Node ≥ 18 (uses global fetch; inject your own otherwise).
Quick start
import { createSmartRecipes, TOKENS } from '@zerodev/smart-recipes'
// projectId is the only required option; serverUrl defaults to ZeroDev's hosted server.
const sr = createSmartRecipes({ projectId: 'abc' })
// Cross-chain deposit into a Morpho vault.
const quote = await sr.morpho.deposit({
owner: '0xUser…',
amount: '100', // display units — server scales by token decimals
token: TOKENS.USDC, // symbol → canonical per-chain address (or pass a raw address)
srcChainId: 8453, // Base
destChainId: 42161, // Arbitrum
into: vault, // a Vault from listVaults(), or a vault id / address
})
// The call PREPARES, it does not broadcast. Send it yourself:
for (const call of quote.transaction.calls) {
await wallet.sendTransaction({ ...call, value: BigInt(call.value) })
}
// …or as one ERC-4337 user op:
await kernelClient.sendUserOp({ callData: quote.userOp.callData })createSmartRecipes is synchronous — all async work happens at call time.
Init config
| Param | Type | Required | Notes |
|--------------|----------------|----------|-----------------------------------------------------|
| projectId | string | yes | ZeroDev project ID (x-project-id header) |
| serverUrl | string | no | Defaults to DEFAULT_SERVER_URL (ZeroDev's hosted server). Set only for a self-hosted or staging server; a blank string counts as absent |
| fetch | typeof fetch | no | Injectable fetch (tests / non-browser) |
| timeoutMs | number | no | Per-request abort timeout, default 30000 |
| maxRetries | number | no | Retries on transient failures (idempotent GETs only), default 2 |
Quote-building POSTs are never retried automatically (each call can create a new SRA server-side); GETs retry 429/502/503 and network failures with backoff.
Surface
Facades (protocol namespaces)
Each binds a protocol — the server resolves it to a per-protocol adapter
that builds the deposit calls and verifies the target on-chain before
quoting (a Morpho-Blue market passed where a 4626 vault is expected fails
fast, not after a refund). Same-chain vs cross-chain is picked by
srcChainId === destChainId.
sr.aave.deposit(params): Promise<Quote> // no `into` — pool implied by token + destChainId
sr.morpho.deposit(params): Promise<Quote> // `into` required (many vaults)
sr.fluid.deposit(params): Promise<Quote>
sr.yearn.deposit(params): Promise<Quote>
sr.erc4626.deposit(params): Promise<Quote> // unbranded ERC-4626 vault by id/addressEngine methods (generic, top-level)
sr.depositIntoVault(params): Promise<Quote> // generic deposit — protocol REQUIRED in params
sr.bridgeAndSwap(params): Promise<Quote> // swap-only; funds returned to ownerdepositIntoVault is the unbound path: pass the vault's protocol explicitly
('fluid', 'morpho', …) or 'erc4626' for an unbranded 4626 vault. An
unregistered protocol → UNKNOWN_PROTOCOL.
await sr.depositIntoVault({
owner, amount: '100', token: TOKENS.USDC,
srcChainId: 42161, destChainId: 42161,
into: vault, protocol: 'fluid', // required
})Discovery + status
sr.listVaults({ asset?, chains?, protocol?, minTvl?, minApy?, page? }): Promise<VaultPage>
sr.getVault(vaultId, chainId?): Promise<VaultDetails>
sr.getChains(): Promise<ChainInfo[]>
sr.getTokens({ chainId? }): Promise<TokenInfo[]>
sr.getStatus(sra): Promise<RecipeStatus>
sr.watchStatus(sra, { interval?, maxRetries?, timeout?, onStatusChange, onError? }): WatcherlistVaults is paged: it returns { vaults: Vault[]; nextPage: number | null }.
Pass a zero-based page and walk until nextPage is null. minTvl filters by
USD TVL — omit it and vaults.fyi applies its own $100,000 minimum, so pass
minTvl: 0 to see lower-TVL vaults. Pass chainId to getVault when known —
it hits the direct single-vault endpoint instead of a list scan.
SRA lifecycle + preflight
sr.getSraInfo(params) sr.getWithdrawCalls(params)
sr.getSraFeeEstimates(params) sr.getDepositStatus(params)
sr.preflight(params) sr.listOpportunities(params) // back-compat alias of listVaultsgetWithdrawCalls is the recovery path: a failed or partially-executed recipe
leaves funds resting in the SRA, and this returns the owner-signed calls that
drain them. preflight reports vault state without quoting — including
depositsDisabled (on-chain maxDeposit = 0), which vault listings miss.
Shared param vocabulary
Every deposit facade/engine takes this core shape. Facades narrow it — Aave
drops into; Morpho/Fluid/Yearn require it.
type DepositParams = {
owner: Address // funds + signs + receives shares + gets refunds (one role)
amount: number | string // display units; server scales by decimals (string math, no float)
token: TokenSymbol | Address // symbol → canonical per-chain address; address to disambiguate (USDC.e)
srcChainId: number // chain the user funds from
destChainId?: number // execution chain; equal to srcChainId ⇒ same-chain (no bridge).
// optional when `into` is a Vault (derived from vault.chainId);
// required for Aave or a string id/address `into`
into?: string | Vault // multi-vault only: vault id/address, or a Vault from listVaults()
slippage?: number // bps, default 100 (= 1%)
}Use a string amount for very large / >15-sig-fig values. owner is a single
role: funder = signer = shares receiver = refund recipient (no beneficiary).
When into is a Vault object from listVaults(), omit destChainId — the
vault carries its own chainId, the single source of truth:
const { vaults } = await sr.listVaults({ asset: TOKENS.USDC, chains: [42161] })
await sr.morpho.deposit({ owner, amount: '100', token: TOKENS.USDC, srcChainId: 8453, into: vaults[0] })
// destChainId derived = vault.chainIdTOKENS: USDC | USDT | DAI | WETH | WBTC | EURC | NATIVE. Pass a symbol for
the canonical per-chain address, or a raw address for a variant / arbitrary
token.
Quote
Unified return for every recipe. Every deposit routes through an SRA — the
transaction is a funding transfer to it (same-chain included), so sra is
always set on deposit quotes.
type Quote = {
quoteId: string
expiresAt: string // ISO-8601, typically now + 60s
sra: Address | null // the address to fund; always set for deposits
transaction: { chainId: number; calls: OnChainCall[] } // the whole src batch; EOA sends in order
userOp: { callData: Hex; calls: OnChainCall[]; chainId: number } // kernel client fills the rest
estimatedFees: { // amounts in route-token base units, NOT USD
totalFeeAmount: string | null // null when chains mix denominations — render perChain
totalFeeToken: Address | null // null whenever totalFeeAmount is
perChain: { chainId: number; feeAmount: string | null; feeToken: Address | null }[]
}
estimatedReceiveAmount: string // base units on destChain
estimatedShares?: string // vault recipes only
vaultApy?: number // percent (2.57 = 2.57%), from vaults.fyi
route?: { // resolved flow, for UI display
bridgeTokenType: string | null // token the SRA is funded with
bridgeTokenSrc?: Address
bridgeTokenDest?: Address // route token, or the vault asset when SRA converts
sameChain: boolean // no bridge leg (still an SRA deposit)
}
}
type OnChainCall = { to: Address; data: Hex; value: string } // value is a decimal stringtransaction and userOp carry the same intent: one ERC-20 transfer funding
the SRA, preceded by owner-signed approve + swap calls only when the funding
token differs from the route token. An EOA sends transaction.calls
sequentially; a kernel client sends userOp.callData as one ERC-4337 user op
(the server encodes a Kernel v3 / ERC-7579 executeBatch(calls); your client
fills sender / nonce / gas / signature). The vault-side approve + deposit runs
on the destination chain by the SRA relayer, not by the user.
Vault
Returned by listVaults, accepted directly as into. Discriminated union on
category — a slim projection of the vaults.fyi listing, carrying both the
fields you choose a vault by and the fields a deposit routes with (no
re-fetch).
type VaultCommon = {
id: string // server vault id — what `into` keys on
address: Address // the vault contract (deposit target)
chainId: number
protocol: string // 'aave' | 'morpho' | 'fluid' | 'yearn' | ...
asset: { symbol: string; address: Address; decimals: number }
apy: number | null // percent (2.57 = 2.57%)
tvlUsd: number | null
name?: string
}
type Vault =
| (VaultCommon & { category: 'lend' })
| (VaultCommon & { category: 'liquid-staking' })
| (VaultCommon & { category: 'fixed-yield'; maturity: string })
// ^ reachable only after narrowing on categorymaturity is meaningful only for fixed-yield vaults, so reading it off a lend
vault is a compile error until you narrow on category.
Status
const watcher = sr.watchStatus(quote.sra, {
interval: 4000, // ms; default 4000
timeout: 600_000, // total watch bound; 0 = poll indefinitely
onStatusChange: (s) => console.log(s.state), // PENDING → BRIDGING → EXECUTING → COMPLETED
onError: (e) => console.error(e), // persistent poll failure or timeout
})
await watcher.done // resolves on terminal state / unsubscribe, rejects on failure
watcher.stop() // or call watcher() — unsubscribe
type RecipeState =
| 'PENDING' | 'BRIDGING' | 'EXECUTING' | 'COMPLETED' | 'FAILED'
| 'ABANDONED' // SRA never received funds within the abandonment window (1h after quote)Polling stops automatically on COMPLETED / FAILED / ABANDONED. FAILED
statuses carry a failureReason; recover resting funds via getWithdrawCalls.
An ABANDONED recipe is not locked out: funds arriving late still execute, and
a fresh watchStatus picks the recipe back up from live evidence.
Errors
Every rejection is a SmartRecipeError with a code, message, and
requestId (from the server's x-request-id — quote it when reporting a bug).
Code-bound subclasses let you branch with instanceof.
import { SmartRecipeError, QuoteExpiredError } from '@zerodev/smart-recipes'
try {
await sr.aave.deposit({ /* … */ })
} catch (e) {
if (e instanceof QuoteExpiredError) { /* re-quote */ }
else if (e instanceof SmartRecipeError) console.error(`[${e.code}] ${e.message}`)
}Codes: INVALID_REQUEST, UNSUPPORTED_TOKEN, INSUFFICIENT_AMOUNT,
SLIPPAGE_TOO_LOW, SWAP_ROUTE_NOT_FOUND, QUOTE_EXPIRED, SANCTIONED_ADDRESS, VAULT_BLOCKED,
VAULT_NOT_ALLOWLISTED, CHAIN_NOT_SUPPORTED, VAULT_CAP_EXCEEDED,
VAULT_DEPOSITS_DISABLED, UNKNOWN_PROTOCOL, VAULT_TYPE_MISMATCH,
ASSET_MISMATCH, FEATURE_DISABLED, IDEMPOTENCY_KEY_CONFLICT,
SRA_NOT_FOUND, SRA_UNAVAILABLE, QUOTER_UNAVAILABLE, RPC_UNAVAILABLE,
SERVICE_UNAVAILABLE, INTERNAL_ERROR.
Security-gate codes (raised by middleware before the route runs):
ACCESS_DENIED (403 — origin/IP not allowlisted), RATE_LIMITED (429 — too many
requests; idempotent GETs are retried with backoff), PAYLOAD_TOO_LARGE (413 —
request body over the cap).
Worth special handling:
FEATURE_DISABLED(403) — the route needs a swap leg and swaps are soft-blocked server-side. Same-token routes (same-chain deposits and plain cross-chain bridges) are unaffected. Retrying with different params won't help until the server flag flips.VAULT_DEPOSITS_DISABLED(400) — the vault's on-chainmaxDepositis 0 (deposits switched off); pick another vault rather than lowering the amount (VAULT_CAP_EXCEEDEDis the try-a-smaller-amount case).
Notes
- All wire amounts are base-10
strings (JSON has no bigint). Parse tobigintyourself:BigInt(quote.estimatedReceiveAmount). - A quote prepares, it never broadcasts — you send
transactionoruserOp. - There is no on-chain refund fallback: a reverted destination action leaves
funds resting in the SRA, recoverable with
getWithdrawCalls.
See SMART_RECIPES_SDK.md (repo root) for the full design.
