diff --git a/README.md b/README.md index 5dbda29..47a6991 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,43 @@ data: { } +/* ---------- when error ---------- */ +// similar to /routeXcm +``` + +### `/routeHomaAuto` +wait for the token to arrive at the router, then route the quest automatically. Returns the request id, which can be used to track route progress +``` +POST /routeHomaAuto +data: { + destAddr: string; // recepient evm or acala native address + chain: string; // 'acala' or 'karura' +} +``` + +example +``` +POST /routeHomaAuto +data: { + destAddr: 0x0085560b24769dAC4ed057F1B2ae40746AA9aAb6 + chain: 'acala' +} + +=> route id +{ + data: 'homa-1711514333845' +} + +GET /routeStatus?routeId=homa-1711514333845 +=> route status +{ data: { status: 0 } } // waiting for token +{ data: { status: 1 } } // token arrived, routing +{ data: { status: 2, txHash: '0x12345 } } // routing tx submitted, waiting for confirmation +{ data: { status: 3, txHash: '0x12345 } } // routing completed +{ data: { status: -1 } } // routing timeout out (usually becuase no token arrive in 3 min) +{ data: { status: -2, error: 'xxx' } } // routing failed +``` + /* ---------- when error ---------- */ // similar to /routeXcm ``` diff --git a/scripts/e2e-prod.ts b/scripts/e2e-prod.ts index cde8c5e..6848ead 100644 --- a/scripts/e2e-prod.ts +++ b/scripts/e2e-prod.ts @@ -8,8 +8,8 @@ import dotenv from 'dotenv'; import path from 'path'; import { BSC_TOKEN, ETH_RPC , RELAYER_URL } from '../src/consts'; -import { ROUTER_CHAIN_ID } from '../src/utils'; -import { getErc20Balance, transferErc20, transferFromBSC } from './scriptUtils'; +import { ROUTER_CHAIN_ID, getTokenBalance } from '../src/utils'; +import { transferErc20, transferFromBSC } from './scriptUtils'; dotenv.config({ path: path.join(__dirname, '.env') }); const key = process.env.KEY; @@ -37,7 +37,7 @@ const routeXcm = async (chainId: ROUTER_CHAIN_ID) => { const wallet = new Wallet(key, provider); const token = chainId === CHAIN_ID_KARURA ? BSC_TOKEN.USDC : BSC_TOKEN.DAI; - const bal = await getErc20Balance(token, wallet); + const bal = await getTokenBalance(token, wallet); if (Number(bal) === 0) { throw new Error('no token balance to transfer!'); } @@ -74,14 +74,15 @@ const routeWormhole = async (chainId: ROUTER_CHAIN_ID) => { const routerAddr = res.data.data.routerAddr; console.log({ routerAddr }); // 0x0FF0e74513fE82A0c4830309811f1aC1e5d06055 / 0xAAbc44730778B9Dc76fA0B1E65eBeF28D8B7B086 - const provider = new AcalaJsonRpcProvider(chainId === CHAIN_ID_KARURA ? ETH_RPC.KARURA : ETH_RPC.ACALA); + const ethRpcUrl = chainId === CHAIN_ID_KARURA ? ETH_RPC.KARURA : ETH_RPC.ACALA; + const provider = new AcalaJsonRpcProvider(ethRpcUrl); const wallet = new Wallet(key, provider); const token = chainId === CHAIN_ID_KARURA ? ROUTER_TOKEN_INFO.usdc.karuraAddr : ROUTER_TOKEN_INFO.dai.acalaAddr; - const bal = await getErc20Balance(token, wallet); + const bal = await getTokenBalance(token, wallet); if (Number(bal) === 0) { throw new Error('no token balance to transfer!'); } diff --git a/scripts/scriptUtils.ts b/scripts/scriptUtils.ts index 856bdfa..f3de94a 100644 --- a/scripts/scriptUtils.ts +++ b/scripts/scriptUtils.ts @@ -10,19 +10,6 @@ import { parseAmount, } from '../src/utils'; -export const getErc20Balance = async (tokenAddr: string, signer: Signer): Promise => { - const erc20 = new Contract(tokenAddr, [ - 'function decimals() view returns (uint8)', - 'function balanceOf(address _owner) public view returns (uint256 balance)', - ], signer); - const [bal, decimals] = await Promise.all([ - erc20.balanceOf(await signer.getAddress()), - erc20.decimals(), - ]); - - return formatUnits(bal, decimals); -}; - export const transferErc20 = async ( tokenAddr: string, amount: string, diff --git a/src/__tests__/configs/acala-fork.yml b/src/__tests__/configs/acala.yml similarity index 98% rename from src/__tests__/configs/acala-fork.yml rename to src/__tests__/configs/acala.yml index 4807865..7aa2d8a 100644 --- a/src/__tests__/configs/acala-fork.yml +++ b/src/__tests__/configs/acala.yml @@ -1,5 +1,5 @@ endpoint: - - wss://crosschain-dev.polkawallet.io:9915 + - wss://acala-rpc.aca-api.network mock-signature-host: true # block: ${env.ACALA_BLOCK_NUMBER} db: ./db.sqlite diff --git a/src/__tests__/route.test.ts b/src/__tests__/route.test.ts index 62abe5a..96e348a 100644 --- a/src/__tests__/route.test.ts +++ b/src/__tests__/route.test.ts @@ -12,7 +12,8 @@ import { JsonRpcProvider } from '@ethersproject/providers'; import { ONE_ACA, almostEq, toHuman } from '@acala-network/asset-router/dist/utils'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { encodeAddress } from '@polkadot/util-crypto'; -import { formatEther, parseEther, parseUnits } from 'ethers/lib/utils'; +import { parseEther, parseUnits } from 'ethers/lib/utils'; +import assert from 'assert'; import { BASILISK_TESTNET_NODE_URL, @@ -22,6 +23,7 @@ import { TEST_KEY, } from './testConsts'; import { ETH_RPC, FUJI_TOKEN, GOERLI_USDC, PARA_ID } from '../consts'; +import { RouteStatus } from '../api'; import { encodeXcmDest, expectError, @@ -31,6 +33,8 @@ import { relayAndRoute, relayAndRouteBatch, routeHoma, + routeHomaAuto, + routeStatus, routeWormhole, routeXcm, shouldRouteHoma, @@ -421,6 +425,9 @@ describe.skip('/routeHoma', () => { }; const testHomaRouter = async (destAddr: string) => { + const relayerBal = await relayerAcalaFork.getBalance(); + assert(relayerBal.gt(parseEther('10')), `relayer doesn't have enough balance to relay! ${relayerAcalaFork.address}`); + const routeArgs = { destAddr, chain: 'acala', @@ -462,12 +469,86 @@ describe.skip('/routeHoma', () => { // user should receive LDOT const routingFee = await fee.getFee(DOT); - const exchangeRate = parseEther((1 / Number(formatEther(await homa.getExchangeRate()))).toString()); // 10{18} DOT => ? LDOT - const expectedLdot = parsedStakeAmount.sub(routingFee).mul(exchangeRate).div(ONE_ACA); + const exchangeRate = await homa.getExchangeRate(); // 10{18} LDOT => ? DOT + const expectedLdot = parsedStakeAmount.sub(routingFee).mul(ONE_ACA).div(exchangeRate); const ldotReceived = bal1.userBalLdot.sub(bal0.userBalLdot); expect(almostEq(expectedLdot, ldotReceived)).to.be.true; - // expect(bal0.userBalDot.sub(bal1.userBalDot)).to.eq(parsedStakeAmount); // TODO: why this has a super slight off? + expect(bal0.userBalDot.sub(bal1.userBalDot).toBigInt()).to.eq(parsedStakeAmount.toBigInt()); + + // relayer should receive DOT fee + expect(bal1.relayerBalDot.sub(bal0.relayerBalDot).toBigInt()).to.eq(routingFee.toBigInt()); + }; + + const testAutoHomaRouter = async (destAddr: string) => { + const relayerBal = await relayerAcalaFork.getBalance(); + assert(relayerBal.gt(parseEther('10')), `relayer doesn't have enough balance to relay! ${relayerAcalaFork.address}`); + + const routeArgs = { + destAddr, + chain: 'acala', + }; + const res = await shouldRouteHoma(routeArgs); + ({ routerAddr } = res.data); + + // make sure user has enough DOT to transfer to router + const bal = await fetchTokenBalances(); + if (bal.userBalDot.lt(parsedStakeAmount)) { + if (bal.relayerBalDot.lt(parsedStakeAmount)) { + throw new Error('both relayer and user do not have enough DOT to transfer to router!'); + } + + console.log('refilling dot for user ...'); + await (await dot.connect(relayerAcalaFork).transfer(TEST_ADDR_USER, parsedStakeAmount)).wait(); + } + + const bal0 = await fetchTokenBalances(); + + console.log('sending auto routing request ...'); + const routeRes = await routeHomaAuto({ + ...routeArgs, + token: DOT, + }); + const reqId = routeRes.data; + console.log(`auto route submitted! reqId: ${reqId}`); + + const waitForRoute = new Promise((resolve, reject) => { + const pollRouteStatus = setInterval(async () => { + const res = await routeStatus({ id: reqId }); + // console.log(`current status: ${res.data.status}`); + + if (res.data.status === RouteStatus.Complete) { + resolve(); + clearInterval(pollRouteStatus); + } + }, 1000); + + setTimeout(reject, 100 * 1000); + }); + + console.log('xcming to router ...'); + await mockXcmToRouter(routerAddr, userAcalaFork, DOT, stakeAmount); + + console.log('waiting for auto routing ...'); + await waitForRoute; + + console.log('route complete!'); + const bal1 = await fetchTokenBalances(); + + // router should be destroyed + const routerCode = await providerKarura.getCode(routerAddr); + expect(routerCode).to.eq('0x'); + expect(bal1.routerBalDot.toNumber()).to.eq(0); + expect(bal1.routerBalLdot.toNumber()).to.eq(0); + + // user should receive LDOT + const routingFee = await fee.getFee(DOT); + const exchangeRate = await homa.getExchangeRate(); // 10{18} LDOT => ? DOT + const expectedLdot = parsedStakeAmount.sub(routingFee).mul(ONE_ACA).div(exchangeRate); + const ldotReceived = bal1.userBalLdot.sub(bal0.userBalLdot); + + expect(almostEq(expectedLdot, ldotReceived)).to.be.true; + expect(bal0.userBalDot.sub(bal1.userBalDot).toBigInt()).to.eq(parsedStakeAmount.toBigInt()); // relayer should receive DOT fee expect(bal1.relayerBalDot.sub(bal0.relayerBalDot).toBigInt()).to.eq(routingFee.toBigInt()); @@ -484,5 +565,18 @@ describe.skip('/routeHoma', () => { await testHomaRouter(userSubstrateAddr); }); + + it('auto route to evm address', async () => { + await testAutoHomaRouter(TEST_ADDR_USER); + }); + + it('auto route to substrate address', async () => { + const ACALA_SS58_PREFIX = 10; + const userAccountId = await evmAccounts.getAccountId(TEST_ADDR_USER); + const userSubstrateAddr = encodeAddress(userAccountId, ACALA_SS58_PREFIX); + + await testAutoHomaRouter(userSubstrateAddr); + }); }); + diff --git a/src/__tests__/shouldRoute.test.ts b/src/__tests__/shouldRoute.test.ts index cb17714..88f6daa 100644 --- a/src/__tests__/shouldRoute.test.ts +++ b/src/__tests__/shouldRoute.test.ts @@ -321,13 +321,13 @@ describe.concurrent.skip('/shouldRouteHoma', () => { }); expect(res).toMatchInlineSnapshot(` - { - "data": { - "routerAddr": "0xa013818BBddc5d2d55ab9cCD50759b3B1953d6cd", - "shouldRoute": true, - }, - } - `); + { + "data": { + "routerAddr": "0x8A4f03B2D615172f0714AaC2E8C399a6f0d9e448", + "shouldRoute": true, + }, + } + `); // should be case insensitive res = await shouldRouteHoma({ @@ -336,13 +336,13 @@ describe.concurrent.skip('/shouldRouteHoma', () => { }); expect(res).toMatchInlineSnapshot(` - { - "data": { - "routerAddr": "0xa013818BBddc5d2d55ab9cCD50759b3B1953d6cd", - "shouldRoute": true, - }, - } - `); + { + "data": { + "routerAddr": "0x8A4f03B2D615172f0714AaC2E8C399a6f0d9e448", + "shouldRoute": true, + }, + } + `); } }); @@ -357,7 +357,7 @@ describe.concurrent.skip('/shouldRouteHoma', () => { expect(res).toMatchInlineSnapshot(` { "data": { - "routerAddr": "0xfD6143c380706912a04230f22cF92c402561820e", + "routerAddr": "0x1140EFc2C45e9307701DA521884F75dDDe28f28f", "shouldRoute": true, }, } diff --git a/src/__tests__/testUtils.ts b/src/__tests__/testUtils.ts index 9520dd9..9d14d9c 100644 --- a/src/__tests__/testUtils.ts +++ b/src/__tests__/testUtils.ts @@ -5,13 +5,14 @@ import { JsonRpcProvider } from '@ethersproject/providers'; import { Wallet } from 'ethers'; import { expect } from 'vitest'; import { formatEther, parseEther, parseUnits } from 'ethers/lib/utils'; +import assert from 'assert'; import axios from 'axios'; import request from 'supertest'; import { ETH_RPC, RELAYER_API, RELAYER_URL } from '../consts'; import { KARURA_USDC_ADDRESS, TEST_KEY } from './testConsts'; import { createApp } from '../app'; -import { transferFromAvax } from '../utils'; +import { getTokenBalance, transferFromAvax } from '../utils'; export const transferFromFujiToKaruraTestnet = async ( amount: string, @@ -22,9 +23,10 @@ export const transferFromFujiToKaruraTestnet = async ( const wallet = new Wallet(TEST_KEY.USER, provider); const bal = await wallet.getBalance(); - if (bal.lt(parseEther('0.03'))) { - throw new Error(`${wallet.address} has insufficient balance on fuji! bal: ${formatEther(bal)}`); - } + assert(bal.gte(parseEther('0.03')), `${wallet.address} has insufficient balance on fuji! bal: ${formatEther(bal)}`); + + const tokenBal = await getTokenBalance(sourceAsset, wallet); + assert(Number(tokenBal) > Number(amount), `${wallet.address} has insufficient token balance on fuji! ${tokenBal} < ${amount}`); return await transferFromAvax( amount, @@ -184,6 +186,14 @@ export const routeHoma = process.env.COVERAGE ? _supertestPost(RELAYER_API.ROUTE_HOMA) : _axiosPost(RELAYER_URL.ROUTE_HOMA); +export const routeHomaAuto = process.env.COVERAGE + ? _supertestPost(RELAYER_API.ROUTE_HOMA_AUTO) + : _axiosPost(RELAYER_URL.ROUTE_HOMA_AUTO); + +export const routeStatus = process.env.COVERAGE + ? _supertestGet(RELAYER_API.ROUTE_STATUS) + : _axiosGet(RELAYER_URL.ROUTE_STATUS); + export const shouldRouteEuphrates = process.env.COVERAGE ? _supertestGet(RELAYER_API.SHOULD_ROUTER_EUPHRATES) : _axiosGet(RELAYER_URL.SHOULD_ROUTER_EUPHRATES); diff --git a/src/api/euphrates.ts b/src/api/euphrates.ts new file mode 100644 index 0000000..f21af22 --- /dev/null +++ b/src/api/euphrates.ts @@ -0,0 +1,64 @@ +import { EuphratesFactory__factory } from '@acala-network/asset-router/dist/typechain-types'; + +import { EUPHRATES_ADDR, EUPHRATES_POOLS } from '../consts'; +import { + Mainnet, + RouteError, + RouteParamsEuphrates, + _populateRelayTx, + _populateRouteTx, + getChainConfig, + getMainnetChainId, +} from '../utils'; + +const prepareRouteEuphrates = async (chain: Mainnet) => { + const chainId = getMainnetChainId(chain); + const chainConfig = await getChainConfig(chainId); + const { feeAddr, euphratesFactoryAddr, wallet } = chainConfig; + + const euphratesFactory = EuphratesFactory__factory.connect(euphratesFactoryAddr!, wallet); + + return { euphratesFactory, feeAddr }; +}; + +export const shouldRouteEuphrates = async (params: RouteParamsEuphrates) => { + try { + const { euphratesFactory, feeAddr } = await prepareRouteEuphrates(Mainnet.Acala); + if (!EUPHRATES_POOLS.includes(params.poolId)) { + throw new RouteError(`euphrates poolId ${params.poolId} is not supported`, params); + } + + const routerAddr = await euphratesFactory.callStatic.deployEuphratesRouter( + feeAddr, + params, + EUPHRATES_ADDR, + ); + + return { + shouldRoute: true, + routerAddr, + }; + } catch (err) { + return { + shouldRoute: false, + msg: err.message, + }; + } +}; + +export const routeEuphrates = async (params: RouteParamsEuphrates) => { + if (params.token === undefined) { + throw new RouteError('no token address provided for routeEuphrates', params); + } + + const { euphratesFactory, feeAddr } = await prepareRouteEuphrates(Mainnet.Acala); + const tx = await euphratesFactory.deployEuphratesRouterAndRoute( + feeAddr, + params, + EUPHRATES_ADDR, + params.token, + ); + const receipt = await tx.wait(); + + return receipt.transactionHash; +}; diff --git a/src/api/homa.ts b/src/api/homa.ts new file mode 100644 index 0000000..6a4c9ed --- /dev/null +++ b/src/api/homa.ts @@ -0,0 +1,144 @@ +import { ContractTransaction } from 'ethers'; +import { DOT } from '@acala-network/contracts/utils/AcalaTokens'; +import { ERC20__factory, HomaFactory__factory } from '@acala-network/asset-router/dist/typechain-types'; +import { KSM } from '@acala-network/contracts/utils/KaruraTokens'; + +import { + Mainnet, + RouteError, + RouteParamsHoma, + _populateRelayTx, + _populateRouteTx, + getChainConfig, + getMainnetChainId, + routeStatusParams, + runWithRetry, + toAddr32, +} from '../utils'; + +const prepareRouteHoma = async (chain: Mainnet) => { + const chainId = getMainnetChainId(chain); + const chainConfig = await getChainConfig(chainId); + const { feeAddr, homaFactoryAddr, wallet } = chainConfig; + + const homaFactory = HomaFactory__factory.connect(homaFactoryAddr!, wallet); + const routeToken = chain === Mainnet.Acala ? DOT : KSM; + + return { homaFactory, feeAddr, routeToken, wallet }; +}; + +export const shouldRouteHoma = async ({ chain, destAddr }: RouteParamsHoma) => { + try { + const { homaFactory, feeAddr } = await prepareRouteHoma(chain); + const routerAddr = await homaFactory.callStatic.deployHomaRouter( + feeAddr, + toAddr32(destAddr), + ); + + return { + shouldRoute: true, + routerAddr, + }; + } catch (err) { + return { + shouldRoute: false, + msg: err.message, + }; + } +}; + +export const routeHoma = async ({ chain, destAddr }: RouteParamsHoma) => { + const { homaFactory, feeAddr, routeToken } = await prepareRouteHoma(chain); + const tx = await homaFactory.deployHomaRouterAndRoute(feeAddr, toAddr32(destAddr), routeToken); + const receipt = await tx.wait(); + + return receipt.transactionHash; +}; + +/* --------------------------------- auto route homa --------------------------------- */ +export enum RouteStatus { + Waiting = 0, + Routing = 1, + Confirming = 2, + Complete = 3, + Timeout = -1, + Failed = -2, +}; + +interface RouteInfo { + status: RouteStatus; + txHash?: string; + err?: any; +} + +let routeReqId = Date.now(); +const routeTracker: Record = {}; +export const routeHomaAuto = async (params: RouteParamsHoma) => { + const { chain, destAddr } = params; + const { routerAddr, shouldRoute, msg } = await shouldRouteHoma(params); + + if (!shouldRoute) { + throw new RouteError(msg, params); + } + + const reqId = `homa-${routeReqId++}`; + const tracker = routeTracker[reqId] = { status: RouteStatus.Waiting } as RouteInfo; + + const { homaFactory, feeAddr, routeToken, wallet } = await prepareRouteHoma(chain); + const dotOrKsm = ERC20__factory.connect(routeToken, wallet); + + const waitForToken = new Promise((resolve, reject) => { + const id = setInterval(async () => { + const bal = await runWithRetry(() => dotOrKsm.balanceOf(routerAddr!)); + if (bal.gt(0)) { + clearInterval(id); + resolve(); + } + }, 3000); + + const timeout = 3 * 60 * 1000; // 3 min + setTimeout(() => { + clearInterval(id); + reject('timeout'); + }, timeout); + }); + + waitForToken.then(async () => { + tracker.status = RouteStatus.Routing; + + let tx: ContractTransaction; + try { + tx = await runWithRetry( + () => homaFactory.deployHomaRouterAndRoute(feeAddr, toAddr32(destAddr), routeToken), + { retry: 3, interval: 20 } + ); + } catch (err) { + tracker.status = RouteStatus.Failed; + tracker.err = err.message; + return; + } + + tracker.txHash = tx.hash; + tracker.status = RouteStatus.Confirming; + + const receipt = await runWithRetry(() => tx.wait(), { retry: 3, interval: 10 }); + tracker.status = receipt.status === 0 + ? RouteStatus.Failed + : RouteStatus.Complete; + }).catch(err => { + if (err === 'timeout') { + tracker.status = RouteStatus.Timeout; + } else { + tracker.status = RouteStatus.Failed; + tracker.err = err; + } + }); + + // clear record after 7 days to avoid memory blow + setTimeout(() => delete routeTracker[reqId], 7 * 24 * 60 * 60 * 1000); + + return reqId; +}; + +export const getRouteStatus = async ({ id }: routeStatusParams) => routeTracker[id]; +export const getAllRouteStatus = async () => routeTracker; diff --git a/src/api/index.ts b/src/api/index.ts index 2ccaff1..ed74015 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,8 @@ export * from './relay'; -export * from './route'; export * from './testTimeout'; export * from './getVersion'; +export * from './wormhole'; +export * from './xcm'; +export * from './homa'; +export * from './euphrates'; +export * from './health'; diff --git a/src/api/route.ts b/src/api/route.ts deleted file mode 100644 index 991c6cf..0000000 --- a/src/api/route.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { DOT } from '@acala-network/contracts/utils/AcalaTokens'; -import { EuphratesFactory__factory, Factory__factory, HomaFactory__factory } from '@acala-network/asset-router/dist/typechain-types'; -import { KSM } from '@acala-network/contracts/utils/KaruraTokens'; -import { XcmInstructionsStruct } from '@acala-network/asset-router/dist/typechain-types/src/Factory'; - -import { - DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID, EUPHRATES_ADDR, EUPHRATES_POOLS, -} from '../consts'; -import { - Mainnet, - RelayAndRouteParams, - RouteParamsEuphrates, - RouteParamsHoma, - RouteParamsWormhole, - RouteParamsXcm, - _populateRelayTx, - _populateRouteTx, - checkShouldRelayBeforeRouting, - getChainConfig, - getEthExtrinsic, - getMainnetChainId, - getRouterChainTokenAddr, - logger, - prepareRouteWormhole, - prepareRouteXcm, - relayEVM, - sendExtrinsic, - toAddr32, -} from '../utils'; -import { RelayerError } from '../middlewares'; - -export const routeXcm = async (routeParamsXcm: RouteParamsXcm): Promise => { - const { chainConfig } = await prepareRouteXcm(routeParamsXcm); - const { feeAddr, factoryAddr, wallet } = chainConfig; - - const xcmInstruction: XcmInstructionsStruct = { - dest: routeParamsXcm.dest, - weight: '0x00', - }; - const factory = Factory__factory.connect(factoryAddr, wallet); - const routerChainTokenAddr = await getRouterChainTokenAddr(routeParamsXcm.originAddr, chainConfig); - - const tx = await factory.deployXcmRouterAndRoute( - feeAddr, - xcmInstruction, - routerChainTokenAddr, - ); - - const receipt = await tx.wait(); - - return receipt.transactionHash; -}; - -export const relayAndRouteBatch = async (params: RelayAndRouteParams): Promise => { - const [relayTx, routeTx] = await Promise.all([ - _populateRelayTx(params), - _populateRouteTx(params), - ]); - - const routerChainId = DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID[params.destParaId]; - const { api, provider, relayerSubstrateAddr } = await getChainConfig(routerChainId); - - const [relayExtrinsic, routeExtrinsic] = await Promise.all([ - getEthExtrinsic(api, provider, relayTx, false), - getEthExtrinsic(api, provider, routeTx, true), - ]); - - const batchTx = api.tx.utility.batchAll([relayExtrinsic, routeExtrinsic]); - await batchTx.signAsync(relayerSubstrateAddr); - - const txHash = await sendExtrinsic(api, provider, batchTx); - - return txHash; -}; - -export const relayAndRoute = async (params: RelayAndRouteParams): Promise<[string, string]> => { - const routerChainId = DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID[params.destParaId]; - const chainConfig = await getChainConfig(routerChainId); - await checkShouldRelayBeforeRouting(params, chainConfig); - - const wormholeReceipt = await relayEVM(chainConfig, params.signedVAA); - logger.debug({ txHash: wormholeReceipt.transactionHash }, 'relay finished'); - - const xcmTxHash = await routeXcm(params); - return [wormholeReceipt.transactionHash, xcmTxHash]; -}; - -export const routeWormhole = async (routeParamsWormhole: RouteParamsWormhole): Promise => { - const { - chainConfig, - routerChainTokenAddr, - wormholeInstructions, - } = await prepareRouteWormhole(routeParamsWormhole); - const { feeAddr, tokenBridgeAddr, factoryAddr, wallet } = chainConfig; - - const factory = Factory__factory.connect(factoryAddr, wallet); - const tx = await factory.deployWormholeRouterAndRoute( - feeAddr, - wormholeInstructions, - tokenBridgeAddr, - routerChainTokenAddr, - ); - const receipt = await tx.wait(); - - return receipt.transactionHash; -}; - -export const shouldRouteXcm = async (data: any) => { - try { - const { routerAddr, routerChainId } = await prepareRouteXcm(data); - return { - shouldRoute: true, - routerAddr, - routerChainId, - }; - } catch (err) { - return { - shouldRoute: false, - msg: err.message, - }; - } -}; - -export const shouldRouteWormhole = async (data: any) => { - try { - const { routerAddr } = await prepareRouteWormhole(data); - return { - shouldRoute: true, - routerAddr, - }; - } catch (err) { - return { - shouldRoute: false, - msg: err.message, - }; - } -}; - -const prepareRouteEuphrates = async (chain: Mainnet) => { - const chainId = getMainnetChainId(chain); - const chainConfig = await getChainConfig(chainId); - const { feeAddr, euphratesFactoryAddr, wallet } = chainConfig; - - const euphratesFactory = EuphratesFactory__factory.connect(euphratesFactoryAddr!, wallet); - - return { euphratesFactory, feeAddr }; -}; - -export const shouldRouteHoma = async ({ chain, destAddr }: RouteParamsHoma) => { - try { - const { homaFactory, feeAddr } = await prepareRouteHoma(chain); - const routerAddr = await homaFactory.callStatic.deployHomaRouter( - feeAddr, - toAddr32(destAddr), - ); - - return { - shouldRoute: true, - routerAddr, - }; - } catch (err) { - return { - shouldRoute: false, - msg: err.message, - }; - } -}; - -export const routeHoma = async ({ chain, destAddr }: RouteParamsHoma) => { - const { homaFactory, feeAddr, routeToken } = await prepareRouteHoma(chain); - const tx = await homaFactory.deployHomaRouterAndRoute(feeAddr, toAddr32(destAddr), routeToken); - const receipt = await tx.wait(); - - return receipt.transactionHash; -}; - -const prepareRouteHoma = async (chain: Mainnet) => { - const chainId = getMainnetChainId(chain); - const chainConfig = await getChainConfig(chainId); - const { feeAddr, homaFactoryAddr, wallet } = chainConfig; - - const homaFactory = HomaFactory__factory.connect(homaFactoryAddr!, wallet); - const routeToken = chain === Mainnet.Acala ? DOT : KSM; - - return { homaFactory, feeAddr, routeToken }; -}; - -export const shouldRouteEuphrates = async (params: RouteParamsEuphrates) => { - try { - const { euphratesFactory, feeAddr } = await prepareRouteEuphrates(Mainnet.Acala); - if (!EUPHRATES_POOLS.includes(params.poolId)) { - throw new RelayerError(`euphrates poolId ${params.poolId} is not supported`, params); - } - - const routerAddr = await euphratesFactory.callStatic.deployEuphratesRouter( - feeAddr, - params, - EUPHRATES_ADDR, - ); - - return { - shouldRoute: true, - routerAddr, - }; - } catch (err) { - return { - shouldRoute: false, - msg: err.message, - }; - } -}; - -export const routeEuphrates = async (params: RouteParamsEuphrates) => { - if (params.token === undefined) { - throw new RelayerError('no token address provided for routeEuphrates', params); - } - - const { euphratesFactory, feeAddr } = await prepareRouteEuphrates(Mainnet.Acala); - const tx = await euphratesFactory.deployEuphratesRouterAndRoute( - feeAddr, - params, - EUPHRATES_ADDR, - params.token, - ); - const receipt = await tx.wait(); - - return receipt.transactionHash; -}; diff --git a/src/api/wormhole.ts b/src/api/wormhole.ts new file mode 100644 index 0000000..84ba26f --- /dev/null +++ b/src/api/wormhole.ts @@ -0,0 +1,43 @@ +import { Factory__factory } from '@acala-network/asset-router/dist/typechain-types'; + +import { + RouteParamsWormhole, + _populateRelayTx, + _populateRouteTx, + prepareRouteWormhole, +} from '../utils'; + +export const shouldRouteWormhole = async (data: any) => { + try { + const { routerAddr } = await prepareRouteWormhole(data); + return { + shouldRoute: true, + routerAddr, + }; + } catch (err) { + return { + shouldRoute: false, + msg: err.message, + }; + } +}; + +export const routeWormhole = async (routeParamsWormhole: RouteParamsWormhole): Promise => { + const { + chainConfig, + routerChainTokenAddr, + wormholeInstructions, + } = await prepareRouteWormhole(routeParamsWormhole); + const { feeAddr, tokenBridgeAddr, factoryAddr, wallet } = chainConfig; + + const factory = Factory__factory.connect(factoryAddr, wallet); + const tx = await factory.deployWormholeRouterAndRoute( + feeAddr, + wormholeInstructions, + tokenBridgeAddr, + routerChainTokenAddr, + ); + const receipt = await tx.wait(); + + return receipt.transactionHash; +}; diff --git a/src/api/xcm.ts b/src/api/xcm.ts new file mode 100644 index 0000000..d8d891a --- /dev/null +++ b/src/api/xcm.ts @@ -0,0 +1,90 @@ +import { Factory__factory } from '@acala-network/asset-router/dist/typechain-types'; +import { XcmInstructionsStruct } from '@acala-network/asset-router/dist/typechain-types/src/Factory'; + +import { DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID } from '../consts'; +import { + RelayAndRouteParams, + RouteParamsXcm, + _populateRelayTx, + _populateRouteTx, + checkShouldRelayBeforeRouting, + getChainConfig, + getEthExtrinsic, + getRouterChainTokenAddr, + logger, + prepareRouteXcm, + relayEVM, + sendExtrinsic, +} from '../utils'; + +export const shouldRouteXcm = async (data: any) => { + try { + const { routerAddr, routerChainId } = await prepareRouteXcm(data); + return { + shouldRoute: true, + routerAddr, + routerChainId, + }; + } catch (err) { + return { + shouldRoute: false, + msg: err.message, + }; + } +}; + +export const routeXcm = async (routeParamsXcm: RouteParamsXcm): Promise => { + const { chainConfig } = await prepareRouteXcm(routeParamsXcm); + const { feeAddr, factoryAddr, wallet } = chainConfig; + + const xcmInstruction: XcmInstructionsStruct = { + dest: routeParamsXcm.dest, + weight: '0x00', + }; + const factory = Factory__factory.connect(factoryAddr, wallet); + const routerChainTokenAddr = await getRouterChainTokenAddr(routeParamsXcm.originAddr, chainConfig); + + const tx = await factory.deployXcmRouterAndRoute( + feeAddr, + xcmInstruction, + routerChainTokenAddr, + ); + + const receipt = await tx.wait(); + + return receipt.transactionHash; +}; + +export const relayAndRouteBatch = async (params: RelayAndRouteParams): Promise => { + const [relayTx, routeTx] = await Promise.all([ + _populateRelayTx(params), + _populateRouteTx(params), + ]); + + const routerChainId = DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID[params.destParaId]; + const { api, provider, relayerSubstrateAddr } = await getChainConfig(routerChainId); + + const [relayExtrinsic, routeExtrinsic] = await Promise.all([ + getEthExtrinsic(api, provider, relayTx, false), + getEthExtrinsic(api, provider, routeTx, true), + ]); + + const batchTx = api.tx.utility.batchAll([relayExtrinsic, routeExtrinsic]); + await batchTx.signAsync(relayerSubstrateAddr); + + const txHash = await sendExtrinsic(api, provider, batchTx); + + return txHash; +}; + +export const relayAndRoute = async (params: RelayAndRouteParams): Promise<[string, string]> => { + const routerChainId = DEST_PARA_ID_TO_ROUTER_WORMHOLE_CHAIN_ID[params.destParaId]; + const chainConfig = await getChainConfig(routerChainId); + await checkShouldRelayBeforeRouting(params, chainConfig); + + const wormholeReceipt = await relayEVM(chainConfig, params.signedVAA); + logger.debug({ txHash: wormholeReceipt.transactionHash }, 'relay finished'); + + const xcmTxHash = await routeXcm(params); + return [wormholeReceipt.transactionHash, xcmTxHash]; +}; diff --git a/src/consts.ts b/src/consts.ts index 6c11510..7f8e957 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -55,6 +55,8 @@ export const RELAYER_API = { SHOULD_ROUTER_HOMA: '/shouldRouteHoma', ROUTE_HOMA: '/routeHoma', + ROUTE_HOMA_AUTO: '/routeHomaAuto', + ROUTE_STATUS: '/routeStatus', SHOULD_ROUTER_EUPHRATES: '/shouldRouteEuphrates', ROUTE_EUPHRATES: '/routeEuphrates', @@ -80,6 +82,8 @@ export const RELAYER_URL = { SHOULD_ROUTER_HOMA: `${RELAYER_BASE_URL}${RELAYER_API.SHOULD_ROUTER_HOMA}`, ROUTE_HOMA: `${RELAYER_BASE_URL}${RELAYER_API.ROUTE_HOMA}`, + ROUTE_HOMA_AUTO: `${RELAYER_BASE_URL}${RELAYER_API.ROUTE_HOMA_AUTO}`, + ROUTE_STATUS: `${RELAYER_BASE_URL}${RELAYER_API.ROUTE_STATUS}`, SHOULD_ROUTER_EUPHRATES: `${RELAYER_BASE_URL}${RELAYER_API.SHOULD_ROUTER_EUPHRATES}`, ROUTE_EUPHRATES: `${RELAYER_BASE_URL}${RELAYER_API.ROUTE_EUPHRATES}`, diff --git a/src/middlewares/error.ts b/src/middlewares/error.ts index 50391a1..f322d55 100644 --- a/src/middlewares/error.ts +++ b/src/middlewares/error.ts @@ -1,28 +1,7 @@ import { NextFunction, Request, Response } from 'express'; import { ValidationError } from 'yup'; -export class RelayerError extends Error { - params: any; - - constructor(message: string, params?: any) { - super(message); - this.name = 'RelayerError'; - this.params = params; - } -} - -export class NoRouteError extends Error { - constructor(message: string) { - super(message); - this.name = 'NoRouteError'; - } -} - -export class RelayError extends RelayerError { - constructor(message: string, params?: any) { - super(message, params); - } -}; +import { NoRouteError, RelayError, RelayerError, RouteError } from '../utils'; export const errorHandler = (err: unknown, req: Request, res: Response, _next: NextFunction) => { if (err instanceof ValidationError) { @@ -41,6 +20,12 @@ export const errorHandler = (err: unknown, req: Request, res: Response, _next: N error: err.message, params: err.params, }); + } else if (err instanceof RouteError) { + res.status(500).json({ + msg: 'cannot route this request!', + error: err.message, + params: err.params, + }); } else if (err instanceof RelayerError) { res.status(500).json({ msg: 'an error occurred!', @@ -54,7 +39,7 @@ export const errorHandler = (err: unknown, req: Request, res: Response, _next: N }); } else { res.status(500).json({ - msg: 'internal server error', + msg: 'unknown internal server error', error: JSON.stringify(err), }); } diff --git a/src/middlewares/router.ts b/src/middlewares/router.ts index 67b51cc..832303c 100644 --- a/src/middlewares/router.ts +++ b/src/middlewares/router.ts @@ -1,28 +1,32 @@ import { NextFunction, Request, Response } from 'express'; import { Schema } from 'yup'; -import { NoRouteError } from './error'; -import { healthCheck } from '../api/health'; -import { logger } from '../utils'; import { + getAllRouteStatus, + getRouteStatus, + healthCheck, relayAndRoute, relayAndRouteBatch, routeEuphrates, routeHoma, + routeHomaAuto, routeWormhole, routeXcm, shouldRouteEuphrates, shouldRouteHoma, shouldRouteWormhole, shouldRouteXcm, -} from '../api/route'; +} from '../api'; import { relayAndRouteSchema, routeEuphratesSchema, routeHomaSchema, routeWormholeSchema, routeXcmSchema, -} from '../utils/validate'; + logger, + NoRouteError, + routeStatusSchema, +} from '../utils'; interface RouterConfig { schema?: Schema; @@ -54,6 +58,13 @@ const ROUTER_CONFIGS: { '/health': { handler: healthCheck, }, + '/routeStatus': { + schema: routeStatusSchema, + handler: getRouteStatus, + }, + '/allRouteStatus': { + handler: getAllRouteStatus, + }, }, POST: { @@ -77,6 +88,10 @@ const ROUTER_CONFIGS: { schema: routeHomaSchema, handler: routeHoma, }, + '/routeHomaAuto': { + schema: routeHomaSchema, + handler: routeHomaAuto, + }, '/routeEuphrates': { schema: routeEuphratesSchema, handler: routeEuphrates, diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..6b017a2 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,30 @@ +export class RelayerError extends Error { + params: any; + + constructor(message: string, params?: any) { + super(message); + this.name = 'RelayerError'; + this.params = params; + } +} + +export class NoRouteError extends Error { + constructor(message: string) { + super(message); + this.name = 'NoRouteError'; + } +} + +export class RelayError extends RelayerError { + constructor(message: string, params?: any) { + super(message, params); + this.name = 'RelayError'; + } +}; + +export class RouteError extends RelayerError { + constructor(message: string, params?: any) { + super(message, params); + this.name = 'RouteError'; + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 94eacc4..1f22026 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,3 +6,5 @@ export * from './utils'; export * from './validate'; export * from './wormhole'; export * from './address'; +export * from './error'; +export * from './token'; diff --git a/src/utils/relay.ts b/src/utils/relay.ts index 5834a13..f2060d0 100644 --- a/src/utils/relay.ts +++ b/src/utils/relay.ts @@ -11,7 +11,7 @@ import { import { ChainConfig } from './configureEnv'; import { RELAYER_SUPPORTED_ADDRESSES_AND_THRESHOLDS } from '../consts'; import { RelayAndRouteParams } from './validate'; -import { RelayError } from '../middlewares/error'; +import { RelayError } from './error'; import { VaaInfo, parseVaaPayload } from './wormhole'; import { logger } from './logger'; diff --git a/src/utils/route.ts b/src/utils/route.ts index acde4a2..f7dc1dd 100644 --- a/src/utils/route.ts +++ b/src/utils/route.ts @@ -9,13 +9,9 @@ import { ROUTE_SUPPORTED_CHAINS_AND_ASSETS, ZERO_ADDR, } from '../consts'; -import { - RelayAndRouteParams, - RouteParamsWormhole, - RouteParamsXcm, - checkShouldRelayBeforeRouting, - getRouterChainTokenAddr, -} from '../utils'; +import { RelayAndRouteParams, RouteParamsWormhole, RouteParamsXcm } from './validate'; +import { checkShouldRelayBeforeRouting } from './relay'; +import { getRouterChainTokenAddr } from './wormhole'; interface RouteProps { routerAddr: string; diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..3035e12 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,29 @@ +import { BigNumber, Signer } from 'ethers'; +import { ERC20__factory } from '@acala-network/asset-router/dist/typechain-types'; +import { Provider } from '@ethersproject/abstract-provider'; +import { formatUnits, parseUnits } from 'ethers/lib/utils'; + +export const parseAmount = async ( + tokenAddr: string, + amount: string, + signerOrProvider: Signer | Provider, +): Promise => { + const token = ERC20__factory.connect(tokenAddr, signerOrProvider); + const decimals = await token.decimals(); + + return parseUnits(amount, decimals); +}; + +export const getTokenBalance = async ( + tokenAddr: string, + signer: Signer, +): Promise => { + const token = ERC20__factory.connect(tokenAddr, signer); + + const [bal, decimals] = await Promise.all([ + token.balanceOf(await signer.getAddress()), + token.decimals(), + ]); + + return formatUnits(bal, decimals); +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6daa242..ae2ff95 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,5 @@ import { ApiPromise, Keyring, WsProvider } from '@polkadot/api'; -import { BigNumber, Contract, PopulatedTransaction, Wallet } from 'ethers'; +import { BigNumber, PopulatedTransaction, Wallet } from 'ethers'; import { CHAIN_ID_ACALA, CHAIN_ID_AVAX, CHAIN_ID_KARURA, CONTRACTS, hexToUint8Array } from '@certusone/wormhole-sdk'; import { DispatchError } from '@polkadot/types/interfaces'; import { ISubmittableResult } from '@polkadot/types/types'; @@ -7,12 +7,13 @@ import { JsonRpcProvider } from '@ethersproject/providers'; import { SubmittableExtrinsic } from '@polkadot/api/types'; import { SubstrateSigner } from '@acala-network/bodhi'; import { cryptoWaitReady } from '@polkadot/util-crypto'; -import { decodeEthGas } from '@acala-network/eth-providers'; +import { decodeEthGas, sleep } from '@acala-network/eth-providers'; import { options } from '@acala-network/api'; -import { parseUnits } from 'ethers/lib/utils'; -import { RelayerError } from '../middlewares'; +import { RelayerError } from './error'; import { bridgeToken, getSignedVAAFromSequence } from './wormhole'; +import { parseAmount } from './token'; +import { logger } from './logger'; export type ROUTER_CHAIN_ID = typeof CHAIN_ID_KARURA | typeof CHAIN_ID_ACALA; @@ -31,17 +32,6 @@ export const getApi = async (privateKey: string, nodeUrl: string) => { return { substrateAddr, api }; }; -export const parseAmount = async ( - tokenAddr: string, - amount: string, - provider: any, -): Promise => { - const erc20 = new Contract(tokenAddr, ['function decimals() view returns (uint8)'], provider); - const decimals = await erc20.decimals(); - - return parseUnits(amount, decimals); -}; - export const transferFromAvax = async ( amount: string, sourceAsset: string, @@ -187,3 +177,22 @@ export const getEthExtrinsic = async ( [], ); }; + +// TODO: ideally this only retries on certain errors, such as network issues +export const runWithRetry = async ( + fn: () => Promise, + { retry = 10, interval = 5 } = {}, +): Promise => { + let error: any; + for (let i = 0; i < retry; i++) { + try { + return await fn(); + } catch (err) { + error = err; + logger.info(`retrying ${fn.name} in ${interval}s [${i + 1}/${retry}]`); + await sleep(interval); + } + } + + throw error; +}; diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 2754447..650107c 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -42,6 +42,10 @@ export interface RelayAndRouteParams extends RouteParamsXcm { signedVAA: string; } +export interface routeStatusParams { + id: string; +} + export const routeXcmSchema: ObjectSchema = object({ originAddr: string().required(), destParaId: string().required(), @@ -72,3 +76,7 @@ export const routeEuphratesSchema: ObjectSchema = object({ recipient: string().required(), token: string(), }); + +export const routeStatusSchema: ObjectSchema = object({ + id: string().required(), +});