UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

168 lines (157 loc) 7.62 kB
import type { ComposeCompileRequest, Flow } from '@lifi/compose-spec'; import { createComposeSdk, materialisers, resources } from '../index.js'; import { API_KEY, BASE_URL, OWNER } from './config.js'; // Base mainnet. Pendle PT-USDC, its underlying USDC, and WETH. const BASE = 8453; const PT_USDC = '0x0d18b7bab00988a442e31065e76286844809dc9a'; const USDC = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; const WETH = '0x4200000000000000000000000000000000000006'; /** * Exit a Pendle PT position and swap **all** of the proceeds to WETH on Base. * * ## The problem this solves * * Every other swap op bakes a fixed input amount into the router calldata at * quote time. That is fine when you know the amount up front, and useless when * you do not: a PT exit routes through an on-chain AMM quote, so it can deliver * a little more or a little less than the estimate. With `lifi.swap` you must * name an amount, and whatever the exit over-delivers is stranded in the proxy * (or, if it under-delivers, the flow reverts). * * `fly.swap` is the only swap that can sit downstream of a producer whose output * is not known until execution. Fly's `DexAggregator` supports a variable-input * mode — the `amountIn` word is excluded from the signed quote, so leaving it * unset makes the contract resolve the amount on-chain: for an erc20 input, * `min(our balance, the allowance we granted)`; for a native input, whatever * `msg.value` the op forwards. The op therefore swaps *all* of whatever * actually arrives. Either leg may be the chain's gas coin. * * ## The two numbers, which are independent * * This is the thing to get right, because they sound alike and are not: * * - **`inputVarianceBps`** describes the **upstream**: how far its real output * may sit either side of the estimate, symmetrically. It is a quantity, not a * price. * - **`maxSlippageBps`** is ordinary **price** protection: expected rate versus * achieved rate, sized on the pair, and completely unaffected by how much * input turns up. It is enforced on-chain as a *rate* against the input * actually consumed, so the percentage protection is identical at any size. * * Neither is derived from the other and neither constrains the other. A * `maxSlippageBps` below `inputVarianceBps` is perfectly legitimate. * * ## Choosing `inputVarianceBps` (default 50 = 0.5%) * * The compile-time input amount is **simulated** at the current block, not * guessed — the compiler runs the upstream and reads what it produced. So this * field covers only simulate-to-execute drift: block delay, yield accrued in * that window, and third parties moving the upstream's rate in between. Tens of * basis points, not percent. * * Widening it is safe but not free. The bottom of the band is what the compose * response publishes as the output's `amount.minimum`, so a wider band * minimum to whoever consumes your quote. * * ## Ignore the 50% slippage in the Fly calldata * * If you inspect the compiled transaction you will see a 50% slippage figure * where you configured 1%. That is deliberate and it is not your setting. All * Fly's own `slippage` parameter does is price its minimum-out floor, which is an * absolute amount fixed at quote time and therefore cannot scale down when a * legitimately smaller input arrives. The op sends a high value so that floor * can never be the binding constraint, and enforces its own rate-based bound * instead — which is strictly tighter for every in-band delivery. The number * that protects you is `maxSlippageBps`. * * ## What happens outside the band * * With `E` the simulated exit output, `v = inputVarianceBps`: * * E = 1000, v = 200 (2%), p = 100 (1%) -> band [980, 1020] * * delivered 1020 -> consumes 1020, nothing left over (band top) * delivered 1000 -> consumes 1000, nothing left over * delivered 980 -> consumes 980, nothing left over (band bottom) * delivered 950 -> reverts on the op's own floor invariant * delivered 1200 -> consumes 1020; the 180 excess is swept back to you * * Both out-of-band outcomes are safe. The upward cap exists because Fly * confiscates output above the quoted expectation rather than rejecting it, so * the op never approves more than it quoted for. Note the excess only comes back * if the run supplies `sweepTo` — without it the residue sits in the proxy until * a later flow or an explicit `POST /compose/sweep` collects it. * * ## Two limitations worth knowing before you author * * 1. **`fly.swap` cannot sit downstream of another *prepared* op.** `lifi.zap` * (used here) and other plain ops are fine. `lifi.swap` and `paraswap.buy` * are prepared, and the compiler rejects those chains with a message about * splitting the flow across a continuation. * 2. **The upstream must deliver to the proxy.** `lifi.zap` does so by * construction. An upstream that exposes its own delivery address — such as * `morphoBlue.withdraw`'s `receiver` — must have it bound to * `context.executionAddress`, or the amount it reports will diverge from what * the proxy actually holds and the op's custody assertion reverts. * * Demonstrates: * - A variable-input swap consuming an upstream's entire, runtime-determined output * - `lifi.zap` as the nondeterministic producer (a PT exit through an AMM quote) * - `sweepTo`, which is what returns any out-of-band residue to the sender * * `fly.swap` is always registered on the backend; without `FLY_API_KEY` it uses * Fly's public host. * * See `docs/references/fly-api.md` for the router behaviour all of this rests on. */ export const buildFlySwapExample = (): { flow: Flow; request: ComposeCompileRequest; } => { const sdk = createComposeSdk({ baseUrl: BASE_URL, apiKey: API_KEY }); const builder = sdk.flow(BASE, { name: 'pt-usdc-exit-swap-all-to-weth', inputs: { amountIn: resources.erc20(PT_USDC, BASE), }, }); // Exit the PT position to its underlying USDC. `lifi.zap` declares no // output-amount estimator, so the compiler cannot resolve the next node's // input statically and instead simulates this node to read what it really // produced. That simulated value is what `fly.swap` quotes against, and it is // why a 50 bps band is enough. const exitPt = builder.lifi.zap('exitPt', { bind: { amountIn: builder.inputs.amountIn }, config: { resourceOut: resources.erc20(USDC, BASE), }, }); // Swap the entire exit output to WETH. // // No slippage guard here, and none is accepted: `amountOut` declares // `providesMinimum`, so the op owns its own bound. Attaching one is a // `guard_error`. The published `amount.minimum` is the op's, derived from the // bottom of the input band. builder.fly.swap('swapAllToWeth', { bind: { amountIn: exitPt.amountOut }, config: { resourceOut: resources.erc20(WETH, BASE), // The exit is a same-block simulation, so only drift needs covering. inputVarianceBps: 50, // Price protection on the USDC/WETH pair, independent of the above. maxSlippageBps: 100, }, }); const flow = builder.build(); const request = sdk.request(flow, { signer: OWNER, inputs: { // 1 PT-USDC (6 decimals). amountIn: materialisers.directDeposit({ amount: '1000000' }), }, // Load-bearing, not boilerplate: this is what returns the unconsumed input // to the sender if the exit over-delivers past the top of the band. sweepTo: builder.context.sender, }); return { flow, request }; };