Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tests: Bouncer test for broker level screening #5377

Merged
merged 19 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bouncer/commands/run_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -67,6 +68,7 @@ async function main() {
testDeltaBasedIngress,
testCancelOrdersBatch,
depositChannelCreation,
testBrokerLevelScreening,
];

// Help message
Expand Down
18 changes: 13 additions & 5 deletions bouncer/shared/send_btc.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const BTC_ENDPOINT = process.env.BTC_ENDPOINT || 'http://127.0.0.1:8332';
const client = new Client({
host: BTC_ENDPOINT.split(':')[1].slice(2),
Expand All @@ -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);
kylezs marked this conversation as resolved.
Show resolved Hide resolved
}

export async function sendBtc(address: string, amount: number | string) {
await sendBtcAndReturnTxId(address, amount);
}
4 changes: 4 additions & 0 deletions bouncer/test_commands/broker_level_screening.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env -S pnpm tsx
import { testBrokerLevelScreening } from '../tests/broker_level_screening';

await testBrokerLevelScreening.run();
kylezs marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions bouncer/tests/all_concurrent_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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.
// Using regex because the localnet script passes in "3-node".
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',
Expand Down Expand Up @@ -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()]);
Expand Down
207 changes: 207 additions & 0 deletions bouncer/tests/broker_level_screening.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string> {
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<string> {
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 👍.`);
}
Loading