From a145add3be73f4ec287fa2ff1198b4b0c68ef3d1 Mon Sep 17 00:00:00 2001 From: bus Date: Sun, 15 Dec 2024 19:24:16 +0000 Subject: [PATCH 1/2] Add CrocSmartSwapPlan --- package.json | 2 +- src/abis/index.ts | 1 + src/abis/multiImpact.ts | 133 ++++++++++ src/constants.ts | 4 +- src/context.ts | 3 + src/croc.ts | 14 ++ src/index.ts | 1 + src/smartSwap/index.ts | 3 + src/smartSwap/routers.ts | 191 +++++++++++++++ src/smartSwap/smartSwap.ts | 415 ++++++++++++++++++++++++++++++++ src/smartSwap/smartSwapRoute.ts | 140 +++++++++++ 11 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 src/abis/multiImpact.ts create mode 100644 src/smartSwap/index.ts create mode 100644 src/smartSwap/routers.ts create mode 100644 src/smartSwap/smartSwap.ts create mode 100644 src/smartSwap/smartSwapRoute.ts diff --git a/package.json b/package.json index 6dd4543..bc7bea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crocswap-libs/sdk", - "version": "1.1.2", + "version": "1.2.0", "description": "🛠🐊🛠 An SDK for building applications on top of CrocSwap", "author": "Ben Wolski ", "repository": "https://github.com/CrocSwap/sdk.git", diff --git a/src/abis/index.ts b/src/abis/index.ts index 8f7d636..311eba6 100644 --- a/src/abis/index.ts +++ b/src/abis/index.ts @@ -1,4 +1,5 @@ export * from "./query"; export * from "./impact"; +export * from "./multiImpact"; export * from "./erc20"; export * from "./croc"; diff --git a/src/abis/multiImpact.ts b/src/abis/multiImpact.ts new file mode 100644 index 0000000..fac3212 --- /dev/null +++ b/src/abis/multiImpact.ts @@ -0,0 +1,133 @@ + +export const MULTI_IMPACT_ABI = [ + { + "inputs": [ + { + "internalType": "address", + "name": "dex", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "poolIdx", + "type": "uint256" + } + ], + "internalType": "struct CrocMultiImpact.SwapHop[]", + "name": "hops", + "type": "tuple[]" + }, + { + "internalType": "uint128", + "name": "qty", + "type": "uint128" + }, + { + "internalType": "bool", + "name": "isReverse", + "type": "bool" + } + ], + "name": "calcMultiHopImpact", + "outputs": [ + { + "internalType": "int128", + "name": "inputFlow", + "type": "int128" + }, + { + "internalType": "int128", + "name": "outputFlow", + "type": "int128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "poolIdx", + "type": "uint256" + } + ], + "internalType": "struct CrocMultiImpact.SwapHop[]", + "name": "hops", + "type": "tuple[]" + }, + { + "internalType": "uint128", + "name": "qty", + "type": "uint128" + }, + { + "internalType": "bool", + "name": "isFixedOutput", + "type": "bool" + } + ], + "internalType": "struct CrocMultiImpact.SwapPath[]", + "name": "paths", + "type": "tuple[]" + } + ], + "name": "calcMultiPathImpact", + "outputs": [ + { + "components": [ + { + "internalType": "int128", + "name": "inputFlow", + "type": "int128" + }, + { + "internalType": "int128", + "name": "outputFlow", + "type": "int128" + } + ], + "internalType": "struct CrocMultiImpact.SwapPathOutput[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dex_", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/constants.ts b/src/constants.ts index 96c011d..914f109 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,5 @@ type ChainAddress = string; -type ChainId = string; +export type ChainId = string; export const MIN_TICK = -665454; export const MAX_TICK = 831818; @@ -17,6 +17,7 @@ export interface ChainSpec { dex: ChainAddress; query: ChainAddress; impact: ChainAddress; + multiImpact?: ChainAddress; router?: ChainAddress routerBypass?: ChainAddress } @@ -343,6 +344,7 @@ const SCROLL_CHAIN: ChainSpec = { dex: "0xaaaaAAAACB71BF2C8CaE522EA5fa455571A74106", query: "0x62223e90605845Cf5CC6DAE6E0de4CDA130d6DDf", impact: "0xc2c301759B5e0C385a38e678014868A33E2F3ae3", + multiImpact: "0x0F5Ef3835d0D1Ecf25A395CcB91E061CBde69205", router: "0xfB5f26851E03449A0403Ca945eBB4201415fd1fc", routerBypass: "0xED5535C6237f72BD9b4fDEAa3b6D8d9998b4C4e4", }, diff --git a/src/context.ts b/src/context.ts index 38eb4c2..9470c73 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import { ChainSpec, CHAIN_SPECS } from "./constants"; import { CROC_ABI, QUERY_ABI, ERC20_ABI } from "./abis"; import { ZeroAddress } from "ethers"; import { IMPACT_ABI } from "./abis/impact"; +import { MULTI_IMPACT_ABI } from "./abis/multiImpact"; import { ERC20_READ_ABI } from "./abis/erc20.read"; export interface CrocContext { @@ -14,6 +15,7 @@ export interface CrocContext { routerBypass?: Contract; query: Contract; slipQuery: Contract; + multiImpact?: Contract; erc20Read: Contract; erc20Write: Contract; chain: ChainSpec; @@ -115,6 +117,7 @@ function inflateContracts( routerBypass: context.addrs.routerBypass ? new Contract(context.addrs.routerBypass || ZeroAddress, CROC_ABI, actor) : undefined, query: new Contract(context.addrs.query, QUERY_ABI, provider), slipQuery: new Contract(context.addrs.impact, IMPACT_ABI, provider), + multiImpact: context.addrs.multiImpact ? new Contract(context.addrs.multiImpact, MULTI_IMPACT_ABI, provider) : undefined, erc20Write: new Contract(ZeroAddress, ERC20_ABI, actor), erc20Read: new Contract(ZeroAddress, ERC20_READ_ABI, provider), chain: context, diff --git a/src/croc.ts b/src/croc.ts index ec28936..2c7a68a 100644 --- a/src/croc.ts +++ b/src/croc.ts @@ -9,6 +9,7 @@ import { CrocPositionView } from './position'; import { CrocSlotReader } from './slots'; import { TransactionResponse } from 'ethers'; import { TempestStrategy, TempestVault } from './vaults/tempest'; +import { CrocSmartSwapPlan, CrocSmartSwapExecOpts } from './smartSwap'; /* This is the main entry point for the Croc SDK. It provides a high-level interface * for interacting with CrocSwap smart contracts in an ergonomic way. */ @@ -56,6 +57,19 @@ export class CrocEnv { return new SellPrefix(ZeroAddress, qty, this.tokens, this.context) } + /* Creates a smart swap plan for swapping from one token to another. The plan will + * automatically select the best route to swap through. + + * @param fromToken The address of the token to swap from. + * @param toToken The address of the token to swap to. + * @param qty The quantity of the swap, either input or output depending on isFixedOutput. + * @param isFixedOutput Whether the quantity is fixed output or fixed input. + * @param opts Optional parameters for the swap plan. */ + smartSwap (fromToken: string, toToken: string, qty: TokenQty, isFixedOutput: boolean, opts?: CrocSmartSwapExecOpts): CrocSmartSwapPlan { + return new CrocSmartSwapPlan(this.tokens.materialize(fromToken), + this.tokens.materialize(toToken), qty, isFixedOutput, this.context, opts) + } + /* Returns a view of the canonical pool for the underlying token pair. For example the * below would return pool view for WBTC/USDC with WBTC as the quote side token: * crocEnv.pool(WBTC, USDC) diff --git a/src/index.ts b/src/index.ts index fd7ca5b..93ab9e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from "./abis"; export * from "./pool"; export * from "./position"; export * from "./swap"; +export * from "./smartSwap"; export * from "./croc"; export * from "./encoding/liquidity"; diff --git a/src/smartSwap/index.ts b/src/smartSwap/index.ts new file mode 100644 index 0000000..2bb6b2f --- /dev/null +++ b/src/smartSwap/index.ts @@ -0,0 +1,3 @@ +export * from "./smartSwap" +export * from "./smartSwapRoute" +export * from "./routers" diff --git a/src/smartSwap/routers.ts b/src/smartSwap/routers.ts new file mode 100644 index 0000000..5792417 --- /dev/null +++ b/src/smartSwap/routers.ts @@ -0,0 +1,191 @@ +import { CrocSmartSwapHop, CrocSmartSwapRoute } from "./smartSwapRoute"; +import { ChainId } from "../constants"; + +export interface CrocSmartSwapRouter { + type: string + suggestRoutes(fromToken: string, toToken: string, qty: bigint, isFixedOutput: boolean, slippage: number): Promise +} + +export interface PoolStats { + base: string, + quote: string, + baseTvl: number, + quoteTvl: number, + events: number, +} + +/* Pool-aware router that suggests routes based on possible paths built from a + * supplied list of pools. */ +export class CrocSmartSwapPoolRouter { + public type: string + private maxDepth: number + private allPoolStats: PoolStats[] + constructor (allPoolStats: PoolStats[], maxDepth: number = 3) { + this.type = "poolStats" + this.allPoolStats = allPoolStats + this.maxDepth = maxDepth + } + + public async suggestRoutes(fromToken: string, toToken: string, qty: bigint, isFixedOutput: boolean, _: number): Promise { + console.log('pool router suggestRoutes', this.allPoolStats); + fromToken = fromToken.toLowerCase() + toToken = toToken.toLowerCase() + const routes: CrocSmartSwapRoute[] = [] + const tokenMap = new Map>() + + for (const pool of this.allPoolStats) { + if (!tokenMap.has(pool.base)) + tokenMap.set(pool.base, new Map()) + tokenMap.get(pool.base)!.set(pool.quote, pool) + if (!tokenMap.has(pool.quote)) + tokenMap.set(pool.quote, new Map()) + tokenMap.get(pool.quote)!.set(pool.base, pool) + } + + // This is BFS of a list of pools that's sorted by the number of events, + // so the resulting paths will be sorted by the number of hops first and + // then by the number of events in each pool (which is a rough proxy for + // the pool's liquidity). + let paths: string[][] = [[fromToken]] + let relevantPaths: string[][] = [] + let hasMore = true + let depth = 0; + while (hasMore && depth < this.maxDepth) { + hasMore = false + depth += 1 + const newPaths: string[][] = [] + for (let path of paths) { + const lastToken = path.at(-1) as string + for (const [nextToken, pool] of tokenMap.get(lastToken)?.entries() || []) { + if (nextToken == toToken) { + if (fromToken != lastToken) // skip the long form direct swap + relevantPaths.push([...path, nextToken]) + continue + } + if (path.includes(nextToken)) + continue + + // Some basic path culling + if (pool.events <= 1 || (toToken < fromToken ? pool.baseTvl <= 0 : pool.quoteTvl <= 0)) + continue + hasMore = true + newPaths.push([...path, nextToken]) + } + } + paths = newPaths + } + + for (const path of relevantPaths) { + const route = new CrocSmartSwapRoute([{ + hops: path.map(token => ({ token, poolIdx: 420 })), + qty, + inputFlow: isFixedOutput ? BigInt(0) : qty, + outputFlow: isFixedOutput ? qty : BigInt(0), + isFixedOutput: isFixedOutput, + }]) + routes.push(route) + } + + return routes + } + +} + +/* Simplest possible router that suggests routes from a list of common + * tokens, whether the suggested pools exist or not. */ +export class CrocSmartSwapHardcodedRouter { + public type: string + private chainId: ChainId + constructor (chainId: ChainId) { + this.type = "hardcoded" + this.chainId = chainId + } + + public async suggestRoutes(fromToken: string, toToken: string, qty: bigint, isFixedOutput: boolean, _: number): Promise { + fromToken = fromToken.toLowerCase() + toToken = toToken.toLowerCase() + const routes = [] + if (this.chainId == "0x82750") { + const candidates: CrocSmartSwapHop[][] = [ + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + [{ token: "0x06efdbff2a14a7c8e15944d1f4a48f9f95f663a4", poolIdx: 420 }], + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }, + { token: "0x06efdbff2a14a7c8e15944d1f4a48f9f95f663a4", poolIdx: 420 }], + [{ token: "0x06efdbff2a14a7c8e15944d1f4a48f9f95f663a4", poolIdx: 420 }, + { token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + ] + for (const candidate of candidates) { + const hops = [{token: fromToken, poolIdx: 420}, ...candidate, {token: toToken, poolIdx: 420}] + const route = new CrocSmartSwapRoute([{ + hops, + qty, + inputFlow: isFixedOutput ? BigInt(0) : qty, + outputFlow: isFixedOutput ? qty : BigInt(0), + isFixedOutput: isFixedOutput, + }]) + routes.push(route) + } + } else if (this.chainId == "0x1") { + const candidates: CrocSmartSwapHop[][] = [ + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + [{ token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", poolIdx: 420 }], + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }, + { token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", poolIdx: 420 }], + [{ token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", poolIdx: 420 }, + { token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + ] + for (const candidate of candidates) { + const hops = [{token: fromToken, poolIdx: 420}, ...candidate, {token: toToken, poolIdx: 420}] + const route = new CrocSmartSwapRoute([{ + hops, + qty, + inputFlow: isFixedOutput ? BigInt(0) : qty, + outputFlow: isFixedOutput ? qty : BigInt(0), + isFixedOutput: isFixedOutput, + }]) + routes.push(route) + } + } else if (this.chainId == "0x13e31") { + const candidates: CrocSmartSwapHop[][] = [ + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + [{ token: "0x4300000000000000000000000000000000000003", poolIdx: 420 }], + [{ token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }, + { token: "0x4300000000000000000000000000000000000003", poolIdx: 420 }], + [{ token: "0x4300000000000000000000000000000000000003", poolIdx: 420 }, + { token: "0x0000000000000000000000000000000000000000", poolIdx: 420 }], + ] + for (const candidate of candidates) { + const hops = [{token: fromToken, poolIdx: 420}, ...candidate, {token: toToken, poolIdx: 420}] + const route = new CrocSmartSwapRoute([{ + hops, + qty, + inputFlow: isFixedOutput ? BigInt(0) : qty, + outputFlow: isFixedOutput ? qty : BigInt(0), + isFixedOutput: isFixedOutput, + }]) + routes.push(route) + } + } + + // filter out routes with duplicate tokens + const filteredRoutes = [] + for (const route of routes) { + const tokenSet = new Set() + let valid = true; + for (const hop of route.paths[0].hops) { + if (tokenSet.has(hop.token)) { + valid = false + break + } + tokenSet.add(hop.token) + } + if (valid) + filteredRoutes.push(route) + } + return filteredRoutes + } +} + +/* Wrapper for an API-based external router. */ +export class CrocSmartExternalRouter { +} diff --git a/src/smartSwap/smartSwap.ts b/src/smartSwap/smartSwap.ts new file mode 100644 index 0000000..3b8f856 --- /dev/null +++ b/src/smartSwap/smartSwap.ts @@ -0,0 +1,415 @@ +import { TransactionResponse, ethers } from "ethers"; +import { CrocContext, ensureChain } from '../context'; +import { CrocPoolView } from '../pool'; +import { decodeCrocPrice, getUnsignedRawTransaction } from '../utils'; +import { CrocEthView, CrocTokenView, sortBaseQuoteViews, TokenQty } from '../tokens'; +import { CrocSurplusFlags, decodeSurplusFlag, encodeSurplusArg } from "../encoding/flags"; +import { MAX_SQRT_PRICE, MIN_SQRT_PRICE } from "../constants"; +import { CrocSlotReader } from "../slots"; +import { GAS_PADDING } from "../utils"; +import { CrocSmartSwapRoute } from "./smartSwapRoute"; +import { CrocSmartSwapRouter } from "./routers"; + +/* Describes the predicted impact of a given swap. + * @property sellQty The total quantity of tokens predicted to be sold by the swapper to the dex. + * @property buyQty The total quantity of tokens predicted to be bought by the swapper from the dex. + * @property finalPrice The final price of the pool after the swap. *Note* this is not the same as the + * realized swap price. Only present in direct swaps. + * @property percentChange The percent change in the pool price after the swap. Note this is not the same + * as the swapper's slippage against the pool. Only present in direct swaps. + * @property routes The possible routes for the swap, including the direct route (always in the first one) + * and any multihop routes. + * @property chosenRoute The index of the chosen route in the routes array. This is the route that will be + * executed when calling the `swap` method and `calcImpacts` sets it to the route with + * the best input/output. + */ +export interface CrocSmartSwapImpact { + sellQty: string, + buyQty: string, + finalPrice?: number, + percentChange?: number + routes: CrocSmartSwapRoute[] + chosenRoute: number +} + +/* Options for execution of a smart swap plan. */ +export interface CrocSmartSwapExecOpts { + settlement?: { fromSurplus: boolean, toSurplus: boolean } + slippage?: number + disableMultihop?: boolean + gasEst?: bigint +} + +export class CrocSmartSwapPlan { + + constructor(fromToken: CrocTokenView, toToken: CrocTokenView, qty: TokenQty, fixedOutput: boolean, + context: Promise, opts: CrocSmartSwapExecOpts = DFLT_SMART_SWAP_ARGS) { + console.log('CrocSmartSwapPlan constructor', fromToken, toToken, qty, fixedOutput, context, opts) + this.fromToken = fromToken + this.toToken = toToken + this.fixedOutput = fixedOutput + + this.poolView = new CrocPoolView(this.baseToken, this.quoteToken, context) + this.qty = fixedOutput ? toToken.normQty(qty) : fromToken.normQty(qty) + this.opts = opts + + this.context = context + this.routers = new Map() + this.callType = "" + } + + withRouter(router: CrocSmartSwapRouter): CrocSmartSwapPlan { + this.routers.set(router.type, router) + return this + } + + async swap(lastImpact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts = {}): Promise { + await ensureChain(await this.context); + const joinedOpts = this.getOpts(opts) + let callArgs = Object.assign({ chainId: (await this.context).chain.chainId }, joinedOpts) + try { + const gasEst = await this.estimateGas(lastImpact, joinedOpts) + callArgs = Object.assign({ gasEst: gasEst }, callArgs) + } catch (e) { + console.log("Failed to estimate gas, using default gas estimate", e) + } + return this.sendTx(lastImpact, Object.assign({}, callArgs)) + } + + async simulate(impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts = {}): Promise { + return this.callStatic(impact, this.getOpts(opts)) + } + + private async sendTx(impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts): Promise { + return this.hotPathCall(await this.txBase(), 'send', impact, opts) + } + + private async callStatic(impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts): Promise { + return this.hotPathCall(await this.txBase(), 'staticCall', impact, opts) + } + + async estimateGas(impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts = {}): Promise { + return this.hotPathCall(await this.txBase(), 'estimateGas', impact, opts) + } + + private async txBase() { + if (this.callType === "router") { + let router = (await this.context).router + if (!router) { throw new Error("Router not available on network") } + return router + + } else if (this.callType === "bypass" && (await this.context).routerBypass) { + let router = (await this.context).routerBypass + if (!router) { throw new Error("Router not available on network") } + return router || (await this.context).dex + + } else { + return (await this.context).dex + } + } + + private async hotPathCall(contract: ethers.Contract, callType: 'send' | 'staticCall' | 'estimateGas' | 'populateTransaction', impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts) { + const reader = new CrocSlotReader(this.context) + if (this.callType === "router") { + return this.swapCall(contract, callType, impact, opts) + } else if (this.callType === "bypass") { + return this.swapCall(contract, callType, impact, opts) + } else if (this.callType === "proxy" || (await this.context).chain.proxyPaths.dfltColdSwap) { + return this.userCmdCall(contract, callType, impact, opts) + } else { + return (await reader.isHotPathOpen() && impact.routes[impact.chosenRoute].isDirect) ? + this.swapCall(contract, callType, impact, opts) : this.userCmdCall(contract, callType, impact, opts) + } + } + + private async swapCall(contract: ethers.Contract, callType: 'send' | 'staticCall' | 'estimateGas' | 'populateTransaction', impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts) { + const TIP = 0 + const surplusFlags = this.maskSurplusArgs(opts) + + return contract.swap[callType](this.baseToken.tokenAddr, this.quoteToken.tokenAddr, (await this.context).chain.poolIndex, + this.isBuy, this.fixedOutput, await this.qty, TIP, + await this.calcLimitPrice(), await this.calcSlipQty(impact), surplusFlags, + await this.buildTxArgs(impact, surplusFlags, opts.gasEst),) + } + + private async userCmdCall(contract: ethers.Contract, callType: 'send' | 'staticCall' | 'estimateGas' | 'populateTransaction', impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts) { + console.log('userCmdCall', callType, opts) + const TIP = 0 + const surplusFlags = this.maskSurplusArgs(opts) + + const txArgs = await this.buildTxArgs(impact, surplusFlags, opts.gasEst) + console.log('txArgs', txArgs) + if (impact.chosenRoute == 0) { + const HOT_PROXY_IDX = 1 + let abi = new ethers.AbiCoder() + let cmd = abi.encode(["address", "address", "uint256", "bool", "bool", "uint128", "uint16", "uint128", "uint128", "uint8"], + [this.baseToken.tokenAddr, this.quoteToken.tokenAddr, (await this.context).chain.poolIndex, + this.isBuy, this.inBaseQty, await this.qty, TIP, + await this.calcLimitPrice(), await this.calcSlipQty(impact), surplusFlags]) + return contract.userCmd[callType](HOT_PROXY_IDX, cmd, txArgs) + + } else { + const dir = impact.routes[impact.chosenRoute].formatDirective(opts) + const cmd = dir.encodeBytes() + return contract.userCmd[callType]((await this.context).chain.proxyPaths.long, cmd, txArgs) + } + } + + async calcImpacts(maxRoutes?: number): Promise { + console.log('calcImpacts') + const TIP = 0 + const limitPrice = this.isBuy ? MAX_SQRT_PRICE : MIN_SQRT_PRICE + + interface ImpactCall { + route?: CrocSmartSwapRoute + call: Promise<[bigint, bigint, bigint]> + } + + let calls: ImpactCall[] = [ + { + call: (await this.context).slipQuery.calcImpact.staticCall + (this.baseToken.tokenAddr, this.quoteToken.tokenAddr, (await this.context).chain.poolIndex, + this.isBuy, this.fixedOutput != this.isBuy, await this.qty, TIP, limitPrice).then( + (impact) => { + if (this.isBuy) { + return [impact[0], impact[1], impact[2]] + } else { + return [impact[1], impact[0], impact[2]] + } + }) + }]; + + const multiImpactContract = (await this.context).multiImpact + if (!this.opts.disableMultihop && multiImpactContract) { + // Query routes from every router and add them to the calls list + for (const router of this.routers.values()) { + let routes = await router.suggestRoutes(this.fromToken.tokenAddr, this.toToken.tokenAddr, await this.qty, this.fixedOutput, this.opts.slippage || DFLT_SMART_SWAP_ARGS.slippage) + for (const route of routes.slice(0, ((maxRoutes || 99999) - 1))) { + try { + let call + if (route.paths.length == 1) // a simpler call when there's only one path + call = multiImpactContract.calcMultiHopImpact.staticCall(route.paths[0].hops, route.paths[0].qty, route.paths[0].isFixedOutput) + else + call = multiImpactContract.calcMultiPathImpact.staticCall(route.paths) + + calls.push({ + route, + call: call.then( + (impact) => { + route.applyImpact(route.paths.length == 1 ? [impact] : impact) + const inputFlows = route.inputFlows + const outputFlows = route.outputFlows + if (inputFlows.size != 1 || outputFlows.size != 1) + throw new Error("Multi-token input or output is not supported") + + const inputFlow = inputFlows.get(this.fromToken.tokenAddr.toLowerCase()) + const outputFlow = outputFlows.get(this.toToken.tokenAddr.toLowerCase()) + if (inputFlow === undefined || outputFlow === undefined) + throw new Error("Transacted tokens not in input or output") + + if ((inputFlow > BigInt(0) && outputFlow > BigInt(0)) || (inputFlow < BigInt(0) && outputFlow < BigInt(0))) + throw new Error("Invalid input/output flows") + + return [inputFlow, outputFlow, BigInt(0)] as [bigint, bigint, bigint] + }).catch( + (_) => { + // console.error("Failed to calculate impact for route:", route, e) + return [BigInt(0), BigInt(0), BigInt(0)] as [bigint, bigint, bigint] + } + ) + }) + } catch (e) { + console.error("Failed create route impact call:", e) + } + } + } + } + + let dedupCalls = calls.filter((c, i) => calls.findIndex((c2) => c2.call === c.call) === i) + calls = dedupCalls + + let impactResults = [] + try { + impactResults = await Promise.allSettled(calls.map(async (c) => c.call)) + console.log('impacts results', impactResults) + } catch (e) { + console.error("Failed to calculate impact:", e) + throw e + } + + + // Find the route with the best return + let maxReturn = 0; + // let maxReturn = impactResults.slice(1).some(i => i.status == 'fulfilled' && i.value[0] > BigInt(0)) ? 1 : 0 // TODO: disable forced multihop + for (let i = 1; i < impactResults.length; i++) { + const impactResult = impactResults[i] + const maxImpactResult = impactResults[maxReturn] + if (impactResult.status == 'rejected' || (impactResult.status == 'fulfilled' && impactResult.value[0] == BigInt(0))) + continue + + const side = this.fixedOutput ? 0 : 1 + if (maxImpactResult.status == 'rejected' || impactResult.value[side] < maxImpactResult.value[side]) + maxReturn = i + } + console.log('impacts maxReturn', maxReturn) + + // Fill in the route object for the direct swap to inform the UI + calls[0].route = new CrocSmartSwapRoute([{ + hops: [{ token: this.fromToken.tokenAddr, poolIdx: (await this.context).chain.poolIndex }, + { token: this.toToken.tokenAddr, poolIdx: (await this.context).chain.poolIndex }], + qty: await this.qty, + inputFlow: impactResults[0].status == 'fulfilled' ? impactResults[0].value[0] : BigInt(0), + outputFlow: impactResults[0].status == 'fulfilled' ? impactResults[0].value[1] : BigInt(0), + isFixedOutput: this.fixedOutput, + }]) + + const maxReturnRoute = impactResults[maxReturn] + const ret = { + sellQty: await this.fromToken.toDisplay(maxReturnRoute.status == 'fulfilled' ? maxReturnRoute.value[0] : BigInt(0)), + buyQty: await this.toToken.toDisplay(maxReturnRoute.status == 'fulfilled' ? -maxReturnRoute.value[1] : BigInt(0)), + routes: calls.map((c) => c.route as CrocSmartSwapRoute), + chosenRoute: maxReturn + } as CrocSmartSwapImpact + + // If direct swap is chosen, fill in its specific fields. + if (maxReturn == 0 && impactResults[0].status == 'fulfilled') { + const spotPrice = decodeCrocPrice(impactResults[0].value[2]) + const startPrice = await this.poolView.displayPrice() + const finalPrice = await this.poolView.toDisplayPrice(spotPrice) + ret.finalPrice = finalPrice + ret.percentChange = (finalPrice - startPrice) / startPrice + } + return ret + } + + private maskSurplusArgs(args?: CrocSmartSwapExecOpts): number { + return encodeSurplusArg(this.maskSurplusFlags(args)) + } + + private maskSurplusFlags(args?: CrocSmartSwapExecOpts): CrocSurplusFlags { + if (!args || !args.settlement) { + return [false, false] + } else if (typeof args.settlement === "boolean") { + return [args.settlement, args.settlement] + } else { + return this.isBuy ? + [args.settlement.toSurplus, args.settlement.fromSurplus] : + [args.settlement.fromSurplus, args.settlement.toSurplus] + } + } + + private async buildTxArgs(impact: CrocSmartSwapImpact, surplusArg: number, gasEst?: bigint) { + const txArgs = await this.attachEthMsg(impact, surplusArg) + + if (gasEst) { + Object.assign(txArgs, { gasLimit: gasEst + GAS_PADDING }); + } + + return txArgs + } + + private async attachEthMsg(impact: CrocSmartSwapImpact, surplusEncoded: number): Promise { + // Only need msg.val if one token is native ETH (will always be base side) + if (!this.fromToken.isNativeEth) { + return {} + } + + // Calculate the maximum amount of ETH we'll need. If on the floating side + // account for potential slippage. (Contract will refund unused ETH) + const val = !this.fixedOutput ? this.qty : this.calcSlipQty(impact) + + if (decodeSurplusFlag(surplusEncoded)[0]) { + // If using surplus calculate the amount of ETH not covered by the surplus + // collateral. + const needed = new CrocEthView(this.context).msgValOverSurplus(await val) + return { value: await needed } + + } else { + // Othwerise we need to send the entire balance in msg.val + return { value: await val } + } + } + + async calcSlipQty(impact: CrocSmartSwapImpact): Promise { + const slippage = this.opts.slippage || DFLT_SMART_SWAP_ARGS.slippage + const slipQty = this.fixedOutput ? + parseFloat((impact).sellQty) * (1 + (slippage ? slippage : slippage)) : + parseFloat((impact).buyQty) * (1 - (slippage ? slippage : slippage)) + + return this.fixedOutput ? + this.fromToken.roundQty(slipQty) : + this.toToken.roundQty(slipQty) + } + + async calcLimitPrice(): Promise { + return this.isBuy ? MAX_SQRT_PRICE : MIN_SQRT_PRICE + } + + forceProxy(): CrocSmartSwapPlan { + this.callType = "proxy" + return this + } + + useRouter(): CrocSmartSwapPlan { + this.callType = "router" + return this + } + + useBypass(): CrocSmartSwapPlan { + this.callType = "bypass" + return this + } + + + /* + * Utility function to generate a "signed" raw transaction for a swap, used for L1 gas estimation on + * L2's like Scroll. + * Extra 0xFF...F is appended to the unsigned raw transaction to simulate the signature and other + * missing fields. + */ + async getFauxRawTx(impact: CrocSmartSwapImpact, opts: CrocSmartSwapExecOpts = {}): Promise<`0x${string}`> { + const unsignedTx = await this.hotPathCall(await this.txBase(), 'populateTransaction', impact, opts) + const f = getUnsignedRawTransaction(unsignedTx) + "f".repeat(160) as `0x${string}` + return f + } + + getOpts(overrideOpts: CrocSmartSwapExecOpts): CrocSmartSwapExecOpts { + return Object.assign({}, this.opts, overrideOpts) + } + + // Methods used for direct swaps only: + + get baseToken(): CrocTokenView { + return sortBaseQuoteViews(this.fromToken, this.toToken)[0] + } + + get quoteToken(): CrocTokenView { + return sortBaseQuoteViews(this.fromToken, this.toToken)[1] + } + + get isBuy(): boolean { + return this.fromToken === this.baseToken + } + + get inBaseQty(): boolean { + return this.fixedOutput ? this.toToken === this.baseToken : this.fromToken === this.baseToken + } + + + readonly fromToken: CrocTokenView + readonly toToken: CrocTokenView + readonly qty: Promise + readonly fixedOutput: boolean + readonly opts: CrocSmartSwapExecOpts + readonly poolView: CrocPoolView + readonly context: Promise + private routers: Map + private callType: string +} + +// Default slippage is set to 1%. User should evaluate this carefully for low liquidity +// pools of when swapping large amounts. +const DFLT_SMART_SWAP_ARGS = { + slippage: 0.01, + disableMultihop: false, +} diff --git a/src/smartSwap/smartSwapRoute.ts b/src/smartSwap/smartSwapRoute.ts new file mode 100644 index 0000000..1236ae0 --- /dev/null +++ b/src/smartSwap/smartSwapRoute.ts @@ -0,0 +1,140 @@ + +import { ZeroAddress } from "ethers"; +import { OrderDirective } from "../encoding/longform"; +import { CrocSmartSwapExecOpts } from "./smartSwap"; + +/* A single hop in a swap path, represents the destination token and pool. + * + * @property tokenAddr Destination token of the swap (or the starting token if + * it's the first hop). + * @property poolIdx Pool where the swap will be taking place. */ +export interface CrocSmartSwapHop { + token: string + poolIdx: number +} + +/* A single path in a swap route, a series of hops with one input and one output. + * + * @property hops Swaps in the path starting with the input token and ending + * with the output token. + * @property inputFlow The quantity of the input token required by the path. + * @property outputFlow The quantity of the output token produced by the path. + * @property isFixedOutput Controls whether the output or the input quantity is fixed. + * @property useInputSurplus Whether to use the exchange balance for the input side. + * @property useOutputSurplus Whether to use the exchange balance for the output side. + * @property limitQtyOverride Overrides the minimum quantity for the flows in the path, + * which would be used instead of standard slippage. */ +export interface CrocSmartSwapPath { + hops: CrocSmartSwapHop[] + qty: bigint + inputFlow: bigint + outputFlow: bigint + isFixedOutput: boolean + useInputSurplus?: boolean + useOutputSurplus?: boolean + limitQtyOverride?: bigint +} + +/* Represents a multihop swap. Can have one path (if there's one input and + * one output), or multiple paths. */ +export class CrocSmartSwapRoute { + constructor (paths: CrocSmartSwapPath[]) { + this.paths = paths + } + + public get isDirect(): boolean { + return this.paths.length == 1 && this.paths[0].hops.length == 2 + } + + /* The map of input tokens to the sum of their flows across all paths in the route. */ + public get inputFlows(): Map { + const tokenFlows = new Map() + for (const path of this.paths) { + const token = path.hops[0].token.toLowerCase() + tokenFlows.set(token, (tokenFlows.get(token) || BigInt(0)) + path.inputFlow) + } + return tokenFlows + } + + /* The map of output tokens to the sum of their flows across all paths in the route. */ + public get outputFlows(): Map { + const tokenFlows = new Map() + for (const path of this.paths) { + const token = path.hops[path.hops.length - 1].token.toLowerCase() + tokenFlows.set(token, (tokenFlows.get(token) || BigInt(0)) + path.outputFlow) + } + return tokenFlows + } + + public formatDirective(args: CrocSmartSwapExecOpts): OrderDirective { + const order = new OrderDirective(ZeroAddress) + let prevSettlement = order.open + for (let p = 0; p < this.paths.length; p++) { + const path = this.paths[p] + // Copy the hops array to not overwrite it if it's reversed + let hops = path.hops.slice() + if (path.isFixedOutput) + hops = hops.reverse() + prevSettlement.token = hops[0].token + prevSettlement.useSurplus = (path.isFixedOutput ? (path.useOutputSurplus || args.settlement?.toSurplus) : (path.useInputSurplus || args.settlement?.fromSurplus)) || false + + let prevToken = hops[0].token + for (let h = 1; h < hops.length; h++) { + const hopDir = order.appendHop(hops[h].token) + // If last hop, set surplus flag for input/output and limitQty to act as minOut or maxIn + if (h == hops.length - 1) { + hopDir.settlement.useSurplus = (path.isFixedOutput ? (path.useInputSurplus || args.settlement?.fromSurplus) : (path.useOutputSurplus || args.settlement?.toSurplus)) || false + if (path.limitQtyOverride) { + hopDir.settlement.limitQty = path.limitQtyOverride + } else if (args.slippage !== undefined) { + const mul = 10000000000 + const slip = BigInt(Math.round(args.slippage * mul)) + + hopDir.settlement.limitQty = path.isFixedOutput ? + path.inputFlow + ((path.inputFlow * slip) / BigInt(mul)) : + path.outputFlow - ((path.outputFlow * slip) / BigInt(mul)) + } + } else { + // Intermediate hops should always use surplus + hopDir.settlement.useSurplus = true + } + const poolDir = order.appendPool(hops[h].poolIdx) + + if (h == 1) { + poolDir.swap.qty = path.isFixedOutput ? (path.outputFlow < BigInt(0) ? -path.outputFlow : path.outputFlow) : path.inputFlow + } else { + // Enable fractional roll to use 100% of the previous hop as qty + poolDir.swap.rollType = ROLL_FRAC_TYPE + poolDir.swap.qty = BigInt(10000) + } + + // Swap direction needs to be inverted based on isFixedOutput + poolDir.swap.isBuy = Boolean((prevToken < hops[h].token) !== path.isFixedOutput) + poolDir.swap.inBaseQty = Boolean(prevToken < hops[h].token) + poolDir.swap.limitPrice = poolDir.swap.isBuy ? BigInt("21267430153580247136652501917186561137") : BigInt("65538") + prevToken = hops[h].token + } + + // If there is more than one path, append a new hop for the next path + if (p < this.paths.length - 1) { + const hop_switch = order.appendHop(ZeroAddress) + prevSettlement = hop_switch.settlement + } + } + + return order + } + + public applyImpact(pathImpacts: [bigint, bigint, bigint][]) { + for (let i = 0; i < this.paths.length; i++) { + const path = this.paths[i] + const impact = pathImpacts[i] + path.inputFlow = impact[0] + path.outputFlow = impact[1] + } + } + + paths: CrocSmartSwapPath[] +} + +export const ROLL_FRAC_TYPE = 4 From e9ec4690b40d88fa73189b40244c80722b1e3606 Mon Sep 17 00:00:00 2001 From: bus Date: Tue, 17 Dec 2024 21:33:46 +0000 Subject: [PATCH 2/2] Fix chosenRoute selection for fixed output when there's no direct pool --- src/smartSwap/smartSwap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smartSwap/smartSwap.ts b/src/smartSwap/smartSwap.ts index 3b8f856..0d7f1d2 100644 --- a/src/smartSwap/smartSwap.ts +++ b/src/smartSwap/smartSwap.ts @@ -248,7 +248,7 @@ export class CrocSmartSwapPlan { continue const side = this.fixedOutput ? 0 : 1 - if (maxImpactResult.status == 'rejected' || impactResult.value[side] < maxImpactResult.value[side]) + if ((maxImpactResult.status == 'rejected' || maxImpactResult.value[side] == BigInt(0)) || (impactResult.value[side] < maxImpactResult.value[side])) maxReturn = i } console.log('impacts maxReturn', maxReturn)