Create a self-contained, runnable **TypeScript + viem** demo project that integrates the **Ryze protocol** (an intent-based AMM on Base) end to end: swap (exact-in), single-asset liquidity join, proportional liquidity join, and proportional liquidity exit. The demo's purpose is to *teach the integration* — code must be heavily commented, each step explicit, and each operation runnable as its own script. ## Protocol model (authoritative — do not deviate) Ryze is intent-based. For every operation: **quote → (ERC-20 approve `MultiHopRouter` if needed) → read per-type nonce → sign an EIP-712 intent with the user's key → POST it to the Ryze execution relayer → poll status.** The relayer batches intents, attaches the required oracle data (Pyth payloads + signed CEX prices) to the execution transaction, executes on-chain, and pays gas. The demo must therefore contain **no price/oracle code**, **no `execute*` contract calls**, and **no raw swap calldata** — the only transaction ever sent from the demo wallet is `approve`. ## Configuration — `.env` (provide `.env.example`) ```bash PRIVATE_KEY= # funded Base Sepolia test wallet (needs test USDC/WETH + a little ETH for approvals) RPC_URL= # Base Sepolia RPC RYZE_CHAIN_ID=84532 RYZE_RELAYER_URL=https://sepolia.relayer.ryze.pro/api/v1 RYZE_ROUTER_URL=https://sepolia.router.ryze.pro RYZE_API_URL=https://sepolia.api.ryze.pro/api RYZE_MULTIHOP_ROUTER=0x477A780Fab142F9289a01C9B22Ed322dE6c6af1A RYZE_POOL_QUERIES=0x1a7B7D9071c30935448b6cC3d114c0c794d0a3C0 # Base Sepolia reference addresses USDC=0x6aEB5326b5DcA2163e0995824DcB816194b157a6 WETH=0x6Dd0C6a2e058bc3F7D6AaCe23965180BcBbb2635 POOL_WETH_USDC=0x1aB1cC9923ed45F1C7F291B222CC3d0D2a578723 ``` (Mainnet, for the README: chainId 8453, `mainnet.*.ryze.pro`, MultiHopRouter `0x8e20A1534a204DE569E9d62D410c19795b81DF70`, PoolQueries `0xD4b5DD638d09aB7b6Eb4Ec3490F02A96d0DD4100`.) ## API surface to implement against **Quotes (Router):** - Swap: `POST {RYZE_ROUTER_URL}/quote` body `{ tokenIn, tokenOut, amountIn, slippageTolerance: , maxHops: 3, userAddress }` → `output.amount` (string, base units), `steps[{pool,tokenIn,tokenOut}]`, `priceImpact`, `prices[]`. Intent `path = steps.map(({pool,tokenIn,tokenOut}) => ({pool,tokenIn,tokenOut}))`. - Join (single & proportional): `POST {RYZE_ROUTER_URL}/join-quote` body `{ poolAddress, tokensIn: [{token, amount}, …] }` — one entry ⇒ `joinType:"single"`, all pool tokens ⇒ `"proportional"` → `sharesOut` (18-dec string). - Exit: on-chain view `WeightedPoolQueries.queryProportionalExit(pool, sharesIn)`. ⚠ The deployed contract returns a **struct-wrapped array** — ABI output is `tuple(uint256[] amountsOut)`, NOT bare `uint256[]` (decoding as `uint256[]` throws). Amounts are ordered like the pool's asset list (pool token order from `GET {RYZE_API_URL}/pools/list`). Slippage (bigint only): `min = expected * (10_000n - slippageBps) / 10_000n`; must be > 0. ## Execution-reality rules (verified against the live deployment — build ALL of these in) These two behaviors are real, verified on-chain, and integrations that skip them fail: 1. **Flat intent fee.** `MultiHopRouter.intentFee()` (view → `uint256`) is a flat **USD fee in WAD** (currently 1e16 = $0.01) charged per execution, converted to token units at the oracle price: `feeTok(t) = ceilDiv(intentFee * 10^decimals(t), blendedPrice(t))` using the quote's `prices[]`. Deduction point: swap — `feeTok(tokenIn) × path.length` off `amountIn` before swapping; single join — `feeTok(tokenIn)` off `amountIn`; proportional join — `feeTok(tokensIn[0])` off `amountsIn[0]`; exit — `feeTok(poolAsset0)` off the **outputs** (contract requires `out[0] ≥ min[0] + fee`). Quotes do NOT include this fee, so derate mins: scale the expected amount by `(input − fee)/input` (swap/joins) before applying slippage; for exits subtract `feeTok(asset0)` from `minAmountsOut[0]` (floor 1n). Use **$25-scale amounts** (e.g. 25 USDC) so the fee is well inside the slippage budget — with 1-USDC amounts the $0.01 fee alone is 1% and every intent reverts with `InvalidSlippage()`. 2. **LP shares are auto-staked in the pool's gauge vault.** When `MultiHopRouter.poolToGaugeVault(pool)` (view → `address`) is set (it is for these pools), join shares go to the LPGaugeVault, not the wallet: `pool.balanceOf(user)` stays 0. LP position = `pool.balanceOf(user) + gauge.getEligibleBalance(user)` (view → `uint256`). Exits burn directly from the gauge balance — still no approval needed. **Nonces** — per-user AND per-type, `readContract` on `RYZE_MULTIHOP_ROUTER`: `swapNonces(address)`, `joinNonces(address)`, `joinProportionalNonces(address)`, `exitProportionalNonces(address)` — all `view returns (uint256)`. The submitted nonce must exactly equal the on-chain value; read it right before signing. **EIP-712** — domain for all types: `{ name: 'MultiHopRouter', version: '1', chainId: RYZE_CHAIN_ID, verifyingContract: RYZE_MULTIHOP_ROUTER }`. Types (exact order): ```ts const TYPES = { SwapIntent: [ { name: 'user', type: 'address' }, { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' }, { name: 'amountIn', type: 'uint256' }, { name: 'minAmountOut', type: 'uint256' }, { name: 'path', type: 'Hop[]' }, { name: 'recipient', type: 'address' }, { name: 'deadline', type: 'uint256' }, { name: 'nonce', type: 'uint256' } ], Hop: [ { name: 'pool', type: 'address' }, { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' } ], JoinIntent: [ { name: 'user', type: 'address' }, { name: 'pool', type: 'address' }, { name: 'tokenIn', type: 'address' }, { name: 'amountIn', type: 'uint256' }, { name: 'minSharesOut', type: 'uint256' }, { name: 'recipient', type: 'address' }, { name: 'deadline', type: 'uint256' }, { name: 'nonce', type: 'uint256' } ], JoinProportionalIntent: [ { name: 'user', type: 'address' }, { name: 'pool', type: 'address' }, { name: 'tokensIn', type: 'address[]' }, { name: 'amountsIn', type: 'uint256[]' }, { name: 'minSharesOut', type: 'uint256' }, { name: 'recipient', type: 'address' }, { name: 'deadline', type: 'uint256' }, { name: 'nonce', type: 'uint256' } ], ExitProportionalIntent: [ { name: 'user', type: 'address' }, { name: 'pool', type: 'address' }, { name: 'sharesIn', type: 'uint256' }, { name: 'minAmountsOut', type: 'uint256[]' }, { name: 'recipient', type: 'address' }, { name: 'deadline', type: 'uint256' }, { name: 'nonce', type: 'uint256' } ], } ``` Sign with `account.signTypedData({domain, types, primaryType, message})` and submit the returned hex string as-is — no splitting into `v`/`r`/`s`. **Submit** — `POST {RYZE_RELAYER_URL}/intents/submit`. Common: `user`, `recipient`, `deadline` (number, unix seconds ≈ now+1800), `nonce` (number), `signature` (the raw hex string). All amounts base-10 **strings**. Type-specific: - `{ type: "swap", tokenIn, tokenOut, amountIn, minAmountOut, path: Hop[] }` - `{ type: "join", pool, tokenIn, amountIn, minSharesOut, otherPoolToken }` — `otherPoolToken` = the pool's other asset; REQUIRED in body, NOT in the signed message. - `{ type: "joinProportional", pool, tokensIn: [], amountsIn: [], minPoolTokensOut }` — body says `minPoolTokensOut`, signed field is `minSharesOut` (same value). - `{ type: "exitProportional", pool, sharesIn, minAmountsOut: [], poolTokens: [] }` — `poolTokens` (ordered pool assets, aligned with `minAmountsOut`) REQUIRED in body, NOT signed. No approval needed for exits. Responses: 202 `{ success, intentId, status: "pending" }` (idempotent on resubmit); 400 `{ success: false, message }`; 429 rate limit → backoff. **Poll** — `GET {RYZE_RELAYER_URL}/intents/{intentId}` every 2s until `intent.status ∈ {confirmed, failed}` (typical 3–12s; timeout 90s printing the intentId). Print `txHash`, `blockNumber`, `gasUsed`; on `failed` print the decoded revert in `error`. ## Project to generate ``` ryze-demo-ts/ ├── package.json # type: module; deps: viem, dotenv; tsx for running ├── tsconfig.json ├── .env.example ├── README.md # setup, funding a test wallet, how to run each script, mainnet switch └── src/ ├── config.ts # env loading + validation ├── ryze/ │ ├── types.ts # quote/intent/response types │ ├── quotes.ts # swap quote, join quote, exit view-call quote, slippage math │ ├── eip712.ts # domain + TYPES + buildXxxIntent + sign helpers │ ├── relayer.ts # submitIntent, waitForIntent (polling), error surfacing │ └── chain.ts # viem clients, nonce reads, allowance check + ensureApproval └── scripts/ ├── 01-swap.ts # USDC → WETH swap ├── 02-join-single.ts # USDC-only join into WETH-USDC pool ├── 03-join-proportional.ts # WETH+USDC proportional join └── 04-exit.ts # exit a fraction of the LP position ``` Each script: loads config → prints the quote (human-readable with decimals from `GET {RYZE_API_URL}/assets`) → applies the execution-reality rules (fee-derated mins) → ensures approval (skipped for exit) → reads the correct nonce → builds + signs the intent → prints the full submit body → submits → polls, printing status transitions to `confirmed`/`failed`. Use $25-scale amounts (e.g. 25 USDC) — large enough that the flat intent fee is negligible, small enough to be cheap. ## Acceptance - `npm install && npm run swap` (and `join-single`, `join-proportional`, `exit`) each run end to end against Base Sepolia with a funded key and a printed txHash. - Type-checks clean (`tsc --noEmit`). All amount math in `bigint`. - All execution-reality rules implemented: intent-fee derating and gauge-vault position reads. - Comments at each step explain WHY (intent model, per-type nonces, the flat intent fee, the gauge auto-stake, and the three field traps: `otherPoolToken`, `minPoolTokensOut` vs `minSharesOut`, `poolTokens`). - README documents the flow diagram, prerequisites, and the mainnet env switch.