diff --git a/bin/bridge-client/src/args.rs b/bin/bridge-client/src/args.rs index 6e2a2badb..92b4338b6 100644 --- a/bin/bridge-client/src/args.rs +++ b/bin/bridge-client/src/args.rs @@ -71,6 +71,9 @@ pub(crate) struct Cli { description = "retry count for the rocksdb database (default: 3)" )] pub retry_count: Option, + + #[argh(option, description = "duty timeout duration in seconds (default: 65)")] + pub duty_timeout_duration: Option, } #[derive(Debug, Clone)] diff --git a/bin/bridge-client/src/constants.rs b/bin/bridge-client/src/constants.rs index 40fa5e0c8..e58338503 100644 --- a/bin/bridge-client/src/constants.rs +++ b/bin/bridge-client/src/constants.rs @@ -4,5 +4,6 @@ pub(super) const DEFAULT_RPC_PORT: u32 = 4781; // first 4 digits in the sha256 o pub(super) const DEFAULT_RPC_HOST: &str = "127.0.0.1"; +pub(super) const DEFAULT_DUTY_TIMEOUT_DURATION: u64 = 65; /// The default bridge rocksdb database retry count, if not overridden by the user. pub(super) const ROCKSDB_RETRY_COUNT: u16 = 3; diff --git a/bin/bridge-client/src/modes/operator/bootstrap.rs b/bin/bridge-client/src/modes/operator/bootstrap.rs index a808bd0d6..4be5d8729 100644 --- a/bin/bridge-client/src/modes/operator/bootstrap.rs +++ b/bin/bridge-client/src/modes/operator/bootstrap.rs @@ -27,7 +27,9 @@ use tracing::{error, info}; use super::{constants::DB_THREAD_COUNT, task_manager::TaskManager}; use crate::{ args::Cli, - constants::{DEFAULT_RPC_HOST, DEFAULT_RPC_PORT, ROCKSDB_RETRY_COUNT}, + constants::{ + DEFAULT_DUTY_TIMEOUT_DURATION, DEFAULT_RPC_HOST, DEFAULT_RPC_PORT, ROCKSDB_RETRY_COUNT, + }, db::open_rocksdb_database, rpc_server::{self, BridgeRpc}, xpriv::resolve_xpriv, @@ -148,9 +150,17 @@ pub(crate) async fn bootstrap(args: Cli) -> anyhow::Result<()> { Duration::from_millis, ); + let duty_timeout_duration = args.duty_timeout_duration.map_or( + Duration::from_secs(DEFAULT_DUTY_TIMEOUT_DURATION), + Duration::from_secs, + ); + // TODO: wrap these in `strata-tasks` let duty_task = tokio::spawn(async move { - if let Err(e) = task_manager.start(duty_polling_interval).await { + if let Err(e) = task_manager + .start(duty_polling_interval, duty_timeout_duration) + .await + { error!(error = %e, "could not start task manager"); }; }); diff --git a/bin/bridge-client/src/modes/operator/task_manager.rs b/bin/bridge-client/src/modes/operator/task_manager.rs index 8f2d81333..d2f87f005 100644 --- a/bin/bridge-client/src/modes/operator/task_manager.rs +++ b/bin/bridge-client/src/modes/operator/task_manager.rs @@ -15,7 +15,10 @@ use strata_rpc_api::StrataApiClient; use strata_rpc_types::RpcBridgeDuties; use strata_state::bridge_duties::{BridgeDuty, BridgeDutyStatus}; use strata_storage::ops::{bridge_duty::BridgeDutyOps, bridge_duty_index::BridgeDutyIndexOps}; -use tokio::{task::JoinSet, time::sleep}; +use tokio::{ + task::JoinSet, + time::{sleep, timeout}, +}; use tracing::{error, info, trace, warn}; pub(super) struct TaskManager @@ -36,7 +39,12 @@ where TxBuildContext: BuildContext + Sync + Send + 'static, Bcast: Broadcaster + Sync + Send + 'static, { - pub(super) async fn start(&self, duty_polling_interval: Duration) -> anyhow::Result<()> { + pub(super) async fn start( + &self, + duty_polling_interval: Duration, + duty_timeout_duration: Duration, + ) -> anyhow::Result<()> { + info!(?duty_polling_interval, "Starting to poll for duties"); loop { let RpcBridgeDuties { duties, @@ -44,6 +52,8 @@ where stop_index, } = self.poll_duties().await?; + info!(num_duties = duties.len(), "got duties"); + let mut handles = JoinSet::new(); for duty in duties { let exec_handler = self.exec_handler.clone(); @@ -54,23 +64,28 @@ where }); } - let any_failed = handles.join_all().await.iter().any(|res| res.is_err()); + // TODO: There should be timeout duration based on duty and not a common timeout + // duration + if let Ok(any_failed) = timeout(duty_timeout_duration, handles.join_all()).await { + if !any_failed.iter().any(|res| res.is_err()) { + info!(%start_index, %stop_index, "updating duty index"); + if let Err(e) = self + .bridge_duty_idx_db_ops + .set_index_async(stop_index) + .await + { + error!(error = %e, %start_index, %stop_index, "could not update duty index"); + } + } + } else { + error!(?duty_timeout_duration, "some duties timed out"); + } // if none of the duties failed, update the duty index so that the // next batch is fetched in the next poll. // // otherwise, don't update the index so that the current batch is refetched and // ones that were not executed successfully are executed again. - if !any_failed { - info!(%start_index, %stop_index, "updating duty index"); - if let Err(e) = self - .bridge_duty_idx_db_ops - .set_index_async(stop_index) - .await - { - error!(error = %e, %start_index, %stop_index, "could not update duty index"); - } - } sleep(duty_polling_interval).await; } diff --git a/crates/bridge-exec/src/handler.rs b/crates/bridge-exec/src/handler.rs index 26150aa25..256ba5e4f 100644 --- a/crates/bridge-exec/src/handler.rs +++ b/crates/bridge-exec/src/handler.rs @@ -297,6 +297,9 @@ where if let Ok(msg) = msg { let payload = msg.payload(); let parsed_payload = borsh::from_slice::(payload); + let scope = msg.scope(); + let parsed_scope = borsh::from_slice::(scope); + debug!(msg=?msg, payload=?parsed_payload, scope=?parsed_scope, "ABC: got message from the L2 Client"); match parsed_payload { Ok(payload) => Some((msg.source_id(), payload)), diff --git a/crates/chaintsn/src/transition.rs b/crates/chaintsn/src/transition.rs index 5f4f50dac..91d427b7f 100644 --- a/crates/chaintsn/src/transition.rs +++ b/crates/chaintsn/src/transition.rs @@ -297,10 +297,13 @@ fn process_deposit_updates( // Check if the deposit is past the threshold. if cur_block_height >= dstate.exec_deadline() { // Pick the next assignee, if there are any. - let new_op_pos = if num_operators > 0 { - let op_off = rng.next_u32() % (num_operators - 1); - (dstate.assignee() + op_off) % num_operators + let new_op_pos = if num_operators > 1 { + // Compute a random offset from 1 to (num_operators - 1), + // ensuring we pick a different operator than the current one. + let offset = 1 + (rng.next_u32() % (num_operators - 1)); + (dstate.assignee() + offset) % num_operators } else { + // If there is 0 or 1 operator, we remain with the current assignee. dstate.assignee() }; diff --git a/functional-tests/constants.py b/functional-tests/constants.py index d467171d5..64d43171c 100644 --- a/functional-tests/constants.py +++ b/functional-tests/constants.py @@ -31,6 +31,7 @@ DEFAULT_OPERATOR_CNT = 2 DEFAULT_PROOF_TIMEOUT = 5 # Secs DEFAULT_TAKEBACK_TIMEOUT = 1008 # Blocks (1 week) +DEFAULT_BRIDGE_DUTY_TIMEOUT_DURATION = 10 # magic values # TODO Remove every single one of these diff --git a/functional-tests/factory.py b/functional-tests/factory.py index 06e5810a3..d3f214a5a 100644 --- a/functional-tests/factory.py +++ b/functional-tests/factory.py @@ -314,6 +314,7 @@ def create_operator( bitcoind_config: dict, ctx: flexitest.EnvContext, message_interval: int = DEFAULT_ROLLUP_PARAMS["message_interval"], + duty_timeout_duration: int = DEFAULT_BRIDGE_DUTY_TIMEOUT_DURATION, ): idx = self.next_idx() name = f"bridge.{idx}" @@ -335,6 +336,7 @@ def create_operator( "--btc-pass", bitcoind_config["bitcoind_pass"], "--rollup-url", node_url, "--message-interval", str(message_interval), + "--duty-timeout-duration", str(duty_timeout_duration), ] # fmt: on diff --git a/functional-tests/fn_bridge_reassignment.py b/functional-tests/fn_bridge_reassignment.py new file mode 100644 index 000000000..c6b211b59 --- /dev/null +++ b/functional-tests/fn_bridge_reassignment.py @@ -0,0 +1,107 @@ +import time + +import flexitest +from strata_utils import get_balance + +import testenv +from constants import UNSPENDABLE_ADDRESS +from fn_bridge_withdraw_happy import ( + DEPOSIT_AMOUNT, + OPERATOR_FEE, + WITHDRAWAL_EXTRA_FEE, + check_initial_eth_balance, + deposit_twice, + do_withdrawal, + setup_services, +) +from utils import wait_until, wait_until_with_value + + +@flexitest.register +class BridgeWithdrawReassignmentTest(testenv.StrataTester): + """ + Makes two DRT deposits, then triggers the withdrawal. + The bridge client associated with assigned operator id is stopped. + After the dispatch assignment duration is over, + Check if new operator is being assigned or not + """ + + def __init__(self, ctx: flexitest.InitContext): + # Example: we spin up 5 operators + ctx.set_env(testenv.BasicEnvConfig(101, n_operators=3, pre_fund_addrs=True)) + + def main(self, ctx: flexitest.RunContext): + address = ctx.env.gen_ext_btc_address() + withdraw_address = ctx.env.gen_ext_btc_address() + self.debug(f"Address: {address}") + self.debug(f"Change Address: {withdraw_address}") + + btc, seq, reth, seqrpc, btcrpc, rethrpc, web3, el_address = setup_services(ctx, self.debug) + btc_url = btcrpc.base_url + btc_user = btc.props["rpc_user"] + btc_password = btc.props["rpc_password"] + original_balance = get_balance(withdraw_address, btc_url, btc_user, btc_password) + self.debug(f"BTC balance before withdraw: {original_balance}") + + # Check initial balance is 0 + check_initial_eth_balance(rethrpc, el_address, self.debug) + + # Perform two deposits + deposit_twice(ctx, web3, seqrpc, rethrpc, el_address, self.debug) + + # Perform a withdrawal + l2_tx_hash, tx_receipt, total_gas_used = do_withdrawal( + web3, rethrpc, el_address, withdraw_address, self.debug, DEPOSIT_AMOUNT + ) + original_balance = get_balance(withdraw_address, btc_url, btc_user, btc_password) + self.debug(f"BTC balance after withdraw: {original_balance}") + + # Check assigned operator + duties = seqrpc.strata_getBridgeDuties(0, 0)["duties"] + withdraw_duty = [d for d in duties if d["type"] == "FulfillWithdrawal"][0] + assigned_op_idx = withdraw_duty["payload"]["assigned_operator_idx"] + assigned_operator = ctx.get_service(f"bridge.{assigned_op_idx}") + self.debug(f"Assigned operator index: {assigned_op_idx}") + + # Stop assigned operator + self.debug("Stopping assigned operator ...") + assigned_operator.stop() + + # Let enough blocks pass so the assignment times out + # (64 is the dispatch_assignment_duration) + btcrpc.proxy.generatetoaddress(64, UNSPENDABLE_ADDRESS) + time.sleep(3) + + # Re-check duties + def my_val(): + btcrpc.proxy.generatetoaddress(64, UNSPENDABLE_ADDRESS) + duties = seqrpc.strata_getBridgeDuties(0, 0)["duties"] + withdraw_duty = [d for d in duties if d["type"] == "FulfillWithdrawal"][0] + new_assigned_op_idx = withdraw_duty["payload"]["assigned_operator_idx"] + new_assigned_operator = ctx.get_service(f"bridge.{new_assigned_op_idx}") + self.debug(f"new assigned operator is {new_assigned_op_idx}") + return new_assigned_operator + + new_assigned_operator = wait_until_with_value( + my_val, + predicate=lambda v: v != assigned_operator, + timeout=10, + ) + + # Ensure a new operator is assigned + assert new_assigned_operator != assigned_operator, "No new operator was assigned" + assigned_operator.start() + abc = assigned_operator.create_rpc() + wait_until(lambda: abc.stratabridge_uptime() is not None, timeout=10) + + btcrpc.proxy.generatetoaddress(64, UNSPENDABLE_ADDRESS) + + difference = DEPOSIT_AMOUNT - OPERATOR_FEE - WITHDRAWAL_EXTRA_FEE + new_balance = wait_until_with_value( + lambda: get_balance(withdraw_address, btc_url, btc_user, btc_password), + predicate=lambda v: v == original_balance + difference, + timeout=20, + ) + + self.debug(f"BTC balance after stopping and starting again: {new_balance}") + return True diff --git a/functional-tests/fn_bridge_withdraw_happy.py b/functional-tests/fn_bridge_withdraw_happy.py index de9dee08e..d2fbed6c4 100644 --- a/functional-tests/fn_bridge_withdraw_happy.py +++ b/functional-tests/fn_bridge_withdraw_happy.py @@ -2,22 +2,18 @@ import flexitest from bitcoinlib.services.bitcoind import BitcoindClient -from strata_utils import ( - deposit_request_transaction, - extract_p2tr_pubkey, - get_balance, -) +from strata_utils import deposit_request_transaction, extract_p2tr_pubkey, get_balance from web3 import Web3 from web3.middleware import SignAndSendRawMiddlewareBuilder import testenv +import utils from constants import ( DEFAULT_ROLLUP_PARAMS, PRECOMPILE_BRIDGEOUT_ADDRESS, SATS_TO_WEI, UNSPENDABLE_ADDRESS, ) -from utils import get_bridge_pubkey, wait_until # Local constants # D BTC @@ -46,210 +42,280 @@ def __init__(self, ctx: flexitest.InitContext): ctx.set_env("basic") def main(self, ctx: flexitest.RunContext): + # Generate addresses address = ctx.env.gen_ext_btc_address() withdraw_address = ctx.env.gen_ext_btc_address() self.debug(f"Address: {address}") self.debug(f"Change Address: {withdraw_address}") - self.debug(f"Gas: {WITHDRAWAL_GAS_FEE}") - - btc = ctx.get_service("bitcoin") - seq = ctx.get_service("sequencer") - reth = ctx.get_service("reth") - - seqrpc = seq.create_rpc() - btcrpc: BitcoindClient = btc.create_rpc() - rethrpc = reth.create_rpc() - seq_addr = seq.get_prop("address") - self.debug(f"Sequencer Address: {seq_addr}") + # Setup + btc, seq, reth, seqrpc, btcrpc, rethrpc, web3, el_address = setup_services(ctx, self.debug) + # Original BTC balance btc_url = btcrpc.base_url btc_user = btc.props["rpc_user"] btc_password = btc.props["rpc_password"] - - self.debug(f"BTC URL: {btc_url}") - self.debug(f"BTC user: {btc_user}") - self.debug(f"BTC password: {btc_password}") - - # Get the original balance of the withdraw address original_balance = get_balance(withdraw_address, btc_url, btc_user, btc_password) self.debug(f"BTC balance before withdraw: {original_balance}") - web3: Web3 = reth.create_web3() - # Create an Ethereum account from the private key - eth_account = web3.eth.account.from_key(ETH_PRIVATE_KEY) - el_address = eth_account.address - self.debug(f"EL address: {el_address}") - - # Add the Ethereum account as auto-signer - # Transactions from `el_address` will then be signed, under the hood, in the middleware - web3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(eth_account), layer=0) - - # Get the balance of the EL address before the deposits - balance = int(rethrpc.eth_getBalance(el_address), 16) - self.debug(f"Strata Balance before deposits: {balance}") - assert balance == 0, "Strata balance is not expected" - - # Gas price - gas_price = web3.to_wei(1, "gwei") - self.debug(f"Gas price: {gas_price}") - - # Get operators pubkey and musig2 aggregates it - bridge_pk = get_bridge_pubkey(seqrpc) - self.debug(f"Bridge pubkey: {bridge_pk}") - - # Deposit to the EL address - # NOTE: we need 2 deposits to make sure we have funds for gas - self.make_drt(ctx, el_address, bridge_pk) - self.make_drt(ctx, el_address, bridge_pk) - balance_after_deposits = int(rethrpc.eth_getBalance(el_address), 16) - self.debug(f"Strata Balance after deposits: {balance_after_deposits}") - wait_until( - lambda: int(rethrpc.eth_getBalance(el_address), 16) == 2 * DEPOSIT_AMOUNT * SATS_TO_WEI - ) + # Make sure starting ETH balance is 0 + check_initial_eth_balance(rethrpc, el_address, self.debug) - # Get the balance of the EL address after the deposits - balance = int(rethrpc.eth_getBalance(el_address), 16) - self.debug(f"Strata Balance after deposits: {balance}") - assert balance == 2 * DEPOSIT_AMOUNT * SATS_TO_WEI, "Strata balance is not expected" + # Make 2 DRT deposits + deposit_twice(ctx, web3, seqrpc, rethrpc, el_address, self.debug) - # Send funds to the bridge precompile address for a withdrawal - change_address_pk = extract_p2tr_pubkey(withdraw_address) - self.debug(f"Change Address PK: {change_address_pk}") - estimated_withdraw_gas = self.estimate_withdraw_gas( - ctx, web3, el_address, change_address_pk - ) - self.debug(f"Estimated withdraw gas: {estimated_withdraw_gas}") - l2_tx_hash = self.make_withdraw( - ctx, web3, el_address, change_address_pk, estimated_withdraw_gas - ).hex() - self.debug(f"Sent withdrawal transaction with hash: {l2_tx_hash}") - - # Wait for the withdrawal to be processed - wait_until(lambda: web3.eth.get_transaction_receipt(l2_tx_hash)) - tx_receipt = web3.eth.get_transaction_receipt(l2_tx_hash) - self.debug(f"Transaction receipt: {tx_receipt}") - total_gas_used = tx_receipt["gasUsed"] * tx_receipt["effectiveGasPrice"] - self.debug(f"Total gas used: {total_gas_used}") - - # Make sure that the balance is expected - balance_post_withdraw = int(rethrpc.eth_getBalance(el_address), 16) - self.debug(f"Strata Balance after withdrawal: {balance_post_withdraw}") - difference = DEPOSIT_AMOUNT * SATS_TO_WEI - total_gas_used - self.debug(f"Strata Balance difference: {difference}") - assert difference == balance_post_withdraw, "balance difference is not expected" - - # Wait for the withdraw address to have a positive balance - self.mine_blocks_until_maturity( - btcrpc, withdraw_address, btc_url, btc_user, btc_password, original_balance + # Withdraw + l2_tx_hash, tx_receipt, total_gas_used = do_withdrawal( + web3, rethrpc, el_address, withdraw_address, self.debug, DEPOSIT_AMOUNT ) - # Make sure that the balance in the BTC wallet is D BTC - operator's fees - btc_balance = get_balance(withdraw_address, btc_url, btc_user, btc_password) - self.debug(f"BTC balance: {btc_balance}") + # Confirm BTC side + # We expect final BTC balance to be D BTC minus operator fees difference = DEPOSIT_AMOUNT - OPERATOR_FEE - WITHDRAWAL_EXTRA_FEE - self.debug(f"BTC expected balance: {original_balance + difference}") - assert btc_balance == original_balance + difference, "BTC balance is not expected" + confirm_btc_withdrawal( + btcrpc, + withdraw_address, + btc_url, + btc_user, + btc_password, + original_balance, + difference, + self.debug, + ) return True - def make_drt(self, ctx: flexitest.RunContext, el_address, musig_bridge_pk): - """ - Deposit Request Transaction - """ - # Get relevant data - btc = ctx.get_service("bitcoin") - seq = ctx.get_service("sequencer") - btcrpc: BitcoindClient = btc.create_rpc() - btc_url = btcrpc.base_url - btc_user = btc.props["rpc_user"] - btc_password = btc.props["rpc_password"] - seq_addr = seq.get_prop("address") - - # Create the deposit request transaction - tx = bytes( - deposit_request_transaction( - el_address, musig_bridge_pk, btc_url, btc_user, btc_password - ) - ).hex() - self.debug(f"Deposit request tx: {tx}") - - # Send the transaction to the Bitcoin network - txid = btcrpc.proxy.sendrawtransaction(tx) - self.debug(f"sent deposit request with txid = {txid} for address {el_address}") - time.sleep(1) - - # time to mature DRT - btcrpc.proxy.generatetoaddress(6, seq_addr) - time.sleep(3) - - # time to mature DT - btcrpc.proxy.generatetoaddress(6, seq_addr) - time.sleep(3) - - def make_withdraw( - self, - ctx: flexitest.RunContext, - web3: Web3, - el_address, - change_address_pk, - gas=WITHDRAWAL_GAS_FEE, - ): - """ - Withdrawal Request Transaction in Strata's EVM. - """ - self.debug(f"EL address: {el_address}") - self.debug(f"Bridge address: {PRECOMPILE_BRIDGEOUT_ADDRESS}") - - data_bytes = bytes.fromhex(change_address_pk) - - transaction = { - "from": el_address, - "to": PRECOMPILE_BRIDGEOUT_ADDRESS, - "value": DEPOSIT_AMOUNT * SATS_TO_WEI, - "gas": gas, - "data": data_bytes, - } - l2_tx_hash = web3.eth.send_transaction(transaction) - return l2_tx_hash - - def estimate_withdraw_gas( - self, ctx: flexitest.RunContext, web3: Web3, el_address, change_address_pk - ): - """ - Estimate the gas for the withdrawal transaction. - """ - self.debug(f"EL address: {el_address}") - self.debug(f"Bridge address: {PRECOMPILE_BRIDGEOUT_ADDRESS}") - - data_bytes = bytes.fromhex(change_address_pk) - - transaction = { - "from": el_address, - "to": PRECOMPILE_BRIDGEOUT_ADDRESS, - "value": DEPOSIT_AMOUNT * SATS_TO_WEI, - "data": data_bytes, - } - return web3.eth.estimate_gas(transaction) - - def mine_blocks_until_maturity( - self, - btcrpc, - withdraw_address, - btc_url, - btc_user, - btc_password, - original_balance, - number_of_blocks=12, - ): - """ - Mine blocks until the withdraw address has a positive balance - By default, the number of blocks to mine is 12: - - 6 blocks to mature the DRT - - 6 blocks to mature the DT - """ - btcrpc.proxy.generatetoaddress(number_of_blocks, UNSPENDABLE_ADDRESS) - wait_until( - lambda: get_balance(withdraw_address, btc_url, btc_user, btc_password) - > original_balance - ) + +def make_drt(ctx: flexitest.RunContext, el_address, musig_bridge_pk): + """ + Deposit Request Transaction + """ + # Get relevant data + btc = ctx.get_service("bitcoin") + seq = ctx.get_service("sequencer") + btcrpc: BitcoindClient = btc.create_rpc() + btc_url = btcrpc.base_url + btc_user = btc.props["rpc_user"] + btc_password = btc.props["rpc_password"] + seq_addr = seq.get_prop("address") + + # Create the deposit request transaction + tx = bytes( + deposit_request_transaction(el_address, musig_bridge_pk, btc_url, btc_user, btc_password) + ).hex() + + # Send the transaction to the Bitcoin network + btcrpc.proxy.sendrawtransaction(tx) + time.sleep(1) + + # time to mature DRT + btcrpc.proxy.generatetoaddress(6, seq_addr) + time.sleep(3) + + # time to mature DT + btcrpc.proxy.generatetoaddress(6, seq_addr) + time.sleep(3) + + +def make_withdraw( + web3: Web3, + el_address, + change_address_pk, + deposit_amount, + gas=WITHDRAWAL_GAS_FEE, +): + """ + Withdrawal Request Transaction in Strata's EVM. + """ + data_bytes = bytes.fromhex(change_address_pk) + + transaction = { + "from": el_address, + "to": PRECOMPILE_BRIDGEOUT_ADDRESS, + "value": deposit_amount * SATS_TO_WEI, + "gas": gas, + "data": data_bytes, + } + l2_tx_hash = web3.eth.send_transaction(transaction) + return l2_tx_hash + + +def estimate_withdraw_gas(web3: Web3, el_address, change_address_pk, deposit_amount): + """ + Estimate the gas for the withdrawal transaction. + """ + + data_bytes = bytes.fromhex(change_address_pk) + + transaction = { + "from": el_address, + "to": PRECOMPILE_BRIDGEOUT_ADDRESS, + "value": deposit_amount * SATS_TO_WEI, + "data": data_bytes, + } + return web3.eth.estimate_gas(transaction) + + +def mine_blocks_until_maturity( + btcrpc, + withdraw_address, + btc_url, + btc_user, + btc_password, + original_balance, + number_of_blocks=12, +): + """ + Mine blocks until the withdraw address has a positive balance + By default, the number of blocks to mine is 12: + - 6 blocks to mature the DRT + - 6 blocks to mature the DT + """ + btcrpc.proxy.generatetoaddress(number_of_blocks, UNSPENDABLE_ADDRESS) + utils.wait_until( + lambda: get_balance(withdraw_address, btc_url, btc_user, btc_password) > original_balance, + timeout=10, + ) + + +def setup_services(ctx, debug_fn=print): + """ + code to set up all necessary services and references: + - The Bitcoin service (btc) and the bitcoind RPC (btcrpc) + - The Sequencer service (seq) and the sequencer RPC (seqrpc) + - The Reth service (reth) and the reth RPC (rethrpc) + - The Web3 instance with signing middleware injected + """ + btc = ctx.get_service("bitcoin") + seq = ctx.get_service("sequencer") + reth = ctx.get_service("reth") + + seqrpc = seq.create_rpc() + btcrpc: BitcoindClient = btc.create_rpc() + rethrpc = reth.create_rpc() + + # Debug info + debug_fn(f"BTC URL: {btcrpc.base_url}") + debug_fn(f"BTC user: {btc.props['rpc_user']}") + debug_fn(f"BTC password: {btc.props['rpc_password']}") + debug_fn(f"Sequencer Address: {seq.get_prop('address')}") + + web3: Web3 = reth.create_web3() + eth_account = web3.eth.account.from_key(ETH_PRIVATE_KEY) + el_address = eth_account.address + + # Inject signing middleware + web3.middleware_onion.inject( + SignAndSendRawMiddlewareBuilder.build(eth_account), + layer=0, + ) + + return btc, seq, reth, seqrpc, btcrpc, rethrpc, web3, el_address + + +def check_initial_eth_balance(rethrpc, address, debug_fn=print): + """Asserts that the initial ETH balance for `address` is zero.""" + balance = int(rethrpc.eth_getBalance(address), 16) + debug_fn(f"Strata Balance before deposits: {balance}") + assert balance == 0, "Strata balance is not expected (should be zero initially)" + + +def deposit_twice(ctx, web3, seqrpc, rethrpc, el_address, debug_fn=print): + """ + Make two DRT deposits to ensure the EL address has enough funds for gas + and for subsequent withdrawals. Wait until the deposit is reflected on L2. + """ + # Get bridge pubkey + bridge_pk = utils.get_bridge_pubkey(seqrpc) + debug_fn(f"Bridge pubkey: {bridge_pk}") + + # Make two deposits + make_drt(ctx, el_address, bridge_pk) + make_drt(ctx, el_address, bridge_pk) + + # Optionally, check balance immediately to see if it's updated + balance_after_deposits = int(rethrpc.eth_getBalance(el_address), 16) + debug_fn(f"Strata Balance right after deposit calls: {balance_after_deposits}") + + # Wait until the deposit is seen on L2 + expected_balance = 2 * DEPOSIT_AMOUNT * SATS_TO_WEI + utils.wait_until(lambda: int(rethrpc.eth_getBalance(el_address), 16) == expected_balance) + + # Final assertion + final_balance = int(rethrpc.eth_getBalance(el_address), 16) + debug_fn(f"Strata Balance after deposits: {final_balance}") + assert final_balance == expected_balance, "Strata balance after deposit is not as expected" + + +def do_withdrawal( + web3: Web3, + rethrpc, + el_address: str, + withdraw_address: str, + debug_fn=print, + deposit_amount=DEPOSIT_AMOUNT, +): + """ + Perform a withdrawal from the L2 to the given BTC withdraw address. + Returns (l2_tx_hash, tx_receipt, total_gas_used). + """ + # Build the p2tr pubkey from the withdraw address + change_address_pk = extract_p2tr_pubkey(withdraw_address) + debug_fn(f"Change Address PK: {change_address_pk}") + + # Estimate gas + estimated_withdraw_gas = estimate_withdraw_gas( + web3, el_address, change_address_pk, deposit_amount + ) + debug_fn(f"Estimated withdraw gas: {estimated_withdraw_gas}") + + l2_tx_hash = make_withdraw( + web3, el_address, change_address_pk, deposit_amount, estimated_withdraw_gas + ).hex() + debug_fn(f"Sent withdrawal transaction with hash: {l2_tx_hash}") + + # Wait for transaction receipt + utils.wait_until(lambda: web3.eth.get_transaction_receipt(l2_tx_hash)) + tx_receipt = web3.eth.get_transaction_receipt(l2_tx_hash) + debug_fn(f"Transaction receipt: {tx_receipt}") + + total_gas_used = tx_receipt["gasUsed"] * tx_receipt["effectiveGasPrice"] + debug_fn(f"Total gas used: {total_gas_used}") + + # Ensure the leftover in the EL address is what's expected (deposit minus gas) + balance_post_withdraw = int(rethrpc.eth_getBalance(el_address), 16) + difference = deposit_amount * SATS_TO_WEI - total_gas_used + debug_fn(f"Strata Balance after withdrawal: {balance_post_withdraw}") + debug_fn(f"Strata Balance difference: {difference}") + assert difference == balance_post_withdraw, "balance difference is not expected" + + return l2_tx_hash, tx_receipt, total_gas_used + + +def confirm_btc_withdrawal( + btcrpc, + withdraw_address, + btc_url, + btc_user, + btc_password, + original_balance, + expected_increase, + debug_fn=print, +): + """ + Wait for the BTC balance to reflect the withdrawal and confirm the final balance + equals `original_balance + expected_increase`. + """ + # Wait for the new balance (and presumably the maturity): + mine_blocks_until_maturity( + btcrpc, withdraw_address, btc_url, btc_user, btc_password, original_balance + ) + + # Check final BTC balance + btc_balance = get_balance(withdraw_address, btc_url, btc_user, btc_password) + debug_fn(f"BTC final balance: {btc_balance}") + debug_fn(f"Expected final balance: {original_balance + expected_increase}") + + assert ( + btc_balance == original_balance + expected_increase + ), "BTC balance after withdrawal is not as expected" diff --git a/functional-tests/testenv.py b/functional-tests/testenv.py index 6cdb4c8d4..276dc0694 100644 --- a/functional-tests/testenv.py +++ b/functional-tests/testenv.py @@ -94,6 +94,7 @@ def __init__( pre_fund_addrs: bool = True, n_operators: int = 2, message_interval: int = DEFAULT_ROLLUP_PARAMS["message_interval"], + duty_timeout_duration: int = DEFAULT_BRIDGE_DUTY_TIMEOUT_DURATION, custom_chain: str = "dev", ): super().__init__() @@ -104,6 +105,7 @@ def __init__( self.pre_fund_addrs = pre_fund_addrs self.n_operators = n_operators self.message_interval = message_interval + self.duty_timeout_duration = duty_timeout_duration self.custom_chain = custom_chain def init(self, ctx: flexitest.EnvContext) -> flexitest.LiveEnv: @@ -225,7 +227,11 @@ def init(self, ctx: flexitest.EnvContext) -> flexitest.LiveEnv: xpriv = f.read().strip() seq_url = sequencer.get_prop("rpc_url") br = bridge_fac.create_operator( - xpriv, seq_url, bitcoind_config, message_interval=self.message_interval + xpriv, + seq_url, + bitcoind_config, + message_interval=self.message_interval, + duty_timeout_duration=self.duty_timeout_duration, ) name = f"bridge.{i}" svcs[name] = br