diff --git a/docs/pages/onramp/smart-recipes/how-it-works.mdx b/docs/pages/onramp/smart-recipes/how-it-works.mdx new file mode 100644 index 0000000..bbabb69 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/how-it-works.mdx @@ -0,0 +1,60 @@ +# How It Works + +Where the funds are at each moment, and what happens when a step fails. Read this to evaluate the trust model. + +## The flow + +``` +your app sr.morpho.deposit(...) 1. quote +user signs one transfer to the SRA 2. fund +ZeroDev relayer bridges to the destination 3. bridge (cross-chain only) +ZeroDev relayer runs the stored deposit calls 4. execute +your app sr.watchStatus(sra) 5. track +``` + +**1. Quote.** The server takes your intent and does four things: verifies the target on-chain (`asset()` for an ERC-4626 vault, the reserve list for Aave), reads live vault state (`maxDeposit` headroom, deposit minimum, `previewDeposit` for expected shares), creates a [Smart Routing Address](/onramp/smart-routing-address) with the deposit actions stored at creation, and returns the quote. A wrong target fails here, typed, before any funds move. The stored actions cannot be changed afterwards. + +**2. Fund.** The user signs one transfer of `amount` `token` to the SRA. That is the only signature in the flow. + +**3. Bridge.** The relayer bridges the funds to the SRA on the destination chain. Same-chain deposits skip this. + +**4. Execute.** The relayer runs the actions stored in the SRA: approve the vault, deposit, credit the shares to the `owner`. The calls take the full arrived amount, so no dust is stranded by a locked-in figure. + +**5. Track.** Status comes from on-chain evidence at the SRA, never from an assertion the server cannot prove. + +## Where the funds are + +| Moment | Funds are | +|---|---| +| Before funding | In the user's wallet | +| After funding, before bridge | In the user's SRA | +| In the bridge | In the bridge protocol, addressed to the SRA | +| After bridge, before execution | In the user's SRA on the destination chain | +| After execution | Vault shares, credited to the `owner` | + +ZeroDev operates the relayer and never has custody. The SRA is a permissionless contract, and funds leave it in exactly two ways: the stored actions, or an owner withdrawal. + +## When something fails + +| Failure | What happens | +|---|---| +| Wrong target vault | Caught by the on-chain probe at quote time. `VAULT_TYPE_MISMATCH` or `ASSET_MISMATCH` in milliseconds, no funds moved. | +| Vault fills up after the quote | The deposit call reverts. Funds stay in the SRA, recoverable by the `owner`. | +| User sends the wrong token | Tokens outside the route rest in the SRA. Same recovery path. | +| User never sends funds | The recipe reports `ABANDONED` after one hour. Nothing is lost, and late funds still execute. | + +No failure mode hands the funds to ZeroDev or to a third party. See [recovering a failed deposit](/onramp/smart-recipes/recipes#recovering-a-failed-deposit). + +## Quote expiry + +`expiresAt` is stamped about 60 seconds out, and it is **advisory**. The server does not reject a late funding transaction, and the SRA stays valid: funds sent after `expiresAt` still execute. + +What goes stale is the numbers. `estimatedFees`, `estimatedShares`, and `vaultApy` drift, and vault headroom can disappear, which turns into a revert at execution rather than an error at quote time. So re-quote to keep a UI honest, and re-quote whenever the user edits the form. A quote is free and read-only. + +## Slippage + +You set `slippage` in bps at quote time, and SRA enforces the minimum-output floor on-chain at execution. A quoted estimate cannot be front-run below your floor. If the slippage you ask for cannot cover the route's own fees, the quote is rejected up front with `SLIPPAGE_TOO_LOW` rather than stranding the deposit at fill time. + +## Why the server builds the route + +Routes, vault lists, calldata, and compliance screening change weekly, so they live server-side. The result: a new vault or protocol reaches your users with no SDK update, a bad vault can be blocklisted globally at once, and every quote screens the owner against the OFAC SDN list. The SDK stays a thin, dependency-free HTTP client that you rarely need to upgrade. diff --git a/docs/pages/onramp/smart-recipes/index.mdx b/docs/pages/onramp/smart-recipes/index.mdx new file mode 100644 index 0000000..4df1cd3 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/index.mdx @@ -0,0 +1,43 @@ +# Smart Recipes + +**Cross-chain DeFi deposits in one call.** Your user holds USDC on Base. Your app deposits it into a vault on Arbitrum. One quote, one signature. You write no bridge, vault, or relayer code. + +```ts +const quote = await sr.morpho.deposit({ + owner: user, + amount: "100", + token: TOKENS.USDC, + srcChainId: 8453, // funds on Base + into: vault, // vault on Arbitrum +}); +// user signs one transfer, funds arrive in the vault +``` + +Without it you would build a bridge integration, per-protocol deposit calldata, a relayer to execute on the destination chain, cross-chain status tracking, and a recovery path for each failure. Smart Recipes replaces all of it: the server quotes the route, a [Smart Routing Address](/onramp/smart-routing-address) (SRA) executes it, your user signs a single transfer. + +Three things worth knowing up front: + +- **Non-custodial.** Funds move through permissionless contracts. ZeroDev never holds them, and only the `owner` can recover a failed deposit. +- **No protocol maintenance.** Vault lists, routes, and calldata live server-side. A new vault needs no SDK update. +- **Any wallet stack.** Each quote carries both a plain transaction batch and an ERC-4337 user operation. The SDK has no signer and no runtime dependencies. + +## Recipes + +There are two, and one of them is currently off: + +| Recipe | Call it with | Status | +|---|---|---| +| Deposit into a vault | `sr.aave.deposit`, `sr.morpho.deposit`, `sr.fluid.deposit`, `sr.yearn.deposit`, `sr.erc4626.deposit`, or `sr.depositIntoVault` | Available | +| Withdraw from a vault | `sr.withdrawFromVault` | Available, same-chain only | +| Bridge and swap | `sr.bridgeAndSwap` | Disabled, always rejects | + +The five deposit facades are one recipe with the protocol pre-bound. `sr.depositIntoVault` is the same engine with `protocol` passed explicitly. Everything else in the SDK is discovery, status, and recovery around those calls. + +Smart Recipes performs no swaps of its own. A deposit whose funding token differs from the vault asset still works: SRA converts on delivery as part of its own route. See [Recipes](/onramp/smart-recipes/recipes). + +## Next + +- [Quickstart](/onramp/smart-recipes/quickstart): a first deposit in about 20 lines +- [Recipes](/onramp/smart-recipes/recipes): every parameter, deposits through recovery +- [How it works](/onramp/smart-recipes/how-it-works): the flow, the custody model, failure behavior +- [Reference](/onramp/smart-recipes/reference): the `Quote` type, every method, every error code diff --git a/docs/pages/onramp/smart-recipes/quickstart.mdx b/docs/pages/onramp/smart-recipes/quickstart.mdx new file mode 100644 index 0000000..cf77c53 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/quickstart.mdx @@ -0,0 +1,100 @@ +# Quickstart + +## Install + +:::code-group + +```bash [npm] +npm i @zerodev/smart-recipes +``` + +```bash [pnpm] +pnpm i @zerodev/smart-recipes +``` + +```bash [yarn] +yarn add @zerodev/smart-recipes +``` + +```bash [bun] +bun add @zerodev/smart-recipes +``` + +::: + +Node 18 or later. Ships ESM and CJS, uses the global `fetch`, and has no runtime dependencies. + +## Create a client + +```ts +import { createSmartRecipes, TOKENS } from "@zerodev/smart-recipes"; + +const sr = createSmartRecipes({ + projectId: "", +}); +``` + +`projectId` is the only required option. There is no API key: the server authorizes requests by `projectId` plus a per-project allowlist of origins and IP addresses. + +| Option | Type | Default | Notes | +|---|---|---|---| +| `projectId` | `string` | required | Sent as `x-project-id` on every request | +| `serverUrl` | `string` | ZeroDev's hosted server | Set only for a self-hosted or staging server | +| `fetch` | `typeof fetch` | global `fetch` | Injectable, for tests or non-browser runtimes | +| `timeoutMs` | `number` | `30000` | Per-request abort timeout | +| `maxRetries` | `number` | `2` | Retries on transient failures, idempotent GETs only | + +`createSmartRecipes` is synchronous. All async work happens when you call a method. + +## Quote a deposit + +Find a vault, then deposit into it: + +```ts +// USDC vaults on Arbitrum +const { vaults } = await sr.listVaults({ asset: TOKENS.USDC, chains: [42161] }); + +// The user's funds are on Base; the vault is on Arbitrum +const quote = await sr.morpho.deposit({ + owner: "0xUSER", // funds, signs, and receives the shares + amount: "100", // display units; the server scales by token decimals + token: TOKENS.USDC, // symbol resolves to the canonical per-chain address + srcChainId: 8453, // Base + into: vaults[0], // a Vault object, so destChainId comes from it +}); + +console.log(quote.sra); // the address the user funds +console.log(quote.estimatedShares); // expected vault shares +console.log(quote.vaultApy); // APY snapshot +``` + +A quote prepares the transaction and does not broadcast. The SDK holds no signer. + +## Execute + +Send the funding transaction with your own wallet. The two forms carry the same intent: + +```ts +// EOA: send the calls in order +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 }); +``` + +The relayer then runs the vault-side approve and deposit on the destination chain. Your user does not sign those. + +Persist `quote.sra` before you execute. It is the handle for tracking and for recovery. + +## Track + +```ts +const watcher = sr.watchStatus(quote.sra, { + onStatusChange: (s) => console.log(s.state), // PENDING, BRIDGING, EXECUTING, COMPLETED +}); +await watcher.done; +``` + +Next: [Recipes](/onramp/smart-recipes/recipes) for every parameter, or [Reference](/onramp/smart-recipes/reference) for the `Quote` type and error codes. diff --git a/docs/pages/onramp/smart-recipes/recipes.mdx b/docs/pages/onramp/smart-recipes/recipes.mdx new file mode 100644 index 0000000..bf097f7 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/recipes.mdx @@ -0,0 +1,199 @@ +# Recipes + +Everything you call, in the order you need it: deposit, discover, track, exit. + +## Deposits + +Every deposit routes through a Smart Routing Address and returns a [`Quote`](/onramp/smart-recipes/reference#quote), same-chain and cross-chain alike. The route is same-chain when `srcChainId === destChainId`. You do not select it. + +```ts +type DepositParams = { + owner: Address // funds, signs, receives shares, receives refunds + amount: number | string // display units; the server scales by decimals + token: TokenSymbol | Address // the funding token, symbol or address + srcChainId: number // the chain the user funds from + destChainId?: number // the execution chain; optional when `into` is a Vault + into?: string | Vault // vault id/address, or a Vault from listVaults() + slippage?: number // bps, 1 to 5000, default 100 (= 1%) +} +``` + +- `owner` is one role: funder, signer, share recipient, and refund recipient. There is no separate beneficiary. +- `amount` is display units, so `"100"` means 100 USDC. Use a string for values above 15 significant figures. +- `token` takes a symbol or a raw address. `TOKENS` covers `USDC | USDT | DAI | WETH | WBTC | EURC | NATIVE`. Pass an address for a variant like USDC.e. +- `destChainId` can be omitted when `into` is a `Vault` object, which carries its own `chainId`. Passing both with different values is rejected as a caller bug. It is required for Aave, or when `into` is a string. +- `slippage` bounds SRA's route. A cross-chain quote whose slippage cannot cover the route fees is rejected with `SLIPPAGE_TOO_LOW`, and `details.minSlippageBps` tells you what to retry with. Same-chain quotes are not floor-gated. + +### Protocol facades + +Each facade binds its protocol so the server picks the right adapter. The adapter verifies the target on-chain before quoting, so pointing a 4626 facade at a Morpho-Blue market fails immediately with a typed error and no funds move. + +```ts +sr.aave.deposit(params) // `into` optional: the pool follows from token + destChainId +sr.morpho.deposit(params) // `into` required +sr.fluid.deposit(params) // `into` required +sr.yearn.deposit(params) // `into` required +sr.erc4626.deposit(params) // any ERC-4626 vault by address, listed or not +``` + +Aave has one pool per chain, so `token` and `destChainId` identify the target: + +```ts +await sr.aave.deposit({ + owner, + amount: "100", + token: TOKENS.USDC, + srcChainId: 8453, // Base + destChainId: 42161, // Arbitrum + slippage: 50, // 0.5% +}); +``` + +The `owner` receives the canonical aToken position. Pass `into` (an Aave listing from `listVaults({ protocol: 'aave' })`) to supply a different reserve than the funding token's own. + +For a vault with no facade, use the generic engine and name the protocol yourself: + +```ts +await sr.depositIntoVault({ + owner, + amount: "100", + token: TOKENS.USDC, + srcChainId: 42161, + destChainId: 42161, + into: "0xVAULT", + protocol: "erc4626", // 'aave' | 'morpho' | 'fluid' | 'yearn' | 'erc4626' +}); +``` + +An unknown protocol is rejected with `UNKNOWN_PROTOCOL`. Prefer a facade when one exists. + +### Funding token vs vault asset + +Smart Recipes performs no swaps. A deposit whose funding token differs from the vault asset still works, because SRA's own cross-token route converts on delivery. `quote.route.bridgeTokenDest` tells you what actually lands on the destination chain: the route token, or the vault asset when SRA converts. There is nothing for you to branch on. + +## Discovery + +`listVaults` returns live APY and TVL, and each result can route a deposit with no further lookups. + +```ts +const { vaults, nextPage } = await sr.listVaults({ + asset: TOKENS.USDC, // optional, filter by asset + chains: [42161], // optional, filter by chain + protocol: "morpho", // optional, filter by protocol + minTvl: 1_000_000, // optional, USD floor + minApy: 2, // optional, percent + page: 0, // zero-based; walk until nextPage is null +}); +``` + +:::info +Omitting `minTvl` applies a default floor of $100,000. Pass `minTvl: 0` to include smaller vaults. +::: + +`Vault` is a union discriminated on `category`, so `maturity` is reachable only after narrowing: + +```ts +type VaultCommon = { + id: string // what `into` keys on + address: Address // the vault contract + chainId: number + protocol: string + 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 }) +``` + +`getVault(vaultId, chainId?)` returns the same shape plus `apyBreakdown`, `apy7day`, `apy30day`, and `description`. Pass `chainId` when you know it to use the direct lookup instead of a list scan. The result is still valid as `into`. + +Build selectors from the server registry with `sr.getChains()` and `sr.getTokens({ chainId })`. + +### preflight + +`preflight` reads vault state without creating a quote. Its one unique signal is `depositsDisabled`: the vault's on-chain `maxDeposit` is 0, so it accepts nothing. Vault listings cannot see this, only the on-chain read can. + +```ts +const check = await sr.preflight({ + owner: "0xUSER", + vaultId: vault.address, + destChainId: vault.chainId, + amount: "100", +}); +// check.depositsDisabled the vault accepts no deposits; pick another +// check.maxDeposit remaining headroom, null when the kind has no per-owner cap +// check.route { bridgeTokenType, bridgeTokenDest } or null +``` + +## Tracking + +Status is derived from on-chain evidence at the SRA: deposits seen, bridges sent, executions settled. The server never asserts a state it cannot prove, so the status stays correct when funds arrive late or a step is retried. + +``` +PENDING -> BRIDGING -> EXECUTING -> COMPLETED + -> FAILED +PENDING (no funds within 1h) -> ABANDONED +``` + +`ABANDONED` is not a lock. Funds arriving later still execute, and a fresh `watchStatus` picks the recipe back up from live evidence. + +```ts +const watcher = sr.watchStatus(quote.sra, { + interval: 4000, // ms, default 4000 + timeout: 600_000, // total watch bound, default 10 min; 0 = poll forever + maxRetries: 5, // consecutive poll failures tolerated, default 5 + onStatusChange: (s) => setPhase(s.state), + onError: (e) => setError(e), +}); + +await watcher.done; // resolves on a terminal state, rejects on persistent failure +watcher.stop(); // or call watcher() to unsubscribe +``` + +Polling backs off on failure and stops on `COMPLETED`, `FAILED`, or `ABANDONED`. A persistent failure both calls `onError` and rejects `done`, so the error is always observable. + +Use `sr.getStatus(sra)` for a single read of the same data (`state`, `deposits`, `failureReason`), or `sr.getDepositStatus({ sra, owner, destChainId, vaultId })` to also get `vaultBalance`. + +## Withdraw + +`withdrawFromVault` builds the owner-signed calls that exit a position. It is same-chain and immediate: no SRA, no bridge, and no quote to expire. + +```ts +const exit = await sr.withdrawFromVault({ + owner, + vaultId: vault.id, + chainId: vault.chainId, + max: true, // or amount: "50"; pass neither for a preview +}); + +for (const call of exit.calls) { + await wallet.sendTransaction({ ...call, value: BigInt(call.value), chainId: exit.chainId }); +} +``` + +- Pass `amount` **or** `max`, not both. Pass neither for a preview: same on-chain reads, reports `available` with no calls, which is how a UI shows a position before the user picks an amount. +- `exitAll: true` means the calls name no amount and use the protocol's own full-exit form, so interest accruing before the signature cannot leave dust behind. `false` means a protocol cap (health factor, liquidity, vault limit) held the exit below the position, and the calls carry an exact amount. +- `available` is the ceiling the amount was checked against, which is not always the position size. On Aave a supply backing a borrow reports only what can leave with the health factor intact. + +## Recovering a failed deposit + +There is no on-chain refund fallback. If the destination action reverts, or the user sends a token outside the route, the funds rest in the SRA. Only the `owner` can move them. + +```ts +const { data } = await sr.getWithdrawCalls({ + sra, + tokens: [{ chainId: 42161, token: usdcAddress }], +}); +for (const { chainId, calls } of data) { + for (const call of calls) { + await wallet.sendTransaction({ ...call, value: BigInt(call.value), chainId }); + } +} +``` + +You can also send users to the [SRA portal](https://smart-routing-address.zerodev.app/), which offers the same recovery with no code on your side. diff --git a/docs/pages/onramp/smart-recipes/reference.mdx b/docs/pages/onramp/smart-recipes/reference.mdx new file mode 100644 index 0000000..4b0a8c3 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/reference.mdx @@ -0,0 +1,172 @@ +# Reference + +Every method, the `Quote` type, and every error code. All methods are async and all rejections are typed `SmartRecipeError`s. + +## Quote + +Every recipe returns the same `Quote`. A quote prepares the transaction and does not broadcast. + +```ts +type Quote = { + quoteId: string + expiresAt: string // ISO-8601, ~60s out, advisory (see How It Works) + sra: Address | null // the address to fund; always set for deposits + transaction: { chainId: number; calls: OnChainCall[] } // the whole src batch, in order + userOp: { callData: Hex; calls: OnChainCall[]; chainId: number } + estimatedFees: { // route-token base units, NOT USD + totalFeeAmount: string | null // null when chains mix denominations; use perChain + totalFeeToken: Address | null // null whenever totalFeeAmount is + perChain: { chainId: number; feeAmount: string | null; feeToken: Address | null }[] + } + estimatedReceiveAmount: string // base units on the dest chain + estimatedShares?: string // vault recipes only + vaultApy?: number // percent (2.57 = 2.57%) + route?: { + bridgeTokenType: string | null // the 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 string +``` + +Every amount on the wire is a base-10 string, because JSON has no bigint. Parse it yourself: `BigInt(quote.estimatedReceiveAmount)`. + +### Executing a quote + +`transaction` and `userOp` carry the same intent: send `amount` of `token` to the SRA. `transaction` is the **complete** src-chain batch, in order. There is no `destTransaction`, because the relayer runs the dest-chain actions stored in the SRA. That is the point of the product: the owner signs once, on one chain. + +```ts +// EOA +for (const call of quote.transaction.calls) { + await wallet.sendTransaction({ ...call, value: BigInt(call.value) }); +} + +// ERC-4337: the server encoded a Kernel v3 / ERC-7579 executeBatch(calls) +await kernelClient.sendUserOp({ callData: quote.userOp.callData }); +``` + +Your kernel client supplies the sender, nonce, gas, and signature. `userOp.calls` ships alongside `callData`, so a non-Kernel account (Safe, Biconomy) can re-encode the same batch in its own format. + +### Fees + +Fee amounts are base units of the route token, not USD. The server has no USD oracle, and base units only add within one denomination, so both `totalFeeAmount` and `totalFeeToken` are `null` when the chains disagree. They are always null together: a non-null amount is complete for its named token. Render the `perChain` rows in the null case, and note that a single chain can be `null` too for the same reason. + +Sponsored fees are excluded from every sum, since they are notional and never charged. Sponsorship follows your project's gas policy through `projectId`. There is no per-quote flag. + +## Methods + +### Deposits + +All return a [`Quote`](#quote) and share `owner`, `amount`, `token`, `srcChainId`, `destChainId?`, `slippage?`. See [Recipes](/onramp/smart-recipes/recipes#deposits). + +| Method | Extra params | Notes | +|---|---|---| +| `sr.aave.deposit(p)` | `destChainId` required, `into` optional | Omit `into` for the funding token's own reserve | +| `sr.morpho.deposit(p)` | `into` required | Vault id, address, or a `Vault` object | +| `sr.fluid.deposit(p)` | `into` required | | +| `sr.yearn.deposit(p)` | `into` required | | +| `sr.erc4626.deposit(p)` | `into` required | Any ERC-4626 vault, listed or not | +| `sr.depositIntoVault(p)` | `into` + `protocol` required | The generic engine behind all five facades | + +### Withdraw + +| Method | Params | Returns | +|---|---|---| +| `sr.withdrawFromVault(p)` | `owner`, `vaultId`, `chainId`, and `amount` **or** `max` (neither = preview) | Owner-signed exit `calls`, plus `available`, `exitAll`, `amount`, `asset` | +| `sr.getWithdrawCalls(p)` | `sra`, `tokens: [{ chainId, token }]` | Per-chain recovery calls for funds stuck in an SRA | + +### Discovery + +| Method | Params | Returns | +|---|---|---| +| `sr.listVaults(p?)` | `asset?`, `chains?`, `protocol?`, `minTvl?`, `minApy?`, `page?` | `{ vaults: Vault[]; nextPage: number \| null }` | +| `sr.getVault(vaultId, chainId?)` | Pass `chainId` for a direct lookup | `VaultDetails` | +| `sr.getChains()` | none | `ChainInfo[]` | +| `sr.getTokens(p?)` | `chainId?` | `TokenInfo[]` | +| `sr.preflight(p)` | `owner`, `vaultId`, `destChainId`, `amount`, `srcChainId?`, `srcToken?` | `maxDeposit`, `depositsDisabled`, `route`, `vaultEntry` | + +Omitting `minTvl` applies a $100,000 default floor. Pass `minTvl: 0` to include smaller vaults. + +### Status + +| Method | Params | Returns | +|---|---|---| +| `sr.getStatus(sra)` | The SRA address | `RecipeStatus`: `state`, `deposits`, `failureReason?` | +| `sr.watchStatus(sra, opts)` | `interval?` (4000), `timeout?` (10 min, `0` = forever), `maxRetries?` (5), `onStatusChange`, `onError?` | `Watcher`: callable unsubscribe, with `.stop()` and `.done` | +| `sr.getDepositStatus(p)` | `sra`, `owner`, `destChainId`, `vaultId` | `phase`, `deposits`, `vaultBalance` | +| `sr.getSraInfo(p)` | `sra` | Stored routing config: owner, actions, src tokens, slippage | +| `sr.getSraFeeEstimates(p)` | `sra` | Per-chain fee estimates with `isSponsored` flags | + +```ts +type RecipeState = "PENDING" | "BRIDGING" | "EXECUTING" | "COMPLETED" | "FAILED" | "ABANDONED"; +``` + +### Disabled + +`sr.bridgeAndSwap(p)` always rejects with `FEATURE_DISABLED`, locally and without a request. Smart Recipes performs no swaps: routing and token conversion belong to SRA. Use a deposit recipe, or route the conversion through SRA directly. + +## Errors + +Each rejection carries a `code`, a `message`, and a `requestId` (from the server's `x-request-id` header). Quote the `requestId` in a bug report. Every code has a bound subclass, so you can branch with `instanceof`: + +```ts +import { SmartRecipeError, VaultCapExceededError } from "@zerodev/smart-recipes"; + +try { + await sr.morpho.deposit({ /* ... */ }); +} catch (e) { + if (e instanceof VaultCapExceededError) suggestSmallerAmount(); + else if (e instanceof SmartRecipeError) showError(e.code, e.message); +} +``` + +| HTTP | Code | When | +|---|---|---| +| 400 | `INVALID_REQUEST` | A parameter is missing or malformed | +| 400 | `UNSUPPORTED_TOKEN` | The token does not resolve | +| 400 | `UNKNOWN_PROTOCOL` | The `protocol` has no registered adapter | +| 400 | `VAULT_TYPE_MISMATCH` | The target is not the expected vault kind (on-chain probe) | +| 400 | `VAULT_NOT_ALLOWLISTED` | The vault is not allowlisted on that chain | +| 400 | `CHAIN_NOT_SUPPORTED` | The chain is not configured on the server | +| 400 | `VAULT_CAP_EXCEEDED` | The amount is over the vault's remaining capacity; retry smaller | +| 400 | `VAULT_DEPOSITS_DISABLED` | The vault's on-chain `maxDeposit` is 0; pick another vault | +| 403 | `SANCTIONED_ADDRESS` | The owner is on the OFAC SDN list | +| 403 | `VAULT_BLOCKED` | The vault is on the server blocklist | +| 403 | `ACCESS_DENIED` | The origin or IP is not on the project's allowlist | +| 403 | `FEATURE_DISABLED` | The requested flow is switched off (see `bridgeAndSwap`) | +| 404 | `SRA_NOT_FOUND` | No SRA at that address | +| 409 | `IDEMPOTENCY_KEY_CONFLICT` | An `Idempotency-Key` was reused with a different body | +| 413 | `PAYLOAD_TOO_LARGE` | The request body is over the size cap | +| 422 | `INSUFFICIENT_AMOUNT` | The amount is below the vault or bridge minimum after fees | +| 422 | `SLIPPAGE_TOO_LOW` | Slippage cannot cover route fees; `details.minSlippageBps` is the retry value | +| 422 | `SWAP_ROUTE_NOT_FOUND` | No route was found for the pair | +| 429 | `RATE_LIMITED` | Too many requests; idempotent GETs retry with backoff | +| 500 | `ASSET_MISMATCH` | The vault asset is not the expected token | +| 500 | `INTERNAL_ERROR` | An unexpected server error | +| 502 | `SRA_UNAVAILABLE` / `QUOTER_UNAVAILABLE` / `RPC_UNAVAILABLE` | An upstream service failed | +| 503 | `SERVICE_UNAVAILABLE` | Access control or another dependency is temporarily down | + +`WATCH_TIMEOUT` (`WatchTimeoutError`) is client-only. `watchStatus` raises it when a recipe stays non-terminal past `timeout`. The recipe may still complete, so raise `timeout` or set it to `0` to poll indefinitely. + +### Retries + +The SDK retries idempotent GETs on 429, 502, 503, and network failures, with backoff, up to `maxRetries`. Quote-building POSTs are **never** retried: each one can create a new SRA server-side, so a transient failure must not duplicate it. + +## Exports + +```ts +import { + createSmartRecipes, + DEFAULT_SERVER_URL, // ZeroDev's hosted server, used when serverUrl is omitted + TOKENS, // USDC | USDT | DAI | WETH | WBTC | EURC | NATIVE + SmartRecipeError, // base error, plus one subclass per code above + VaultCapExceededError, + SlippageTooLowError, + WatchTimeoutError, +} from "@zerodev/smart-recipes"; +``` + +Types: `Quote`, `Vault`, `VaultDetails`, `DepositParams`, `VaultWithdrawParams`, `VaultWithdrawResult`, `RecipeStatus`, `RecipeState`, `Watcher`, and the params and result types of every method above. diff --git a/vocs.config.tsx b/vocs.config.tsx index 0cba285..c3688c7 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -291,6 +291,32 @@ export default defineConfig({ }, ], }, + { + text: "Smart Recipes", + collapsed: false, + items: [ + { + text: "Introduction", + link: "/onramp/smart-recipes", + }, + { + text: "Quickstart", + link: "/onramp/smart-recipes/quickstart", + }, + { + text: "Recipes", + link: "/onramp/smart-recipes/recipes", + }, + { + text: "How It Works", + link: "/onramp/smart-recipes/how-it-works", + }, + { + text: "Reference", + link: "/onramp/smart-recipes/reference", + }, + ], + }, ], "/smart-accounts": [ {