From 6e1fb1a37cebb0f3b33ab3f069656f618cb40295 Mon Sep 17 00:00:00 2001 From: Janislav Date: Wed, 6 Nov 2024 12:38:48 +0100 Subject: [PATCH] tests: Bouncer test for broker level screening (#5377) * tests: Added first draft of bouncer test * chore: added current work in progress * chore: added current status * chore: restored * chore: added current status * chore: added current state * tests: integrated broker level screening test * chore: Tidy up * chore: eslint / prettier * chore: added some comments + smallish refactors * chore: parse unknown to string type * chore: Make test verification more explicit * chore: addressed comments * chore: removed from runAllConcurrentTests * feature: added mode to run some tests concurrent in localnet * chore: Fixed var name --- bouncer/commands/run_test.ts | 2 + bouncer/shared/send_btc.ts | 18 +- .../test_commands/broker_level_screening.ts | 4 + bouncer/tests/all_concurrent_tests.ts | 9 + bouncer/tests/broker_level_screening.ts | 207 ++++++++++++++++++ 5 files changed, 235 insertions(+), 5 deletions(-) create mode 100755 bouncer/test_commands/broker_level_screening.ts create mode 100644 bouncer/tests/broker_level_screening.ts diff --git a/bouncer/commands/run_test.ts b/bouncer/commands/run_test.ts index fe73e8618d..c61f82f0bf 100755 --- a/bouncer/commands/run_test.ts +++ b/bouncer/commands/run_test.ts @@ -40,6 +40,7 @@ import { testDeltaBasedIngress } from '../tests/delta_based_ingress'; import { testCancelOrdersBatch } from '../tests/create_and_delete_multiple_orders'; import { depositChannelCreation } from '../tests/request_swap_deposit_address_with_affiliates'; import { testDCASwaps } from '../tests/DCA_test'; +import { testBrokerLevelScreening } from '../tests/broker_level_screening'; async function main() { const testName = process.argv[2]; @@ -67,6 +68,7 @@ async function main() { testDeltaBasedIngress, testCancelOrdersBatch, depositChannelCreation, + testBrokerLevelScreening, ]; // Help message diff --git a/bouncer/shared/send_btc.ts b/bouncer/shared/send_btc.ts index 8f674dfdd1..701a06a2bc 100644 --- a/bouncer/shared/send_btc.ts +++ b/bouncer/shared/send_btc.ts @@ -1,7 +1,10 @@ import Client from 'bitcoin-core'; import { sleep, btcClientMutex } from './utils'; -export async function sendBtc(address: string, amount: number | string) { +export async function sendBtcAndReturnTxId( + address: string, + amount: number | string, +): Promise { const BTC_ENDPOINT = process.env.BTC_ENDPOINT || 'http://127.0.0.1:8332'; const client = new Client({ host: BTC_ENDPOINT.split(':')[1].slice(2), @@ -12,19 +15,24 @@ export async function sendBtc(address: string, amount: number | string) { }); // Btc client has a limit on the number of concurrent requests - const txid = await btcClientMutex.runExclusive(async () => + const txId = (await btcClientMutex.runExclusive(async () => client.sendToAddress(address, amount, '', '', false, true, null, 'unset', null, 1), - ); + )) as string; for (let i = 0; i < 50; i++) { - const transactionDetails = await client.getTransaction(txid); + const transactionDetails = await client.getTransaction(txId); const confirmations = transactionDetails.confirmations; if (confirmations < 1) { await sleep(1000); } else { - return; + break; } } + return Promise.resolve(txId); +} + +export async function sendBtc(address: string, amount: number | string) { + await sendBtcAndReturnTxId(address, amount); } diff --git a/bouncer/test_commands/broker_level_screening.ts b/bouncer/test_commands/broker_level_screening.ts new file mode 100755 index 0000000000..ae4d30fb29 --- /dev/null +++ b/bouncer/test_commands/broker_level_screening.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env -S pnpm tsx +import { testBrokerLevelScreening } from '../tests/broker_level_screening'; + +await testBrokerLevelScreening.run(); diff --git a/bouncer/tests/all_concurrent_tests.ts b/bouncer/tests/all_concurrent_tests.ts index 738886ca5f..28b3628b32 100755 --- a/bouncer/tests/all_concurrent_tests.ts +++ b/bouncer/tests/all_concurrent_tests.ts @@ -14,6 +14,7 @@ import { testCancelOrdersBatch } from './create_and_delete_multiple_orders'; import { testAllSwaps } from './all_swaps'; import { depositChannelCreation } from './request_swap_deposit_address_with_affiliates'; import { testDCASwaps } from './DCA_test'; +import { testBrokerLevelScreening } from './broker_level_screening'; async function runAllConcurrentTests() { // Specify the number of nodes via providing an argument to this script. @@ -21,6 +22,8 @@ async function runAllConcurrentTests() { const match = process.argv[2] ? process.argv[2].match(/\d+/) : null; const givenNumberOfNodes = match ? parseInt(match[0]) : null; const numberOfNodes = givenNumberOfNodes ?? 1; + // If the third argument is not explicitly false, we assume it's true and we are in a localnet environment. + const addConcurrentLocalnetTests = process.argv[3] !== 'false'; const broadcastAborted = observeBadEvent(':BroadcastAborted', { label: 'Concurrent broadcast aborted', @@ -52,6 +55,12 @@ async function runAllConcurrentTests() { tests.push(...multiNodeTests); } + // Tests that only work with localnet but can be run concurrent. + if (addConcurrentLocalnetTests) { + const localnetTests = [testBrokerLevelScreening.run()]; + tests.push(...localnetTests); + } + await Promise.all(tests); await Promise.all([broadcastAborted.stop(), feeDeficitRefused.stop()]); diff --git a/bouncer/tests/broker_level_screening.ts b/bouncer/tests/broker_level_screening.ts new file mode 100644 index 0000000000..a3c04299b6 --- /dev/null +++ b/bouncer/tests/broker_level_screening.ts @@ -0,0 +1,207 @@ +import { randomBytes } from 'crypto'; +import { execSync } from 'child_process'; +import { InternalAsset } from '@chainflip/cli'; +import { ExecutableTest } from '../shared/executable_test'; +import { sendBtcAndReturnTxId } from '../shared/send_btc'; +import { + hexStringToBytesArray, + newAddress, + sleep, + handleSubstrateError, + brokerMutex, +} from '../shared/utils'; +import { getChainflipApi, observeEvent } from '../shared/utils/substrate'; +import Keyring from '../polkadot/keyring'; +import { requestNewSwap } from '../shared/perform_swap'; +import { FillOrKillParamsX128 } from '../shared/new_swap'; +import { getBtcBalance } from '../shared/get_btc_balance'; + +const keyring = new Keyring({ type: 'sr25519' }); +const broker = keyring.createFromUri('//BROKER_1'); + +/* eslint-disable @typescript-eslint/no-use-before-define */ +export const testBrokerLevelScreening = new ExecutableTest('Broker-Level-Screening', main, 300); + +/** + * Observes the balance of a BTC address and returns true if the balance changes. Times out after 100 seconds and returns false if the balance does not change. + * + * @param address - The address to observe the balance of. + * @returns - Whether the balance changed. + */ +async function observeBtcAddressBalanceChange(address: string): Promise { + const MAX_RETRIES = 100; + const initialBalance = await getBtcBalance(address); + for (let i = 0; i < MAX_RETRIES; i++) { + await sleep(1000); + const balance = await getBtcBalance(address); + if (balance !== initialBalance) { + return Promise.resolve(true); + } + } + console.error(`BTC balance for ${address} did not change after ${MAX_RETRIES} seconds.`); + return Promise.resolve(false); +} + +/** + * Generates a new address for an asset. + * + * @param asset - The asset to generate an address for. + * @param seed - The seed to generate the address with. If no seed is provided, a random one is generated. + * @returns - The new address. + */ +async function newAssetAddress(asset: InternalAsset, seed = null): Promise { + return Promise.resolve(newAddress(asset, seed || randomBytes(32).toString('hex'))); +} + +/** + * Submits a transaction as tainted to the extrinsic on the state chain. + * + * @param txId - The txId to submit as tainted as byte array in the order it is on the Bitcoin chain - which + * is reverse of how it's normally displayed in block explorers. + */ +async function submitTxAsTainted(txId: number[]) { + await using chainflip = await getChainflipApi(); + return brokerMutex.runExclusive(async () => + chainflip.tx.bitcoinIngressEgress + .markTransactionAsTainted(txId) + .signAndSend(broker, { nonce: -1 }, handleSubstrateError(chainflip)), + ); +} + +/** + * Pauses or resumes the bitcoin block production. We send a command to the docker container to start or stop mining blocks. + * + * @param pause - Whether to pause or resume the block production. + * @returns - Whether the command was successful. + */ +function pauseBtcBlockProduction(pause: boolean): boolean { + try { + execSync( + pause + ? 'docker exec bitcoin rm /root/mine_blocks' + : 'docker exec bitcoin touch /root/mine_blocks', + ); + return true; + } catch (error) { + console.error(error); + return false; + } +} + +/** + * Runs a test scenario for broker level screening based on the given parameters. + * + * @param amount - The deposit amount. + * @param doBoost - Whether to boost the deposit. + * @param refundAddress - The address to refund to. + * @param stopBlockProductionFor - The number of blocks to stop block production for. We need this to ensure that the tainted tx is on chain before the deposit is witnessed/prewitnessed. + * @param waitBeforeReport - The number of milliseconds to wait before reporting the tx as tainted. + * @returns - The the channel id of the deposit channel. + */ +async function brokerLevelScreeningTestScenario( + amount: string, + doBoost: boolean, + refundAddress: string, + stopBlockProductionFor = 0, + waitBeforeReport = 0, +): Promise { + const destinationAddressForUsdc = await newAssetAddress('Usdc'); + const refundParameters: FillOrKillParamsX128 = { + retryDurationBlocks: 0, + refundAddress, + minPriceX128: '0', + }; + const swapParams = await requestNewSwap( + 'Btc', + 'Usdc', + destinationAddressForUsdc, + 'brokerLevelScreeningTest', + undefined, + 0, + true, + doBoost ? 100 : 0, + refundParameters, + ); + const txId = await sendBtcAndReturnTxId(swapParams.depositAddress, amount); + if (stopBlockProductionFor > 0) { + pauseBtcBlockProduction(true); + } + await sleep(waitBeforeReport); + // Note: The bitcoin core js lib returns the txId in reverse order. + // On chain we expect the txId to be in the correct order (like the Bitcoin internal representation). + // Because of this we need to reverse the txId before submitting it as tainted. + await submitTxAsTainted(hexStringToBytesArray(txId).reverse()); + await sleep(stopBlockProductionFor); + if (stopBlockProductionFor > 0) { + pauseBtcBlockProduction(false); + } + return Promise.resolve(swapParams.channelId.toString()); +} + +// -- Test suite for broker level screening -- +// +// In this tests we are interested in the following scenarios: +// +// 1. No boost and early tx report -> Tainted tx is reported early and the swap is refunded. +// 2. Boost and early tx report -> Tainted tx is reported early and the swap is refunded. +// 3. Boost and late tx report -> Tainted tx is reported late and the swap is not refunded. +async function main() { + const MILLI_SECS_PER_BLOCK = 6000; + const BLOCKS_TO_WAIT = 2; + + // 1. -- Test no boost and early tx report -- + testBrokerLevelScreening.log('Testing broker level screening with no boost...'); + let btcRefundAddress = await newAssetAddress('Btc'); + + await brokerLevelScreeningTestScenario( + '0.2', + false, + btcRefundAddress, + MILLI_SECS_PER_BLOCK * BLOCKS_TO_WAIT, + ); + + await observeEvent('bitcoinIngressEgress:TaintedTransactionRejected').event; + if (!(await observeBtcAddressBalanceChange(btcRefundAddress))) { + throw new Error(`Didn't receive funds refund to address ${btcRefundAddress} within timeout!`); + } + + testBrokerLevelScreening.log(`Tainted transaction was rejected and refunded 👍.`); + + // 2. -- Test boost and early tx report -- + testBrokerLevelScreening.log( + 'Testing broker level screening with boost and a early tx report...', + ); + btcRefundAddress = await newAssetAddress('Btc'); + + await brokerLevelScreeningTestScenario( + '0.2', + true, + btcRefundAddress, + MILLI_SECS_PER_BLOCK * BLOCKS_TO_WAIT, + ); + await observeEvent('bitcoinIngressEgress:TaintedTransactionRejected').event; + + if (!(await observeBtcAddressBalanceChange(btcRefundAddress))) { + throw new Error(`Didn't receive funds refund to address ${btcRefundAddress} within timeout!`); + } + testBrokerLevelScreening.log(`Tainted transaction was rejected and refunded 👍.`); + + // 3. -- Test boost and late tx report -- + // Note: We expect the swap to be executed and not refunded because the tainted tx was reported too late. + testBrokerLevelScreening.log('Testing broker level screening with boost and a late tx report...'); + btcRefundAddress = await newAssetAddress('Btc'); + + const channelId = await brokerLevelScreeningTestScenario( + '0.2', + true, + btcRefundAddress, + 0, + MILLI_SECS_PER_BLOCK * BLOCKS_TO_WAIT, + ); + + await observeEvent('bitcoinIngressEgress:DepositFinalised', { + test: (event) => event.data.channelId === channelId, + }).event; + + testBrokerLevelScreening.log(`Swap was executed and tainted transaction was not refunded 👍.`); +}