diff --git a/.github/workflows/check-code.yml b/.github/workflows/check-code.yml index 119082a8..59c47c66 100644 --- a/.github/workflows/check-code.yml +++ b/.github/workflows/check-code.yml @@ -7,10 +7,15 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check-code: name: Check Code runs-on: ubuntu-latest + timeout-minutes: 25 permissions: id-token: write contents: read diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index eaa0e48f..1fbf17fb 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -4,10 +4,15 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: integration: name: End to End Test runs-on: ubuntu-latest + timeout-minutes: 25 permissions: id-token: write contents: read diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 4ea8cd36..a734617b 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -4,10 +4,15 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: integration: name: Integration Test runs-on: ubuntu-latest + timeout-minutes: 25 permissions: id-token: write contents: read diff --git a/src/batch/repo.rs b/src/batch/repo.rs index 9eac1234..aacf8227 100644 --- a/src/batch/repo.rs +++ b/src/batch/repo.rs @@ -13,6 +13,13 @@ pub struct BatchInfo { pub created_ledger_tx_id: LedgerTxId, } +pub struct BatchBroadcastLedgerTx<'a> { + pub tx: Transaction<'a, Postgres>, + pub batch_info: BatchInfo, + pub ledger_tx_id: LedgerTxId, + pub(crate) was_newly_set: bool, +} + #[derive(Debug, Clone)] pub struct Batches { pool: PgPool, @@ -255,7 +262,7 @@ impl Batches { &self, bitcoin_tx_id: bitcoin::Txid, wallet_id: WalletId, - ) -> Result, BatchInfo, LedgerTxId)>, BatchError> { + ) -> Result>, BatchError> { let mut tx = self.pool.begin().await?; let row = sqlx::query!( r#"WITH b AS ( @@ -284,15 +291,16 @@ impl Batches { let batch_id = BatchId::from(row.id); let payout_queue_id = PayoutQueueId::from(row.payout_queue_id); if let Some(ledger_id) = row.ledger_id { - return Ok(Some(( + return Ok(Some(BatchBroadcastLedgerTx { tx, - BatchInfo { + batch_info: BatchInfo { id: batch_id, payout_queue_id, created_ledger_tx_id, }, - LedgerTxId::from(ledger_id), - ))); + ledger_tx_id: LedgerTxId::from(ledger_id), + was_newly_set: false, + })); } let ledger_transaction_id = LedgerTxId::new(); sqlx::query!( @@ -307,15 +315,16 @@ impl Batches { .execute(&mut *tx) .await?; - Ok(Some(( + Ok(Some(BatchBroadcastLedgerTx { tx, - BatchInfo { + batch_info: BatchInfo { id: batch_id, payout_queue_id, created_ledger_tx_id, }, - ledger_transaction_id, - ))) + ledger_tx_id: ledger_transaction_id, + was_newly_set: true, + })) } #[instrument(name = "batches.set_batch_cancel_ledger_tx_id", skip(self))] diff --git a/src/job/sync_wallet.rs b/src/job/sync_wallet.rs index 24d8910a..8a5a446c 100644 --- a/src/job/sync_wallet.rs +++ b/src/job/sync_wallet.rs @@ -5,7 +5,7 @@ use bdk::{ }; use electrum_client::{Client, ConfigBuilder}; use serde::{Deserialize, Serialize}; -use tracing::{info, instrument}; +use tracing::{info, instrument, warn}; use super::error::JobError; use crate::{ @@ -22,10 +22,10 @@ use crate::{ fees::{self, FeesClient}, ledger::*, primitives::*, - utxo::{error::UtxoError, Utxos, WalletUtxo}, + utxo::{error::UtxoError, SpendDetectedOutcome, Utxos, WalletUtxo}, wallet::*, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncWalletData { @@ -79,6 +79,22 @@ struct KeychainSyncContext<'a> { fees_to_encumber: Satoshis, } +enum SpendInputState { + CompleteInputs { + income_bria_utxos: Vec, + }, + MissingInputs { + expected: usize, + found: usize, + missing_outpoints: Vec, + }, +} + +enum SpendOutcome { + Applied, + Deferred, +} + const MAX_TXS_PER_SYNC: usize = 100; #[instrument( @@ -181,21 +197,53 @@ async fn process_unsynced_txs( unsynced_tx.inputs.iter().map(|i| i.0.outpoint).collect(); let is_spend_tx = !input_outpoints.is_empty(); - // For spend transactions, all inputs must already be tracked as bria UTXOs. - // If they aren't yet (e.g. parent tx not synced), defer this tx and move on. - let income_bria_utxos = if is_spend_tx { - let utxos_by_keychain = HashMap::from([(ctx.keychain_id, input_outpoints.clone())]); - let found = ctx - .deps - .bria_utxos - .list_utxos_by_outpoint(&utxos_by_keychain) - .await?; + // Batch broadcast is recorded before input validation intentionally. + // During incident recovery, inputs from a prior wallet may not yet be synced + // when this tx is first seen. Recording the broadcast ledger entry early ensures + // it is not lost; spend accounting is deferred until inputs converge on a later + // sync cycle. This creates a temporary window where a broadcast entry exists + // without a matching spend_detected entry — this is expected and observable via + // the "batch_broadcast_recorded_while_spend_deferred" log event. + let batch_broadcast_info = if is_spend_tx { + maybe_record_batch_broadcast(ctx, &unsynced_tx).await? + } else { + None + }; - if found.len() != input_outpoints.len() { - txs_to_skip.push(unsynced_tx.tx_id.to_string()); - continue; + let income_bria_utxos = if is_spend_tx { + let spend_input_state = spend_input_state(ctx, &input_outpoints).await?; + match spend_input_state { + SpendInputState::CompleteInputs { income_bria_utxos } => income_bria_utxos, + SpendInputState::MissingInputs { + expected, + found, + missing_outpoints, + } => { + if let Some((batch_info, batch_broadcast_ledger_tx_id)) = + batch_broadcast_info.as_ref() + { + info!( + message = "batch_broadcast_recorded_while_spend_deferred", + wallet_id = %ctx.wallet.id, + keychain_id = %ctx.keychain_id, + tx_id = %unsynced_tx.tx_id, + batch_id = %batch_info.id, + batch_broadcast_ledger_tx_id = %batch_broadcast_ledger_tx_id, + ); + } + warn!( + message = "spend_inputs_missing", + wallet_id = %ctx.wallet.id, + keychain_id = %ctx.keychain_id, + tx_id = %unsynced_tx.tx_id, + expected, + found, + ?missing_outpoints, + ); + txs_to_skip.push(unsynced_tx.tx_id.to_string()); + continue; + } } - found } else { Vec::new() }; @@ -337,7 +385,18 @@ async fn process_unsynced_txs( } if is_spend_tx { - process_spend_tx(ctx, &unsynced_tx, &income_bria_utxos, &change_outputs).await?; + let outcome = process_spend_tx( + ctx, + &unsynced_tx, + &income_bria_utxos, + &change_outputs, + batch_broadcast_info, + ) + .await?; + if let SpendOutcome::Deferred = outcome { + txs_to_skip.push(unsynced_tx.tx_id.to_string()); + continue; + } } ctx.bdk_txs.mark_as_synced(unsynced_tx.tx_id).await?; @@ -392,18 +451,16 @@ async fn process_spend_tx( unsynced_tx: &UnsyncedTransaction, income_bria_utxos: &[WalletUtxo], change_outputs: &[(LocalUtxo, u32)], -) -> Result<(), JobError> { - let (mut tx, batch_info, tx_id) = if let Some((tx, create_batch, tx_id)) = ctx - .batches - .set_batch_broadcast_ledger_tx_id(unsynced_tx.tx_id, ctx.wallet.id) - .await? - { - (tx, Some(create_batch), tx_id) + batch_broadcast_info: Option<(BatchInfo, LedgerTransactionId)>, +) -> Result { + let (mut tx, batch_info, tx_id) = if let Some((batch_info, tx_id)) = batch_broadcast_info { + (ctx.pool.begin().await?, Some(batch_info), tx_id) } else { (ctx.pool.begin().await?, None, LedgerTransactionId::new()) }; let mut change_utxos: Vec<(&LocalUtxo, AddressInfo)> = Vec::new(); + let mut change_addrs = Vec::new(); for (utxo, path) in change_outputs { let address_info = ctx .keychain_wallet @@ -419,10 +476,7 @@ async fn process_spend_tx( .metadata(Some(address_metadata(&unsynced_tx.tx_id))) .build() .expect("Could not build new address in sync wallet"); - ctx.deps - .bria_addresses - .persist_if_not_present(&mut tx, found_addr) - .await?; + change_addrs.push(found_addr); change_utxos.push((utxo, address_info)); } @@ -448,79 +502,161 @@ async fn process_spend_tx( ) .await?; - if let Some((settled_sats, allocations)) = spend_detected { - if let Some(BatchInfo { - created_ledger_tx_id, - .. - }) = batch_info - { - ctx.deps - .ledger - .batch_broadcast( - tx, - created_ledger_tx_id, - tx_id, - ctx.fees_to_encumber, - ctx.wallet.ledger_account_ids, - ) - .await?; - } else { - let reserved_fees = ctx - .deps - .ledger - .sum_reserved_fees_in_txs(income_bria_utxos.iter().fold( - HashMap::new(), - |mut m, u| { - m.entry(u.utxo_detected_ledger_tx_id) - .or_default() - .push(u.outpoint); - m - }, - )) - .await?; - ctx.deps - .ledger - .spend_detected( - tx, - tx_id, - SpendDetectedParams { - journal_id: ctx.wallet.journal_id, - ledger_account_ids: ctx.wallet.ledger_account_ids, - reserved_fees, - meta: SpendDetectedMeta { - encumbered_spending_fees: change_utxos - .iter() - .map(|(u, _)| (u.outpoint, ctx.fees_to_encumber)) - .collect(), - withdraw_from_effective_when_settled: allocations, - tx_summary: WalletTransactionSummary { - account_id: ctx.data.account_id, - wallet_id: ctx.wallet.id, - current_keychain_id: ctx.keychain_id, - bitcoin_tx_id: unsynced_tx.tx_id, - total_utxo_in_sats: unsynced_tx.total_utxo_in_sats, - total_utxo_settled_in_sats: settled_sats, - fee_sats: unsynced_tx.fee_sats, - cpfp_details: None, - cpfp_fee_sats: None, - change_utxos: change_utxos + match spend_detected { + SpendDetectedOutcome::Applied(settled_sats, allocations) => { + for addr in change_addrs { + ctx.deps + .bria_addresses + .persist_if_not_present(&mut tx, addr) + .await?; + } + + if batch_info.is_none() { + let reserved_fees = ctx + .deps + .ledger + .sum_reserved_fees_in_txs(income_bria_utxos.iter().fold( + HashMap::new(), + |mut m, u| { + m.entry(u.utxo_detected_ledger_tx_id) + .or_default() + .push(u.outpoint); + m + }, + )) + .await?; + ctx.deps + .ledger + .spend_detected( + tx, + tx_id, + SpendDetectedParams { + journal_id: ctx.wallet.journal_id, + ledger_account_ids: ctx.wallet.ledger_account_ids, + reserved_fees, + meta: SpendDetectedMeta { + encumbered_spending_fees: change_utxos .iter() - .map(|(u, a)| ChangeOutput { - outpoint: u.outpoint, - address: a.address.clone().into(), - satoshis: Satoshis::from(u.txout.value), - }) + .map(|(u, _)| (u.outpoint, ctx.fees_to_encumber)) .collect(), + withdraw_from_effective_when_settled: allocations, + tx_summary: WalletTransactionSummary { + account_id: ctx.data.account_id, + wallet_id: ctx.wallet.id, + current_keychain_id: ctx.keychain_id, + bitcoin_tx_id: unsynced_tx.tx_id, + total_utxo_in_sats: unsynced_tx.total_utxo_in_sats, + total_utxo_settled_in_sats: settled_sats, + fee_sats: unsynced_tx.fee_sats, + cpfp_details: None, + cpfp_fee_sats: None, + change_utxos: change_utxos + .iter() + .map(|(u, a)| ChangeOutput { + outpoint: u.outpoint, + address: a.address.clone().into(), + satoshis: Satoshis::from(u.txout.value), + }) + .collect(), + }, + confirmation_time: unsynced_tx.confirmation_time.clone(), }, - confirmation_time: unsynced_tx.confirmation_time.clone(), }, - }, - ) - .await?; + ) + .await?; + } else { + tx.commit().await?; + } + + Ok(SpendOutcome::Applied) + } + SpendDetectedOutcome::AlreadyApplied => { + tx.commit().await?; + Ok(SpendOutcome::Applied) + } + SpendDetectedOutcome::Deferred => { + // Explicit rollback for readability: dropping sqlx::Transaction also rolls back, + // but this makes the deferred control flow obvious to future maintainers. + tx.rollback().await?; + warn!( + message = "spend_detected_deferred", + wallet_id = %ctx.wallet.id, + keychain_id = %ctx.keychain_id, + tx_id = %unsynced_tx.tx_id, + ); + Ok(SpendOutcome::Deferred) } } +} - Ok(()) +async fn maybe_record_batch_broadcast( + ctx: &KeychainSyncContext<'_>, + unsynced_tx: &UnsyncedTransaction, +) -> Result, JobError> { + let Some(BatchBroadcastLedgerTx { + tx, + batch_info, + ledger_tx_id: tx_id, + was_newly_set, + }) = ctx + .batches + .set_batch_broadcast_ledger_tx_id(unsynced_tx.tx_id, ctx.wallet.id) + .await? + else { + return Ok(None); + }; + + if was_newly_set { + ctx.deps + .ledger + .batch_broadcast( + tx, + batch_info.created_ledger_tx_id, + tx_id, + ctx.fees_to_encumber, + ctx.wallet.ledger_account_ids, + ) + .await?; + } else { + tx.commit().await?; + } + + Ok(Some((batch_info, tx_id))) +} + +async fn spend_input_state( + ctx: &KeychainSyncContext<'_>, + input_outpoints: &[bitcoin::OutPoint], +) -> Result { + let utxos_by_keychain = HashMap::from([(ctx.keychain_id, input_outpoints.to_vec())]); + let found = ctx + .deps + .bria_utxos + .list_utxos_by_outpoint(&utxos_by_keychain) + .await?; + + if found.len() == input_outpoints.len() { + return Ok(SpendInputState::CompleteInputs { + income_bria_utxos: found, + }); + } + + let found_outpoints = found + .iter() + .map(|WalletUtxo { outpoint, .. }| *outpoint) + .collect::>(); + let missing_outpoints = input_outpoints + .iter() + .copied() + .filter(|outpoint| !found_outpoints.contains(outpoint)) + .take(10) + .collect::>(); + + Ok(SpendInputState::MissingInputs { + expected: input_outpoints.len(), + found: found.len(), + missing_outpoints, + }) } // Settles income UTXOs that BDK has confirmed but bria hasn't settled yet. diff --git a/src/utxo/mod.rs b/src/utxo/mod.rs index 226cd7ef..98676676 100644 --- a/src/utxo/mod.rs +++ b/src/utxo/mod.rs @@ -16,6 +16,12 @@ pub use entity::*; use error::UtxoError; use repo::*; +pub enum SpendDetectedOutcome { + Applied(Satoshis, HashMap), + AlreadyApplied, + Deferred, +} + #[derive(Debug, Clone, Copy)] pub enum UtxoSelectionMode { Payout, @@ -112,62 +118,60 @@ impl Utxos { tx_fee: Satoshis, tx_vbytes: u64, current_block_height: u32, - ) -> Result)>, UtxoError> { - let mut inputs = Vec::new(); - let mut input_tx_ids = Vec::new(); - - for input in inputs_iter { - input_tx_ids.push(input.txid.to_string()); - inputs.push(input); - } + ) -> Result { + let (inputs, input_tx_ids): (Vec<&OutPoint>, Vec) = inputs_iter + .map(|input| (input, input.txid.to_string())) + .unzip(); - for (utxo, address) in change_utxos.iter() { - let mut new_utxo = NewUtxo::builder() - .account_id(account_id) - .wallet_id(wallet_id) - .keychain_id(keychain_id) - .utxo_detected_ledger_tx_id(tx_id) - .outpoint(utxo.outpoint) - .kind(address.keychain) - .address_idx(address.index) - .address(address.to_string()) - .script_hex(format!("{:x}", utxo.txout.script_pubkey)) - .value(utxo.txout.value) - .bdk_spent(utxo.is_spent) - .detected_block_height(current_block_height) - .origin_tx_vbytes(tx_vbytes) - .origin_tx_fee(tx_fee) - .self_pay(true) - .origin_tx_trusted_input_tx_ids(Some(&input_tx_ids)); - if let Some((batch_id, payout_queue_id)) = batch { - new_utxo = new_utxo - .origin_tx_batch_id(batch_id) - .origin_tx_payout_queue_id(payout_queue_id); - } - - let res = self - .utxos - .persist_utxo(tx, new_utxo.build().expect("Could not build NewUtxo")) - .await?; - if res.is_none() { - return Ok(None); - } - } - let utxos = self + let mark_spent_res = self .utxos .mark_spent(tx, keychain_id, inputs.into_iter(), tx_id) .await?; - if utxos.is_empty() { - return Ok(None); + match mark_spent_res { + MarkSpentResult::Spent(utxos) => { + for (utxo, address) in change_utxos.iter() { + let mut new_utxo = NewUtxo::builder() + .account_id(account_id) + .wallet_id(wallet_id) + .keychain_id(keychain_id) + .utxo_detected_ledger_tx_id(tx_id) + .outpoint(utxo.outpoint) + .kind(address.keychain) + .address_idx(address.index) + .address(address.to_string()) + .script_hex(format!("{:x}", utxo.txout.script_pubkey)) + .value(utxo.txout.value) + .bdk_spent(utxo.is_spent) + .detected_block_height(current_block_height) + .origin_tx_vbytes(tx_vbytes) + .origin_tx_fee(tx_fee) + .self_pay(true) + .origin_tx_trusted_input_tx_ids(Some(&input_tx_ids)); + if let Some((batch_id, payout_queue_id)) = batch { + new_utxo = new_utxo + .origin_tx_batch_id(batch_id) + .origin_tx_payout_queue_id(payout_queue_id); + } + + self.utxos + .persist_utxo(tx, new_utxo.build().expect("Could not build NewUtxo")) + .await?; + } + + let (total_settled_in, allocations) = + effective_allocation::withdraw_from_effective_when_settled( + utxos, + change_utxos.iter().fold(Satoshis::ZERO, |s, (u, _)| { + s + Satoshis::from(u.txout.value) + }), + ); + Ok(SpendDetectedOutcome::Applied(total_settled_in, allocations)) + } + MarkSpentResult::AlreadySpent => Ok(SpendDetectedOutcome::AlreadyApplied), + // Defense-in-depth: inputs were validated earlier in sync_wallet, but state can still + // drift between checks due to concurrent retries or manual DB edits. + MarkSpentResult::Deferred => Ok(SpendDetectedOutcome::Deferred), } - let (total_settled_in, allocations) = - effective_allocation::withdraw_from_effective_when_settled( - utxos, - change_utxos.iter().fold(Satoshis::ZERO, |s, (u, _)| { - s + Satoshis::from(u.txout.value) - }), - ); - Ok(Some((total_settled_in, allocations))) } #[instrument(name = "utxos.spend_settled", skip(self, tx, inputs), err)] diff --git a/src/utxo/repo.rs b/src/utxo/repo.rs index 3807e2c6..e17cb638 100644 --- a/src/utxo/repo.rs +++ b/src/utxo/repo.rs @@ -7,6 +7,12 @@ use std::collections::{HashMap, HashSet}; use super::{cpfp::CpfpCandidate, entity::*, error::UtxoError}; use crate::primitives::{bitcoin::*, *}; +pub(super) enum MarkSpentResult { + Spent(Vec), + AlreadySpent, + Deferred, +} + pub struct ReservableUtxo { pub keychain_id: KeychainId, #[allow(dead_code)] @@ -146,7 +152,12 @@ impl UtxoRepo { keychain_id: KeychainId, utxos: impl Iterator, tx_id: LedgerTransactionId, - ) -> Result, UtxoError> { + ) -> Result { + let input_outpoints: Vec<(String, i32)> = utxos + .map(|out| (out.txid.to_string(), out.vout as i32)) + .collect(); + let n_inputs = input_outpoints.len(); + let mut query_builder: QueryBuilder = QueryBuilder::new( r#"WITH updated AS ( UPDATE bria_utxos SET bdk_spent = true, modified_at = NOW(), spend_detected_ledger_tx_id = "#, @@ -154,12 +165,10 @@ impl UtxoRepo { query_builder.push_bind(tx_id); query_builder .push("WHERE spend_detected_ledger_tx_id IS NULL AND (keychain_id, tx_id, vout) IN"); - let mut n_inputs = 0; - query_builder.push_tuples(utxos, |mut builder, out| { - n_inputs += 1; + query_builder.push_tuples(input_outpoints.iter(), |mut builder, (txid, vout)| { builder.push_bind(keychain_id); - builder.push_bind(out.txid.to_string()); - builder.push_bind(out.vout as i32); + builder.push_bind(txid); + builder.push_bind(vout); }); query_builder.push( r#"RETURNING tx_id, vout, value, kind, sats_per_vbyte_when_created, CASE WHEN income_settled_ledger_tx_id IS NOT NULL THEN value ELSE 0 END as settled_value ) @@ -170,22 +179,45 @@ impl UtxoRepo { ); let query = query_builder.build(); - let res = query.fetch_all(&mut **tx).await?; - Ok(if n_inputs == res.len() { - res.into_iter() - .map(|row| SpentUtxo { - outpoint: OutPoint { - txid: row.get::("tx_id").parse().unwrap(), - vout: row.get::("vout") as u32, - }, - value: Satoshis::from(row.get::("value")), - confirmed: row.get("confirmed"), - change_address: row.get("change_address"), - }) - .collect() + let updated_rows = query.fetch_all(&mut **tx).await?; + let spent_utxos: Vec = updated_rows + .into_iter() + .map(|row| SpentUtxo { + outpoint: OutPoint { + txid: row.get::("tx_id").parse().unwrap(), + vout: row.get::("vout") as u32, + }, + value: Satoshis::from(row.get::("value")), + confirmed: row.get("confirmed"), + change_address: row.get("change_address"), + }) + .collect(); + + if spent_utxos.len() == n_inputs { + return Ok(MarkSpentResult::Spent(spent_utxos)); + } + + let mut existing_query_builder: QueryBuilder = QueryBuilder::new( + "SELECT COUNT(*) as existing_count FROM bria_utxos WHERE keychain_id = ", + ); + existing_query_builder.push_bind(keychain_id); + existing_query_builder.push(" AND (tx_id, vout) IN "); + existing_query_builder.push_tuples(input_outpoints.iter(), |mut builder, (txid, vout)| { + builder.push_bind(txid); + builder.push_bind(vout); + }); + + let existing_count: i64 = existing_query_builder + .build() + .fetch_one(&mut **tx) + .await? + .get("existing_count"); + + if existing_count as usize == n_inputs { + Ok(MarkSpentResult::AlreadySpent) } else { - Vec::new() - }) + Ok(MarkSpentResult::Deferred) + } } pub async fn settle_utxo( diff --git a/tests/e2e/bitcoind_sync.bats b/tests/e2e/bitcoind_sync.bats index 94bd7c58..2a980a73 100644 --- a/tests/e2e/bitcoind_sync.bats +++ b/tests/e2e/bitcoind_sync.bats @@ -33,12 +33,8 @@ teardown_file() { bitcoin_cli -regtest sendtoaddress ${bitcoind_signer_address} 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_income) == 100000000 ]] && break - sleep 1 - done - [[ $(cached_pending_income) == 100000000 ]] || exit 1 + retry 60 1 wallet_pending_income_is 100000000 + wallet_pending_income_is 100000000 || exit 1 n_addresses=$(bria_cmd list-addresses -w default | jq -r '.addresses | length') [ "$n_addresses" = "2" ] || exit 1 @@ -50,12 +46,8 @@ teardown_file() { bitcoin_cli -generate 2 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_current_settled) == 100000000 ]] && break - sleep 1 - done - [[ $(cached_current_settled) == 100000000 ]] || exit 1; + retry 60 1 wallet_current_settled_is 100000000 + wallet_current_settled_is 100000000 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -67,13 +59,10 @@ teardown_file() { @test "bitcoind_signer_sync: Detects outgoing transactions" { bitcoind_address=$(bitcoin_cli -regtest getnewaddress) bitcoin_signer_cli -regtest sendtoaddress "${bitcoind_address}" 0.5 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 50000000 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 50000000 ]] || exit 1 - [[ $(cached_current_settled) == 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 50000000 + wallet_pending_outgoing_is 50000000 || exit 1 + retry 60 1 wallet_current_settled_is 0 + wallet_current_settled_is 0 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -83,12 +72,10 @@ teardown_file() { bitcoin_cli -generate 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_current_settled) != 0 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 0 ]] || exit 1 + retry 60 1 wallet_current_settled_is_not 0 + wallet_current_settled_is_not 0 || exit 1 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -108,37 +95,25 @@ teardown_file() { bitcoin_cli -regtest sendtoaddress ${bitcoind_signer_address} 1 bitcoind_address=$(bitcoin_cli -regtest getnewaddress) - for i in {1..20}; do - [[ $(bitcoin_signer_cli getunconfirmedbalance) == "2.00000000" ]] && break - sleep 1 - done + retry 20 1 signer_unconfirmed_balance_is "2.00000000" + signer_unconfirmed_balance_is "2.00000000" || exit 1 bitcoin_signer_cli_send_all_utxos \ 2.1 \ 0.38 \ ${bitcoind_address} - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 210000000 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 210000000 ]] || exit 1 - [[ $(cached_effective_settled) != 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 210000000 + wallet_pending_outgoing_is 210000000 || exit 1 + retry 60 1 wallet_effective_settled_is_not 0 + wallet_effective_settled_is_not 0 || exit 1 bitcoin_cli -generate 2 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 0 ]] && break - sleep 1 - done + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 - bitcoind_signer_balance_in_btc=$(bitcoin_signer_cli getbalance) - bitcoind_signer_balance=$(convert_btc_to_sats "${bitcoind_signer_balance_in_btc}") - if [[ "$(cached_effective_settled)" != "${bitcoind_signer_balance}" ]]; then - echo "$(cached_effective_settled)" != "${bitcoind_signer_balance}" - exit 1 - fi + retry 60 1 wallet_effective_settled_matches_signer_balance + wallet_effective_settled_matches_signer_balance || exit 1 } @test "bitcoind_signer_sync: Can sweep all" { @@ -147,37 +122,21 @@ teardown_file() { bitcoind_address=$(bitcoin_cli -regtest getnewaddress) bitcoin_signer_cli -named sendall recipients="[\"${bitcoind_address}\"]" fee_rate=1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_current_settled) == 0 ]] \ - && [[ $(cached_pending_outgoing) != 0 ]] \ - && break - sleep 1 - done - [[ $(cached_current_settled) == 0 ]] \ - && [[ $(cached_pending_outgoing) != 0 ]] \ - || exit 1 + retry 60 1 wallet_current_settled_is_zero_and_pending_outgoing_is_not_zero + wallet_current_settled_is_zero_and_pending_outgoing_is_not_zero || exit 1 bitcoin_cli -generate 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 0 ]] \ - && [[ $(cached_encumbered_fees) == 0 ]] \ - && break - sleep 1 - done - [[ $(cached_encumbered_fees) == 0 ]] || exit 1 - [[ $(cached_effective_settled) == 0 ]] || exit 1 - [[ $(cached_pending_outgoing) == 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_and_encumbered_fees_are_zero + wallet_pending_outgoing_and_encumbered_fees_are_zero || exit 1 + retry 60 1 wallet_effective_settled_is 0 + wallet_effective_settled_is 0 || exit 1 } @test "bitcoind_signer_sync: Can spend only from unconfirmed" { bitcoind_signer_address=$(bitcoin_signer_cli getnewaddress) bitcoin_cli -regtest sendtoaddress ${bitcoind_signer_address} 1 - for i in {1..20}; do - [[ $(bitcoin_signer_cli getunconfirmedbalance) == "1.00000000" ]] && break - sleep 1 - done + retry 20 1 signer_unconfirmed_balance_is "1.00000000" + signer_unconfirmed_balance_is "1.00000000" || exit 1 bitcoind_address=$(bitcoin_cli -regtest getnewaddress) bitcoin_signer_cli_send_all_utxos \ @@ -185,23 +144,176 @@ teardown_file() { 0.39 \ ${bitcoind_address} + retry 60 1 wallet_pending_outgoing_is 60000000 + wallet_pending_outgoing_is 60000000 || exit 1 + retry 60 1 wallet_effective_settled_is 0 + wallet_effective_settled_is 0 || exit 1 + + bitcoin_cli -generate 2 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 + retry 60 1 wallet_effective_settled_matches_current_settled + wallet_effective_settled_matches_current_settled || exit 1 + retry 60 1 wallet_effective_settled_matches_signer_balance + wallet_effective_settled_matches_signer_balance || exit 1 +} + +@test "bitcoind_signer_sync: Batch broadcast ledger marker is set even when spend inputs are missing in bria_utxos" { + cache_wallet_balance + initial_settled=$(cached_current_settled) + fund_btc_each="1" + fund_sats_each=$(convert_btc_to_sats "${fund_btc_each}") + expected_funding_sats=$(( fund_sats_each * 2 )) + target_settled=$(( initial_settled + expected_funding_sats )) + + bria_cmd set-signer-config \ + --xpub "68bfb290" bitcoind \ + --endpoint "${BITCOIND_SIGNER_ENDPOINT}" \ + --rpc-user "rpcuser" \ + --rpc-password "invalidpassword" + + bria_cmd create-payout-queue -n drift_manual -m true + + bria_address=$(bria_cmd new-address -w default | jq -r '.address') + bitcoin_cli -regtest sendtoaddress "${bria_address}" "${fund_btc_each}" + bitcoin_cli -regtest sendtoaddress "${bria_address}" "${fund_btc_each}" + bitcoin_cli -generate 6 + + retry 60 1 wallet_current_settled_ge ${target_settled} + wallet_current_settled_ge ${target_settled} || exit 1 + + funded_delta=$(( $(cached_current_settled) - initial_settled )) + [[ ${funded_delta} -ge ${expected_funding_sats} ]] || exit 1 + payout_amount=$(( funded_delta * 60 / 100 )) + [[ ${payout_amount} -gt 0 ]] || exit 1 + + payout_id=$(bria_cmd submit-payout -w default --queue-name drift_manual --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount "${payout_amount}" | jq -r '.id') + [[ "${payout_id}" != "null" ]] || exit 1 + + for i in {1..40}; do + bria_cmd trigger-payout-queue --name drift_manual + batch_id=$(bria_cmd get-payout -i "${payout_id}" | jq -r '.payout.batchId') + [[ "${batch_id}" != "null" ]] && break + sleep 1 + done + [[ "${batch_id}" != "null" ]] || exit 1 + + reserved_outpoint=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT tx_id || ':' || vout FROM bria_utxos WHERE spending_batch_id = '${batch_id}' LIMIT 1" | tr -d '[:space:]') + [[ -n "${reserved_outpoint}" ]] || exit 1 + + reserved_txid=${reserved_outpoint%:*} + reserved_vout=${reserved_outpoint#*:} + docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -c "DELETE FROM bria_utxos WHERE tx_id = '${reserved_txid}' AND vout = ${reserved_vout}" > /dev/null + bria_utxo_exists=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT COUNT(*) FROM bria_utxos WHERE tx_id = '${reserved_txid}' AND vout = ${reserved_vout}" | tr -d '[:space:]') + [[ "${bria_utxo_exists}" -eq 0 ]] || exit 1 + + bdk_copy_exists=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT COUNT(*) FROM bdk_utxos WHERE tx_id = '${reserved_txid}' AND vout = ${reserved_vout}" | tr -d '[:space:]') + [[ "${bdk_copy_exists}" -ge 1 ]] || exit 1 + + bria_cmd set-signer-config \ + --xpub "68bfb290" bitcoind \ + --endpoint "${BITCOIND_SIGNER_ENDPOINT}" \ + --rpc-user "rpcuser" \ + --rpc-password "rpcpassword" + + for i in {1..40}; do + payout_tx_id=$(bria_cmd get-payout -i "${payout_id}" | jq -r '.payout.txId') + [[ "${payout_tx_id}" != "null" ]] && break + sleep 1 + done + [[ "${payout_tx_id}" != "null" ]] || exit 1 + + retry 60 1 bdk_tx_synced_flag_is "${payout_tx_id}" 0 + bdk_tx_synced_flag_is "${payout_tx_id}" 0 || exit 1 + for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 60000000 ]] && break + broadcast_ledger_id=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT batch_broadcast_ledger_tx_id::text FROM bria_batch_wallet_summaries WHERE batch_id = '${batch_id}' LIMIT 1" | tr -d '[:space:]') + [[ -n "${broadcast_ledger_id}" && "${broadcast_ledger_id}" != "null" ]] && break sleep 1 done - [[ $(cached_pending_outgoing) == 60000000 ]] || exit 1 - [[ $(cached_effective_settled) == 0 ]] || exit 1 + [[ -n "${broadcast_ledger_id}" && "${broadcast_ledger_id}" != "null" ]] || exit 1 - bitcoin_cli -generate 2 for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 0 ]] && break + grep -q "spend_inputs_missing.*\"tx_id\":\"${payout_tx_id}\"" .e2e-logs && break sleep 1 done - [[ $(cached_pending_outgoing) == 0 ]] || exit 1 - [[ $(cached_effective_settled) == $(cached_current_settled) ]] || exit 1 - bitcoind_signer_balance_in_btc=$(bitcoin_signer_cli getbalance) - bitcoind_signer_balance=$(convert_btc_to_sats "${bitcoind_signer_balance_in_btc}") - [[ "$(cached_effective_settled)" == "${bitcoind_signer_balance}" ]] || exit 1 + grep -q "spend_inputs_missing.*\"tx_id\":\"${payout_tx_id}\"" .e2e-logs || exit 1 +} + +@test "bitcoind_signer_sync: AlreadyApplied spend path does not persist conflicting change state" { + cache_wallet_balance + initial_settled=$(cached_current_settled) + fund_btc_each="1" + fund_sats_each=$(convert_btc_to_sats "${fund_btc_each}") + expected_funding_sats=$(( fund_sats_each * 2 )) + target_settled=$(( initial_settled + expected_funding_sats )) + queue_name="already_applied_manual_$RANDOM" + + bria_cmd set-signer-config \ + --xpub "68bfb290" bitcoind \ + --endpoint "${BITCOIND_SIGNER_ENDPOINT}" \ + --rpc-user "rpcuser" \ + --rpc-password "invalidpassword" + + bria_cmd create-payout-queue -n "${queue_name}" -m true + + bria_address=$(bria_cmd new-address -w default | jq -r '.address') + bitcoin_cli -regtest sendtoaddress "${bria_address}" "${fund_btc_each}" + bitcoin_cli -regtest sendtoaddress "${bria_address}" "${fund_btc_each}" + bitcoin_cli -generate 6 + + retry 60 1 wallet_current_settled_ge ${target_settled} + wallet_current_settled_ge ${target_settled} || exit 1 + + funded_delta=$(( $(cached_current_settled) - initial_settled )) + [[ ${funded_delta} -ge ${expected_funding_sats} ]] || exit 1 + payout_amount=$(( funded_delta * 60 / 100 )) + [[ ${payout_amount} -gt 0 ]] || exit 1 + + payout_id=$(bria_cmd submit-payout -w default --queue-name "${queue_name}" --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount "${payout_amount}" | jq -r '.id') + [[ "${payout_id}" != "null" ]] || exit 1 + + for i in {1..40}; do + bria_cmd trigger-payout-queue --name "${queue_name}" + batch_id=$(bria_cmd get-payout -i "${payout_id}" | jq -r '.payout.batchId') + [[ "${batch_id}" != "null" ]] && break + sleep 1 + done + [[ "${batch_id}" != "null" ]] || exit 1 + + reserved_count=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT COUNT(*) FROM bria_utxos WHERE spending_batch_id = '${batch_id}'" | tr -d '[:space:]') + [[ "${reserved_count}" -ge 1 ]] || exit 1 + + docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -c "UPDATE bria_utxos SET spend_detected_ledger_tx_id = gen_random_uuid(), bdk_spent = true WHERE spending_batch_id = '${batch_id}'" > /dev/null + + bria_cmd set-signer-config \ + --xpub "68bfb290" bitcoind \ + --endpoint "${BITCOIND_SIGNER_ENDPOINT}" \ + --rpc-user "rpcuser" \ + --rpc-password "rpcpassword" + + for i in {1..40}; do + payout_tx_id=$(bria_cmd get-payout -i "${payout_id}" | jq -r '.payout.txId') + [[ "${payout_tx_id}" != "null" ]] && break + sleep 1 + done + [[ "${payout_tx_id}" != "null" ]] || exit 1 + + retry 180 1 bdk_tx_synced_flag_is "${payout_tx_id}" 1 + bdk_tx_synced_flag_is "${payout_tx_id}" 1 || exit 1 + + for i in {1..60}; do + broadcast_ledger_id=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT batch_broadcast_ledger_tx_id::text FROM bria_batch_wallet_summaries WHERE batch_id = '${batch_id}' LIMIT 1" | tr -d '[:space:]') + [[ -n "${broadcast_ledger_id}" && "${broadcast_ledger_id}" != "null" ]] && break + sleep 1 + done + [[ -n "${broadcast_ledger_id}" && "${broadcast_ledger_id}" != "null" ]] || exit 1 + + payout_utxo_count=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT COUNT(*) FROM bria_utxos WHERE tx_id = '${payout_tx_id}'" | tr -d '[:space:]') + [[ "${payout_utxo_count}" -eq 0 ]] || exit 1 + + payout_addr_event_count=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT COUNT(*) FROM bria_address_events WHERE event_type = 'metadata_updated' AND event->'metadata'->>'synced_in_tx' = '${payout_tx_id}'" | tr -d '[:space:]') + [[ "${payout_addr_event_count}" -eq 0 ]] || exit 1 + + ! grep -q "spend_inputs_missing.*\"tx_id\":\"${payout_tx_id}\"" .e2e-logs } diff --git a/tests/e2e/bria.docker.yml b/tests/e2e/bria.docker.yml index 91f6c6c8..8932e107 100644 --- a/tests/e2e/bria.docker.yml +++ b/tests/e2e/bria.docker.yml @@ -5,6 +5,3 @@ app: fees: mempool_space: url: http://mempool:8999 - jobs: - sync_all_wallets_delay: 1 - process_all_payout_queues_delay: 1 diff --git a/tests/e2e/bria.local.yml b/tests/e2e/bria.local.yml index 29f8bf3f..ed288e79 100644 --- a/tests/e2e/bria.local.yml +++ b/tests/e2e/bria.local.yml @@ -5,6 +5,3 @@ app: fees: mempool_space: url: http://localhost:8999 - jobs: - sync_all_wallets_delay: 1 - process_all_payout_queues_delay: 1 diff --git a/tests/e2e/helpers.bash b/tests/e2e/helpers.bash index a299408d..24dbfb0b 100644 --- a/tests/e2e/helpers.bash +++ b/tests/e2e/helpers.bash @@ -200,15 +200,201 @@ retry() { local delay=$1 shift local i + local attempt_status - for ((i=0; i < attempts; i++)); do - run "$@" - if [[ "$status" -eq 0 ]] ; then + for ((i = 0; i < attempts; i++)); do + if [[ "${BATS_TEST_DIRNAME}" = "" ]]; then + "$@" + attempt_status=$? + else + run "$@" + attempt_status=$status + fi + + if [[ "$attempt_status" -eq 0 ]]; then return 0 fi + sleep "$delay" done echo "Command \"$*\" failed $attempts times. Output: $output" false } + +wallet_pending_outgoing_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_pending_outgoing)" == "${expected}" ]] +} + +wallet_pending_income_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_pending_income)" == "${expected}" ]] +} + +wallet_pending_income_is_not() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_pending_income)" != "${expected}" ]] +} + +wallet_current_settled_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_current_settled)" == "${expected}" ]] +} + +wallet_current_settled_ge() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ $(cached_current_settled) -ge ${expected} ]] +} + +wallet_pending_outgoing_is_not() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_pending_outgoing)" != "${expected}" ]] +} + +wallet_encumbered_outgoing_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_encumbered_outgoing)" == "${expected}" ]] +} + +wallet_current_settled_is_not() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_current_settled)" != "${expected}" ]] +} + +wallet_current_settled_or_pending_outgoing_is_not_zero() { + local wallet_name="${1:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_current_settled)" != "0" || "$(cached_pending_outgoing)" != "0" ]] +} + +wallet_encumbered_fees_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_encumbered_fees)" == "${expected}" ]] +} + +wallet_effective_settled_is() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_effective_settled)" == "${expected}" ]] +} + +wallet_encumbered_outgoing_is_and_effective_settled_ge() { + local encumbered_expected="$1" + local effective_settled_min="$2" + local wallet_name="${3:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_encumbered_outgoing)" == "${encumbered_expected}" && $(cached_effective_settled) -ge ${effective_settled_min} ]] +} + +wallet_encumbered_outgoing_is_and_effective_settled_is() { + local encumbered_expected="$1" + local effective_settled_expected="$2" + local wallet_name="${3:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_encumbered_outgoing)" == "${encumbered_expected}" && "$(cached_effective_settled)" == "${effective_settled_expected}" ]] +} + +wallet_effective_settled_matches_current_settled() { + local wallet_name="${1:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_effective_settled)" == "$(cached_current_settled)" ]] +} + +wallet_current_settled_is_zero_and_pending_outgoing_is_not_zero() { + local wallet_name="${1:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_current_settled)" == "0" && "$(cached_pending_outgoing)" != "0" ]] +} + +wallet_pending_outgoing_and_encumbered_fees_are_zero() { + local wallet_name="${1:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_pending_outgoing)" == "0" && "$(cached_encumbered_fees)" == "0" ]] +} + +bdk_tx_synced_flag_is() { + local tx_id="$1" + local expected="$2" + local synced_flag + + synced_flag=$(docker exec "${COMPOSE_PROJECT_NAME}-postgres-1" psql "${PG_CON}" -t -A -c "SELECT synced_to_bria::int FROM bdk_transactions WHERE tx_id = '${tx_id}' ORDER BY modified_at DESC LIMIT 1" | tr -d '[:space:]') + [[ "${synced_flag}" == "${expected}" ]] +} + +wallet_encumbered_outgoing_is_zero() { + local wallet_name="${1:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_encumbered_outgoing)" == "0" ]] +} + +wallet_effective_settled_is_not() { + local expected="$1" + local wallet_name="${2:-default}" + + cache_wallet_balance "${wallet_name}" + [[ "$(cached_effective_settled)" != "${expected}" ]] +} + +signer_unconfirmed_balance_is() { + local expected="$1" + [[ "$(bitcoin_signer_cli getunconfirmedbalance)" == "${expected}" ]] +} + +wallet_effective_settled_matches_signer_balance() { + local wallet_name="${1:-default}" + local bitcoind_signer_balance_in_btc + local bitcoind_signer_balance + + cache_wallet_balance "${wallet_name}" + bitcoind_signer_balance_in_btc=$(bitcoin_signer_cli getbalance) + bitcoind_signer_balance=$(convert_btc_to_sats "${bitcoind_signer_balance_in_btc}") + + [[ "$(cached_effective_settled)" == "${bitcoind_signer_balance}" ]] +} + +wallet_effective_settled_matches_lnd_balance() { + local wallet_name="${1:-default}" + local lnd_balance + + cache_wallet_balance "${wallet_name}" + lnd_balance=$(lnd_cli walletbalance | jq -r '.total_balance') + + [[ "$(cached_effective_settled)" == "${lnd_balance}" ]] +} diff --git a/tests/e2e/lnd_sync.bats b/tests/e2e/lnd_sync.bats index b2f48d89..2d7d412d 100644 --- a/tests/e2e/lnd_sync.bats +++ b/tests/e2e/lnd_sync.bats @@ -33,12 +33,8 @@ teardown_file() { bitcoin_cli -regtest sendtoaddress ${lnd_address} 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_income) == 100000000 ]] && break - sleep 1 - done - [[ $(cached_pending_income) == 100000000 ]] || exit 1 + retry 60 1 wallet_pending_income_is 100000000 + wallet_pending_income_is 100000000 || exit 1 n_addresses=$(bria_cmd list-addresses -w default | jq -r '.addresses | length') [ "$n_addresses" = "2" ] || exit 1 @@ -50,12 +46,8 @@ teardown_file() { bitcoin_cli -generate 2 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_current_settled) == 100000000 ]] && break - sleep 1 - done - [[ $(cached_current_settled) == 100000000 ]] || exit 1; + retry 60 1 wallet_current_settled_is 100000000 + wallet_current_settled_is 100000000 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -67,13 +59,10 @@ teardown_file() { @test "lnd_sync: Detects outgoing transactions" { bitcoind_address=$(bitcoin_cli -regtest getnewaddress) lnd_cli sendcoins --addr=${bitcoind_address} --amt=50000000 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 50000000 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 50000000 ]] || exit 1 - [[ $(cached_current_settled) == 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 50000000 + wallet_pending_outgoing_is 50000000 || exit 1 + retry 60 1 wallet_current_settled_is 0 + wallet_current_settled_is 0 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -83,12 +72,10 @@ teardown_file() { bitcoin_cli -generate 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_current_settled) != 0 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 0 ]] || exit 1 + retry 60 1 wallet_current_settled_is_not 0 + wallet_current_settled_is_not 0 || exit 1 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 utxos=$(bria_cmd list-utxos -w default) n_utxos=$(jq '.keychains[0].utxos | length' <<< "${utxos}") @@ -110,23 +97,17 @@ teardown_file() { bitcoind_address=$(bitcoin_cli -regtest getnewaddress) lnd_cli sendcoins --addr=${bitcoind_address} --amt=210000000 --min_confs 0 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 210000000 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 210000000 ]] || exit 1 - [[ $(cached_effective_settled) != 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 210000000 + wallet_pending_outgoing_is 210000000 || exit 1 + retry 60 1 wallet_effective_settled_is_not 0 + wallet_effective_settled_is_not 0 || exit 1 bitcoin_cli -generate 2 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 0 ]] && break - sleep 1 - done - - lnd_balance=$(lnd_cli walletbalance | jq -r '.total_balance') - [[ "$(cached_effective_settled)" == "${lnd_balance}" ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 + + retry 60 1 wallet_effective_settled_matches_lnd_balance + wallet_effective_settled_matches_lnd_balance || exit 1 } @test "lnd_sync: Can sweep all" { @@ -134,13 +115,10 @@ teardown_file() { lnd_cli sendcoins --addr=${bitcoind_address} --sweepall bitcoin_cli -generate 1 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_encumbered_fees) == 0 ]] && break - sleep 1 - done - [[ $(cached_encumbered_fees) == 0 ]] || exit 1 - [[ $(cached_effective_settled) == 0 ]] || exit 1 + retry 60 1 wallet_encumbered_fees_is 0 + wallet_encumbered_fees_is 0 || exit 1 + retry 60 1 wallet_effective_settled_is 0 + wallet_effective_settled_is 0 || exit 1 } @test "lnd_sync: Can spend only from unconfirmed" { @@ -149,22 +127,16 @@ teardown_file() { bitcoind_address=$(bitcoin_cli -regtest getnewaddress) lnd_cli sendcoins --addr=${bitcoind_address} --amt=60000000 --min_confs 0 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 60000000 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 60000000 ]] || exit 1 - [[ $(cached_effective_settled) == 0 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 60000000 + wallet_pending_outgoing_is 60000000 || exit 1 + retry 60 1 wallet_effective_settled_is 0 + wallet_effective_settled_is 0 || exit 1 bitcoin_cli -generate 2 - for i in {1..60}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 0 ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == 0 ]] || exit 1 - [[ $(cached_effective_settled) == $(cached_current_settled) ]] || exit 1 - lnd_balance=$(lnd_cli walletbalance | jq -r '.total_balance') - [[ "$(cached_effective_settled)" == "${lnd_balance}" ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 + retry 60 1 wallet_effective_settled_matches_current_settled + wallet_effective_settled_matches_current_settled || exit 1 + retry 60 1 wallet_effective_settled_matches_lnd_balance + wallet_effective_settled_matches_lnd_balance || exit 1 } diff --git a/tests/e2e/multisig_payout.bats b/tests/e2e/multisig_payout.bats index d2c73c63..67feacc7 100644 --- a/tests/e2e/multisig_payout.bats +++ b/tests/e2e/multisig_payout.bats @@ -97,23 +97,13 @@ teardown_file() { echo "signing_failure_reason: ${signing_failure_reason}" fi - for i in {1..20}; do - cache_wallet_balance multisig - [[ $(cached_pending_income) != 0 ]] && break; - sleep 1 - done - - [[ $(cached_pending_income) != 0 ]] || exit 1 + retry 60 1 wallet_pending_income_is_not 0 multisig + wallet_pending_income_is_not 0 multisig || exit 1 [[ $(cached_current_settled) == 0 ]] || exit 1 bitcoin_cli -generate 2 - for i in {1..20}; do - cache_wallet_balance multisig - [[ $(cached_current_settled) != 0 ]] && break; - sleep 1 - done - - [[ $(cached_current_settled) != 0 ]] || exit 1; + retry 60 1 wallet_current_settled_is_not 0 multisig + wallet_current_settled_is_not 0 multisig || exit 1 } @test "multisig_payout: Broadcast a txn using bitcoind and check if balance updated" { @@ -135,19 +125,11 @@ teardown_file() { hex=$(bitcoin_cli finalizepsbt "${signed_psbt2}" true | jq -r '.hex') bitcoin_cli sendrawtransaction "${hex}" - for i in {1..20}; do - cache_wallet_balance multisig - [[ $(cached_pending_income) != 0 ]] && break; - sleep 1 - done - [[ $(cached_pending_income) != 0 ]] || exit 1 + retry 60 1 wallet_pending_income_is_not 0 multisig + wallet_pending_income_is_not 0 multisig || exit 1 bitcoin_cli -generate 2 - for i in {1..20}; do - cache_wallet_balance multisig - [[ $(cached_pending_income) == 0 ]] && break; - sleep 1 - done - [[ $(cached_pending_income) == 0 ]] || exit 1; + retry 60 1 wallet_pending_income_is 0 multisig + wallet_pending_income_is 0 multisig || exit 1 } diff --git a/tests/e2e/outbox.bats b/tests/e2e/outbox.bats index 8933d976..81c161a9 100644 --- a/tests/e2e/outbox.bats +++ b/tests/e2e/outbox.bats @@ -17,15 +17,10 @@ teardown_file() { @test "outbox: Emits utxo_dropped event" { bria_address=$(bria_cmd new-address -w default | jq -r '.address') bitcoin_cli -regtest sendtoaddress ${bria_address} 1 - for i in {1..60}; do - n_utxos=$(bria_cmd list-utxos -w default | jq '.keychains[0].utxos | length') - [[ "${n_utxos}" == "1" ]] && break - sleep 1 - done + retry 60 1 wallet_pending_income_is 100000000 + wallet_pending_income_is 100000000 || exit 1 event=$(bria_cmd watch-events -a 0 -o | jq -r '.payload.utxoDetected') [ "$event" != "null" ] || exit 1 - cache_wallet_balance - [[ $(cached_pending_income) == 100000000 ]] || exit 1; restart_bitcoin_stack bitcoind_init @@ -33,8 +28,8 @@ teardown_file() { event=$(bria_cmd watch-events -a 1 -o | jq -r '.payload.utxoDropped') [ "$event" != "null" ] || exit 1 - cache_wallet_balance - [[ $(cached_pending_income) == 0 ]] || exit 1; + retry 60 1 wallet_pending_income_is 0 + wallet_pending_income_is 0 || exit 1 } @test "outbox: Adds address augmentation to events" { diff --git a/tests/e2e/payout.bats b/tests/e2e/payout.bats index 4253737e..0eb3abda 100644 --- a/tests/e2e/payout.bats +++ b/tests/e2e/payout.bats @@ -17,12 +17,8 @@ teardown_file() { @test "payout: Batch inclusion and payout cancellation" { bria_cmd create-payout-queue --name high --interval-trigger 5 payout_id=$(bria_cmd submit-payout -w default --queue-name high --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount 75000000 | jq -r '.id') - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 75000000 ]] && break; - sleep 1 - done - [[ $(cached_encumbered_outgoing) == 75000000 ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is 75000000 + wallet_encumbered_outgoing_is 75000000 || exit 1 estimated_at=$(bria_cmd get-payout --id ${payout_id} | jq -r '.payout.batchInclusionEstimatedAt') [[ "${estimated_at}" != "null" ]] || exit 1 @@ -32,12 +28,8 @@ teardown_file() { estimated_at=$(bria_cmd get-payout --id ${payout_id} | jq -r '.payout.batchInclusionEstimatedAt') [[ "${estimated_at}" = "null" ]] || exit 1 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 0 ]] && break; - sleep 1 - done - [[ $(cached_encumbered_outgoing) == 0 ]] || exit 1; + retry 60 1 wallet_encumbered_outgoing_is 0 + wallet_encumbered_outgoing_is 0 || exit 1 } @test "payout: Fund an address and see if the balance is reflected" { @@ -89,13 +81,8 @@ teardown_file() { sleep 1 done [[ "${batch_id}" != "null" ]] || exit 1 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 150000000 ]] && break; - sleep 1 - done - - [[ $(cached_pending_outgoing) == 150000000 ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 150000000 + wallet_pending_outgoing_is 150000000 || exit 1 [[ $(cached_pending_fees) != 0 ]] || exit 1 [[ $(cached_encumbered_fees) == 0 ]] || exit 1 } @@ -130,23 +117,13 @@ teardown_file() { echo "signing_failure_reason: ${signing_failure_reason}" fi - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_pending_income) != 0 ]] && break; - sleep 1 - done - - [[ $(cached_pending_income) != 0 ]] || exit 1 + retry 60 1 wallet_pending_income_is_not 0 + wallet_pending_income_is_not 0 || exit 1 [[ $(cached_current_settled) == 0 ]] || exit 1 bitcoin_cli -generate 2 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_current_settled) != 0 ]] && break; - sleep 1 - done - - [[ $(cached_current_settled) != 0 ]] || exit 1; + retry 60 1 wallet_current_settled_or_pending_outgoing_is_not_zero + wallet_current_settled_or_pending_outgoing_is_not_zero || exit 1 } @test "payout: Creates a manually triggered payout-queue and triggers it" { @@ -182,22 +159,13 @@ teardown_file() { vout=$(echo ${payout} | jq -r '.vout') [[ "${batch_id}" != "null" && "${tx_id}" != "null" && "${vout}" != "null" ]] || exit 1 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_pending_income) != 0 ]] && break; - echo $(bria_cmd wallet-balance -w default) - sleep 1 - done - [[ $(cached_pending_income) != 0 ]] || exit 1 + retry 60 1 wallet_pending_income_is_not 0 + wallet_pending_income_is_not 0 || exit 1 bitcoin_cli -generate 2 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_pending_income) == 0 ]] && break; - sleep 1 - done - [[ $(cached_pending_income) == 0 ]] || exit 1; + retry 60 1 wallet_pending_income_is 0 + wallet_pending_income_is 0 || exit 1 } @test "payout: Can send to another wallet" { @@ -216,12 +184,8 @@ teardown_file() { [[ "${transfer_metadata}" == "true" ]] || exit 1 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_pending_outgoing) == 70000000 ]] && break; - sleep 1 - done - [[ $(cached_pending_outgoing) == 70000000 ]] || exit 1; + retry 60 1 wallet_pending_outgoing_is 70000000 + wallet_pending_outgoing_is 70000000 || exit 1 } @test "payout: Can CPFP when enabled in payout queue" { @@ -244,12 +208,8 @@ teardown_file() { --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej \ --amount 100000 - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 100000 ]] && break; - sleep 1 - done - [[ $(cached_encumbered_outgoing) == 100000 ]] || exit 1; + retry 60 1 wallet_encumbered_outgoing_is 100000 + wallet_encumbered_outgoing_is 100000 || exit 1 batch_id=$(bria_cmd list-payouts -w default | jq -r '.payouts[0].batchId') [[ "${batch_id}" == "null" ]] || exit 1 @@ -264,8 +224,8 @@ teardown_file() { done [[ "${batch_id}" != "null" ]] || exit 1; - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 0 ]] && break; + retry 60 1 wallet_encumbered_outgoing_is_zero + wallet_encumbered_outgoing_is_zero || exit 1 } @test "payout: Create and cancel an unsigned batch" { @@ -284,12 +244,8 @@ teardown_file() { payout_id=$(bria_cmd submit-payout -w default --queue-name cancel_queue --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount 1300000 | jq -r '.id') # Wait for payout to be encumbered - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 1300000 && $(cached_effective_settled) -ge 100000000 ]] && break - sleep 2 - done - [[ $(cached_encumbered_outgoing) == 1300000 && $(cached_effective_settled) -ge 100000000 ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is_and_effective_settled_ge 1300000 100000000 + wallet_encumbered_outgoing_is_and_effective_settled_ge 1300000 100000000 || exit 1 effective_settled=$(cached_effective_settled) # Wait for the batch to be created @@ -338,13 +294,8 @@ teardown_file() { [[ $(echo ${batch} | jq -r '.id') == "${batch_id}" && $(echo ${batch} | jq -r '.cancelled') == "true" ]] || exit 1 # Check that the funds are no longer encumbered - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 0 && $(cached_effective_settled) == ${effective_settled} ]] && break - sleep 1 - done - [[ $(cached_encumbered_outgoing) == 0 ]] || exit 1 - [[ $(cached_effective_settled) == ${effective_settled} ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is_and_effective_settled_is 0 ${effective_settled} + wallet_encumbered_outgoing_is_and_effective_settled_is 0 ${effective_settled} || exit 1 } @test "payout: Error when try to create and cancel a signed batch" { @@ -362,12 +313,8 @@ teardown_file() { payout_id=$(bria_cmd submit-payout -w default --queue-name cancel_queue --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount 1300000 | jq -r '.id') # Wait for payout to be encumbered - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 1300000 && $(cached_effective_settled) -ge 100000000 ]] && break - sleep 2 - done - [[ $(cached_encumbered_outgoing) == 1300000 && $(cached_effective_settled) -ge 100000000 ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is_and_effective_settled_ge 1300000 100000000 + wallet_encumbered_outgoing_is_and_effective_settled_ge 1300000 100000000 || exit 1 # Wait for the batch to be created for i in {1..20}; do @@ -388,12 +335,8 @@ teardown_file() { [[ "$output" == *"BatchError - Batch is already signed"* ]] # Check that the funds are no longer encumbered - for i in {1..20}; do - cache_wallet_balance - [[ $(cached_encumbered_outgoing) == 0 ]] && break - sleep 1 - done - [[ $(cached_encumbered_outgoing) == 0 ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is 0 + wallet_encumbered_outgoing_is 0 || exit 1 # Verify the batch is not marked as cancelled batch=$(bria_cmd get-batch -b "${batch_id}") @@ -445,52 +388,27 @@ teardown_file() { bitcoin_cli -generate 6 - for i in {1..60}; do - cache_wallet_balance - settled=$(cached_current_settled) - echo "Settled balance: ${settled}" - [[ "${settled}" -ge "130000000" ]] && break - sleep 1 - done - [[ $(cached_current_settled) -ge "130000000" ]] || exit 1 + retry 60 1 wallet_current_settled_ge 130000000 + wallet_current_settled_ge 130000000 || exit 1 echo "Creating transaction with 130+ inputs..." bitcoind_address=$(bitcoin_cli -regtest getnewaddress) bitcoin_signer_cli -named sendall recipients="[\"${bitcoind_address}\"]" fee_rate=1 echo "Waiting for spend to be detected..." - for i in {1..60}; do - cache_wallet_balance - pending=$(cached_pending_outgoing) - echo "Pending outgoing: ${pending}" - [[ "${pending}" != "0" ]] && break - sleep 1 - done - - cache_wallet_balance - echo "Pending outgoing after detection: $(cached_pending_outgoing)" - [[ $(cached_pending_outgoing) != "0" ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is_not 0 + wallet_pending_outgoing_is_not 0 || exit 1 [[ $(cached_current_settled) == "0" ]] || exit 1 echo "Confirming the spending transaction..." bitcoin_cli -generate 6 echo "Waiting for spend to be settled..." - for i in {1..120}; do - cache_wallet_balance - pending=$(cached_pending_outgoing) - encumbered=$(cached_encumbered_outgoing) - settled=$(cached_current_settled) - echo "Pending outgoing: ${pending}, encumbered: ${encumbered}, settled: ${settled}" - [[ "${pending}" == "0" ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == "0" ]] || exit 1 + retry 120 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 - cache_wallet_balance - settled=$(cached_current_settled) - echo "Final settled balance: ${settled}" - [[ "${settled}" == "0" ]] || exit 1 + retry 60 1 wallet_current_settled_is 0 + wallet_current_settled_is 0 || exit 1 } @test "payout: Can create payout batch with 120+ inputs without payload error" { @@ -524,27 +442,15 @@ teardown_file() { bitcoin_cli -generate 6 - for i in {1..60}; do - cache_wallet_balance - settled=$(cached_current_settled) - echo "Settled balance: ${settled}" - [[ "${settled}" == "130000000" ]] && break - sleep 1 - done - [[ $(cached_current_settled) == "130000000" ]] || exit 1 + retry 60 1 wallet_current_settled_is 130000000 + wallet_current_settled_is 130000000 || exit 1 echo "Submitting payout that will use 130 inputs..." destination="bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej" payout_id=$(bria_cmd submit-payout -w default --queue-name large-tx-queue --destination ${destination} --amount 125000000 | jq -r '.id') - for i in {1..60}; do - cache_wallet_balance - encumbered=$(cached_encumbered_outgoing) - echo "Encumbered outgoing: ${encumbered}" - [[ "${encumbered}" == "125000000" ]] && break - sleep 1 - done - [[ $(cached_encumbered_outgoing) == "125000000" ]] || exit 1 + retry 60 1 wallet_encumbered_outgoing_is 125000000 + wallet_encumbered_outgoing_is 125000000 || exit 1 echo "Waiting for batch creation and broadcast..." for i in {1..60}; do @@ -557,35 +463,19 @@ teardown_file() { [[ "${batch_id}" != "null" ]] || exit 1 echo "Waiting for spend to be detected..." - for i in {1..60}; do - cache_wallet_balance - pending=$(cached_pending_outgoing) - echo "Pending outgoing: ${pending}" - [[ "${pending}" != "0" ]] && break - sleep 1 - done - - cache_wallet_balance - echo "Pending outgoing after batch broadcast: $(cached_pending_outgoing)" - [[ $(cached_pending_outgoing) == "125000000" ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 125000000 + wallet_pending_outgoing_is 125000000 || exit 1 [[ $(cached_encumbered_outgoing) == "0" ]] || exit 1 echo "Confirming the batch transaction..." bitcoin_cli -generate 6 echo "Waiting for batch to be settled..." - for i in {1..60}; do - cache_wallet_balance - pending=$(cached_pending_outgoing) - echo "Pending outgoing: ${pending}" - [[ "${pending}" == "0" ]] && break - sleep 1 - done - [[ $(cached_pending_outgoing) == "0" ]] || exit 1 + retry 60 1 wallet_pending_outgoing_is 0 + wallet_pending_outgoing_is 0 || exit 1 cache_wallet_balance settled=$(cached_current_settled) - echo "Final settled balance: ${settled}" [[ "${settled}" -gt "0" && "${settled}" -le "5000000" ]] || exit 1 } @@ -622,12 +512,8 @@ teardown_file() { bitcoin_cli -regtest sendtoaddress "${stale_address}" 1 bitcoin_cli -generate 6 - for i in {1..60}; do - cache_wallet_balance default - [[ $(cached_current_settled) -ge 100000000 ]] && break - sleep 1 - done - [[ $(cached_current_settled) -ge 100000000 ]] || exit 1 + retry 60 1 wallet_current_settled_ge 100000000 + wallet_current_settled_ge 100000000 || exit 1 payout_id=$(bria_cmd submit-payout -w default --queue-name stale_signer_queue --destination bcrt1q208tuy5rd3kvy8xdpv6yrczg7f3mnlk3lql7ej --amount 99000000 | jq -r '.id') [[ "${payout_id}" != "null" ]] || exit 1 diff --git a/tests/utxos.rs b/tests/utxos.rs new file mode 100644 index 00000000..febfb4b2 --- /dev/null +++ b/tests/utxos.rs @@ -0,0 +1,369 @@ +mod helpers; + +use bdk::{ + bitcoin::{ScriptBuf, TxOut}, + wallet::AddressInfo, + KeychainKind, LocalUtxo, +}; +use bria::{ + primitives::{bitcoin::*, *}, + utxo::{SpendDetectedOutcome, Utxos}, +}; +use sqlx::Row; +use uuid::Uuid; + +#[tokio::test] +async fn spend_detected_is_idempotent() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let utxos = Utxos::new(&pool); + + let profile = helpers::create_test_account(&pool).await?; + let account_id = profile.account_id; + let wallet_id = WalletId::new(); + let keychain_id = KeychainId::new(); + let tx_id = LedgerTransactionId::new(); + + sqlx::query("INSERT INTO bria_wallets (id, account_id, name) VALUES ($1, $2, $3)") + .bind(Uuid::from(wallet_id)) + .bind(Uuid::from(account_id)) + .bind(format!("wallet_{}", wallet_id)) + .execute(&pool) + .await?; + + let income_outpoint = OutPoint { + txid: "4010e27ff7dc6d9c66a5657e6b3d94b4c4e394d968398d16fefe4637463d194d".parse()?, + vout: 0, + }; + let income_local_utxo = LocalUtxo { + outpoint: income_outpoint, + txout: TxOut { + value: 100_000_000u64, + script_pubkey: ScriptBuf::new(), + }, + keychain: KeychainKind::External, + is_spent: false, + }; + let income_address_info = AddressInfo { + index: 0, + address: "bcrt1qzg4a08kc2xrp08d9k5jadm78ehf7catp735zn0" + .parse::>()? + .assume_checked(), + keychain: KeychainKind::External, + }; + + let change_outpoint = OutPoint { + txid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse()?, + vout: 1, + }; + let change_local_utxo = LocalUtxo { + outpoint: change_outpoint, + txout: TxOut { + value: 40_000_000u64, + script_pubkey: ScriptBuf::new(), + }, + keychain: KeychainKind::Internal, + is_spent: false, + }; + let change_address_info = AddressInfo { + index: 0, + address: "bcrt1q6q79yce8vutqzpnwkxr5x8p5kxw5rc0hqqzwym" + .parse::>()? + .assume_checked(), + keychain: KeychainKind::Internal, + }; + let change_utxos: Vec<(&LocalUtxo, AddressInfo)> = + vec![(&change_local_utxo, change_address_info)]; + + let (_, db_tx) = utxos + .new_utxo_detected( + account_id, + wallet_id, + keychain_id, + &income_address_info, + &income_local_utxo, + Satoshis::from(1_000u64), + 200, + false, + 1, + ) + .await? + .expect("income utxo should be newly inserted"); + db_tx.commit().await?; + + let mut db_tx = pool.begin().await?; + let result = utxos + .spend_detected( + &mut db_tx, + account_id, + wallet_id, + keychain_id, + tx_id, + std::iter::once(&income_outpoint), + &change_utxos, + None, + Satoshis::from(300u64), + 200, + 1, + ) + .await?; + assert!( + matches!(result, SpendDetectedOutcome::Applied(..)), + "first call must be Applied" + ); + db_tx.commit().await?; + + let row = sqlx::query( + "SELECT spend_detected_ledger_tx_id FROM bria_utxos WHERE keychain_id = $1 AND tx_id = $2 AND vout = $3", + ) + .bind(keychain_id) + .bind(income_outpoint.txid.to_string()) + .bind(income_outpoint.vout as i32) + .fetch_one(&pool) + .await?; + assert!( + row.get::, _>("spend_detected_ledger_tx_id") + .is_some(), + "spend_detected_ledger_tx_id must be set after Applied" + ); + + let mut db_tx = pool.begin().await?; + let result = utxos + .spend_detected( + &mut db_tx, + account_id, + wallet_id, + keychain_id, + tx_id, + std::iter::once(&income_outpoint), + &change_utxos, + None, + Satoshis::from(300u64), + 200, + 1, + ) + .await?; + assert!( + matches!(result, SpendDetectedOutcome::AlreadyApplied), + "retry must be AlreadyApplied, not Deferred" + ); + db_tx.commit().await?; + + Ok(()) +} + +#[tokio::test] +async fn spend_detected_deferred_when_inputs_missing() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let utxos = Utxos::new(&pool); + + let profile = helpers::create_test_account(&pool).await?; + let account_id = profile.account_id; + let wallet_id = WalletId::new(); + let keychain_id = KeychainId::new(); + let unknown_outpoint = OutPoint { + txid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".parse()?, + vout: 0, + }; + let change_utxos: Vec<(&LocalUtxo, AddressInfo)> = vec![]; + + sqlx::query("INSERT INTO bria_wallets (id, account_id, name) VALUES ($1, $2, $3)") + .bind(Uuid::from(wallet_id)) + .bind(Uuid::from(account_id)) + .bind(format!("wallet_{}", wallet_id)) + .execute(&pool) + .await?; + + let mut db_tx = pool.begin().await?; + let result = utxos + .spend_detected( + &mut db_tx, + account_id, + wallet_id, + keychain_id, + LedgerTransactionId::new(), + std::iter::once(&unknown_outpoint), + &change_utxos, + None, + Satoshis::from(300u64), + 200, + 1, + ) + .await?; + assert!( + matches!(result, SpendDetectedOutcome::Deferred), + "missing inputs must return Deferred" + ); + db_tx.rollback().await?; + + Ok(()) +} + +#[tokio::test] +async fn spend_detected_already_applied_does_not_persist_conflicting_change() -> anyhow::Result<()> +{ + let pool = helpers::init_pool().await?; + let utxos = Utxos::new(&pool); + + let profile = helpers::create_test_account(&pool).await?; + let account_id = profile.account_id; + let wallet_id = WalletId::new(); + let keychain_id = KeychainId::new(); + + sqlx::query("INSERT INTO bria_wallets (id, account_id, name) VALUES ($1, $2, $3)") + .bind(Uuid::from(wallet_id)) + .bind(Uuid::from(account_id)) + .bind(format!("wallet_{}", wallet_id)) + .execute(&pool) + .await?; + + let input_outpoint = OutPoint { + txid: "4010e27ff7dc6d9c66a5657e6b3d94b4c4e394d968398d16fefe4637463d194d".parse()?, + vout: 0, + }; + let input_local_utxo = LocalUtxo { + outpoint: input_outpoint, + txout: TxOut { + value: 100_000_000u64, + script_pubkey: ScriptBuf::new(), + }, + keychain: KeychainKind::External, + is_spent: false, + }; + let input_address_info = AddressInfo { + index: 0, + address: "bcrt1qzg4a08kc2xrp08d9k5jadm78ehf7catp735zn0" + .parse::>()? + .assume_checked(), + keychain: KeychainKind::External, + }; + + let (_, init_tx) = utxos + .new_utxo_detected( + account_id, + wallet_id, + keychain_id, + &input_address_info, + &input_local_utxo, + Satoshis::from(1_000u64), + 200, + false, + 1, + ) + .await? + .expect("input utxo should be inserted"); + init_tx.commit().await?; + + let change1_outpoint = OutPoint { + txid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse()?, + vout: 1, + }; + let change1_local_utxo = LocalUtxo { + outpoint: change1_outpoint, + txout: TxOut { + value: 40_000_000u64, + script_pubkey: ScriptBuf::new(), + }, + keychain: KeychainKind::Internal, + is_spent: false, + }; + let change1_address_info = AddressInfo { + index: 0, + address: "bcrt1q6q79yce8vutqzpnwkxr5x8p5kxw5rc0hqqzwym" + .parse::>()? + .assume_checked(), + keychain: KeychainKind::Internal, + }; + let change1_utxos: Vec<(&LocalUtxo, AddressInfo)> = + vec![(&change1_local_utxo, change1_address_info)]; + + let mut tx = pool.begin().await?; + let first = utxos + .spend_detected( + &mut tx, + account_id, + wallet_id, + keychain_id, + LedgerTransactionId::new(), + std::iter::once(&input_outpoint), + &change1_utxos, + None, + Satoshis::from(300u64), + 200, + 1, + ) + .await?; + assert!(matches!(first, SpendDetectedOutcome::Applied(..))); + tx.commit().await?; + + let change2_outpoint = OutPoint { + txid: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".parse()?, + vout: 2, + }; + let change2_local_utxo = LocalUtxo { + outpoint: change2_outpoint, + txout: TxOut { + value: 30_000_000u64, + script_pubkey: ScriptBuf::new(), + }, + keychain: KeychainKind::Internal, + is_spent: false, + }; + let change2_address = "bcrt1qcv9xq3me73wsv4scy6qvx3f24e3dnt56h9m9z6"; + let change2_address_info = AddressInfo { + index: 1, + address: change2_address + .parse::>()? + .assume_checked(), + keychain: KeychainKind::Internal, + }; + let change2_utxos: Vec<(&LocalUtxo, AddressInfo)> = + vec![(&change2_local_utxo, change2_address_info)]; + + let mut tx = pool.begin().await?; + let second = utxos + .spend_detected( + &mut tx, + account_id, + wallet_id, + keychain_id, + LedgerTransactionId::new(), + std::iter::once(&input_outpoint), + &change2_utxos, + None, + Satoshis::from(350u64), + 220, + 1, + ) + .await?; + assert!(matches!(second, SpendDetectedOutcome::AlreadyApplied)); + tx.commit().await?; + + let c2_utxo_count: i64 = sqlx::query( + "SELECT COUNT(*) AS count FROM bria_utxos WHERE keychain_id = $1 AND tx_id = $2 AND vout = $3", + ) + .bind(keychain_id) + .bind(change2_outpoint.txid.to_string()) + .bind(change2_outpoint.vout as i32) + .fetch_one(&pool) + .await? + .get("count"); + assert_eq!( + c2_utxo_count, 0, + "conflicting change utxo must not be persisted" + ); + + let c2_addr_count: i64 = sqlx::query( + "SELECT COUNT(*) AS count FROM bria_addresses WHERE account_id = $1 AND address = $2", + ) + .bind(account_id) + .bind(change2_address) + .fetch_one(&pool) + .await? + .get("count"); + assert_eq!( + c2_addr_count, 0, + "conflicting change address must not be persisted" + ); + + Ok(()) +}