diff --git a/README.md b/README.md index f502f4d5..58fbde8d 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,14 @@ ibc-setup # should be available This is just mean for manual testing with the local CI chains. First get some keys: ```bash -ibc-setup init --src local_wasmd --dest local_simapp +ibc-setup init --src local_wasm --dest local_simapp ibc-setup keys list ``` -Then edit [testutils.spec.ts](./src/lib/testutils.spec.ts) under `'fund relayer'` and place your keys there. -Change it from `skip` to `only` and run it: +Then edit [manual/consts.ts](./src/lib/manual/consts.ts) and place your keys in those address variables. ```bash -yarn build && yarn test:unit ./src/lib/testutils.spec.ts +yarn build && yarn test:unit ./src/lib/manual/fund-relayer.spec.ts ``` Now you should see an updated balance, and can make an ics20 channel: @@ -72,13 +71,19 @@ ibc-setup balances ibc-setup ics20 --dest-port custom ``` -With that set up, let's start the relayer: +Now we have a channel, let's send some packets. Go back to [manual/consts.ts](./src/lib/manual/consts.ts) +place the proper channel ids from in the channels object. Make sure to place the channel that was listed +next to (custom) on the top part. Then run a task to generate packets: ```bash -ibc-relayer start +yarn build && yarn test:unit ./src/lib/manual/create-packets.spec.ts ``` -TODO: how to send some transfer packets to test it? Another testutils function??? +With a connection, channel, and packets, let's start the relayer: + +```bash +ibc-relayer start +``` ### Testing diff --git a/package.json b/package.json index 1d1075ed..bccb3cf7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:lint": "eslint src --ext .ts", "test:prettier": "prettier \"src/**/*.ts\" --list-different", "test:unit": "nyc --silent ava --serial", - "focused-test": "run-s build && yarn test:unit ./src/binary/ibc-setup/commands/connect.spec.ts", + "focused-test": "run-s build && yarn test:unit ./src/lib/link.spec.ts", "check-cli": "run-s test diff-integration-tests check-integration-tests", "check-integration-tests": "run-s check-integration-test:*", "diff-integration-tests": "mkdir -p diff && rm -rf diff/test && cp -r test diff/test && rm -rf diff/test/test-*/.git && cd diff && git init --quiet && git add -A && git commit --quiet --no-verify --allow-empty -m 'WIP' && echo '\\n\\nCommitted most recent integration test output in the \"diff\" directory. Review the changes with \"cd diff && git diff HEAD\" or your preferred git diff viewer.'", diff --git a/src/binary/ibc-relayer/commands/start.ts b/src/binary/ibc-relayer/commands/start.ts index f325c905..c64a4631 100644 --- a/src/binary/ibc-relayer/commands/start.ts +++ b/src/binary/ibc-relayer/commands/start.ts @@ -2,6 +2,7 @@ import path from 'path'; import { Logger } from 'winston'; +import { Link } from '../../../lib/link'; import { registryFile } from '../../constants'; import { createLogger, Level } from '../../create-logger'; import { LoggerFlags } from '../../types'; @@ -12,6 +13,7 @@ import { resolveRequiredOption } from '../../utils/options/resolve-required-opti import { resolveHomeOption } from '../../utils/options/shared/resolve-home-option'; import { resolveKeyFileOption } from '../../utils/options/shared/resolve-key-file-option'; import { resolveMnemonicOption } from '../../utils/options/shared/resolve-mnemonic-option'; +import { signingClient } from '../../utils/signing-client'; type Flags = { interactive: boolean; @@ -24,6 +26,15 @@ type Flags = { destConnection?: string; } & LoggerFlags; +// TODO: do we want to make this a flag? +type LoopOptions = { + runOnce: boolean; + // number of seconds old the client on chain A can be + maxAgeA: number; + // number of seconds old the client on chain B can be + maxAgeB: number; +}; + type Options = { home: string; src: string; @@ -31,7 +42,7 @@ type Options = { mnemonic: string; srcConnection: string; destConnection: string; -}; +} & LoopOptions; export async function start(flags: Flags) { const logLevel = resolveOption( @@ -81,12 +92,17 @@ export async function start(flags: Flags) { mnemonic, srcConnection, destConnection, + // TODO: make configurable + runOnce: true, + // once per day: 86400s + maxAgeA: 86400, + maxAgeB: 86400, }; - run(options, logger); + await run(options, logger); } -function run(options: Options, logger: Logger) { +async function run(options: Options, logger: Logger) { const registryFilePath = path.join(options.home, registryFile); const { chains } = loadAndValidateRegistry(registryFilePath); const srcChain = chains[options.src]; @@ -98,6 +114,30 @@ function run(options: Options, logger: Logger) { throw new Error('dest chain not found in registry'); } - console.log('ibc-relayer start with options:', options); - logger.info('logger is available'); + const nodeA = await signingClient(srcChain, options.mnemonic, logger); + const nodeB = await signingClient(destChain, options.mnemonic, logger); + const link = await Link.createWithExistingConnections( + nodeA, + nodeB, + options.srcConnection, + options.destConnection, + logger + ); + + await relayerLoop(link, options); +} + +async function relayerLoop(link: Link, options: LoopOptions) { + if (!options.runOnce) { + throw new Error('Loop is not supported yet, try runOnce = true'); + } + + // TODO: fill this in with real data (how far back do we start querying... where do we store state?) + let nextRelay = {}; + nextRelay = await link.checkAndRelayPacketsAndAcks(nextRelay); + console.log(nextRelay); + + // ensure the headers are up to date (only submits if old and we didn't just update them above) + await link.updateClientIfStale('A', options.maxAgeB); + await link.updateClientIfStale('B', options.maxAgeA); } diff --git a/src/binary/ibc-relayer/index.ts b/src/binary/ibc-relayer/index.ts index d107caa1..71815dbe 100644 --- a/src/binary/ibc-relayer/index.ts +++ b/src/binary/ibc-relayer/index.ts @@ -10,6 +10,7 @@ import { mnemonicOption, srcOption, } from '../commander-options'; +import { rootBoundary } from '../utils/error-boundary'; import { start } from './commands/start'; @@ -28,7 +29,6 @@ program .addOption(mnemonicOption) .option('--src-connection ') .option('--dest-connection ') - .addOption( new Option('--log-level ').choices([ 'debug', @@ -40,7 +40,6 @@ program ) .option('-v, --verbose') .option('-q, --quiet') - - .action(start); + .action(rootBoundary(start)); program.parse(process.argv); diff --git a/src/binary/types.ts b/src/binary/types.ts index 6e1b574f..31719229 100644 --- a/src/binary/types.ts +++ b/src/binary/types.ts @@ -1,3 +1,5 @@ +import { GasPrice } from '@cosmjs/launchpad'; + export type Chain = { chain_id: string; prefix: string; @@ -25,3 +27,7 @@ export type LoggerFlags = { verbose: boolean; quiet: boolean; }; + +export function feeDenom(chain: Chain): string { + return GasPrice.fromString(chain.gas_price).denom; +} diff --git a/src/binary/utils/error-boundary.ts b/src/binary/utils/error-boundary.ts new file mode 100644 index 00000000..06fca730 --- /dev/null +++ b/src/binary/utils/error-boundary.ts @@ -0,0 +1,31 @@ +import { Logger } from '../../lib/logger'; + +// This is meant to be used around top-level command. +// If you have access to a logger, please use errorBoundary +export function rootBoundary( + command: (flags: T) => Promise +): (flags: T) => Promise { + return async function (flags: T) { + try { + await command(flags); + } catch (e) { + // TODO: should we format this somehow? + console.error(e); + process.exit(1); + } + }; +} + +// FIXME: figure this one out better once we have loggers +export async function errorBoundary( + logger: Logger, + command: () => Promise +) { + try { + await command(); + } catch (e) { + // TODO: should we format this somehow? + logger.error(e); + process.exit(1); + } +} diff --git a/src/binary/utils/signing-client.ts b/src/binary/utils/signing-client.ts new file mode 100644 index 00000000..e63b1c24 --- /dev/null +++ b/src/binary/utils/signing-client.ts @@ -0,0 +1,33 @@ +import { stringToPath } from '@cosmjs/crypto'; +import { GasPrice } from '@cosmjs/launchpad'; +import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; + +import { IbcClient, IbcClientOptions } from '../../lib/ibcclient'; +import { Logger } from '../../lib/logger'; +import { Chain } from '../types'; + +export async function signingClient( + chain: Chain, + mnemonic: string, + logger?: Logger +): Promise { + const hdPath = chain.hd_path ? stringToPath(chain.hd_path) : undefined; + const signer = await DirectSecp256k1HdWallet.fromMnemonic( + mnemonic, + hdPath, + chain.prefix + ); + const { address } = (await signer.getAccounts())[0]; + const options: IbcClientOptions = { + prefix: chain.prefix, + gasPrice: GasPrice.fromString(chain.gas_price), + logger, + }; + const client = await IbcClient.connectWithSigner( + chain.rpc[0], + signer, + address, + options + ); + return client; +} diff --git a/src/lib/endpoint.spec.ts b/src/lib/endpoint.spec.ts index 48b1370b..cfa72907 100644 --- a/src/lib/endpoint.spec.ts +++ b/src/lib/endpoint.spec.ts @@ -1,7 +1,7 @@ import test from 'ava'; import { Link } from './link'; -import { ics20, randomAddress, setup, simapp, wasmd } from './testutils.spec'; +import { ics20, randomAddress, setup, simapp, wasmd } from './testutils'; import { parseAcksFromLogs } from './utils'; test.serial('submit multiple tx, query all packets', async (t) => { diff --git a/src/lib/endpoint.ts b/src/lib/endpoint.ts index 3d666932..d041436e 100644 --- a/src/lib/endpoint.ts +++ b/src/lib/endpoint.ts @@ -46,15 +46,15 @@ export class Endpoint { this.connectionID = connectionID; } - public async chainId(): Promise { - return this.client.getChainId(); + public chainId(): string { + return this.client.chainId; } public async getLatestCommit(): Promise { return this.client.getCommit(); } - // TODO: return info for pagination, accept arg + // returns all packets (auto-paginates, so be careful about not setting a minHeight) public async querySentPackets({ minHeight, maxHeight, @@ -67,8 +67,7 @@ export class Endpoint { query = `${query} AND tx.height<=${maxHeight}`; } - // TODO: txSearchAll or do we paginate? - const search = await this.client.tm.txSearch({ query }); + const search = await this.client.tm.txSearchAll({ query }); const resultsNested = search.txs.map(({ height, result }) => { const parsedLogs = parseRawLog(result.log); const sender = logs.findAttribute(parsedLogs, 'message', 'sender').value; @@ -81,7 +80,7 @@ export class Endpoint { return ([] as PacketWithMetadata[]).concat(...resultsNested); } - // TODO: return info for pagination, accept arg + // returns all acks (auto-paginates, so be careful about not setting a minHeight) public async queryWrittenAcks({ minHeight, maxHeight, @@ -94,8 +93,7 @@ export class Endpoint { query = `${query} AND tx.height<=${maxHeight}`; } - // TODO: txSearchAll or do we paginate? - const search = await this.client.tm.txSearch({ query }); + const search = await this.client.tm.txSearchAll({ query }); const resultsNested = search.txs.map(({ height, result }) => { const parsedLogs = parseRawLog(result.log); // const sender = logs.findAttribute(parsedLogs, 'message', 'sender').value; @@ -106,9 +104,6 @@ export class Endpoint { }); return ([] as AckWithMetadata[]).concat(...resultsNested); } - - // TODO: subscription based packets/acks? - // until then, poll every X seconds } /** diff --git a/src/lib/ibcclient.spec.ts b/src/lib/ibcclient.spec.ts index bfcf931f..09e43213 100644 --- a/src/lib/ibcclient.spec.ts +++ b/src/lib/ibcclient.spec.ts @@ -12,7 +12,7 @@ import { simapp, TestLogger, wasmd, -} from './testutils.spec'; +} from './testutils'; import { buildClientState, buildConsensusState, diff --git a/src/lib/ibcclient.ts b/src/lib/ibcclient.ts index f8129577..d467384b 100644 --- a/src/lib/ibcclient.ts +++ b/src/lib/ibcclient.ts @@ -304,6 +304,13 @@ export class IbcClient { return this.sign.getChainId(); } + public async header(height: number): Promise { + this.logger.verbose(`Get header for height ${height}`); + // TODO: expose header method on tmClient and use that + const resp = await this.tm.blockchain(height, height); + return resp.blockMetas[0].header; + } + public async latestHeader(): Promise { this.logger.verbose('Get latest header'); // TODO: expose header method on tmClient and use that @@ -311,11 +318,9 @@ export class IbcClient { return block.block.header; } - public async header(height: number): Promise { - this.logger.verbose(`Get header for height ${height}`); - // TODO: expose header method on tmClient and use that - const resp = await this.tm.blockchain(height, height); - return resp.blockMetas[0].header; + public async currentHeight(): Promise { + const status = await this.tm.status(); + return status.syncInfo.latestBlockHeight; } public async waitOneBlock(): Promise { diff --git a/src/lib/link.spec.ts b/src/lib/link.spec.ts index 38f34788..47d4a06e 100644 --- a/src/lib/link.spec.ts +++ b/src/lib/link.spec.ts @@ -1,18 +1,18 @@ -import { sleep } from '@cosmjs/utils'; +import { assert, sleep } from '@cosmjs/utils'; import test from 'ava'; import { State } from '../codec/ibc/core/channel/v1/channel'; import { prepareChannelHandshake } from './ibcclient'; -import { Link } from './link'; +import { Link, RelayedHeights } from './link'; import { ics20, - randomAddress, setup, simapp, TestLogger, + transferTokens, wasmd, -} from './testutils.spec'; +} from './testutils'; test.serial('establish new client-connection', async (t) => { const logger = new TestLogger(); @@ -289,25 +289,16 @@ test.serial('submit multiple tx, get unreceived packets', async (t) => { const noPackets = await link.endA.querySentPackets(); t.is(noPackets.length, 0); - // some basic setup for the transfers - const recipient = randomAddress(wasmd.prefix); - const destHeight = await nodeB.timeoutHeight(500); // valid for 500 blocks - const amounts = [1000, 2222, 3456]; - // const totalSent = amounts.reduce((a, b) => a + b, 0); - // let's make 3 transfer tx at different heights - const txHeights = []; - for (const amount of amounts) { - const token = { amount: amount.toString(), denom: simapp.denomFee }; - const { height } = await nodeA.transferTokens( - channels.src.portId, - channels.src.channelId, - token, - recipient, - destHeight - ); - txHeights.push(height); - } + const amounts = [1000, 2222, 3456]; + const txHeights = await transferTokens( + nodeA, + simapp.denomFee, + nodeB, + wasmd.prefix, + channels.src, + amounts + ); // ensure these are different t.assert(txHeights[1] > txHeights[0], txHeights.toString()); t.assert(txHeights[2] > txHeights[1], txHeights.toString()); @@ -385,51 +376,34 @@ test.serial( const noPackets = await link.endA.querySentPackets(); t.is(noPackets.length, 0); - // some basic setup for the transfers - const recipient = randomAddress(wasmd.prefix); - const destHeight = await nodeB.timeoutHeight(500); // valid for 500 blocks - const amounts = [1000, 2222, 3456]; - // const totalSent = amounts.reduce((a, b) => a + b, 0); - // let's make 3 transfer tx at different heights on each channel pair - interface Meta { - height: number; - channelId: string; - } + const amounts = [1000, 2222, 3456]; + const tx1 = await transferTokens( + nodeA, + simapp.denomFee, + nodeB, + wasmd.prefix, + channels1.src, + amounts + ); + const tx2 = await transferTokens( + nodeA, + simapp.denomFee, + nodeB, + wasmd.prefix, + channels2.src, + amounts + ); const txHeights = { - channels1: [] as Meta[], - channels2: [] as Meta[], + channels1: tx1.map((height) => ({ + height, + channelId: channels1.src.channelId, + })), + channels2: tx2.map((height) => ({ + height, + channelId: channels2.src.channelId, + })), }; - - for (const amount of amounts) { - const token = { - amount: amount.toString(), - denom: simapp.denomFee, - }; - const { height } = await nodeA.transferTokens( - channels1.src.portId, - channels1.src.channelId, - token, - recipient, - destHeight - ); - txHeights.channels1.push({ height, channelId: channels1.src.channelId }); - } - for (const amount of amounts) { - const token = { - amount: amount.toString(), - denom: simapp.denomFee, - }; - const { height } = await nodeA.transferTokens( - channels2.src.portId, - channels2.src.channelId, - token, - recipient, - destHeight - ); - txHeights.channels2.push({ height, channelId: channels2.src.channelId }); - } - // need to wait briefly for it to be indexed await sleep(100); @@ -484,3 +458,130 @@ test.serial( t.deepEqual(postAcks[1], acks[2]); } ); + +test.serial( + 'updateClientIfStale only runs if it is too long since an update', + async (t) => { + // setup + const logger = new TestLogger(); + const [nodeA, nodeB] = await setup(logger); + const link = await Link.createWithNewConnections(nodeA, nodeB, logger); + + // height before waiting + const heightA = (await nodeA.latestHeader()).height; + const heightB = (await nodeB.latestHeader()).height; + + // wait a few seconds so we can get stale ones + await sleep(3000); + + // we definitely have updated within the last 1000 seconds, this should do nothing + const noUpdateA = await link.updateClientIfStale('A', 1000); + t.is(noUpdateA, null); + const noUpdateB = await link.updateClientIfStale('B', 1000); + t.is(noUpdateB, null); + + // we haven't updated in the last 2 seconds, this should trigger the update + const updateA = await link.updateClientIfStale('A', 2); + assert(updateA); + t.assert(updateA.revisionHeight.toNumber() > heightA); + const updateB = await link.updateClientIfStale('B', 2); + assert(updateB); + t.assert(updateB.revisionHeight.toNumber() > heightB); + } +); + +test.serial( + 'checkAndRelayPacketsAndAcks relays packets properly', + async (t) => { + // setup a channel + const [nodeA, nodeB] = await setup(); + const link = await Link.createWithNewConnections(nodeA, nodeB); + const channels = await link.createChannel( + 'A', + ics20.srcPortId, + ics20.destPortId, + ics20.ordering, + ics20.version + ); + + const checkPending = async ( + packA: number, + packB: number, + ackA: number, + ackB: number + ) => { + const packetsA = await link.getPendingPackets('A'); + t.is(packetsA.length, packA); + const packetsB = await link.getPendingPackets('B'); + t.is(packetsB.length, packB); + + const acksA = await link.getPendingAcks('A'); + t.is(acksA.length, ackA); + const acksB = await link.getPendingAcks('B'); + t.is(acksB.length, ackB); + }; + + // no packets here + await checkPending(0, 0, 0, 0); + + // ensure no problems running relayer with no packets + await link.checkAndRelayPacketsAndAcks({}); + + // send 3 from A -> B + const amountsA = [1000, 2222, 3456]; + const txHeightsA = await transferTokens( + nodeA, + simapp.denomFee, + nodeB, + wasmd.prefix, + channels.src, + amountsA + ); + // send 2 from B -> A + const amountsB = [76543, 12345]; + const txHeightsB = await transferTokens( + nodeB, + wasmd.denomFee, + nodeA, + simapp.prefix, + channels.dest, + amountsB + ); + + // ensure these packets are present in query + await checkPending(3, 2, 0, 0); + + // let's one on each side (should filter only the last == minHeight) + const relayFrom: RelayedHeights = { + packetHeightA: txHeightsA[2], + packetHeightB: txHeightsB[1], + }; + // check the result here and ensure it is after the latest height + const nextRelay = await link.checkAndRelayPacketsAndAcks(relayFrom); + + // next acket is more recent than the transactions + assert(nextRelay.packetHeightA); + t.assert(nextRelay.packetHeightA > txHeightsA[2]); + assert(nextRelay.packetHeightB); + // since we don't wait a block after this transfer, it may be the same + t.assert(nextRelay.packetHeightB >= txHeightsB[1]); + // next ack queries is more recent than the packet queries + assert(nextRelay.ackHeightA); + t.assert(nextRelay.ackHeightA > nextRelay.packetHeightA); + assert(nextRelay.ackHeightB); + t.assert(nextRelay.ackHeightB > nextRelay.packetHeightB); + + // ensure those packets were sent, and their acks as well + await checkPending(2, 1, 0, 0); + + // if we send again with the return of this last relay, we don't get anything new + await link.checkAndRelayPacketsAndAcks(nextRelay); + await checkPending(2, 1, 0, 0); + + // sent the remaining packets (no minimum) + await link.checkAndRelayPacketsAndAcks({}); + + // ensure those packets were sent, and their acks as well + await checkPending(0, 0, 0, 0); + } +); diff --git a/src/lib/link.ts b/src/lib/link.ts index 4d3dc8b5..48266674 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -17,7 +17,11 @@ import { prepareConnectionHandshake, } from './ibcclient'; import { Logger, NoopLogger } from './logger'; -import { parseAcksFromLogs, toIntHeight } from './utils'; +import { + parseAcksFromLogs, + timestampFromDateNanos, + toIntHeight, +} from './utils'; /** * Many actions on link focus on a src and a dest. Rather than add two functions, @@ -33,6 +37,15 @@ export function otherSide(side: Side): Side { } } +// This records the block heights from the last point where we successfully relayed packets. +// This can be used to optimize the next round of relaying +export interface RelayedHeights { + packetHeightA?: number; + packetHeightB?: number; + ackHeightA?: number; + ackHeightB?: number; +} + // measured in seconds // Note: client parameter is checked against the actual keeper - must use real values from genesis.json // TODO: make this more adaptable for chains (query from staking?) @@ -50,6 +63,25 @@ export class Link { public readonly endB: Endpoint; public readonly logger: Logger; + private readonly chainA: string; + private readonly chainB: string; + + private chain(side: Side): string { + if (side === 'A') { + return this.chainA; + } else { + return this.chainB; + } + } + + private otherChain(side: Side): string { + if (side === 'A') { + return this.chainB; + } else { + return this.chainA; + } + } + /** * findConnection attempts to reuse an existing Client/Connection. * If none exists, then it returns an error. @@ -64,6 +96,8 @@ export class Link { connB: string, logger?: Logger ): Promise { + const [chainA, chainB] = [nodeA.chainId, nodeB.chainId]; + const [ { connection: connectionA }, { connection: connectionB }, @@ -72,26 +106,30 @@ export class Link { nodeB.query.ibc.connection.connection(connB), ]); if (!connectionA) { - throw new Error(`Connection not found for ID ${connA}`); + throw new Error(`[${chainA}] Connection not found for ID ${connA}`); } if (!connectionB) { - throw new Error(`Connection not found for ID ${connB}`); + throw new Error(`[${chainB}] Connection not found for ID ${connB}`); } if (!connectionA.counterparty) { - throw new Error(`Counterparty not found for connection with ID ${connA}`); + throw new Error( + `[${chainA}] Counterparty not found for connection with ID ${connA}` + ); } if (!connectionB.counterparty) { - throw new Error(`Counterparty not found for connection with ID ${connB}`); + throw new Error( + `[${chainB}] Counterparty not found for connection with ID ${connB}` + ); } // ensure the connection is open if (connectionA.state != State.STATE_OPEN) { throw new Error( - `Connection A must be in state open, it has state ${connectionA.state}` + `Connection on ${chainA} must be in state open, it has state ${connectionA.state}` ); } if (connectionB.state != State.STATE_OPEN) { throw new Error( - `Connection B must be in state open, it has state ${connectionB.state}` + `Connection on ${chainB} must be in state open, it has state ${connectionB.state}` ); } @@ -106,20 +144,18 @@ export class Link { `Client ID ${connectionB.clientId} for connection with ID ${connB} does not match counterparty client ID ${connectionA.counterparty.clientId} for connection with ID ${connA}` ); } - const [chainIdA, chainIdB, clientStateA, clientStateB] = await Promise.all([ - nodeA.getChainId(), - nodeB.getChainId(), + const [clientStateA, clientStateB] = await Promise.all([ nodeA.query.ibc.client.stateTm(clientIdA), nodeB.query.ibc.client.stateTm(clientIdB), ]); - if (chainIdA !== clientStateB.chainId) { + if (nodeA.chainId !== clientStateB.chainId) { throw new Error( - `Chain ID ${chainIdA} for connection with ID ${connA} does not match remote chain ID ${clientStateA.chainId}` + `Chain ID ${nodeA.chainId} for connection with ID ${connA} does not match remote chain ID ${clientStateA.chainId}` ); } - if (chainIdB !== clientStateA.chainId) { + if (nodeB.chainId !== clientStateA.chainId) { throw new Error( - `Chain ID ${chainIdB} for connection with ID ${connB} does not match remote chain ID ${clientStateB.chainId}` + `Chain ID ${nodeB.chainId} for connection with ID ${connB} does not match remote chain ID ${clientStateB.chainId}` ); } @@ -195,6 +231,9 @@ export class Link { ): Promise { const [clientIdA, clientIdB] = await createClients(nodeA, nodeB); + // wait a block to ensure we have proper proofs for creating a connection (this has failed on CI before) + await Promise.all([nodeA.waitOneBlock(), nodeB.waitOneBlock()]); + // connectionInit on nodeA const { connectionId: connIdA } = await nodeA.connOpenInit( clientIdA, @@ -237,11 +276,13 @@ export class Link { } // you can use this if you already have the info out of bounds - // TODO; check the validity of that data? + // FIXME: check the validity of that data? public constructor(endA: Endpoint, endB: Endpoint, logger?: Logger) { this.endA = endA; this.endB = endB; this.logger = logger ?? new NoopLogger(); + this.chainA = endA.client.chainId; + this.chainB = endB.client.chainId; } /** @@ -254,23 +295,65 @@ export class Link { * Just needs trusting period on both side */ public async updateClient(sender: Side): Promise { - this.logger.info(`Update client for sender ${sender}`); + this.logger.info(`Update Client on ${this.otherChain(sender)}`); const { src, dest } = this.getEnds(sender); const height = await dest.client.doUpdateClient(dest.clientID, src.client); return height; } - // Ensures the dest has a proof of at least minHeight from source. - // Will not execute any tx if not needed. - // Will wait a block if needed until the header is available. - // - // Returns the latest header now available on dest + /** + * Checks if the last proven header on the destination is older than maxAge, + * and if so, update the client. Returns the new client height if updated, + * or null if no update needed + * + * @param sender + * @param maxAge + */ + public async updateClientIfStale( + sender: Side, + maxAge: number + ): Promise { + this.logger.info( + `Checking if ${this.otherChain(sender)} has recent header of ${this.chain( + sender + )}` + ); + const { src, dest } = this.getEnds(sender); + const knownHeader = await dest.client.query.ibc.client.consensusStateTm( + dest.clientID + ); + const currentHeader = await src.client.latestHeader(); + + // quit now if we don't need to update + const knownSeconds = knownHeader.timestamp?.seconds?.toNumber(); + if (knownSeconds) { + const curSeconds = timestampFromDateNanos( + currentHeader.time + ).seconds.toNumber(); + if (curSeconds - knownSeconds < maxAge) { + return null; + } + } + + // otherwise, do the update + return this.updateClient(sender); + } + + /** + * Ensures the dest has a proof of at least minHeight from source. + * Will not execute any tx if not needed. + * Will wait a block if needed until the header is available. + * + * Returns the latest header height now available on dest + */ public async updateClientToHeight( source: Side, minHeight: number ): Promise { this.logger.info( - `Check whether client for source ${source} >= height ${minHeight}` + `Check whether client on ${this.otherChain( + source + )} >= height ${minHeight}` ); const { src, dest } = this.getEnds(source); const client = await dest.client.query.ibc.client.stateTm(dest.clientID); @@ -295,7 +378,9 @@ export class Link { version: string ): Promise { this.logger.info( - `Create channel with sender ${sender}: ${srcPort} => ${destPort}` + `Create channel with sender ${this.chain( + sender + )}: ${srcPort} => ${destPort}` ); const { src, dest } = this.getEnds(sender); // init on src @@ -364,11 +449,86 @@ export class Link { }; } + /** + * This will check both sides for pending packets and relay them. + * It will then relay all acks (previous and generated by the just-submitted packets). + * + * Returns the most recent heights it relay, which can be used as a start for the next round + * + * TODO: support handling timeouts once https://github.com/confio/ts-relayer/pull/90 is merged + */ + public async checkAndRelayPacketsAndAcks( + relayFrom: RelayedHeights + ): Promise { + // FIXME: is there a cleaner way to get the height we queries at? + const [ + packetHeightA, + packetHeightB, + packetsA, + packetsB, + ] = await Promise.all([ + this.endA.client.currentHeight(), + this.endB.client.currentHeight(), + this.getPendingPackets('A', { minHeight: relayFrom.packetHeightA }), + this.getPendingPackets('B', { minHeight: relayFrom.packetHeightB }), + ]); + + if (packetsA.length > 0) { + this.logger.info( + `Relaying ${packetsA.length} packets from ${this.chainA} => ${this.chainB}` + ); + } + if (packetsB.length > 0) { + this.logger.info( + `Relaying ${packetsB.length} packets from ${this.chainB} => ${this.chainA}` + ); + } + + // FIXME: use these acks first? Then query for others? + await Promise.all([ + this.relayPackets('A', packetsA), + this.relayPackets('B', packetsB), + ]); + + // let's wait a bit to ensure our newly committed items are indexed + await this.endA.client.waitOneBlock(); + + const [ackHeightA, ackHeightB, acksA, acksB] = await Promise.all([ + this.endA.client.currentHeight(), + this.endB.client.currentHeight(), + this.getPendingAcks('A', { minHeight: relayFrom.ackHeightA }), + this.getPendingAcks('B', { minHeight: relayFrom.ackHeightB }), + ]); + + if (acksA.length > 0) { + this.logger.info( + `Relaying ${acksA.length} acks from ${this.chainA} => ${this.chainB}` + ); + } + if (acksB.length > 0) { + this.logger.info( + `Relaying ${acksB.length} acks from ${this.chainB} => ${this.chainA}` + ); + } + + await Promise.all([this.relayAcks('A', acksA), this.relayAcks('B', acksB)]); + + const nextRelay = { + packetHeightA, + packetHeightB, + ackHeightA, + ackHeightB, + }; + this.logger.verbose('next heights to relay', nextRelay); + + return nextRelay; + } + public async getPendingPackets( source: Side, opts: QueryOpts = {} ): Promise { - this.logger.verbose(`Get pending packets for source ${source}`); + this.logger.verbose(`Get pending packets on ${this.chain(source)}`); const { src, dest } = this.getEnds(source); const allPackets = await src.querySentPackets(opts); @@ -396,7 +556,7 @@ export class Link { source: Side, opts: QueryOpts = {} ): Promise { - this.logger.verbose(`Get pending acks for source ${source}`); + this.logger.verbose(`Get pending acks on ${this.chain(source)}`); const { src, dest } = this.getEnds(source); const allAcks = await src.queryWrittenAcks(opts); @@ -466,7 +626,7 @@ export class Link { // Returns the last height that this side knows of the other blockchain public async lastKnownHeader(side: Side): Promise { - this.logger.verbose(`Get last known header for side ${side}`); + this.logger.verbose(`Get last known header on ${this.chain(side)}`); const { src } = this.getEnds(side); const client = await src.client.query.ibc.client.stateTm(src.clientID); return client.latestHeight?.revisionHeight?.toNumber() ?? 0; @@ -480,7 +640,14 @@ export class Link { source: Side, packets: readonly PacketWithMetadata[] ): Promise { - this.logger.info(`Relay packets for source ${source}`); + if (packets.length === 0) { + return []; + } + this.logger.info( + `Relay ${packets.length} packets from ${this.chain( + source + )} => ${this.otherChain(source)}` + ); const { src, dest } = this.getEnds(source); // check if we need to update client at all @@ -504,12 +671,20 @@ export class Link { // (yes, dest is where the packet was sent, but the ack was written on src). // if acks are all older than the last consensusHeight, then we don't update the client. // - // Returns the block height the acks were included in + // Returns the block height the acks were included in, or null if no acks sent public async relayAcks( source: Side, acks: readonly AckWithMetadata[] - ): Promise { - this.logger.info(`Relay acks for source ${source}`); + ): Promise { + if (acks.length === 0) { + return null; + } + + this.logger.info( + `Relay ${acks.length} acks from ${this.chain( + source + )} => ${this.otherChain(source)}` + ); const { src, dest } = this.getEnds(source); // check if we need to update client at all @@ -548,12 +723,12 @@ const packetId = (packet: Packet) => const ackId = (packet: Packet) => `${packet.sourcePort}${idDelim}${packet.sourceChannel}`; -interface EndpointPair { +export interface EndpointPair { readonly src: Endpoint; readonly dest: Endpoint; } -interface ChannelPair { +export interface ChannelPair { readonly src: ChannelInfo; readonly dest: ChannelInfo; } diff --git a/src/lib/manual/consts.ts b/src/lib/manual/consts.ts new file mode 100644 index 00000000..d3adb037 --- /dev/null +++ b/src/lib/manual/consts.ts @@ -0,0 +1,20 @@ +import { ChannelPair } from '../link'; +import { ics20 } from '../testutils'; + +// TODO: use env vars +// copy these values from `ibc-setup keys list` +export const simappAddress = 'cosmos1th0wrczcl2zatnku20zdmmctmdrwh22t89r4s0'; +export const wasmdAddress = 'wasm1x8ztrc7zqj2t5jvtyr6ncv7fwp62z2y22alpwu'; + +// TODO: use env vars +// we assume src is simapp for all these tests +export const channels: ChannelPair = { + src: { + channelId: 'channel-17', + portId: ics20.srcPortId, // custom + }, + dest: { + channelId: 'channel-15', + portId: ics20.destPortId, // transfer + }, +}; diff --git a/src/lib/manual/create-packets.spec.ts b/src/lib/manual/create-packets.spec.ts new file mode 100644 index 00000000..98d4d983 --- /dev/null +++ b/src/lib/manual/create-packets.spec.ts @@ -0,0 +1,47 @@ +/* +This file is designed to be run to fund accounts and send packets when manually +testing ibc-setup and ibc-relayer on localhost. + +Please configure the global variables to match the accounts displayed by +`ibc-setup keys list` before running. + +Execute via: + +yarn build && yarn test:unit ./src/lib/manual/create-packets.spec.ts +*/ + +import test from 'ava'; + +import { setup, simapp, TestLogger, transferTokens, wasmd } from '../testutils'; + +import { channels } from './consts'; + +test.serial.skip('send valid packets on existing channel', async (t) => { + // create the basic clients + const logger = new TestLogger(); + const [src, dest] = await setup(logger); + + // send some from src to dest + const srcAmounts = [1200, 32222, 3456]; + const srcPackets = await transferTokens( + src, + simapp.denomFee, + dest, + wasmd.prefix, + channels.src, + srcAmounts + ); + t.is(srcAmounts.length, srcPackets.length); + + // send some from dest to src + const destAmounts = [426238, 321989]; + const destPackets = await transferTokens( + dest, + wasmd.denomFee, + src, + simapp.prefix, + channels.dest, + destAmounts + ); + t.is(destAmounts.length, destPackets.length); +}); diff --git a/src/lib/manual/fund-relayer.spec.ts b/src/lib/manual/fund-relayer.spec.ts new file mode 100644 index 00000000..04439bb0 --- /dev/null +++ b/src/lib/manual/fund-relayer.spec.ts @@ -0,0 +1,25 @@ +/* +This file is designed to be run to fund accounts and send packets when manually +testing ibc-setup and ibc-relayer on localhost. + +Please configure the global variables to match the accounts displayed by +`ibc-setup keys list` before running. + +Execute via: + +yarn build && yarn test:unit ./src/lib/manual/fund-relayer.spec.ts +*/ + +import test from 'ava'; + +import { fundAccount, simapp, wasmd } from '../testutils'; + +import { simappAddress, wasmdAddress } from './consts'; + +test.serial('fund relayer', async (t) => { + await fundAccount(simapp, simappAddress, '50000000'); + await fundAccount(wasmd, wasmdAddress, '50000000'); + + // to make ava happy + t.is(1, 1); +}); diff --git a/src/lib/testutils.spec.ts b/src/lib/testutils.ts similarity index 69% rename from src/lib/testutils.spec.ts rename to src/lib/testutils.ts index 512af6b3..6af027b0 100644 --- a/src/lib/testutils.spec.ts +++ b/src/lib/testutils.ts @@ -4,12 +4,11 @@ import { Bech32 } from '@cosmjs/encoding'; import { Decimal } from '@cosmjs/math'; import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; import { StargateClient } from '@cosmjs/stargate'; -import test from 'ava'; import sinon, { SinonSpy } from 'sinon'; import { Order } from '../codec/ibc/core/channel/v1/channel'; -import { IbcClient, IbcClientOptions } from './ibcclient'; +import { ChannelInfo, IbcClient, IbcClientOptions } from './ibcclient'; import { Logger, LogMethod } from './logger'; export class TestLogger implements Logger { @@ -108,7 +107,7 @@ export const ics20 = { ordering: Order.ORDER_UNORDERED, }; -interface SigningOpts { +export interface SigningOpts { readonly tendermintUrlHttp: string; readonly prefix: string; readonly denomFee: string; @@ -161,8 +160,8 @@ export async function setup(logger?: Logger): Promise { const mnemonic = generateMnemonic(); const src = await signingClient(simapp, mnemonic, logger); const dest = await signingClient(wasmd, mnemonic, logger); - await fundAccount(wasmd, dest.senderAddress, '400000'); - await fundAccount(simapp, src.senderAddress, '400000'); + await fundAccount(wasmd, dest.senderAddress, '4000000'); + await fundAccount(simapp, src.senderAddress, '4000000'); return [src, dest]; } @@ -188,68 +187,35 @@ export function randomAddress(prefix: string): string { return Bech32.encode(prefix, random); } -test('query account balance - simapp', async (t) => { - const client = await queryClient(simapp); - const account = await client.getAllBalancesUnverified(simapp.unused.address); - t.is(account.length, 2); - t.deepEqual(account[0], { amount: '1000000000', denom: simapp.denomFee }); - t.deepEqual(account[1], { amount: '10000000', denom: simapp.denomStaking }); -}); - -test('query account balance - wasmd', async (t) => { - const client = await queryClient(wasmd); - const account = await client.getAllBalancesUnverified(wasmd.unused.address); - t.is(account.length, 2); - t.deepEqual(account[0], { amount: '1000000000', denom: wasmd.denomFee }); - t.deepEqual(account[1], { amount: '1000000000', denom: wasmd.denomStaking }); -}); - -test.serial('send initial funds - simapp', async (t) => { - const client = await queryClient(simapp); - const newbie = randomAddress(simapp.prefix); - - // account empty at start - let account = await client.getAllBalancesUnverified(newbie); - t.deepEqual(account, []); - - // let's send some tokens - await fundAccount(simapp, newbie, '500'); - - // account has tokens - account = await client.getAllBalancesUnverified(newbie); - t.is(account.length, 1); - t.deepEqual(account[0], { amount: '500', denom: simapp.denomFee }); -}); - -test.serial('send initial funds - wasmd', async (t) => { - const client = await queryClient(wasmd); - const newbie = randomAddress(wasmd.prefix); - - // account empty at start - let account = await client.getAllBalancesUnverified(newbie); - t.deepEqual(account, []); - - // let's send some tokens - await fundAccount(wasmd, newbie, '500'); - - // account has tokens - account = await client.getAllBalancesUnverified(newbie); - t.is(account.length, 1); - t.deepEqual(account[0], { amount: '500', denom: wasmd.denomFee }); -}); - -test.serial.skip('fund relayer', async (t) => { - // copy these values from `ibc-setup keys list` - await fundAccount( - wasmd, - 'wasm1090w503askudf40zzkkaj45dax98mdjym7p32e', - '50000000' - ); - await fundAccount( - simapp, - 'cosmos1t4p6yt2r9rcwfesj0feyu9x3ywhlvyww0azh0a', - '50000000' - ); - // to make ava happy - t.is(1, 1); -}); +// Makes multiple transfers, one per item in amounts. +// Return a list of the block heights the packets were committed in. +export async function transferTokens( + src: IbcClient, + srcDenom: string, + dest: IbcClient, + destPrefix: string, + channel: ChannelInfo, + amounts: number[], + timeout?: number +): Promise { + const txHeights: number[] = []; + const destRcpt = randomAddress(destPrefix); + const destHeight = await dest.timeoutHeight(timeout ?? 500); // valid for 500 blocks or timeout if specified + + for (const amount of amounts) { + const token = { + amount: amount.toString(), + denom: srcDenom, + }; + const { height } = await src.transferTokens( + channel.portId, + channel.channelId, + token, + destRcpt, + destHeight + ); + txHeights.push(height); + } + + return txHeights; +}