diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9bd72af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# flyctl launch added from .gitignore +**/node_modules +**/bin +**/.env +**/.DS_Store +fly.toml diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml new file mode 100644 index 0000000..b0c246e --- /dev/null +++ b/.github/workflows/fly-deploy.yml @@ -0,0 +1,18 @@ +# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ + +name: Fly Deploy +on: + push: + branches: + - main +jobs: + deploy: + name: Deploy app + runs-on: ubuntu-latest + concurrency: deploy-group # optional: ensure only one action runs at a time + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..90a3a1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:latest + +WORKDIR /app + +COPY . . + +RUN apt-get -y update +RUN apt-get install -y procps && rm -rf /var/lib/apt/lists/* + +RUN bun install --verbose + +ENTRYPOINT ["sh", "-c", "bun run --bun start"] \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index aad72c6..6ad4ebb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 93f91eb..4c407f8 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { "name": "quark-crank", "scripts": { - "start": "bun --bun run ./src/app.ts" + "start": "bun run ./src/app.ts" }, "type": "module", "dependencies": { - "@cosmjs/crypto": "^0.31.1", - "@cosmjs/proto-signing": "^0.31.1", - "@cosmjs/utils": "^0.31.1", + "@cosmjs/crypto": "^0.32.4", + "@cosmjs/proto-signing": "^0.32.4", + "@cosmjs/utils": "^0.32.4", + "@cosmjs/tendermint-rpc": "^0.32.4", + "@cosmjs/stargate": "^0.32.4", + "@cosmjs/encoding": "^0.32.4", + "@entropic-labs/quark.js": "^0.0.34", "@types/node": "^18.11.9", - "cosmjs-types": "^0.7.2", - "kujira.js": "^0.9.33", + "cosmjs-types": "^0.9.0", + "kujira.js": "^1.0.61", "protobufjs": "^7.1.2", "text-encoding": "^0.7.0" } diff --git a/src/app.ts b/src/app.ts index ccac233..9f85949 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,7 @@ import { createGrant, getGrant } from "./workers/index.js"; import * as hub from "./workers/hub.js"; import * as unifier from "./workers/unifier.js"; -const ENABLED = [...unifier.contracts,];// ...hub.contracts,]; +const ENABLED = [...unifier.contracts, ...hub.contracts,]; const run = async () => { await Promise.all( diff --git a/src/config.ts b/src/config.ts index 326e341..d67f6dd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,22 +5,22 @@ export const NETWORK = process.env.NETWORK === "mainnet" ? MAINNET : TESTNET; export enum Contract { HUB = "hub", + ICA_HUB = "ica_hub", UNIFIER = "unifier", } const RPCS = { [MAINNET]: [ - "https://kujira-rpc.polkachu.com", "https://rpc-kujira.starsquid.io", + "https://kujira-rpc.polkachu.com", "https://rpc-kujira.mintthemoon.xyz", ], - [TESTNET]: - [ - "https://kujira-testnet-rpc.polkachu.com", - "https://test-rpc-kujira.mintthemoon.xyz", - "https://dev-rpc-kujira.mintthemoon.xyz", - ] -} + [TESTNET]: [ + "https://kujira-testnet-rpc.polkachu.com", + "https://test-rpc-kujira.mintthemoon.xyz", + "https://dev-rpc-kujira.mintthemoon.xyz", + ], +}; const RPC_DEFAULT = process.env.NETWORK === "mainnet" ? RPCS[MAINNET][0] : RPCS[TESTNET][0]; @@ -29,4 +29,4 @@ export const PREFIX = process.env.PREFIX || "kujira"; export const RPC_ENDPOINT = process.env.RPC_ENDPOINT || RPC_DEFAULT; export const GAS_PRICE = GasPrice.fromString( process.env.GAS_PRICE || "0.0034ukuji" -); \ No newline at end of file +); diff --git a/src/query.ts b/src/query.ts index 8e4c2aa..1cde070 100644 --- a/src/query.ts +++ b/src/query.ts @@ -20,10 +20,10 @@ export const getAllContractState = async ( const pageRequest = pageResponse ? PageRequest.fromPartial({ key: pageResponse.nextKey, - limit: Long.fromNumber(100000), + limit: 100000n, }) : PageRequest.fromPartial({ - limit: Long.fromNumber(100000), + limit: 100000n, }); const res = await client.wasm.getAllContractState(address, pageRequest); diff --git a/src/wallet.ts b/src/wallet.ts index 2f7a584..f8ecd5b 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -11,11 +11,11 @@ import { DeliverTxResponse, SigningStargateClient } from "@cosmjs/stargate"; import { registry } from "kujira.js"; import { GAS_PRICE, PREFIX, RPC_ENDPOINT } from "./config.js"; -export const wallet = (account: number) => { +export const wallet = (account: number, prefix = PREFIX) => { if (!process.env.MNEMONIC) throw new Error("MNEMONIC not set"); return DirectSecp256k1HdWallet.fromMnemonic(process.env.MNEMONIC, { - prefix: PREFIX, + prefix, hdPaths: [ [ Slip10RawIndex.hardened(44), @@ -30,22 +30,26 @@ export const wallet = (account: number) => { export type Client = [SigningStargateClient, string]; -export const client = async (account: number): Promise => { - const signer = await wallet(account); +export const client = async ( + account: number, + rpc = RPC_ENDPOINT, + gasPrice = GAS_PRICE, + prefix?: string +): Promise => { + const signer = await wallet(account, prefix); const [acc] = await signer.getAccounts(); - const c = await SigningStargateClient.connectWithSigner( - RPC_ENDPOINT, - signer, - { registry, gasPrice: GAS_PRICE } - ); + const c = await SigningStargateClient.connectWithSigner(rpc, signer, { + registry, + gasPrice, + }); return [c, acc.address]; }; export const ORCHESTRATOR = client(0); -export function calculateFee(gasLimit: number, granter: string): StdFee { +export function calculateFee(gasLimit: number, granter?: string): StdFee { const { denom, amount: gasPriceAmount } = GAS_PRICE; // Note: Amount can exceed the safe integer range (https://github.com/cosmos/cosmjs/issues/1134), // which we handle by converting from Decimal to string without going through number. @@ -63,15 +67,17 @@ export function calculateFee(gasLimit: number, granter: string): StdFee { export async function signAndBroadcast( account: Client, messages: readonly EncodeObject[], - memo = "" + memo = "", + granter?: string | null ): Promise { const gasEstimation = await account[0].simulate(account[1], messages, memo); - const multiplier = 1.5; - const orchestrator = await ORCHESTRATOR; - const fee = calculateFee( - Math.round(gasEstimation * multiplier), - orchestrator[1] - ); + const multiplier = 2.0; + if (granter === null) { + granter = undefined; + } else { + granter = granter || (await ORCHESTRATOR)[1]; + } + const fee = calculateFee(Math.round(gasEstimation * multiplier), granter); return account[0].signAndBroadcast(account[1], messages, fee, memo); } @@ -79,18 +85,16 @@ export async function signAndBroadcast( /// Uses the ORACLE_KEY to sign arbitrary data export async function oracleSignArbitrary( data: Uint8Array -): Promise<{ signature: Uint8Array, pubkey: Uint8Array }> { +): Promise<{ signature: Uint8Array; pubkey: Uint8Array }> { if (!process.env.ORACLE_KEY) throw new Error("ORACLE_KEY not set"); // convert ORACLE_KEY to Uint8Array private key - const privateKey = new Uint8Array( - Buffer.from(process.env.ORACLE_KEY, "hex") - ); + const privateKey = new Uint8Array(Buffer.from(process.env.ORACLE_KEY, "hex")); const { pubkey } = await Secp256k1.makeKeypair(privateKey); const compressedPubkey = await Secp256k1.compressPubkey(pubkey); - const extendedSignature = (await Secp256k1.createSignature(data, privateKey)); + const extendedSignature = await Secp256k1.createSignature(data, privateKey); const signature = new Uint8Array([ - ...extendedSignature.r(32), - ...extendedSignature.s(32) + ...extendedSignature.r(32), + ...extendedSignature.s(32), ]); return { signature, pubkey: compressedPubkey }; -} \ No newline at end of file +} diff --git a/src/workers/hub.ts b/src/workers/hub.ts index 15434a3..53f30ff 100644 --- a/src/workers/hub.ts +++ b/src/workers/hub.ts @@ -1,24 +1,24 @@ import { Contract, NETWORK } from "config.js"; import { client, signAndBroadcast } from "wallet.js"; import { msg } from "kujira.js"; +import { HUBS } from "@entropic-labs/quark.js"; export const registry = { - "kaiyo-1": [ - { address: "kujira1eulxny0ffvhkkec9l2s44tg9a868ly0fgg86raduypku735zyeyqv7lsun", interval: 86400 }, - { address: "kujira17nsll5xs4ak8lsguqelhh0etvvfe2cw6lmhg0jpja28zedunddkq0d4jv4", interval: 86400 }, - { address: "kujira1y52rf2kv6t9yvev7g3s5jvxutw7jxj3a87p3yydrh4dxtdglkssq06sjy2", interval: 86400 } - ], - "harpoon-4": [ - { address: "kujira1jhmfj3avt2dlswrqlgx7fssvgrqfqzyj9m5cvgavuwekquxhu27ql88enu", interval: 60 }, - { address: "kujira16t7wlfluk27c7emzvxfcqxzged7tkne9p7rrwexzc7gdg6dvk4psxm5waf", interval: 60 } - ] + "kaiyo-1": Object.values(HUBS["kaiyo-1"]).map((x) => ({ + address: x!.address, + interval: 86400, + })), + "harpoon-4": Object.keys(HUBS["harpoon-4"]).map((address) => ({ + address, + interval: 60, + })), }; export const contracts = [ - ...registry[NETWORK].map(({ address }) => ({ - address, - contract: Contract.HUB, - })), + ...registry[NETWORK].map(({ address }) => ({ + address, + contract: Contract.HUB, + })), ]; /// We need to run the "{ crank: {} }" message on the hub to: @@ -26,35 +26,35 @@ export const contracts = [ /// 2. Vote on proposals /// 3. Queue unbondings, etc. export async function run(address: string, idx: number) { - const config = registry[NETWORK].find((x) => x.address === address); - if (!config) throw new Error(`${address} hub not found`); + const config = registry[NETWORK].find((x) => x.address === address); + if (!config) throw new Error(`${address} hub not found`); - try { - const w = await client(idx); + try { + const w = await client(idx); - const msgs = [ - msg.wasm.msgExecuteContract({ - sender: w[1], - contract: address, - msg: Buffer.from( - JSON.stringify({ - crank: {}, - }) - ), - funds: [], - }), - ]; - try { - console.debug(`[HUB:${address}] Cranking...`); - const res = await signAndBroadcast(w, msgs, "auto"); - console.info(`[HUB:${address}] Cranked: ${res.transactionHash}`); - } catch (e: any) { - console.error(`[HUB:${address}] ${e}`); - } - } catch (error: any) { - console.error(`[HUB:${address}] ${error.message}`); - } finally { - await new Promise((resolve) => setTimeout(resolve, config.interval * 1000)); - await run(address, idx); + const msgs = [ + msg.wasm.msgExecuteContract({ + sender: w[1], + contract: address, + msg: Buffer.from( + JSON.stringify({ + crank: "empty", + }) + ), + funds: [], + }), + ]; + try { + console.debug(`[HUB:${address}] Cranking...`); + const res = await signAndBroadcast(w, msgs, "auto"); + console.info(`[HUB:${address}] Cranked: ${res.transactionHash}`); + } catch (e: any) { + console.error(`[HUB:${address}] ${e}`); } + } catch (error: any) { + console.error(`[HUB:${address}] ${error.message}`); + } finally { + await new Promise((resolve) => setTimeout(resolve, config.interval * 1000)); + await run(address, idx); + } } diff --git a/src/workers/unifier.ts b/src/workers/unifier.ts index b8ebe21..70d24fd 100644 --- a/src/workers/unifier.ts +++ b/src/workers/unifier.ts @@ -1,303 +1,389 @@ import { Contract, NETWORK } from "config.js"; -import { Client, client, oracleSignArbitrary, signAndBroadcast } from "wallet.js"; +import { + Client, + client, + oracleSignArbitrary, + signAndBroadcast, +} from "wallet.js"; import { Denom, MAINNET, msg } from "kujira.js"; import { querier } from "query.js"; import { sha256 } from "@cosmjs/crypto"; +import { UNIFIERS } from "@entropic-labs/quark.js"; type Config = { - address: string; - target: string; -} + address: string; + target: string; +}; type Coin = { - denom: string; - amount: string; -} + denom: string; + amount: string; +}; export const registry: { [k: string]: Config[] } = { - "kaiyo-1": [ - { - address: "kujira157g8vxwyp9v8jvh0hytkmautuuwj2at64mkwdlhx0q8yx0g8gcusjczwrp", - target: "factory/kujira1643jxg8wasy5cfcn7xm8rd742yeazcksqlg4d7/umnta" - }, - { - address: "kujira1vp2lvn3nryezv6767g5kcayd9k52a4v6tsca59a0mxjw2fhm8apqq0nzl0", - target: "ukuji" - }, - { - address: "kujira15d7wlz5fy879geplhm88pgey8wpav6z2y6xl8qdeq33a85hyqrpsunzq4l", - target: "factory/kujira1sc6a0347cc5q3k890jj0pf3ylx2s38rh4sza4t/ufuzn" - } - ], - "harpoon-4": [ - { - address: "kujira1rfkn3h4ph8ud28qwa2papetys727p9ndcfxyjv79szs4u5t22akqxl8u8x", - target: "factory/kujira1643jxg8wasy5cfcn7xm8rd742yeazcksqlg4d7/umnta", - }, - { - address: "kujira1j30vkmwea4ktsc7wq3as95x5dqsa34a3skv6jujfeje5nlxj5maqsw4gs0", - target: "ukuji", - } - ] + "kaiyo-1": Object.values(UNIFIERS["kaiyo-1"]).map((config) => ({ + address: config!.address, + target: config!.target_denom, + })), + "harpoon-4": Object.values(UNIFIERS["harpoon-4"]).map((config) => ({ + address: config!.address, + target: config!.target_denom, + })), }; export const contracts = [ - ...registry[NETWORK].map(({ address }) => ({ - address, - contract: Contract.UNIFIER, - })), + ...registry[NETWORK].map(({ address }) => ({ + address, + contract: Contract.UNIFIER, + })), ]; -const mantaSwapURL = () => NETWORK === MAINNET ? "https://api.mantadao.app" : `https://api.mantadao.app/${NETWORK}`; +const mantaSwapURL = () => + NETWORK === MAINNET + ? "https://api.mantaswap.app" + : `https://api.mantaswap.app/${NETWORK}`; -const hardcodedRoutes: { [denom: string]: { address: string, output: string, maxSwapAmount: string } } = { - "factory/kujira166ysf07ze5suazfzj0r05tv8amk2yn8zvsfuu7/uplnk": { - address: "kujira1phpdpsnrkfvqr5883p8jlqslknuc7ypfd78er5ajkfpd3cuy7hqs93a4n7", - output: "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", - maxSwapAmount: "1000000000" //1000 PLNK +const hardcodedRoutes: { + [denom: string]: { + address: string; + output: string; + maxSwapAmount: string; + for?: string; + }; +} = { + "factory/kujira166ysf07ze5suazfzj0r05tv8amk2yn8zvsfuu7/uplnk": { + address: + "kujira1phpdpsnrkfvqr5883p8jlqslknuc7ypfd78er5ajkfpd3cuy7hqs93a4n7", + output: + "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", + maxSwapAmount: "1000000000", //1000 PLNK + }, + "factory/kujira17clmjjh9jqvnzpt0s90qx4zag8p5m2p6fq3mj9727msdf5gyx87qanzf3m/ulp": + { + address: + "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", + output: + "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", + maxSwapAmount: "10000000000", //10,000 LP FUZN-USK }, - "factory/kujira17clmjjh9jqvnzpt0s90qx4zag8p5m2p6fq3mj9727msdf5gyx87qanzf3m/ulp": { - address: "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", - output: "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", - maxSwapAmount: "10000000000" //10,000 LP FUZN-USK + "factory/kujira1w4yaama77v53fp0f9343t9w2f932z526vj970n2jv5055a7gt92sxgwypf/urcpt": + { + address: + "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", + output: + "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", + maxSwapAmount: "10000000000", //10,000 xUSK }, - "factory/kujira1w4yaama77v53fp0f9343t9w2f932z526vj970n2jv5055a7gt92sxgwypf/urcpt": { - address: "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", - output: "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", - maxSwapAmount: "10000000000" //10,000 xUSK + "factory/kujira1jelmu9tdmr6hqg0d6qw4g6c9mwrexrzuryh50fwcavcpthp5m0uq20853h/urcpt": + { + address: + "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", + output: + "ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9", + maxSwapAmount: "10000000000", //10,000 xUSDC }, - "factory/kujira143fwcudwy0exd6zd3xyvqt2kae68ud6n8jqchufu7wdg5sryd4lqtlvvep/urcpt": { - address: "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", - output: "ukuji", - maxSwapAmount: "10000000000" //10,000 xKUJI - } + "factory/kujira143fwcudwy0exd6zd3xyvqt2kae68ud6n8jqchufu7wdg5sryd4lqtlvvep/urcpt": + { + address: + "kujira1vunhdfym5au07lfdq9ljayect0k6w6krl3ykz0q4pukz3xuj4m5s62wv5h", + output: "ukuji", + maxSwapAmount: "10000000000", //10,000 xKUJI + }, + "factory/kujira13x2l25mpkhwnwcwdzzd34cr8fyht9jlj7xu9g4uffe36g3fmln8qkvm3qn/unami": + { + address: + "kujira19m0dg0ggvpv8js3gt0jx04r6rx7ru9x2ra5v0mwhmc8zlk6vngxsj9r4qd", + output: + "ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9", + maxSwapAmount: "10000000000", //10,000 NAMI + }, + "ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9": { + for: "kujira1v4pe9h3yf54fj5g67fd52gcv365272ntqpwuyew5j37kdvr3kxass8pckn", // ONLY FOR AQLA + address: + "kujira1nswv58h3acql85587rkusqx3zn7k9qx3a3je8wqd3xnw39erpwnsddsm8z", + output: "factory/kujira1xe0awk5planmtsmjel5xtx2hzhqdw5p8z66yqd/uaqla", + maxSwapAmount: "100000000", // 100 USDC -> AQLA + }, }; // Tokens that we shouldn't swap const blacklist: string[] = []; const MIN_OVERRIDES: { [denom: string]: bigint } = { - "ibc/31ED168F5E93D988FCF223B1298113ACA818DB7BED8F7B73764C5C9FAC293609": 100000000000n, // 100 ROAR - "ibc/6A4CEDCEA40B587A4BCF7FDFB1D5A13D13F8A807A22A4E759EA702640CE086B0": 100000000000000n, // 0.0001 DYDX + "ibc/31ED168F5E93D988FCF223B1298113ACA818DB7BED8F7B73764C5C9FAC293609": + 100000000000n, // 100 ROAR + "ibc/6A4CEDCEA40B587A4BCF7FDFB1D5A13D13F8A807A22A4E759EA702640CE086B0": + 100000000000000n, // 0.0001 DYDX }; const MIN_SWAP_AMOUNT = (denom: string): bigint => { - if (MIN_OVERRIDES[denom]) { - return MIN_OVERRIDES[denom]; - } - let d = Denom.from(denom); - if (d.decimals === 18) { - return 100000000000000n; // 0.0001 of an 18 decimal token - } else { - return 10000n; // 0.01 of a 6 decimal token - } + if (MIN_OVERRIDES[denom]) { + return MIN_OVERRIDES[denom]; + } + let d = Denom.from(denom); + if (d.decimals === 18) { + return 100000000000000n; // 0.0001 of an 18 decimal token + } else { + return 10000n; // 0.01 of a 6 decimal token + } }; - async function queryMantaSwapWhitelist(): Promise { - const res = await fetch(`${mantaSwapURL()}/whitelist`).then((res) => res.json()); - if (res.error) { - console.error(`[UNIFIER:whitelist] Error fetching whitelist: ${res.error}`); - return []; - } - return res.map((x: any) => x.denom); + const res = await fetch(`${mantaSwapURL()}/whitelist`).then((res) => + res.json() + ); + if (res.error) { + console.error(`[UNIFIER:whitelist] Error fetching whitelist: ${res.error}`); + return []; + } + return res.map((x: any) => x.denom); } -async function queryMantaSwap(input: string, amount: string, output: string, slippage: string = "0.01"): Promise<[{ address: string, denom: string }[], Coin]> { - const inputCoin = { denom: input, amount }; - const body = { - input: { - denom: input, - amount, - slippage, - }, - output: { - denom: output - } - }; - const res = await fetch(`${mantaSwapURL()}/route`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }).then((res) => res.json()); - if (res.error) { - // Don't spam the console with "denom not found" errors - console.error(`[UNIFIER:${input}] ${res.error}`); - return [[], inputCoin]; - } - if (res.routes.length === 0) { - return [[], inputCoin]; - } - const stages: [[string, string]][] = res.routes[0].tx.swap.stages; - const route = stages.map(([[address, denom]]) => ({ - address, - denom - })); +async function queryMantaSwap( + input: string, + amount: string, + output: string, + slippage: string = "0.01" +): Promise<[{ address: string; denom: string }[], Coin]> { + const inputCoin = { denom: input, amount }; + const body = { + input: { + denom: input, + amount, + slippage, + }, + output: { + denom: output, + }, + }; + const res = await fetch(`${mantaSwapURL()}/route`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }).then((res) => res.json()); + if (res.error) { + // Don't spam the console with "denom not found" errors + console.error(`[UNIFIER:${input}] ${res.error}`); + return [[], inputCoin]; + } + if (res.routes.length === 0) { + return [[], inputCoin]; + } + const stages: [[string, string]][] = res.routes[0].tx.swap.stages; + const route = stages.map(([[address, denom]]) => ({ + address, + denom, + })); - return [route.reverse(), inputCoin]; + return [route.reverse(), inputCoin]; } /** * Constructs a multi-input single-output swap path. - * + * * Queries MantaSwap API for the best single-input single-output swap path. * Combines them using a graph: * - Multiple sources, single sink * - Each "chain" (run of degree 1) is a single-input single-output swap path * - Many inputs will converge at various points in the graph */ -async function constructMultiInputSwap(balances: Coin[], config: Config): Promise<[[string, string][][], Coin[]]> { - // Query all one-to-one swap paths - const results = await Promise.all(balances.map(async ({ denom, amount }) => { - if (BigInt(amount) < MIN_SWAP_AMOUNT(denom)) { - console.debug(`[UNIFIER:${config.address}] Skipping ${denom} due to small balance`); - return [[], { denom, amount }] as [{ address: string, denom: string }[], Coin]; - } - if (blacklist.includes(denom)) { - console.debug(`[UNIFIER:${config.address}] Skipping ${denom} due to blacklist`); - return [[], { denom, amount }] as [{ address: string, denom: string }[], Coin]; +async function constructMultiInputSwap( + balances: Coin[], + config: Config +): Promise<[[string, string][][], Coin[]]> { + // Query all one-to-one swap paths + const results = await Promise.all( + balances.map(async ({ denom, amount }) => { + if (BigInt(amount) < MIN_SWAP_AMOUNT(denom)) { + console.debug( + `[UNIFIER:${config.address}] Skipping ${denom} due to small balance` + ); + return [[], { denom, amount }] as [ + { address: string; denom: string }[], + Coin + ]; + } + if (blacklist.includes(denom)) { + console.debug( + `[UNIFIER:${config.address}] Skipping ${denom} due to blacklist` + ); + return [[], { denom, amount }] as [ + { address: string; denom: string }[], + Coin + ]; + } + console.debug( + `[UNIFIER:${config.address}] Querying MantaSwap API for ${amount} ${denom} -> ${config.target}` + ); + if ( + hardcodedRoutes[denom] && + (!hardcodedRoutes[denom].for || + hardcodedRoutes[denom].for === config.address) + ) { + const { address, output, maxSwapAmount } = hardcodedRoutes[denom]; + // Rough parseInt since precision here doesn't matter + if (parseInt(amount) > parseInt(maxSwapAmount)) { + amount = maxSwapAmount; } - console.debug(`[UNIFIER:${config.address}] Querying MantaSwap API for ${amount} ${denom} -> ${config.target}`); - if (hardcodedRoutes[denom]) { - const { address, output, maxSwapAmount } = hardcodedRoutes[denom]; - // Rough parseInt since precision here doesn't matter - if (parseInt(amount) > parseInt(maxSwapAmount)) { - amount = maxSwapAmount; - } - console.debug(`[UNIFIER:${config.address}] Using hardcoded route for ${denom} -> ${output}`); + console.debug( + `[UNIFIER:${config.address}] Using hardcoded route for ${denom} -> ${output}` + ); - return [[{ address, denom }], { denom, amount }] as [{ address: string, denom: string }[], Coin]; + return [[{ address, denom }], { denom, amount }] as [ + { address: string; denom: string }[], + Coin + ]; + } + return await queryMantaSwap(denom, amount, config.target).catch( + (e: any) => { + console.error(`[UNIFIER:${config.address}] (${denom}) query: ${e}`); + return [[], { denom, amount }] as [ + { address: string; denom: string }[], + Coin + ]; } - return await queryMantaSwap(denom, amount, config.target).catch((e: any) => { - console.error(`[UNIFIER:${config.address}] (${denom}) query: ${e}`); - return [[], { denom, amount }] as [{ address: string, denom: string }[], Coin]; - }); - })); + ); + }) + ); - type Node = { - address: string; - denom: string; - maxStage: number; - next?: Node; - } + type Node = { + address: string; + denom: string; + maxStage: number; + next?: Node; + }; + + // Construct graph to find multi-input single-output swap paths + let graph: { [k: string]: Node } = {}; + let inputCoins: { denom: string; amount: string }[] = []; + let sources: { [k: string]: boolean } = {}; + results.forEach(([route, coin], idx) => { + if (route.length) inputCoins.push(coin); + let prev: Node | undefined = undefined; + for (let stage = 0; stage < route.length; stage++) { + const { address, denom } = route[stage]; + if (!graph[address]) { + graph[address] = { + address, + denom, + maxStage: stage, + }; + } else { + graph[address].maxStage = Math.max(graph[address].maxStage, stage); + } - // Construct graph to find multi-input single-output swap paths - let graph: { [k: string]: Node } = {}; - let inputCoins: { denom: string, amount: string }[] = []; - let sources: { [k: string]: boolean } = {}; - results.forEach(([route, coin], idx) => { - if (route.length) inputCoins.push(coin); - let prev: Node | undefined = undefined; - for (let stage = 0; stage < route.length; stage++) { - const { address, denom } = route[stage]; - if (!graph[address]) { - graph[address] = { - address, - denom, - maxStage: stage, - }; - } else { - graph[address].maxStage = Math.max(graph[address].maxStage, stage); - } + sources[address] = graph[address].maxStage === 0; - sources[address] = graph[address].maxStage === 0; + if (prev) { + prev.next = graph[address]; + } + prev = graph[address]; + } + }); - if (prev) { - prev.next = graph[address]; - } - prev = graph[address]; + // Traverse down the graph from each source, + // pausing until maxStage is reached for inclusion in the current stage + let cur = Object.values(graph).filter(({ address }) => sources[address]); + const stages: [string, string][][] = []; + let stage = 0; + while (cur.length) { + stages.push([]); + const next: Node[] = []; + cur.forEach((node) => { + if (node.maxStage > stage) { + next.push(node); + return; + } + // Filter out duplicate addresses and denoms + // Yes, this is slow, but who cares + if ( + !stages[stage].find( + ([address, denom]) => address === node.address || denom === node.denom + ) + ) { + stages[stage].push([node.address, node.denom]); + if (node.next) { + next.push(node.next); } + } }); + cur = next; + stage++; + } - // Traverse down the graph from each source, - // pausing until maxStage is reached for inclusion in the current stage - let cur = Object.values(graph).filter(({ address }) => sources[address]); - const stages: [string, string][][] = []; - let stage = 0; - while (cur.length) { - stages.push([]); - const next: Node[] = []; - cur.forEach((node) => { - if (node.maxStage > stage) { - next.push(node); - return; - } - // Filter out duplicate addresses and denoms - // Yes, this is slow, but who cares - if (!stages[stage].find(([address, denom]) => (address === node.address || denom === node.denom))) { - stages[stage].push([node.address, node.denom]); - if (node.next) { - next.push(node.next); - } - } - }); - cur = next; - stage++; - } - - // The swap contract pops, so reverse the stages - return [stages.reverse(), inputCoins]; + // The swap contract pops, so reverse the stages + return [stages.reverse(), inputCoins]; } export async function run(address: string, idx: number) { - const config = registry[NETWORK].find((x) => x.address === address); - if (!config) throw new Error(`${address} unifier not found`); - try { - const w = await client(idx); - const whitelist = await queryMantaSwapWhitelist(); - // Fetch balances to be used as inputs - const { balances }: { balances: Coin[] } = - await querier.wasm.queryContractSmart(config.address, { pending_swaps: {} }); - // Filter out non-whitelisted tokens and small balances - let filteredBalances = balances.filter(({ denom }) => whitelist.includes(denom) || !!hardcodedRoutes[denom]); - const [stages, funds] = await constructMultiInputSwap(filteredBalances, config); + const config = registry[NETWORK].find((x) => x.address === address); + if (!config) throw new Error(`${address} unifier not found`); + try { + const w = await client(idx); + const whitelist = await queryMantaSwapWhitelist(); + // Fetch balances to be used as inputs + const { balances }: { balances: Coin[] } = + await querier.wasm.queryContractSmart(config.address, { + pending_swaps: {}, + }); + // Filter out non-whitelisted tokens, small balances, and already-target denom + let filteredBalances = balances.filter( + ({ denom }) => + whitelist.includes(denom) || + !!hardcodedRoutes[denom] || + denom === config.target + ); + const [stages, funds] = await constructMultiInputSwap( + filteredBalances, + config + ); - if (stages.length === 0 || funds.length === 0) { - console.debug(`[UNIFIER:${address}] No swaps to be made`); - return; - } + if (stages.length === 0 || funds.length === 0) { + console.debug(`[UNIFIER:${address}] No swaps to be made`); + return; + } - const seconds = Math.floor(Date.now() / 1000); - const toSign = { stages, funds, timestamp: seconds }; - // Recent versions of JavaScript guarantee that JSON.stringify - // returns a deterministic string, so we can safely hash it. - const hash = await sha256(Buffer.from(JSON.stringify(toSign))); - const { signature, pubkey } = await oracleSignArbitrary(hash); + const seconds = Math.floor(Date.now() / 1000); + const toSign = { stages, funds, timestamp: seconds }; + // Recent versions of JavaScript guarantee that JSON.stringify + // returns a deterministic string, so we can safely hash it. + const hash = await sha256(Buffer.from(JSON.stringify(toSign))); + const { signature, pubkey } = await oracleSignArbitrary(hash); - const tx = { - crank: { - stages, - funds, - signature: { - timestamp: seconds, - pubkey: Buffer.from(pubkey).toString("hex"), - signature: Buffer.from(signature).toString("hex"), - } - } - } + const tx = { + crank: { + stages, + funds, + signature: { + timestamp: seconds, + pubkey: Buffer.from(pubkey).toString("hex"), + signature: Buffer.from(signature).toString("hex"), + }, + }, + }; - console.debug(`[UNIFIER:${address}] Cranking with ${JSON.stringify(tx)}`); + console.debug(`[UNIFIER:${address}] Cranking...`); - const msgs = [ - msg.wasm.msgExecuteContract({ - sender: w[1], - contract: address, - msg: Buffer.from(JSON.stringify(tx)), - funds: [], - }), - ]; - try { - console.debug(`[UNIFIER:${address}] Cranking...`); - const res = await signAndBroadcast(w, msgs, "auto"); - console.info(`[UNIFIER:${address}] Cranked: ${res.transactionHash}`); - } catch (e: any) { - console.error(`[UNIFIER:${address}] ${e}`); - } - } catch (error: any) { - console.error(`[UNIFIER:${address}] ${error.message}`); - } finally { - await new Promise((resolve) => setTimeout(resolve, 33200000)); - await run(address, idx); + const msgs = [ + msg.wasm.msgExecuteContract({ + sender: w[1], + contract: address, + msg: Buffer.from(JSON.stringify(tx)), + funds: [], + }), + ]; + try { + console.debug(`[UNIFIER:${address}] Cranking...`); + const res = await signAndBroadcast(w, msgs, "auto"); + console.info(`[UNIFIER:${address}] Cranked: ${res.transactionHash}`); + } catch (e: any) { + console.error(`[UNIFIER:${address}] ${e}`); } + } catch (error: any) { + console.error(`[UNIFIER:${address}] ${error.message}`); + } finally { + await new Promise((resolve) => setTimeout(resolve, 33200000)); + await run(address, idx); + } }