diff --git a/docs/pages/onramp/smart-recipes/api-reference.mdx b/docs/pages/onramp/smart-recipes/api-reference.mdx new file mode 100644 index 0000000..4174614 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/api-reference.mdx @@ -0,0 +1,82 @@ +# API Reference + +This page lists every method on the client from `createSmartRecipes(config)`. All methods are async. All rejections are typed [`SmartRecipeError`](/onramp/smart-recipes/errors)s. + +## Deposits + +All deposit methods return a [`Quote`](/onramp/smart-recipes/quotes). They share the core parameters: `owner`, `amount`, `token`, `srcChainId`, `destChainId?`, `slippage?`. See [Deposits](/onramp/smart-recipes/deposits) for the shared vocabulary. + +| Method | Extra params | Notes | +|---|---|---| +| `sr.aave.deposit(p)` | `destChainId` required, `into` optional | Omit `into` to supply the funding token's reserve. Pass an Aave listing to select a different 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. `protocol`: `'aave' \| 'morpho' \| 'fluid' \| 'yearn' \| 'erc4626'`. | + +## Swaps + +| Method | Params | Returns | +|---|---|---| +| `sr.bridgeAndSwap(p)` | `owner`, `amount`, `token`, `toToken`, `srcChainId`, `destChainId`, `slippage?` | `Quote` (no vault fields) | + +## Discovery + +| Method | Params | Returns | +|---|---|---| +| `sr.listVaults(p?)` | `asset?`, `chains?`, `protocol?`, `minTvl?`, `minApy?`, `page?` | `{ vaults: Vault[]; nextPage: number \| null }` | +| `sr.getVault(vaultId, chainId?)` | Pass `chainId` when known, for a direct lookup | `VaultDetails` | +| `sr.getChains()` | (none) | `ChainInfo[]` | +| `sr.getTokens(p?)` | `chainId?` | `TokenInfo[]` | +| `sr.listOpportunities(p?)` | Alias of `listVaults` (back-compat) | `VaultPage` | + +If you omit `minTvl` on `listVaults`, a $100,000 default minimum applies. 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?` (default 4000 ms), `timeout?` (default 10 min, `0` = forever), `maxRetries?` (default 5), `onStatusChange`, `onError?` | `Watcher`: callable unsubscribe, with `.stop()` and `.done` | + +`watchStatus` polls with backoff. It stops on `COMPLETED`, `FAILED`, or `ABANDONED`. A persistent poll failure rejects `watcher.done` and calls `onError`. + +## SRA lifecycle + +| Method | Params | Returns | +|---|---|---| +| `sr.preflight(p)` | `owner`, `vaultId`, `destChainId`, `amount`, `srcChainId?`, `srcToken?` | Vault entry, `maxDeposit`, `depositsDisabled`, route | +| `sr.getSraInfo(p)` | `sra` | The stored routing config: owner, actions, src tokens, slippage | +| `sr.getSraFeeEstimates(p)` | `sra` | Per-chain fee estimates, with `isSponsored` flags | +| `sr.getDepositStatus(p)` | `sra`, `owner`, `destChainId`, `vaultId` (the vault address) | `phase`, `deposits`, `vaultBalance` | +| `sr.getWithdrawCalls(p)` | `sra`, `tokens: [{ chainId, token }]` | Owner-signed recovery calls, per chain | + +## Client config + +```ts +createSmartRecipes({ + serverUrl: string, // required + projectId: string, // required - sent on every request + fetch?: typeof fetch, // injectable, for tests / non-browser runtimes + timeoutMs?: number, // per-request abort timeout, default 30000 + maxRetries?: number, // GET retries on 429/502/503/network, default 2 +}) +``` + +The factory is synchronous. POST requests (quotes) are never retried. Each attempt can create a new SRA. + +## Exports + +```ts +import { + createSmartRecipes, + TOKENS, // USDC | USDT | DAI | WETH | WBTC | EURC | NATIVE + SmartRecipeError, // base error, plus one subclass per code + QuoteExpiredError, + VaultCapExceededError, + // ... see Errors for the full list +} from "@zerodev/smart-recipes"; +``` + +Types: `Quote`, `Vault`, `VaultDetails`, `DepositParams`, `BridgeAndSwapParams`, `RecipeStatus`, `RecipeState`, `Watcher`, and the params and result types of every method above. diff --git a/docs/pages/onramp/smart-recipes/bridge-and-swap.mdx b/docs/pages/onramp/smart-recipes/bridge-and-swap.mdx new file mode 100644 index 0000000..b14cf8a --- /dev/null +++ b/docs/pages/onramp/smart-recipes/bridge-and-swap.mdx @@ -0,0 +1,31 @@ +# Bridge & Swap + +Bridge funds to a different chain and swap them into a target token, with one signature. The flow uses the same SRA as a deposit. The destination action is a swap. The `owner` receives the output. + +```ts +const quote = await sr.bridgeAndSwap({ + owner: "0xUSER", + amount: "100", + token: TOKENS.USDC, // source funding token + srcChainId: 8453, // Base + destChainId: 42161, // Arbitrum + toToken: "0xTARGET", // the token to receive on the destination chain + slippage: 100, // bps, default 100 (= 1%) +}); +``` + +Execute and track the quote in the same way as a deposit: + +```ts +for (const call of quote.transaction.calls) { + await wallet.sendTransaction({ ...call, value: BigInt(call.value) }); +} + +await sr.watchStatus(quote.sra, { onStatusChange: (s) => console.log(s.state) }).done; +``` + +The quote does not include the vault fields (`vaultApy`, `estimatedShares`). It includes `swapMinOutput`. This value is an estimate. The server derives the enforced minimum output from your `slippage` value. + +:::info +The server can disable swap routes. A disabled route rejects with `FEATURE_DISABLED` (403). Same-token routes are not affected. This includes same-chain deposits and plain cross-chain bridges. See [Errors](/onramp/smart-recipes/errors). +::: diff --git a/docs/pages/onramp/smart-recipes/deposits.mdx b/docs/pages/onramp/smart-recipes/deposits.mdx new file mode 100644 index 0000000..dcb0a52 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/deposits.mdx @@ -0,0 +1,95 @@ +# Deposits + +Each deposit routes through a Smart Routing Address and returns a [Quote](/onramp/smart-recipes/quotes). This applies to same-chain and cross-chain deposits. The route is same-chain when `srcChainId === destChainId`. You do not select the route yourself. + +## Protocol facades + +Each facade binds its protocol. The server maps the protocol to an adapter. The adapter builds the deposit calls and verifies the target on-chain before it quotes. Example: you pass a Morpho-Blue market where a 4626 vault is expected. The call fails immediately with a typed error. No funds move. + +```ts +sr.aave.deposit(params) // no `into` - 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) // unbranded ERC-4626 vault by id/address +``` + +## Parameters + +Each deposit takes this core shape. Facades narrow it. Aave drops `into`. Morpho, Fluid, and Yearn require it. + +```ts +type DepositParams = { + owner: Address // funds + signs + receives shares + gets refunds (one role) + amount: number | string // display units; the server scales by decimals (string math, no float) + token: TokenSymbol | Address // symbol → canonical per-chain address; address to disambiguate (USDC.e) + srcChainId: number // the chain the user funds from + destChainId?: number // the 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%) +} +``` + +- `owner` is one role: funder, signer, shares receiver, and refund recipient. There is no separate `beneficiary`. +- `amount` is in display units. `"100"` means 100 USDC. Use a `string` for very large values or values with more than 15 significant figures. +- `token` accepts a symbol or an address. `TOKENS` contains `USDC | USDT | DAI | WETH | WBTC | EURC | NATIVE`. A symbol resolves to the canonical per-chain address. Pass a raw address for a variant (USDC.e) or an arbitrary token. +- `slippage` is an integer in bps. `50` means 0.5%. The server enforces the real floor. The quote's `swapMinOutput` is an estimate. + +### Chain discovery into deposits with `into` + +When `into` is a `Vault` object from [`listVaults()`](/onramp/smart-recipes/vault-discovery), you can omit `destChainId`. The vault contains its own `chainId`: + +```ts +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.chainId +}); +``` + +## Aave + +Aave has one pool for each chain. The `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. + +`into` is optional for Aave. Omit it to supply the reserve of the funding token. Pass an Aave listing from `listVaults({ protocol: 'aave' })` to select a different reserve. The funding token then routes into that reserve. + +## Generic deposits with `depositIntoVault` + +Use `depositIntoVault` for a vault that has no facade. This includes ERC-4626 vaults that ZeroDev does not list. No facade binds the protocol here, so you must pass it: + +```ts +await sr.depositIntoVault({ + owner, + amount: "100", + token: TOKENS.USDC, + srcChainId: 42161, + destChainId: 42161, + into: "0xVAULT", + protocol: "erc4626", // required - 'aave' | 'morpho' | 'fluid' | 'yearn' | 'erc4626' +}); +``` + +The server rejects an unknown protocol with `UNKNOWN_PROTOCOL`. Use a facade when one exists. + +## Failure behavior + +A destination action can revert after the bridge. In that case the funds stay in the SRA. ZeroDev does not hold the funds. Only the `owner` can recover them, with `getWithdrawCalls`. See [Tracking Status](/onramp/smart-recipes/tracking-status#recovering-funds). diff --git a/docs/pages/onramp/smart-recipes/errors.mdx b/docs/pages/onramp/smart-recipes/errors.mdx new file mode 100644 index 0000000..2827e20 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/errors.mdx @@ -0,0 +1,53 @@ +# Errors + +Each rejection is a `SmartRecipeError`. It contains a `code`, a `message`, and a `requestId` (from the server's `x-request-id` header). Include the `requestId` in a bug report. Each code has a bound subclass, so you can branch with `instanceof`: + +```ts +import { SmartRecipeError, QuoteExpiredError } from "@zerodev/smart-recipes"; + +try { + await sr.aave.deposit({ /* … */ }); +} catch (e) { + if (e instanceof QuoteExpiredError) { + // request a new quote + } else if (e instanceof SmartRecipeError) { + console.error(`[${e.code}] ${e.message} (request ${e.requestId})`); + } +} +``` + +## Error codes + +| 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 | `ASSET_MISMATCH` | The vault asset is not the expected token | +| 400 | `CHAIN_NOT_SUPPORTED` | The chain is not configured on the server | +| 400 | `VAULT_DEPOSITS_DISABLED` | The vault's on-chain `maxDeposit` is 0. It accepts no deposits. | +| 403 | `SANCTIONED_ADDRESS` | The owner is on the OFAC SDN list | +| 403 | `VAULT_BLOCKED` | The vault is on the server blocklist | +| 403 | `FEATURE_DISABLED` | Swap-leg routes are disabled on the server | +| 403 | `ACCESS_DENIED` | The origin or IP is not on the project's allowlist | +| 409 | `VAULT_CAP_EXCEEDED` | The amount is over the vault's remaining capacity | +| 410 | `QUOTE_EXPIRED` | The quote is past its TTL. Request a new quote. | +| 413 | `PAYLOAD_TOO_LARGE` | The request body is over the size cap | +| 422 | `INSUFFICIENT_AMOUNT` | The amount is below the vault minimum after fees | +| 422 | `SWAP_ROUTE_NOT_FOUND` | No swap route was found | +| 429 | `RATE_LIMITED` | Too many requests. Idempotent GETs retry with backoff. | +| 500 | `INTERNAL_ERROR` | An unexpected server error | +| 502 | `SRA_UNAVAILABLE` / `QUOTER_UNAVAILABLE` / `RPC_UNAVAILABLE` | An upstream service failed | + +## Codes that need special handling + +`VAULT_CAP_EXCEEDED` and `VAULT_DEPOSITS_DISABLED` are different conditions. For `VAULT_CAP_EXCEEDED`, try a smaller amount. For `VAULT_DEPOSITS_DISABLED`, the vault accepts no deposits at all. Select a different vault. [`preflight`](/onramp/smart-recipes/vault-discovery#preflight) shows both conditions before you quote. + +`FEATURE_DISABLED` means the route needs a swap leg, and the server has swaps disabled. Same-token routes are not affected. This includes same-chain deposits and plain cross-chain bridges. A retry with different parameters does not help. + +`QUOTE_EXPIRED` occurs because a quote lives for about 60 seconds. Request a new quote. + +## Retry behavior + +The SDK does not retry quote-building POST requests. Each POST can create a new SRA on the server. The SDK retries idempotent GET requests on 429, 502, 503, and network failures, with backoff, up to the client's `maxRetries`. 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..522cd07 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/how-it-works.mdx @@ -0,0 +1,76 @@ +# How It Works + +This page explains the flow from signature to vault shares. It shows where the funds are at each moment. It also shows what happens on failure. Read it 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 receives your intent: owner, amount, token, chains, target vault. It then does four things. + +- It verifies the target on-chain. For an ERC-4626 vault it reads `asset()`. For Aave it checks the reserve list. A wrong target fails here, with a typed error. No funds have moved. +- It reads the vault's live state: `maxDeposit` headroom, the deposit minimum, and `previewDeposit` for expected shares. +- It creates a [Smart Routing Address](/onramp/smart-routing-address) (SRA v1). The deposit actions are stored at creation. Nobody can change them afterwards. +- It returns the Quote: the SRA, a ready-to-sign transaction, a user operation, fees, and expected output. + +### 2. Fund + +The user signs one transfer of `amount` `token` to the SRA. That is the only signature in the flow. A source swap adds approve and swap calls to the same batch. + +### 3. Bridge + +The relayer bridges the funds to the SRA on the destination chain. A same-chain deposit skips this step. + +### 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. No dust is stranded by a locked-in amount. + +### 5. Track + +The status comes from on-chain evidence at the SRA: deposits seen, bridges sent, executions settled. The server never asserts a state it cannot prove. `watchStatus` polls for you and stops on a terminal state. + +## 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. ZeroDev never has custody. The SRA is a permissionless contract. Funds leave it in two ways only: the stored actions, or an owner withdrawal. + +## When something fails + +**The target vault is wrong.** The on-chain probe catches it at quote time. You get `VAULT_TYPE_MISMATCH` or `ASSET_MISMATCH` in milliseconds. No funds moved. + +**The vault fills up after the quote.** The deposit call reverts. The funds stay in the SRA. The `owner` recovers them with [`getWithdrawCalls`](/onramp/smart-recipes/tracking-status#recovering-funds), or through the [SRA portal](https://smart-routing-address.zerodev.app/). + +**The user sends the wrong token.** Tokens outside the route rest in the SRA. Same recovery path. + +**The user never sends funds.** The recipe becomes `ABANDONED` after one hour. Nothing is lost. Late funds still execute. + +No failure mode gives the funds to ZeroDev or to a third party. + +## Slippage + +You set `slippage` in bps at quote time. The SRA enforces the minimum-output floor on-chain, at execution. A quoted estimate cannot be front-run below your floor. + +## Why the server builds the route + +Routes, vault lists, calldata, and compliance screening change weekly. 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. +- Each quote screens the owner against the OFAC SDN list, refreshed daily. + +The SDK stays a thin, dependency-free HTTP client. You almost never need to upgrade it. diff --git a/docs/pages/onramp/smart-recipes/index.mdx b/docs/pages/onramp/smart-recipes/index.mdx new file mode 100644 index 0000000..20b6410 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/index.mdx @@ -0,0 +1,65 @@ +# 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, swap, 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 +``` + +## The problem it removes + +A cross-chain deposit normally requires all of this: + +- a bridge integration, with its failure modes +- a swap integration on one or both chains +- deposit calldata and ABIs for each protocol +- a relayer to execute on the destination chain +- status tracking across two chains +- a recovery path for failed steps + +Smart Recipes replaces all of it with one SDK call. The server quotes the route. The latest [Smart Routing Address](/onramp/smart-routing-address) (SRA v1) executes it. Your user signs a single transfer. + +## Why teams choose it + +**One signature for the user.** The user sends one transfer to a deposit address. The bridge, the swap, and the vault deposit happen behind it. No chain switching. No multi-step approval flows. + +**Zero protocol maintenance.** Vault lists, routes, calldata, and fees live on the server. A new vault or protocol needs no SDK update and no redeploy. + +**Non-custodial by construction.** Funds move through permissionless smart contracts. ZeroDev never holds them. Failed funds rest in the user's own routing address. Only the `owner` can recover them. + +**Fails fast, not expensively.** The server verifies each deposit target on-chain before it quotes. A wrong vault address returns a typed error in milliseconds. You do not learn about it from a failed bridge. + +**Works with any wallet stack.** Each quote carries a plain transaction batch and an ERC-4337 user operation. The SDK has no signer and no runtime dependencies. + +**Gas sponsorship built in.** Your project's gas policy applies to each deposit automatically. Sponsor your users' fees from the dashboard. No code change is necessary. + +## What you can build + +- **Earn features.** Show vaults with live APY and TVL. Deposit from the chain the user's funds are on. +- **Cross-chain onboarding.** Accept deposits from any chain, or from a CEX. +- **Chain-abstracted swaps.** Bridge and swap to a target token in one signature. + +## Supported protocols + +| Facade | Notes | +|---|---| +| `sr.aave.deposit` | The token and chain identify the pool. No vault is necessary. | +| `sr.morpho.deposit` | A vault is required. | +| `sr.fluid.deposit` | A vault is required. | +| `sr.yearn.deposit` | A vault is required. | +| `sr.erc4626.deposit` | Any ERC-4626 vault, by address. Your own vaults included. | + +New protocols land server-side. Your integration does not change. + +## Try it + +- [Quickstart](/onramp/smart-recipes/quickstart): a first deposit in about 20 lines +- [Live demo](https://github.com/zerodevapp/smart-recipes): vault picker, quote, execute, track +- [How it works](/onramp/smart-recipes/how-it-works): the flow, the custody model, failure behavior diff --git a/docs/pages/onramp/smart-recipes/integration-guide.mdx b/docs/pages/onramp/smart-recipes/integration-guide.mdx new file mode 100644 index 0000000..00ca203 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/integration-guide.mdx @@ -0,0 +1,162 @@ +# Integration Guide + +Build a complete earn feature: vault list, cross-chain deposit, progress tracking, and fund recovery. Most teams ship it in a day. + +The [demo app](https://github.com/zerodevapp/smart-recipes/tree/master/examples/demo) implements this exact flow in React. Use it as a reference. + +## 1. Set up the client + +Create one client for your whole app. There is no API key and no signer. + +```ts +// client.ts +import { createSmartRecipes } from "@zerodev/smart-recipes"; + +export const sr = createSmartRecipes({ + serverUrl: import.meta.env.VITE_SERVER_URL, + projectId: import.meta.env.VITE_ZD_PROJECT_ID, +}); +``` + +## 2. Show vaults + +`listVaults` returns live APY and TVL. Each result can route a deposit directly. A table row becomes a deposit with no extra lookups. + +```ts +const { vaults } = await sr.listVaults({ + chains: [8453, 42161], + minTvl: 1_000_000, +}); + +// render: vault.name, vault.protocol, vault.apy, vault.tvlUsd, vault.asset.symbol +``` + +Build your token selector from the server's registry: + +```ts +const tokens = await sr.getTokens({ chainId: 8453 }); +``` + +## 3. Quote + +The user picked a vault, a source chain, and an amount. Quote it: + +```ts +const quote = await sr.morpho.deposit({ + owner: userAddress, + amount: "100", + token: TOKENS.USDC, + srcChainId: 8453, + into: vault, // the Vault object from listVaults + slippage: 100, // 1% +}); +``` + +Show the quote before the user commits: + +- `quote.estimatedShares`: what they receive +- `quote.vaultApy`: the APY snapshot +- `quote.estimatedFees`: route fees, in token base units +- `quote.expiresAt`: quotes live about 60 seconds + +A quote is free and read-only. Re-quote each time the user edits the form. + +## 4. Execute + +The quote carries two equivalent forms. Use the one that matches your wallet stack. + +For an EOA (wagmi, viem, ethers): + +```ts +for (const call of quote.transaction.calls) { + await walletClient.sendTransaction({ + to: call.to, + data: call.data, + value: BigInt(call.value), + chainId: quote.transaction.chainId, + }); +} +``` + +For a smart account (ERC-4337, EIP-7702), send one batched user op: + +```ts +await kernelClient.sendUserOp({ callData: quote.userOp.callData }); +``` + +The user signs this one step. The relayer runs the bridge and the vault deposit. No further signatures are needed. + +## 5. Track + +Drive your progress UI from `watchStatus`: + +```ts +const watcher = sr.watchStatus(quote.sra, { + onStatusChange: (s) => { + // PENDING → BRIDGING → EXECUTING → COMPLETED + setPhase(s.state); + }, + onError: (e) => setError(e), +}); +await watcher.done; +``` + +`getDepositStatus` adds the vault balance: + +```ts +const ds = await sr.getDepositStatus({ + sra: quote.sra, + owner: userAddress, + destChainId: vault.chainId, + vaultId: vault.address, +}); +// ds.phase: 'pending' | 'bridging' | 'deposited' | 'failed' +// ds.vaultBalance: the user's balance in the vault +``` + +## 6. Handle failure and recovery + +Persist `quote.sra` before execution. Use local storage or your backend. It is the recovery handle. A reload, a revert, or a wrong token leaves funds in the SRA. Only the `owner` can drain it. + +```ts +const { data } = await sr.getWithdrawCalls({ + sra, + tokens: [{ chainId: 42161, token: usdcAddress }], +}); +for (const { chainId, calls } of data) { + for (const call of calls) { + await walletClient.sendTransaction({ ...call, value: BigInt(call.value), chainId }); + } +} +``` + +You can also send users to the [SRA portal](https://smart-routing-address.zerodev.app/). It provides the same recovery with no code on your side. + +## 7. Handle errors + +Every rejection carries a typed code. Three cover most of your UI: + +```ts +import { SmartRecipeError, QuoteExpiredError, VaultCapExceededError } from "@zerodev/smart-recipes"; + +try { + await getQuote(); +} catch (e) { + if (e instanceof QuoteExpiredError) requote(); + else if (e instanceof VaultCapExceededError) suggestSmallerAmount(); + else if (e instanceof SmartRecipeError) showError(e.code, e.message); +} +``` + +See [Errors](/onramp/smart-recipes/errors) for the full code table. + +## Checklist + +- [ ] Client created once, with `serverUrl` and `projectId` +- [ ] Vault list from `listVaults`, token selector from `getTokens` +- [ ] Quote display: shares, APY, fees, expiry countdown +- [ ] Execute path for your wallet stack +- [ ] Progress UI from `watchStatus` +- [ ] `quote.sra` persisted before execution +- [ ] Recovery via `getWithdrawCalls` +- [ ] Error handling for `QUOTE_EXPIRED` and `VAULT_CAP_EXCEEDED` diff --git a/docs/pages/onramp/smart-recipes/quickstart.mdx b/docs/pages/onramp/smart-recipes/quickstart.mdx new file mode 100644 index 0000000..fcbcd5f --- /dev/null +++ b/docs/pages/onramp/smart-recipes/quickstart.mdx @@ -0,0 +1,99 @@ +# Quickstart + +## Installation + +:::code-group + +```bash [npm] +npm i @zerodev/smart-recipes +``` + +```bash [yarn] +yarn add @zerodev/smart-recipes +``` + +```bash [pnpm] +pnpm i @zerodev/smart-recipes +``` + +```bash [bun] +bun add @zerodev/smart-recipes +``` + +::: + +The SDK needs Node 18 or later. It uses the global `fetch`. You can inject a different fetch. The package ships as ESM and CJS. + +## Create a client + +```ts +import { createSmartRecipes, TOKENS } from "@zerodev/smart-recipes"; + +const sr = createSmartRecipes({ + serverUrl: "https://recipes.example.com", + projectId: "", +}); +``` + +`createSmartRecipes` is synchronous. All async work occurs when you call a method. + +| Param | Type | Required | Notes | +|---|---|---|---| +| `serverUrl` | `string` | yes | The Smart Recipes server base URL | +| `projectId` | `string` | yes | Your ZeroDev project ID. Sent on every request. | +| `fetch` | `typeof fetch` | no | An injectable fetch, for tests or non-browser environments | +| `timeoutMs` | `number` | no | The abort timeout for each request. Default 30000. | +| `maxRetries` | `number` | no | Retries on transient failures. Applies to idempotent GETs only. Default 2. | + +There is no API key. The server controls access with your `projectId` and a per-project allowlist of origins and IP addresses. + +## Get a quote + +Find a vault. Then deposit into it with one call: + +```ts +// Find USDC vaults on Arbitrum +const { vaults } = await sr.listVaults({ asset: TOKENS.USDC, chains: [42161] }); + +// Quote a deposit: the user funds from Base, the vault is on Arbitrum +const quote = await sr.morpho.deposit({ + owner: "0xUSER", // funds + signs + receives shares + amount: "100", // display units - the server scales by token decimals + token: TOKENS.USDC, // symbol → canonical per-chain address + srcChainId: 8453, // Base + into: vaults[0], // Vault object - destChainId derived from it +}); + +console.log(quote.sra); // the address the user funds +console.log(quote.vaultApy); // APY snapshot +console.log(quote.estimatedShares); // expected vault shares +``` + +A quote prepares the transaction. It does not broadcast. The SDK has no signer. + +## Execute + +Send the funding transaction with your own wallet: + +```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 with a kernel client +await kernelClient.sendUserOp({ callData: quote.userOp.callData }); +``` + +The two forms have the same intent: send `amount` of `token` to the SRA. The relayer runs the vault-side approve and deposit calls on the destination chain. Your user does not sign those calls. + +## Track + +```ts +const watcher = sr.watchStatus(quote.sra, { + onStatusChange: (s) => console.log(s.state), // PENDING → BRIDGING → EXECUTING → COMPLETED +}); +await watcher.done; +``` + +See [Tracking Status](/onramp/smart-recipes/tracking-status) for the full lifecycle. See [Deposits](/onramp/smart-recipes/deposits) for all protocols and parameters. diff --git a/docs/pages/onramp/smart-recipes/quotes.mdx b/docs/pages/onramp/smart-recipes/quotes.mdx new file mode 100644 index 0000000..e102c9e --- /dev/null +++ b/docs/pages/onramp/smart-recipes/quotes.mdx @@ -0,0 +1,66 @@ +# Quotes & Execution + +Each recipe returns a **Quote**. A quote prepares the transaction. It does not broadcast. You send it with your own wallet. + +## The `Quote` type + +```ts +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[] } // EOA sends in order + userOp: { callData: Hex; calls: OnChainCall[]; chainId: number } // kernel client fills the rest + srcTransactions?: { chainId: number; calls: OnChainCall[] }[] // present when a src swap is needed + estimatedFees: { // amounts in route-token base units, NOT USD + totalFeeAmount: string // summed in totalFeeToken units; partial when denominations mix + totalFeeToken: Address | null // null = mixed/none - render perChain instead + perChain: { chainId: number; feeAmount: string; feeToken: Address | null }[] + } + estimatedReceiveAmount: string // base units on destChain + estimatedShares?: string // vault recipes only + vaultApy?: number // percent (2.57 = 2.57%) + swapMinOutput?: string // estimate - the server enforces the real floor + route?: { // the resolved flow, for UI display + bridgeTokenType: string | null // the token the SRA is funded with + requiresSrcSwap: boolean // owner-signed swap before funding + requiresDestSwap: boolean // server-synthesized swap before the deposit + sameChain: boolean // no bridge leg (still an SRA deposit) + } + recipeVersion: number +} + +type OnChainCall = { to: Address; data: Hex; value: string } // value is a decimal string +``` + +All wire amounts are base-10 strings, because JSON has no bigint. Parse them yourself: `BigInt(quote.estimatedReceiveAmount)`. + +## Execute a quote + +`transaction` and `userOp` have the same intent: send `amount` of `token` to the SRA. When the funding token is different from the route token, the calls start with owner-signed approve and swap calls. + +For an EOA, send `transaction.calls` in sequence: + +```ts +for (const call of quote.transaction.calls) { + await wallet.sendTransaction({ ...call, value: BigInt(call.value) }); +} +``` + +For ERC-4337, send `userOp.callData` as one user operation. The server encodes a Kernel v3 / ERC-7579 `executeBatch(calls)`. Your kernel client supplies the sender, nonce, gas, and signature: + +```ts +await kernelClient.sendUserOp({ callData: quote.userOp.callData }); +``` + +The relayer runs the vault-side approve and deposit on the destination chain. Your user signs only the funding transaction. + +## Expiry + +A quote usually expires after 60 seconds. Send the funds promptly. A stale quote rejects with `QuoteExpiredError`. Request a new quote in that case. + +## Fees + +The `estimatedFees` amounts are in base units of the route token, not USD. When the fee tokens differ across chains, `totalFeeToken` is `null`. Show the `perChain` entries in that case. + +Sponsored fee entries are not included in the sums. Sponsorship follows the gas policy of your project, through your `projectId`. There is no per-quote flag. diff --git a/docs/pages/onramp/smart-recipes/tracking-status.mdx b/docs/pages/onramp/smart-recipes/tracking-status.mdx new file mode 100644 index 0000000..e2cc876 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/tracking-status.mdx @@ -0,0 +1,77 @@ +# Tracking Status + +The server derives the recipe status from on-chain evidence at the SRA: deposits seen, bridges sent, executions settled. The server does not assert a status. The status stays correct when funds arrive late or when a retry occurs. + +## Lifecycle + +``` +PENDING → BRIDGING → EXECUTING → COMPLETED + → FAILED +PENDING (no funds within 1h) → ABANDONED +``` + +```ts +type RecipeState = + | "PENDING" // quote issued, SRA not yet funded + | "BRIDGING" // funds received, bridge in flight (cross-chain only) + | "EXECUTING" // destination action in progress + | "COMPLETED" + | "FAILED" // carries failureReason + | "ABANDONED"; // the SRA received no funds within 1 hour after the quote +``` + +An `ABANDONED` recipe is not locked. Funds that arrive late still execute. A new `watchStatus` call picks the recipe up again from live evidence. + +## `watchStatus` + +The SDK polls the server, with backoff. Polling stops on a terminal state (`COMPLETED` / `FAILED` / `ABANDONED`). + +```ts +const watcher = sr.watchStatus(quote.sra, { + interval: 4000, // ms; default 4000 + timeout: 600_000, // total watch bound, default 10 min; 0 = poll indefinitely + maxRetries: 5, // consecutive poll failures tolerated; default 5 + onStatusChange: (s) => console.log(s.state), + onError: (e) => console.error(e), // persistent poll failure or timeout +}); + +await watcher.done; // resolves on terminal state / unsubscribe, rejects on persistent failure +watcher.stop(); // or call watcher() - unsubscribe +``` + +## `getStatus` + +Use `getStatus` for one read of the same data: + +```ts +const status = await sr.getStatus(sra); +// status.state - RecipeState +// status.deposits - per-deposit evidence: deposit tx, bridge tx, execution tx +// status.failureReason - the first deposit error when state === 'FAILED' +``` + +## Recovering funds + +There is no on-chain refund fallback. When the destination action reverts, the funds stay in the SRA. The SRA is non-custodial. Only the `owner` can recover the funds. `getWithdrawCalls` returns the recovery calls for the owner to sign: + +```ts +const { data, receiver } = await sr.getWithdrawCalls({ + sra, + tokens: [{ chainId: 42161, token: "0xTOKEN" }], +}); + +for (const { chainId, calls } of data) { + for (const call of calls) { + await wallet.sendTransaction({ ...call, chainId, value: BigInt(call.value) }); + } +} +``` + +## Other SRA reads + +```ts +sr.getSraInfo({ sra }) // the stored routing config: owner, actions, src tokens, slippage +sr.getSraFeeEstimates({ sra }) // per-chain bridge fee estimates (incl. sponsored entries) +sr.getDepositStatus({ sra, owner, destChainId, vaultId }) + // deposit phase (pending/bridging/deposited/failed) + vault balance +``` diff --git a/docs/pages/onramp/smart-recipes/vault-discovery.mdx b/docs/pages/onramp/smart-recipes/vault-discovery.mdx new file mode 100644 index 0000000..cfb7ef9 --- /dev/null +++ b/docs/pages/onramp/smart-recipes/vault-discovery.mdx @@ -0,0 +1,84 @@ +# Vault Discovery + +The server finds vaults for you. You can pass a result directly into a deposit. You do not enter the address, chain, or asset again. + +## `listVaults` + +```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 TVL floor + minApy: 2, // optional - percent + page: 0, // zero-based; walk until nextPage is null +}); +``` + +:::info +If you omit `minTvl`, the listing applies a default minimum of $100,000. Pass `minTvl: 0` to include vaults with a lower TVL. +::: + +## The `Vault` type + +The type is a union that discriminates on `category`. A vault contains the fields that you select a vault by, and the fields that a deposit routes with: + +```ts +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 }) +``` + +`maturity` exists only on fixed-yield vaults. The compiler blocks access to it until you narrow on `category`. + +You can pass a `Vault` directly as the `into` of a deposit. The deposit gets `destChainId` from the vault. + +## `getVault` + +`getVault` returns the detail view: a `Vault` plus data that the list omits. You can also pass the result as `into`. + +```ts +const details = await sr.getVault(vaultId, chainId); +// details.apyBreakdown { base?, reward?, total? } +// details.apy7day / details.apy30day +// details.description +``` + +Pass `chainId` when you know it. The call then uses the direct single-vault endpoint and does not scan a list. + +## `preflight` + +`preflight` reports the vault state without a quote. It includes `depositsDisabled`: the vault's on-chain `maxDeposit` is 0, so the vault accepts no deposits. Vault listings cannot see this state. Only the on-chain read shows it. + +```ts +const check = await sr.preflight({ + owner: "0xUSER", + vaultId: vault.address, + destChainId: vault.chainId, + amount: "100", +}); +if (check.depositsDisabled) { + // the vault accepts no deposits - select a different vault +} +// check.maxDeposit - remaining cap headroom (null when the vault kind has no per-owner cap) +// check.route - the bridge token, and whether a src or dest swap is required +``` + +## Supported chains and tokens + +```ts +const chains = await sr.getChains(); // ChainInfo[] +const tokens = await sr.getTokens({ chainId: 8453 }); // TokenInfo[] +``` diff --git a/vocs.config.tsx b/vocs.config.tsx index 0cba285..e823e48 100644 --- a/vocs.config.tsx +++ b/vocs.config.tsx @@ -291,6 +291,56 @@ export default defineConfig({ }, ], }, + { + text: "Smart Recipes", + collapsed: false, + items: [ + { + text: "Introduction", + link: "/onramp/smart-recipes", + }, + { + text: "Quickstart", + link: "/onramp/smart-recipes/quickstart", + }, + { + text: "How It Works", + link: "/onramp/smart-recipes/how-it-works", + }, + { + text: "Integration Guide", + link: "/onramp/smart-recipes/integration-guide", + }, + { + text: "Deposits", + link: "/onramp/smart-recipes/deposits", + }, + { + text: "Vault Discovery", + link: "/onramp/smart-recipes/vault-discovery", + }, + { + text: "Bridge & Swap", + link: "/onramp/smart-recipes/bridge-and-swap", + }, + { + text: "Quotes & Execution", + link: "/onramp/smart-recipes/quotes", + }, + { + text: "Tracking Status", + link: "/onramp/smart-recipes/tracking-status", + }, + { + text: "Errors", + link: "/onramp/smart-recipes/errors", + }, + { + text: "API Reference", + link: "/onramp/smart-recipes/api-reference", + }, + ], + }, ], "/smart-accounts": [ {