depolymerization

wire gateway for Bitcoin/Ethereum
Log | Files | Refs | Submodules | README | LICENSE

commit 0b53546c861f33ed588dc3bba314b2c7fcd9389c
parent 324a965cc1d702d8ca7dc413596ee7758e025547
Author: Antoine A <>
Date:   Fri,  3 Jul 2026 16:43:38 +0200

Improve sync state machine, improve db test and rewrite test harness

Diffstat:
M.gitignore | 5+++--
MCargo.toml | 5+++--
Mdepolymerizer-bitcoin/Cargo.toml | 10++++------
Mdepolymerizer-bitcoin/db/depolymerizer-bitcoin-0001.sql | 19+++++++------------
Mdepolymerizer-bitcoin/db/depolymerizer-bitcoin-procedures.sql | 136+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mdepolymerizer-bitcoin/src/api.rs | 17++++++++++-------
Adepolymerizer-bitcoin/src/bin/btc-harness.rs | 1319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdepolymerizer-bitcoin/src/cli.rs | 35++++++++---------------------------
Mdepolymerizer-bitcoin/src/db.rs | 740+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Ddepolymerizer-bitcoin/src/fail_point.rs | 31-------------------------------
Mdepolymerizer-bitcoin/src/lib.rs | 45++++++++++++++++++++++++++++++---------------
Mdepolymerizer-bitcoin/src/loops.rs | 6++++--
Mdepolymerizer-bitcoin/src/loops/analysis.rs | 4++--
Mdepolymerizer-bitcoin/src/loops/watcher.rs | 7+++++--
Mdepolymerizer-bitcoin/src/loops/worker.rs | 188+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mdepolymerizer-bitcoin/src/payto.rs | 11++++-------
Mdepolymerizer-bitcoin/src/rpc.rs | 264+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mdepolymerizer-bitcoin/src/rpc_utils.rs | 19+------------------
Mdepolymerizer-bitcoin/src/segwit.rs | 6+++---
Mdepolymerizer-bitcoin/src/setup.rs | 29++++++++++++-----------------
Mdepolymerizer-bitcoin/src/sql.rs | 7+------
Mdepolymerizer-common/src/status.rs | 17++++++++++++++++-
Mmakefile | 18++++++++++--------
Dtestbench/Cargo.toml | 31-------------------------------
Dtestbench/conf/bitcoin.conf | 14--------------
Dtestbench/conf/bitcoindev.conf | 5-----
Dtestbench/conf/taler_btc.conf | 14--------------
Dtestbench/conf/taler_btc_bump.conf | 16----------------
Dtestbench/conf/taler_btc_dev.conf | 17-----------------
Dtestbench/conf/taler_btc_lifetime.conf | 18------------------
Dtestbench/conf/taler_eth.conf | 14--------------
Dtestbench/conf/taler_eth_bump.conf | 16----------------
Dtestbench/conf/taler_eth_lifetime.conf | 19-------------------
Dtestbench/src/btc.rs | 1102-------------------------------------------------------------------------------
Dtestbench/src/main.rs | 527-------------------------------------------------------------------------------
Dtestbench/src/utils.rs | 571-------------------------------------------------------------------------------
36 files changed, 2338 insertions(+), 2964 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -17,9 +17,10 @@ taler.conf *.mk testbench/instrumentation testbench/env +depolymerizer-bitcoin/test tools debian/depolymerizer-bitcoin debian/files debian/*.substvars debian/*debhelper* -.history -\ No newline at end of file +.*history +\ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml @@ -3,8 +3,7 @@ resolver = "3" members = [ "depolymerizer-bitcoin", "depolymerizer-common", - "uri-pack", - "testbench", + "uri-pack" ] [workspace.package] @@ -35,6 +34,7 @@ taler-common = { git = "git://git.taler.net/taler-rust.git/" } taler-api = { git = "git://git.taler.net/taler-rust.git/" } taler-build = { git = "git://git.taler.net/taler-rust.git/" } taler-test-utils = { git = "git://git.taler.net/taler-rust.git/" } +failure-injection = { git = "git://git.taler.net/taler-rust.git/" } depolymerizer-common = { path = "depolymerizer-common" } bitcoin = { version = "0.32.5", features = [ "std", @@ -50,3 +50,4 @@ rand = { version = "0.10.1" } tracing-subscriber = "0.3" jiff = { version = "0.2", default-features = false, features = ["perf-inline", "std"] } compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } +owo-colors = "4.2.3" diff --git a/depolymerizer-bitcoin/Cargo.toml b/depolymerizer-bitcoin/Cargo.toml @@ -8,10 +8,6 @@ homepage.workspace = true repository.workspace = true license-file.workspace = true -[features] -# Enable random failures -fail = [] - [dependencies] bech32 = "0.12.0" serde_repr = "0.1.16" @@ -19,13 +15,14 @@ depolymerizer-common.workspace = true bitcoin.workspace = true clap.workspace = true serde.workspace = true -serde_json.workspace = true +serde_json = {workspace = true, features = ["raw_value"]} thiserror.workspace = true hex.workspace = true anyhow.workspace = true taler-api.workspace = true taler-build.workspace = true taler-common.workspace = true +failure-injection.workspace = true sqlx.workspace = true tokio.workspace = true tracing.workspace = true @@ -35,10 +32,11 @@ rand.workspace = true url.workspace = true jiff.workspace = true compact_str.workspace = true +owo-colors.workspace = true +taler-test-utils.workspace = true [dev-dependencies] criterion.workspace = true -taler-test-utils.workspace = true [[bench]] name = "metadata" diff --git a/depolymerizer-bitcoin/db/depolymerizer-bitcoin-0001.sql b/depolymerizer-bitcoin/db/depolymerizer-bitcoin-0001.sql @@ -1,6 +1,6 @@ -- -- This file is part of TALER --- Copyright (C) 2025, 2026 Taler Systems SA +-- Copyright (C) 2025-2026 Taler Systems SA -- -- TALER is free software; you can redistribute it and/or modify it under the -- terms of the GNU General Public License as published by the Free Software @@ -91,6 +91,7 @@ COMMENT ON TABLE pending_recurrent_in IS 'Pending recurrent incoming transaction CREATE TYPE debit_status AS ENUM( 'requested', + 'ignored', 'sent', 'confirmed' ); @@ -107,22 +108,16 @@ CREATE TABLE transfer ( exchange_url TEXT NOT NULL, request_uid BYTEA NOT NULL UNIQUE CHECK (LENGTH(request_uid)=64), metadata TEXT, - status debit_status NOT NULL + status debit_status NOT NULL, + status_msg TEXT ); COMMENT ON TABLE transfer IS 'Wire Gateway transfers'; -CREATE TYPE bounce_status AS ENUM( - 'requested', - 'ignored', - 'sent', - 'confirmed' -); -COMMENT ON TYPE bounce_status IS 'Status of a bounce'; - CREATE TABLE bounced ( tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE SET NULL, txid BYTEA UNIQUE CHECK (LENGTH(txid)=32), - reason TEXT, - status bounce_status NOT NULL + reason TEXT NOT NULL, + status debit_status NOT NULL, + status_msg TEXT ); COMMENT ON TABLE state IS 'Bounced incoming transactions'; \ No newline at end of file diff --git a/depolymerizer-bitcoin/db/depolymerizer-bitcoin-procedures.sql b/depolymerizer-bitcoin/db/depolymerizer-bitcoin-procedures.sql @@ -232,7 +232,7 @@ COMMENT ON FUNCTION register_bounce_tx_in IS 'Register an incoming transaction a CREATE FUNCTION sync_out( IN in_txid BYTEA, - IN in_replace_txid BYTEA, + IN in_replaces_txid BYTEA, IN in_amount taler_amount, IN in_credit_acc TEXT, IN in_wtid BYTEA, @@ -240,6 +240,7 @@ CREATE FUNCTION sync_out( IN in_metadata TEXT, IN in_bounced_txid BYTEA, IN in_created_at INT8, + IN in_confirmed BOOLEAN, IN in_now INT8, -- Success return OUT out_tx_row_id INT8, @@ -250,67 +251,92 @@ CREATE FUNCTION sync_out( LANGUAGE plpgsql AS $$ DECLARE local_id INT8; + local_status debit_status; + local_update BOOLEAN; BEGIN -UPDATE tx_out SET txid=in_txid WHERE txid=in_replace_txid; -out_replaced=FOUND; -out_new=NOT EXISTS(SELECT FROM tx_out WHERE txid = in_txid); -IF NOT out_new THEN - RETURN; +IF in_confirmed THEN + local_status='confirmed'; +ELSE + local_status='sent'; END IF; - --- Insert new outgoing transaction -INSERT INTO tx_out ( - amount, - credit_acc, - txid, - created_at -) VALUES ( - in_amount, - in_credit_acc, - in_txid, - in_created_at -) RETURNING tx_out_id INTO out_tx_row_id; --- Notify new outgoing transaction registration -PERFORM pg_notify('tx_out', out_tx_row_id || ''); - IF in_wtid IS NOT NULL THEN - -- Insert new outgoing talerable transaction - INSERT INTO taler_out ( - tx_out_id, - wtid, - exchange_base_url, - metadata - ) VALUES ( - out_tx_row_id, - in_wtid, - in_exchange_base_url, - in_metadata - ) ON CONFLICT (wtid) DO NOTHING; - IF FOUND THEN - -- Notify new outgoing talerable transaction registration - PERFORM pg_notify('taler_out', out_tx_row_id || ''); + -- Sync transfer status + SELECT + txid=in_replaces_txid, + txid IS NULL, + status!=local_status OR txid!=in_txid + INTO + out_replaced, + out_recovered, + local_update + FROM transfer + WHERE wtid=in_wtid; + IF local_update THEN + UPDATE transfer SET status=local_status,txid=in_txid + WHERE wtid=in_wtid; END IF; - -- Update transfer state - UPDATE transfer SET status='confirmed',txid=in_txid WHERE wtid=in_wtid; - out_recovered=FOUND; ELSIF in_bounced_txid IS NOT NULL THEN - -- Update bounce state - IF NOT EXISTS(SELECT FROM bounced JOIN tx_in USING (tx_in_id) WHERE tx_in.txid=in_bounced_txid) THEN - INSERT INTO bounced ( - tx_in_id, - txid, - status - ) VALUES ( - (SELECT tx_in_id FROM tx_in WHERE txid=in_bounced_txid), - in_txid, - 'confirmed' - ); - out_recovered=TRUE; - ELSE - UPDATE bounced SET status='confirmed',txid=in_txid + -- Sync bounce status + SELECT + bounced.txid=in_replaces_txid, + bounced.txid IS NULL, + status!=local_status OR bounced.txid!=in_txid + INTO + out_replaced, + out_recovered, + local_update + FROM bounced JOIN tx_in USING (tx_in_id) + WHERE tx_in.txid=in_bounced_txid; + IF local_update THEN + UPDATE bounced SET status=local_status,txid=in_txid FROM tx_in WHERE bounced.tx_in_id=tx_in.tx_in_id AND tx_in.txid=in_bounced_txid; - out_recovered=FALSE; + END IF; +END IF; + +IF in_confirmed THEN + -- Sync tx_out status + UPDATE tx_out SET txid=in_txid WHERE txid=in_replaces_txid; + out_replaced=out_replaced OR FOUND; + SELECT tx_out_id INTO out_tx_row_id + FROM tx_out WHERE txid=in_txid; + IF FOUND THEN + RETURN; + END IF; + out_new = TRUE; + + -- Insert new outgoing transaction + INSERT INTO tx_out ( + amount, + credit_acc, + txid, + created_at + ) VALUES ( + in_amount, + in_credit_acc, + in_txid, + in_created_at + ) RETURNING tx_out_id INTO out_tx_row_id; + -- Notify new outgoing transaction registration + PERFORM pg_notify('tx_out', out_tx_row_id || ''); + + IF in_wtid IS NOT NULL THEN + -- Insert new outgoing talerable transaction + INSERT INTO taler_out ( + tx_out_id, + wtid, + exchange_base_url, + metadata + ) VALUES ( + out_tx_row_id, + in_wtid, + in_exchange_base_url, + in_metadata + ) ON CONFLICT (wtid) DO NOTHING; + IF FOUND THEN + -- Notify new outgoing talerable transaction registration + PERFORM pg_notify('taler_out', out_tx_row_id || ''); + END IF; END IF; END IF; END $$; diff --git a/depolymerizer-bitcoin/src/api.rs b/depolymerizer-bitcoin/src/api.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA + Copyright (C) 2025-2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -378,7 +378,7 @@ pub mod test { use crate::{ CONFIG_SOURCE, api::ServerState, - db::{TxOutKind, sync_out, test::rand_tx_id}, + db::{TxOut, TxOutKind, sync_out, test::rand_tx_id}, payto::FullBtcPayto, }; @@ -442,16 +442,19 @@ pub mod test { let sub = &OutgoingSubject::rand(); sync_out( &db, - &rand_tx_id(), - None, - &amount("BTC:10"), - &EXCHANGE.0, + &TxOut { + id: rand_tx_id(), + replaces_txid: None, + amount: amount("BTC:10"), + credit_acc: &EXCHANGE.0, + block_time: Timestamp::now(), + }, &TxOutKind::Talerable { wtid: &sub.wtid, url: &sub.exchange_base_url, metadata: sub.metadata.as_deref(), }, - &Timestamp::now(), + true, ) .await .unwrap(); diff --git a/depolymerizer-bitcoin/src/bin/btc-harness.rs b/depolymerizer-bitcoin/src/bin/btc-harness.rs @@ -0,0 +1,1319 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{ + assert_matches, + collections::BTreeSet, + net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}, + process::{Child, Stdio}, + str::FromStr, + time::Duration, +}; + +use bitcoin::{Address, Amount, Txid, address::NetworkUnchecked, hashes::Hash}; +use clap::Parser as _; +use depolymerizer_bitcoin::{ + CONFIG_SOURCE, RpcApiExtended as _, + config::{RpcAuth, RpcCfg, WorkerCfg}, + db::{self, dbinit, incoming_history, pool}, + loops::{LoopError, LoopResult, analysis::analysis, worker::worker_step}, + payto::{BtcWallet, FullBtcPayto}, + rpc::{Category, Error, ErrorCode, Rpc, RpcApi, WalletRpc}, + setup::setup, + taler_utils::{btc_to_taler, taler_to_btc}, +}; +use failure_injection::set_failure_scenario; +use owo_colors::OwoColorize as _; +use sqlx::PgPool; +use taler_api::notification::dummy_listen; +use taler_build::long_version; +use taler_common::{ + CommonArgs, + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + params::{History, Page, Pooling}, + wire::{IncomingBankTransaction, TransferRequest, TransferState}, + }, + config::Config, + taler_main, + types::{ + amount::{Currency, amount}, + url, + }, +}; +use taler_test_utils::repl::Repl; +use tracing::info; + +#[derive(Debug)] +struct PanicErr; + +type PanicRes<T> = Result<T, PanicErr>; + +impl<E: std::fmt::Display> From<E> for PanicErr { + #[track_caller] + fn from(value: E) -> Self { + panic!("{value}") + } +} + +/// Bictoin Adapter harness test suite +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +struct Args { + #[clap(flatten)] + common: CommonArgs, + + #[command(subcommand)] + cmd: Command, +} + +#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)] +enum Network { + Local, + Test, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Run logic tests + Logic { + #[arg(short, long)] + reset: bool, + }, + /// Run online tests + Online { + #[arg(short, long)] + reset: bool, + network: Network, + }, +} + +fn step(step: &str) { + println!("{}", step.green()); +} + +#[derive(Debug)] +struct SystemCtx { + n1_dir: String, + n2_dir: String, + c_dir: String, + peer_addr: SocketAddr, + n1_addr: SocketAddr, + n2_addr: SocketAddr, + n1_cookie: String, + n2_cookie: String, +} + +impl SystemCtx { + fn new(reset: bool) -> Self { + // Prepare dir + let dir = "depolymerizer-bitcoin/test/logic"; + std::fs::create_dir_all(dir).unwrap(); + if reset { + std::fs::remove_dir_all(dir).unwrap(); + } + let n1_dir = format!("{dir}/n1"); + let n2_dir = format!("{dir}/n2"); + let c_dir = format!("{dir}/c"); + for dir in [&n1_dir, &n2_dir] { + std::fs::create_dir_all(dir).unwrap(); + } + + // Choose unused port + let btc_port = unused_port(); + let btc_rpc_port = unused_port(); + let btc2_port = unused_port(); + let btc2_rpc_port = unused_port(); + + // Bitcoin config + for (root, btc, rpc) in [ + (&n1_dir, btc_port, btc_rpc_port), + (&n2_dir, btc2_port, btc2_rpc_port), + ] { + std::fs::write( + format!("{root}/bitcoin.conf"), + format!( + " + regtest=1 + txindex=1 + maxtxfee=0.01 + fallbackfee=0.00000001 + rpcservertimeout=0 + dbcache=4 + maxmempool=5 + par=2 + rpcthreads=5 + + [regtest] + bind=127.0.0.1:{btc} + rpcport={rpc} + " + ), + ) + .unwrap(); + } + + Self { + n1_addr: SocketAddrV4::new(Ipv4Addr::LOCALHOST, btc_rpc_port).into(), + n1_cookie: format!("{n1_dir}/regtest/.cookie"), + n2_addr: SocketAddrV4::new(Ipv4Addr::LOCALHOST, btc2_rpc_port).into(), + n2_cookie: format!("{n2_dir}/regtest/.cookie"), + peer_addr: SocketAddrV4::new(Ipv4Addr::LOCALHOST, btc2_port).into(), + n1_dir, + n2_dir, + c_dir, + } + } + + async fn test_cfg(&self, btc_patch: &str, cfg_patch: &str) { + let port = unused_port(); + let Self { c_dir, .. } = self; + std::fs::remove_dir_all(c_dir).ok(); + std::fs::create_dir_all(c_dir).unwrap(); + std::fs::write( + format!("{c_dir}/bitcoin.conf"), + format!( + " + regtest=1 + txindex=1 + maxtxfee=0.01 + fallbackfee=0.00000001 + rpcservertimeout=0 + dbcache=4 + maxmempool=5 + par=2 + rpcthreads=5 + + [regtest] + rpcport={port} + + {btc_patch} + " + ), + ) + .unwrap(); + let cfg = Config::from_mem_with_env( + CONFIG_SOURCE, + &format!( + " + [depolymerizer-bitcoin-worker] + RPC_BIND=127.0.0.1:{port} + + {cfg_patch} + " + ), + ) + .unwrap(); + let mut n = cmd("bitcoind", &[format!("-datadir={}", self.c_dir).as_str()]); + let cfg = RpcCfg::parse(&cfg).unwrap(); + wait_up(&cfg).await; + n.0.kill().unwrap(); + n.0.wait().unwrap(); + } + + async fn start_n1(&self, args: &[&str]) -> (ChildGuard, Rpc) { + let Self { + n1_dir, + n1_addr, + n1_cookie, + .. + } = self; + let n = cmd( + "bitcoind", + Vec::from_iter( + [format!("-datadir={n1_dir}").as_str()] + .iter() + .chain(args) + .copied(), + ) + .as_slice(), + ); + let mut rpc = wait_up(&RpcCfg { + addr: *n1_addr, + auth: RpcAuth::Cookie(n1_cookie.clone()), + }) + .await; + for name in ["wire", "client", "reserve"] { + if let Err(e) = rpc.load_wallet(name).await { + if let Error::RPC { + code: ErrorCode::RpcWalletNotFound, + .. + } = e + { + rpc.create_wallet(name, "").await.unwrap(); + } else { + break; + } + } + } + (n, rpc) + } + + async fn start_n2(&self, rpc1: &mut impl RpcApi) -> (ChildGuard, Rpc) { + let n = cmd("bitcoind", &[&format!("-datadir={}", self.n2_dir)]); + let rpc = wait_up(&RpcCfg { + addr: self.n2_addr, + auth: RpcAuth::Cookie(self.n2_cookie.clone()), + }) + .await; + rpc1.add_node(&self.peer_addr.to_string()).await.unwrap(); + (n, rpc) + } +} + +struct Ctx<'a> { + sc: &'a SystemCtx, + n1: &'a mut ChildGuard, + pool: &'a PgPool, + rpc: Rpc, + rpc2: Rpc, + wire_addr: &'a Address, + client_addr: &'a Address, + reserve_addr: &'a Address, + s: WorkerCfg, + status: bool, +} + +impl<'a> Ctx<'a> { + /// Run the worker once + async fn run_worker(&mut self) -> LoopResult<()> { + let db = &mut self.pool.acquire().await.unwrap().detach(); + let res = worker_step( + db, + &mut self.rpc.wallet("wire"), + &mut self.s, + &mut self.status, + ) + .await; + if let Err(e) = &res { + tracing::error!(target: "worker", "{e}"); + } + res + } + + /// Run the worker once successfuly + async fn worker(&mut self) { + self.run_worker().await.unwrap(); + } + + /// Run the worker once and fail on an injected error + async fn w_injected_err(&mut self) { + assert_matches!(self.run_worker().await, Err(LoopError::Injected(_))); + } + + /// Run the worker once and fail on a rpc error + async fn w_rpc_err(&mut self) { + assert_matches!( + self.run_worker().await, + Err(LoopError::Rpc(Error::RPC { .. })) + ); + } + + async fn restart_node(&mut self, args: &[&str]) { + // Stop node cleanly + self.rpc.stop().await.unwrap(); + self.n1.0.wait().unwrap(); + // Start a new node + let (n, rpc) = self.sc.start_n1(args).await; + *self.n1 = n; + self.rpc = rpc; + self.rpc + .add_node(&self.sc.peer_addr.to_string()) + .await + .unwrap(); + } + + async fn cluster_deco(&mut self) { + self.rpc + .disconnect_node(&self.sc.peer_addr.to_string()) + .await + .unwrap(); + } + + async fn cluster_fork(&mut self) -> u32 { + let n1h = self.rpc.get_blockchain_info().await.unwrap().blocks; + let n2h = self.rpc2.get_blockchain_info().await.unwrap().blocks; + let diff = n1h - n2h; + self.rpc2.mine(diff + 1, self.reserve_addr).await.unwrap(); + self.rpc + .add_node(&self.sc.peer_addr.to_string()) + .await + .unwrap(); + self.rpc.wait_for_block_height(n1h + 1).await.unwrap(); + diff + 2 + } + + async fn forget_tx(&self, tx: &Txid) { + sqlx::query("UPDATE tx_in SET txid=NULL WHERE txid=$1") + .bind(tx.as_byte_array()) + .execute(self.pool) + .await + .unwrap(); + } + + /* ----- Rpc ----- */ + + fn client(&mut self) -> WalletRpc<'_> { + self.rpc.wallet("client") + } + + fn wire(&mut self) -> WalletRpc<'_> { + self.rpc.wallet("wire") + } + + fn reserve(&mut self) -> WalletRpc<'_> { + self.rpc.wallet("reserve") + } + + /* ----- Balance ---- */ + + async fn c_balance(&mut self) -> Amount { + self.client().get_balance().await.unwrap() + } + + async fn w_balance(&mut self) -> Amount { + self.wire().get_balance().await.unwrap() + } + + async fn expect_c_balance(&mut self, amount: Amount) { + assert_eq!(self.c_balance().await, amount) + } + + async fn expect_w_balance(&mut self, amount: Amount) { + assert_eq!(self.w_balance().await, amount) + } + + /* ----- Mining ----- */ + + /// Mine nb block + async fn mine(&mut self, nb: u32) { + self.rpc.mine(nb, self.reserve_addr).await.unwrap(); + } + + /// Mine enough block to reach confirmation + async fn next_conf(&mut self) { + self.mine(self.s.conf).await + } + + /// Mine one block + async fn next_block(&mut self) { + self.mine(1).await + } + + /// Mine while txs are pending + async fn mine_pending(&mut self) { + while self.rpc.get_mempool_info().await.unwrap().size > 0 { + self.mine(1).await + } + } + + /// Mine while txs are pending or unconfirmed + async fn mine_conf(&mut self) { + self.mine_pending().await; + self.next_conf().await; + } + + /* ----- Transaction ----- */ + + async fn withdrawal(&mut self, amount: Amount) -> (Txid, EddsaPublicKey) { + let key = EddsaPublicKey::rand(); + let id = self.withdrawal_with_key(amount, &key).await; + (id, key) + } + + async fn withdrawal_with_key(&mut self, amount: Amount, key: &EddsaPublicKey) -> Txid { + loop { + match self + .rpc + .wallet("client") + .send_segwit_key(self.wire_addr, amount, &key) + .await + { + Ok(id) => return id, + Err(e) => match e { + Error::RPC { + code: ErrorCode::RpcWalletError, + .. + } => { + self.mine(1).await; + } + _ => panic!("{e:?}"), + }, + } + } + } + + async fn transfer_to(&mut self, addr: &Address, amount: Amount) -> u64 { + let payto = FullBtcPayto::new(BtcWallet(addr.clone()), "Bitcoin Client"); + match db::transfer( + self.pool, + &payto, + &TransferRequest { + request_uid: HashCode::rand(), + amount: btc_to_taler(&amount.to_signed().unwrap(), &Currency::KUDOS), + exchange_base_url: url("https://test.com"), + metadata: None, + wtid: ShortHashCode::rand(), + credit_account: payto.as_uri(), + }, + ) + .await + .unwrap() + { + db::TransferResult::Success(it) => it.row_id, + it => panic!("{it:?}"), + } + } + + async fn transfer(&mut self, amount: Amount) -> u64 { + self.transfer_to(self.client_addr, amount).await + } + + async fn c_send(&mut self, addr: &Address, amount: Amount) -> Txid { + self.rpc + .wallet("client") + .send(addr, amount, None, false) + .await + .unwrap() + } + + async fn malformed_credit(&mut self, amount: Amount) -> Txid { + self.c_send(self.wire_addr, amount).await + } + + async fn abandon(mut rpc: impl RpcApi) { + let list = rpc.list_since_block(None, 1).await.unwrap(); + for tx in list.transactions { + if tx.category == Category::Send && tx.confirmations == 0 { + rpc.abandon_tx(&tx.txid).await.unwrap(); + } + } + } + + async fn abandon_wire(&mut self) { + Self::abandon(self.wire()).await; + } + + async fn abandon_client(&mut self) { + Self::abandon(self.client()).await; + } + + /* ----- Checks ----- */ + + async fn expect_incoming(&self, key: EddsaPublicKey) { + let transfer = incoming_history( + self.pool, + &History { + page: Page { + limit: -1, + offset: None, + }, + pooling: Pooling { timeout_ms: None }, + }, + &Currency::TEST, + dummy_listen, + ) + .await + .unwrap(); + assert_matches!( + transfer.first().unwrap(), + IncomingBankTransaction::Reserve { reserve_pub, .. } if *reserve_pub == key, + ); + } + + async fn expect_transfer_status(&self, id: u64, status: TransferState) { + let mut attempts = 0; + loop { + let transfer = db::transfer_by_id(self.pool, id, &Currency::KUDOS) + .await + .unwrap() + .unwrap(); + if (transfer.status, transfer.status_msg.as_deref()) == (status, None) { + return; + } + if attempts > 40 { + assert_eq!( + (transfer.status, transfer.status_msg.as_deref()), + (status, None) + ); + } + attempts += 1; + tokio::time::sleep(Duration::from_millis(200)).await; + } + } +} + +fn unused_port() -> u16 { + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .unwrap() + .local_addr() + .unwrap() + .port() +} + +async fn wait_up(cfg: &RpcCfg) -> Rpc { + for _ in 0..100 { + if let Ok(mut rpc) = Rpc::new(cfg).await + && rpc.get_blockchain_info().await.is_ok() + { + return rpc; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let mut rpc = Rpc::new(cfg).await.unwrap(); + rpc.get_blockchain_info().await.unwrap(); + rpc +} + +struct ChildGuard(pub Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + self.0.kill().ok(); + } +} + +#[track_caller] +fn cmd(cmd: &str, args: &[&str]) -> ChildGuard { + let child = std::process::Command::new(cmd) + .args(args) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .unwrap(); + ChildGuard(child) +} + +/// Run logic tests against local regtest bitcoin chain +async fn logic_harness(reset: bool) -> PanicRes<()> { + step("Run Bitcoin logic harness tests"); + + step("Start bitcoin network"); + let sc = SystemCtx::new(reset); + + // Start bitcoin nodes + let (mut n1, mut rpc) = sc.start_n1(&[]).await; + let (mut n2, rpc2) = sc.start_n2(&mut rpc).await; + let reserve_addr = &rpc.wallet("reserve").gen_addr().await?; + let client_addr = &rpc.wallet("client").gen_addr().await?; + let wire_addr = &rpc.wallet("wire").gen_addr().await?; + + let cfg = Config::from_mem_with_env( + CONFIG_SOURCE, + &format!( + " + [depolymerizer-bitcoin] + CURRENCY = BTC + NAME = Exchange Owner + WALLET = {wire_addr} + + [depolymerizer-bitcoin-worker] + BOUNCE_FEE = BTC:0.00001 + CONFIRMATION = 3 + RPC_BIND = {} + WALLET_NAME = wire + RPC_COOKIE_FILE = {} + ", + sc.n1_addr, sc.n1_cookie + ), + )?; + + step("Setup"); + dbinit(&cfg, reset).await?; + setup(&cfg, reset).await?; + let state = WorkerCfg::parse(&cfg)?; + let pool = &pool(&cfg).await?; + + let c = &mut Ctx { + sc: &sc, + n1: &mut n1, + pool, + rpc, + rpc2, + client_addr, + wire_addr, + reserve_addr, + s: state, + status: true, + }; + + if c.client().get_balance().await? < Amount::ONE_BTC { + while c.reserve().get_balance().await? < Amount::ONE_BTC { + c.mine(1).await; + } + c.reserve() + .send(client_addr, Amount::ONE_BTC, None, false) + .await + .unwrap(); + } + + let fee = c.s.bounce_fee; + + step("Warmup"); + c.worker().await; + c.mine_conf().await; + c.worker().await; + + step("Withdrawal"); + let amount = Amount::from_sat(330000); + let b = c.wire().get_balance().await?; + // Send tx + let (_, reserve_pub) = c.withdrawal(amount).await; + // Confirm tx + c.mine_conf().await; + // Check received + c.expect_w_balance(b + amount).await; + // Work register it + c.worker().await; + // Check registered + c.expect_incoming(reserve_pub).await; + + step("Deposit"); + let amount = Amount::from_sat(34000); + let b = c.c_balance().await; + // Request tx + let id = c.transfer(amount).await; + // Check start pending + c.expect_transfer_status(id, TransferState::pending).await; + // Send it + c.worker().await; + // Mine it + c.mine_pending().await; + // Client already received the fund + c.expect_c_balance(b + amount).await; + // Still pending until confirmed + c.worker().await; + c.expect_transfer_status(id, TransferState::pending).await; + // Confirm it + c.mine_conf().await; + c.worker().await; + // Now success + c.expect_transfer_status(id, TransferState::success).await; + c.expect_c_balance(b + amount).await; + + step("Bounce"); + let b = c.w_balance().await; + let amount = Amount::from_sat(10000); + // Send malformed + c.malformed_credit(amount).await; + // Ignore until confirmed + c.mine_conf().await; + c.expect_w_balance(b + amount).await; + // Confirm it & bounce + c.mine_conf().await; + c.worker().await; + // Keep fee + c.expect_w_balance(b + fee).await; + // Confirm the bounce + c.mine_conf().await; + c.worker().await; + + // TODO lifetime ? + // TODO reconnect rpc and database? + + step("Transfer to unknown account"); + c.transfer_to( + &Address::<NetworkUnchecked>::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") + .unwrap() + .assume_checked(), + Amount::from_sat(34100), + ) + .await; + c.worker().await; + + step("Debit failure"); + set_failure_scenario(&["debit"]); + let amount = Amount::from_sat(34000); + let b = c.c_balance().await; + let id = c.transfer(amount).await; + // Check idempotent + c.w_injected_err().await; + c.worker().await; + // Confirm it + c.mine_conf().await; + c.worker().await; + c.expect_transfer_status(id, TransferState::success).await; + // Check only sent once + c.expect_c_balance(b + amount).await; + + step("Bounce failure"); + set_failure_scenario(&["bounce"]); + let b = c.w_balance().await; + let amount = Amount::from_sat(20000); + // Send malformed + c.malformed_credit(amount).await; + c.mine_pending().await; + // Ignore until confirmed + c.mine_conf().await; + c.expect_w_balance(b + amount).await; + // Check idempotent + c.w_injected_err().await; + c.worker().await; + // Kept fee + c.expect_w_balance(b + fee).await; + c.mine_conf().await; + c.worker().await; + c.expect_w_balance(b + fee).await; + + step("Conflict send"); + let amount = Amount::from_sat(35000); + let b = c.c_balance().await; + // Perform deposit + c.transfer(amount).await; + c.worker().await; + // Abandon pending transaction + c.restart_node(&["-minrelaytxfee=0.0001"]).await; + c.abandon_wire().await; + c.expect_c_balance(b).await; + // Generate conflict + let conflict = Amount::from_sat(40000); + c.transfer(conflict).await; + c.worker().await; + c.mine_pending().await; + c.expect_c_balance(b + conflict).await; + // Handle conflict + c.worker().await; + c.mine_pending().await; + c.expect_c_balance(b + amount + conflict).await; + c.mine_conf().await; + c.worker().await; + + step("Conflict bounce"); + let b = c.w_balance().await; + let amount = Amount::from_sat(20000); + // Perform bounce + c.malformed_credit(amount).await; + c.mine_pending().await; + c.expect_w_balance(b + amount).await; + c.mine_conf().await; + c.worker().await; + c.expect_w_balance(b + fee).await; + // Abandon pending transaction + c.restart_node(&["-minrelaytxfee=0.0002"]).await; + c.abandon_wire().await; + c.expect_w_balance(b + amount).await; + // Generate conflict + c.malformed_credit(amount).await; + c.mine_conf().await; + c.worker().await; + c.expect_w_balance(b + amount + fee).await; + // Handle conflict + c.mine_pending().await; + c.worker().await; + c.expect_w_balance(b + fee * 2).await; + c.mine_conf().await; + c.worker().await; + + step("Reorg withdrawal"); + c.cluster_deco().await; + let before = c.w_balance().await; + c.withdrawal(Amount::from_sat(100000)).await; + c.mine_conf().await; + let after = c.w_balance().await; + c.worker().await; + assert!(c.status); + let fork = c.cluster_fork().await; + c.worker().await; + assert!(!c.status); + c.expect_w_balance(before).await; + c.mine(fork).await; + c.worker().await; + assert!(c.status); + c.expect_w_balance(after).await; + + step("Reorg deposit"); + c.cluster_deco().await; + let before = c.c_balance().await; + c.transfer(Amount::from_sat(1111)).await; + c.worker().await; + c.mine_conf().await; + c.worker().await; + let after = c.c_balance().await; + assert!(c.status); + let fork = c.cluster_fork().await; + c.worker().await; + assert!(c.status); + c.expect_c_balance(before).await; + c.mine(fork).await; + c.worker().await; + assert!(c.status); + c.expect_c_balance(after).await; + + step("Reorg bounce"); + c.cluster_deco().await; + c.malformed_credit(Amount::from_sat(100000)).await; + c.mine_conf().await; + c.worker().await; + c.mine_pending().await; + assert!(c.status); + let fork = c.cluster_fork().await; + c.worker().await; + assert!(!c.status); + c.mine(fork).await; + c.worker().await; + assert!(c.status); + + c.s.bump_delay = Some(1); + + step("Bump fee"); + let amount = Amount::from_sat(40000); + let b = c.c_balance().await; + c.transfer(amount).await; + c.worker().await; + let until = tokio::time::Instant::now() + Duration::from_secs(1); + c.worker().await; + c.restart_node(&["-minrelaytxfee=0.0003"]).await; + tokio::time::sleep_until(until).await; + c.worker().await; + c.expect_c_balance(b).await; + c.mine_conf().await; + c.expect_c_balance(b + amount).await; + c.worker().await; + + step("Bump fee failure"); + let amount = Amount::from_sat(41000); + let b = c.c_balance().await; + c.transfer(amount).await; + c.worker().await; + let until = tokio::time::Instant::now() + Duration::from_secs(1); + c.worker().await; + c.restart_node(&["-minrelaytxfee=0.0004"]).await; + tokio::time::sleep_until(until).await; + set_failure_scenario(&["bumpfee"]); + c.w_injected_err().await; + c.worker().await; + c.expect_c_balance(b).await; + c.mine_conf().await; + c.expect_c_balance(b + amount).await; + c.worker().await; + + step("Bump fee reorg"); + c.cluster_deco().await; + let amount = Amount::from_sat(42000); + let b = c.c_balance().await; + c.transfer(amount).await; + c.worker().await; + let until = tokio::time::Instant::now() + Duration::from_secs(1); + c.worker().await; + c.cluster_fork().await; + c.restart_node(&["-minrelaytxfee=0.0005"]).await; + c.next_block().await; + tokio::time::sleep_until(until).await; + c.worker().await; + c.expect_c_balance(b).await; + c.mine_conf().await; + c.expect_c_balance(b + amount).await; + c.worker().await; + + c.s.bump_delay = None; + + step("Reorg conflict withdrawal"); + let b = c.w_balance().await; + c.cluster_deco().await; + + // Withdrawal + let amount = Amount::from_sat(420000); + let (id, _) = c.withdrawal(amount).await; + c.mine_conf().await; + c.worker().await; + c.expect_w_balance(b + amount).await; + + // Perform fork + assert!(c.status); + c.cluster_fork().await; + c.worker().await; + assert!(!c.status); + + // Generate conflict + c.restart_node(&["-minrelaytxfee=0.0006"]).await; + c.abandon_client().await; + let conflict = Amount::from_sat(54000); + c.withdrawal(conflict).await; + c.mine_pending().await; + c.expect_w_balance(b + conflict).await; + + // Check cannot recover again adversarial attack + c.mine_conf().await; + c.worker().await; + assert!(!c.status); + + // Manual drop from db + c.forget_tx(&id).await; + c.worker().await; + assert!(c.status); + c.expect_w_balance(b + conflict).await; + + step("Reorg conflict bounce"); + let b = c.w_balance().await; + c.cluster_deco().await; + + // Withdrawal + let amount = Amount::from_sat(420000); + let id = c.malformed_credit(amount).await; + c.mine_conf().await; + c.worker().await; + c.expect_w_balance(b + fee).await; + + // Perform fork + assert!(c.status); + c.cluster_fork().await; + c.worker().await; + assert!(!c.status); + + // Generate conflict + c.restart_node(&["-minrelaytxfee=0.0007"]).await; + c.abandon_client().await; + let conflict = Amount::from_sat(54000); + c.withdrawal(conflict).await; + c.mine_pending().await; + c.expect_w_balance(b + conflict).await; + + // Check cannot recover again adversarial attack + c.mine_conf().await; + c.worker().await; + assert!(!c.status); + + // Manual drop from db + c.forget_tx(&id).await; + c.worker().await; + assert!(c.status); + c.expect_w_balance(b + conflict).await; + + step("Analysis"); + assert_eq!(c.s.conf, 3); + c.s.conf = analysis(&mut c.rpc, c.s.conf, c.s.max_conf).await?; + assert_eq!(c.s.conf, 6); + + step("Maxfee"); + c.restart_node(&["-maxtxfee=0.0000001", "-minrelaytxfee=0.0000001"]) + .await; + let amount = Amount::from_sat(34500); + let b = c.c_balance().await; + c.transfer(amount).await; + c.w_rpc_err().await; + c.mine_pending().await; + c.w_rpc_err().await; + c.expect_c_balance(b).await; + c.restart_node(&[]).await; + c.worker().await; + c.mine_conf().await; + c.expect_c_balance(b + amount).await; + c.worker().await; + + c.abandon_client().await; + c.abandon_wire().await; + + step("Stop bitcoin network"); + let cb = c.c_balance().await; + let wb = c.w_balance().await; + c.worker().await; + c.mine_conf().await; + c.worker().await; + c.expect_c_balance(cb).await; + c.expect_w_balance(wb).await; + + c.rpc.stop().await?; + c.rpc2.stop().await?; + + n1.0.wait()?; + n2.0.wait()?; + + step("Config"); + // Connect with custom cookie files + sc.test_cfg( + " + rpccookiefile=catch_me_if_you_can + ", + &format!( + " + [depolymerizer-bitcoin-worker] + RPC_COOKIE_FILE={}/regtest/catch_me_if_you_can + ", + sc.c_dir + ), + ) + .await; + // Connect with password + sc.test_cfg( + " + rpcuser=bob + rpcpassword=password + ", + " + [depolymerizer-bitcoin-worker] + RPC_AUTH_METHOD=basic + RPC_USERNAME=bob + RPC_PASSWORD=password + ", + ) + .await; + // Connect with token + sc.test_cfg( + " + rpcauth=bob:9641cec731e1fad1ded02e1d31536e44$36b8b8af0a38104997a57f017805ff56bf8963ae4a2ed40252ca0e0e070fc19e + ", + " + [depolymerizer-bitcoin-worker] + RPC_AUTH_METHOD=basic + RPC_USERNAME=bob + RPC_PASSWORD=password + ", + ) + .await; + Ok(()) +} + +#[derive(clap::Parser, Debug)] +#[command(no_binary_name = true)] +struct Shell { + #[clap(subcommand)] + cmd: Option<Cmd>, +} +#[derive(Clone, clap::Subcommand, Debug)] +enum Cmd { + Setup, + Reset, + ResetDb, + Sync, + Credit, + Debit, + Mine { + amount: Option<u32>, + addr: Option<Address<NetworkUnchecked>>, + }, + Exit, + Tx { + txid: Txid, + }, + Track { + txid: Txid, + }, + Untrack { + txid: Txid, + }, +} + +async fn online_harness(network: Network, reset: bool) -> PanicRes<()> { + // Prepare dir + let network_dir = match network { + Network::Local => "btc-local", + Network::Test => "btc-test", + }; + let dir = format!("depolymerizer-bitcoin/test/{network_dir}"); + std::fs::create_dir_all(&dir)?; + if reset { + std::fs::remove_dir_all(&dir)?; + } + + // Bitcoin config + let cfg = match network { + Network::Local => { + " + chain=regtest + txindex=1 + rpcservertimeout=0 + fallbackfee=0.00000001 + [regtest] + rpcport=18345 + " + } + Network::Test => { + " + chain=signet + txindex=1 + rpcservertimeout=0 + fallbackfee=0.00000001 + [signet] + rpcport=18345 + " + } + }; + std::fs::create_dir_all(&dir)?; + std::fs::write(format!("{dir}/bitcoin.conf"), cfg)?; + + let mut n = cmd("bitcoind", &[format!("-datadir={dir}").as_str()]); + let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18345).into(); + + let datadir = match network { + Network::Local => format!("{dir}/regtest"), + Network::Test => format!("{dir}/signet"), + }; + let cookie = format!("{datadir}/.cookie"); + let mut rpc = wait_up(&RpcCfg { + addr, + auth: RpcAuth::Cookie(cookie.clone()), + }) + .await; + + for name in ["wire", "client"] { + if let Err(e) = rpc.load_wallet(name).await { + if let Error::RPC { + code: ErrorCode::RpcWalletNotFound, + .. + } = e + { + rpc.create_wallet(name, "").await?; + } else { + break; + } + } + } + + let wire_addr = &rpc.wallet("wire").gen_addr().await?; + let client_addr = &rpc.wallet("client").gen_addr().await?; + + let cfg = Config::from_mem_with_env( + CONFIG_SOURCE, + &format!( + " + [depolymerizer-bitcoin] + CURRENCY = BTC + NAME = Exchange Owner + WALLET = {wire_addr} + + [depolymerizer-bitcoin-worker] + BOUNCE_FEE = BTC:0.00001 + CONFIRMATION = 1 + RPC_BIND = {addr} + WALLET_NAME = wire + RPC_COOKIE_FILE = {cookie} + " + ), + ) + .unwrap(); + + step("Setup"); + let mut pool = dbinit(&cfg, reset).await?; + setup(&cfg, reset).await?; + let mut s = WorkerCfg::parse(&cfg)?; + let mut status = true; + + let mut repl: Repl<Shell> = Repl::new(".btc_history"); + let mut tracked = BTreeSet::new(); + loop { + let info = rpc.get_blockchain_info().await.unwrap(); + let wire_balance = rpc.wallet("wire").get_balance().await.unwrap(); + println!("wire {wire_addr} {wire_balance}"); + let client_balance = rpc.wallet("client").get_balance().await.unwrap(); + println!("client {client_addr} {client_balance}"); + for txid in &tracked { + match rpc.wallet("client").get_tx(txid).await { + Ok(info) => println!( + "{} {txid} {} {}", + "tx".cyan(), + info.amount, + info.confirmations + ), + Err(e) => println!("{} {txid} {}", "tx".cyan(), e.red()), + } + } + if let Some(shell) = + repl.read_line(&info.chain, &format!("{:.6}", info.verification_progress)) + { + let Some(cmd) = shell.cmd else { continue }; + match cmd { + Cmd::Setup => setup(&cfg, false).await?, + Cmd::Reset => { + pool = dbinit(&cfg, true).await?; + } + Cmd::ResetDb => { + pool = dbinit(&cfg, true).await?; + setup(&cfg, false).await?; + } + Cmd::Sync => { + let db = &mut pool.acquire().await.unwrap().detach(); + let res = worker_step(db, &mut rpc.wallet("wire"), &mut s, &mut status).await; + if let Err(e) = &res { + tracing::error!(target: "worker", "{e}"); + } + } + Cmd::Credit => { + let reserve_pub = EddsaPublicKey::rand(); + let amount = amount("DEVBTC:0.00012"); + match rpc + .wallet("client") + .send_segwit_key(&wire_addr, taler_to_btc(&amount), &reserve_pub) + .await + { + Ok(txid) => { + tracked.insert(txid); + info!(target: "testbench", "Credit {reserve_pub} {amount} {txid} to {wire_addr}"); + } + Err(e) => tracing::error!(target: "worker", "{e}"), + } + } + Cmd::Debit => { + let payto = FullBtcPayto::new(BtcWallet(client_addr.clone()), "Bitcoin Client"); + let wtid = ShortHashCode::rand(); + let amount = amount("DEVBTC:0.00011"); + match db::transfer( + &pool, + &payto, + &TransferRequest { + request_uid: HashCode::rand(), + amount, + exchange_base_url: url("https://test.com"), + metadata: None, + wtid, + credit_account: payto.as_uri(), + }, + ) + .await + .unwrap() + { + db::TransferResult::Success(_) => {} + it => panic!("{it:?}"), + }; + info!(target: "testbench", "Debit {wtid} {amount} to {wire_addr}"); + } + Cmd::Mine { amount, addr } => { + let amount = amount.unwrap_or(1); + let addr = addr.map(|a| a.assume_checked()); + let addr = addr.as_ref().unwrap_or(&client_addr); + rpc.wallet("client").mine(amount, addr).await.unwrap(); + } + Cmd::Exit => break, + Cmd::Tx { txid } => { + let info = rpc.wallet("client").get_tx(&txid).await.unwrap(); + info!(target: "testbench", "{txid} {} {}", info.amount, info.confirmations); + } + Cmd::Track { txid } => { + tracked.insert(txid); + } + Cmd::Untrack { txid } => { + tracked.retain(|id| *id != txid); + } + } + } else { + break; + } + } + + step("Finish"); + rpc.stop().await?; + n.0.wait()?; + + Ok(()) +} + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common, async |_| match args.cmd { + Command::Logic { reset } => Ok(logic_harness(reset).await.unwrap()), + Command::Online { reset, network } => Ok(online_harness(network, reset).await.unwrap()), + }); +} diff --git a/depolymerizer-bitcoin/src/cli.rs b/depolymerizer-bitcoin/src/cli.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA + Copyright (C) 2025-2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -17,18 +17,13 @@ use axum::{Router, middleware}; use taler_api::api::TalerRouter as _; use taler_build::long_version; -use taler_common::{ - CommonArgs, - cli::ConfigCmd, - config::Config, - db::{dbinit, pool}, -}; +use taler_common::{CommonArgs, cli::ConfigCmd, config::Config}; use tracing::info; use crate::{ - CONFIG_SOURCE, DB_SCHEMA, api::{ServerState, status_middleware}, - config::{ServeCfg, WorkerCfg, parse_db_cfg}, + config::{ServeCfg, WorkerCfg}, + db::{dbinit, pool}, loops::{ watcher::watcher, worker::{worker_loop, worker_transient}, @@ -81,33 +76,20 @@ pub enum Command { pub async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { match cmd { Command::Dbinit { reset } => { - let cfg = parse_db_cfg(cfg)?; - let pool = pool(cfg.cfg, DB_SCHEMA).await?; - let mut conn = pool.acquire().await?; - dbinit( - &mut conn, - cfg.sql_dir.as_ref(), - CONFIG_SOURCE.component_name, - reset, - ) - .await?; + dbinit(cfg, reset).await?; } Command::Setup { reset } => { setup(cfg, reset).await?; } Command::Worker { transient } => { let state = WorkerCfg::parse(cfg)?; - let db_cfg = parse_db_cfg(cfg)?; - let pool = pool(db_cfg.cfg, DB_SCHEMA).await?; - - #[cfg(feature = "fail")] - tracing::warn!("Running with random failures"); + let pool = pool(cfg).await?; if transient { worker_transient(state, pool).await?; } else { tokio::spawn(watcher(state.rpc_cfg.clone(), pool.clone())); - tokio::spawn(worker_loop(state, pool)).await?; + worker_loop(state, pool.clone()).await; info!("btc-wire stopped"); } @@ -119,8 +101,7 @@ pub async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { std::process::exit(1); } } else { - let db = parse_db_cfg(cfg)?; - let pool = pool(db.cfg, DB_SCHEMA).await?; + let pool = pool(cfg).await?; let cfg = ServeCfg::parse(cfg)?; let api = ServerState::start(pool, cfg.payto, cfg.currency).await; let mut router = Router::new(); diff --git a/depolymerizer-bitcoin/src/db.rs b/depolymerizer-bitcoin/src/db.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA + Copyright (C) 2025-2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -34,6 +34,7 @@ use taler_common::{ TransferResponse, TransferState, TransferStatus, }, }, + config::Config, db::IncomingType, types::amount::{Amount, Currency}, }; @@ -41,10 +42,33 @@ use tokio::sync::watch::Receiver; use url::Url; use crate::{ + config::parse_db_cfg, payto::FullBtcPayto, sql::{sql_addr, sql_btc_amount, sql_generic_payto, sql_payto}, }; +const SCHEMA: &str = "depolymerizer_bitcoin"; + +pub async fn pool(cfg: &Config) -> anyhow::Result<PgPool> { + let db = parse_db_cfg(cfg)?; + let pool = taler_common::db::pool(db.cfg, SCHEMA).await?; + Ok(pool) +} + +pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> { + let db_cfg = parse_db_cfg(cfg)?; + let pool = taler_common::db::pool(db_cfg.cfg, SCHEMA).await?; + let mut db = pool.acquire().await?; + taler_common::db::dbinit( + &mut db, + db_cfg.sql_dir.as_ref(), + "depolymerizer-bitcoin", + reset, + ) + .await?; + Ok(pool) +} + /// Initialize the worker status pub async fn init_status(db: &PgPool) -> sqlx::Result<()> { sqlx::query( @@ -163,9 +187,8 @@ pub async fn transfer_page( Some(s) => match s { TransferState::pending => Some(DebitStatus::requested), TransferState::success => Some(DebitStatus::sent), - TransferState::transient_failure - | TransferState::permanent_failure - | TransferState::late_failure => { + TransferState::permanent_failure => Some(DebitStatus::ignored), + TransferState::transient_failure | TransferState::late_failure => { return Ok(Vec::new()); } }, @@ -197,10 +220,7 @@ pub async fn transfer_page( |r: PgRow| { Ok(TransferListStatus { row_id: r.try_get_u64(0)?, - status: match r.try_get(1)? { - DebitStatus::requested | DebitStatus::sent => TransferState::pending, - DebitStatus::confirmed => TransferState::success, - }, + status: r.try_get::<DebitStatus, _>(1)?.into(), amount: r.try_get_amount(2, currency)?, credit_account: sql_payto(&r, 3, 4)?, timestamp: r.try_get_taler_timestamp(5)?, @@ -221,6 +241,7 @@ pub async fn transfer_by_id( " SELECT status, + status_msg, amount, exchange_url, wtid, @@ -234,17 +255,14 @@ pub async fn transfer_by_id( .bind(id as i64) .try_map(|r: PgRow| { Ok(TransferStatus { - status: match r.try_get(0)? { - DebitStatus::requested | DebitStatus::sent => TransferState::pending, - DebitStatus::confirmed => TransferState::success, - }, - status_msg: None, - amount: r.try_get_amount(1, currency)?, - origin_exchange_url: r.try_get(2)?, - wtid: r.try_get(3)?, - credit_account: sql_payto(&r, 4, 5)?, - metadata: r.try_get(6)?, - timestamp: r.try_get_taler_timestamp(7)?, + status: r.try_get::<DebitStatus, _>(0)?.into(), + status_msg: r.try_get(1)?, + amount: r.try_get_amount(2, currency)?, + origin_exchange_url: r.try_get(3)?, + wtid: r.try_get(4)?, + credit_account: sql_payto(&r, 5, 6)?, + metadata: r.try_get(7)?, + timestamp: r.try_get_taler_timestamp(8)?, }) }) .fetch_optional(db) @@ -544,7 +562,7 @@ pub async fn transfer_unregister( } /// Update a transaction id after bumping it -pub async fn bump_tx_id( +pub async fn transfer_bumpfee( db: &mut PgConnection, to: &Txid, wtid: &ShortHashCode, @@ -632,8 +650,14 @@ pub async fn reorg<'a>(e: impl PgExecutor<'a>, ids: &[Txid]) -> sqlx::Result<Vec .await } -#[derive(Debug)] -pub enum SyncOutResult { +#[derive(Debug, PartialEq, Eq)] +pub struct SyncOutResult { + pub id: Option<u64>, + pub state: SyncOutState, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SyncOutState { New, Replaced, Recovered, @@ -651,25 +675,30 @@ pub enum TxOutKind<'a> { }, } +pub struct TxOut<'a> { + pub id: Txid, + pub replaces_txid: Option<Txid>, + pub amount: Amount, + pub credit_acc: &'a Address, + pub block_time: Timestamp, +} + pub async fn sync_out<'a>( e: impl PgExecutor<'a>, - txid: &Txid, - replace_txid: Option<&Txid>, - amount: &Amount, - credit_acc: &Address, + tx: &TxOut<'_>, kind: &TxOutKind<'_>, - created: &Timestamp, + confirmed: bool, ) -> sqlx::Result<SyncOutResult> { let query = sqlx::query( " - SELECT out_replaced, out_recovered, out_new - FROM sync_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + SELECT out_tx_row_id, out_replaced, out_recovered, out_new + FROM sync_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ", ) - .bind(txid.as_byte_array()) - .bind(replace_txid.map(|it| it.as_byte_array())) - .bind(amount) - .bind(credit_acc.to_string()); + .bind(tx.id.as_byte_array()) + .bind(tx.replaces_txid.as_ref().map(|it| it.as_byte_array())) + .bind(tx.amount) + .bind(tx.credit_acc.to_string()); match kind { TxOutKind::Simple => query .bind(None::<&[u8]>) @@ -691,17 +720,21 @@ pub async fn sync_out<'a>( .bind(metadata) .bind(None::<&[u8]>), } - .bind_timestamp(created) + .bind_timestamp(&tx.block_time) + .bind(confirmed) .bind_timestamp(&Timestamp::now()) .try_map(|r: PgRow| { - Ok(if r.try_get_flag(0)? { - SyncOutResult::Replaced - } else if r.try_get_flag(1)? { - SyncOutResult::Recovered - } else if r.try_get_flag(2)? { - SyncOutResult::New - } else { - SyncOutResult::None + Ok(SyncOutResult { + id: r.try_get_opt_u64(0)?, + state: if r.try_get_flag(1)? { + SyncOutState::Replaced + } else if r.try_get_flag(2)? { + SyncOutState::Recovered + } else if r.try_get_flag(3)? { + SyncOutState::New + } else { + SyncOutState::None + }, }) }) .fetch_one(e) @@ -713,7 +746,7 @@ pub async fn pending_transfer<'a>( currency: &Currency, ) -> sqlx::Result< Option<( - i64, + u64, bitcoin::Amount, ShortHashCode, Address, @@ -736,7 +769,7 @@ pub async fn pending_transfer<'a>( ) .try_map(|r: PgRow| { Ok(( - r.try_get(0)?, + r.try_get_u64(0)?, sql_btc_amount(&r, 1, currency)?, r.try_get(2)?, sql_addr(&r, 3)?, @@ -748,10 +781,24 @@ pub async fn pending_transfer<'a>( .await } +/// Update transfer status to 'ignored' and bind it to a txid +pub async fn transfer_ignored<'a>( + e: impl PgExecutor<'a>, + id: u64, + reason: &str, +) -> sqlx::Result<()> { + sqlx::query("UPDATE transfer SET status='ignored', status_msg=$2 WHERE transfer_id=$1") + .bind(id as i64) + .bind(reason) + .execute(e) + .await?; + Ok(()) +} + /// Update transfer status to 'sent' and bind it to a txid -pub async fn transfer_sent<'a>(e: impl PgExecutor<'a>, id: i64, txid: &Txid) -> sqlx::Result<()> { +pub async fn transfer_sent<'a>(e: impl PgExecutor<'a>, id: u64, txid: &Txid) -> sqlx::Result<()> { sqlx::query("UPDATE transfer SET status='sent', txid=$2 WHERE transfer_id=$1") - .bind(id) + .bind(id as i64) .bind(txid.as_byte_array()) .execute(e) .await?; @@ -772,7 +819,7 @@ pub async fn transfer_conflict<'a>(e: impl PgExecutor<'a>, id: &Txid) -> sqlx::R pub async fn pending_bounce<'a>( e: impl PgExecutor<'a>, -) -> sqlx::Result<Option<(i64, Txid, Option<String>)>> { +) -> sqlx::Result<Option<(i64, Txid, CompactString)>> { sqlx::query( " SELECT @@ -781,7 +828,8 @@ pub async fn pending_bounce<'a>( reason FROM bounced JOIN tx_in USING (tx_in_id) - WHERE status='requested' ORDER BY received_at LIMIT 1 + WHERE status='requested' AND tx_in.txid IS NOT NULL + ORDER BY received_at LIMIT 1 ", ) .try_map(|r: PgRow| { @@ -848,10 +896,11 @@ pub mod test { CONFIG_SOURCE, api::test::CLIENT, db::{ - AddIncomingResult, ProblematicTx, SyncOutResult, TransferResult, TxOutKind, bounce, - bounce_sent, bump_tx_id, get_sync_state, incoming_history, init_status, + AddIncomingResult, ProblematicTx, SyncOutResult, SyncOutState, TransferResult, TxOut, + TxOutKind, bounce, bounce_sent, get_sync_state, incoming_history, init_status, init_sync_state, pending_bounce, register_tx_in, register_tx_in_admin, reorg, - revenue_history, swap_sync_state, sync_out, transfer, update_status, + revenue_history, swap_sync_state, sync_out, transfer, transfer_bumpfee, + transfer_conflict, transfer_ignored, transfer_sent, update_status, }, }; @@ -977,14 +1026,14 @@ pub mod test { // Reserve transaction routine( - &Some(IncomingSubject::Reserve(first.clone())), + &Some(IncomingSubject::Reserve(first)), &Some(IncomingSubject::Reserve(second)), ) .await; // Kyc transaction routine( - &Some(IncomingSubject::Kyc(first.clone())), + &Some(IncomingSubject::Kyc(first)), &Some(IncomingSubject::Kyc(first)), ) .await; @@ -1070,6 +1119,408 @@ pub mod test { } #[tokio::test] + async fn sync_out_simple() { + let (_, pool) = setup().await; + let amount = amount("KUDOS:10"); + let now = now_sql_stable_ts(); + + // Sync + let txid = rand_tx_id(); + let out = TxOut { + id: txid, + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, false) + .await + .unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::None + } + ); + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, true) + .await + .unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::New + } + ); + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, true) + .await + .unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::None + } + ); + + // Replaced + let out = TxOut { + id: rand_tx_id(), + replaces_txid: Some(txid), + amount, + credit_acc: &ADDR, + block_time: now, + }; + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, false) + .await + .unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::None + } + ); + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, true) + .await + .unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::Replaced + } + ); + assert_eq!( + sync_out(&pool, &out, &TxOutKind::Simple, true) + .await + .unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::None + } + ); + } + + #[tokio::test] + async fn sync_out_talerable() { + let (mut db, poll) = setup().await; + let amount = amount("KUDOS:10"); + let now = now_sql_stable_ts(); + + let prepare_transfer = async || { + let t = TransferRequest { + amount, + exchange_base_url: url("https://exchange.example.com"), + request_uid: HashCode::rand(), + wtid: ShortHashCode::rand(), + metadata: None, + credit_account: CLIENT.as_uri(), + }; + let id = match transfer(&poll, &CLIENT, &t).await.unwrap() { + TransferResult::Success(res) => res.row_id, + _ => unreachable!(), + }; + (id, t.wtid, t.exchange_base_url) + }; + + // Sync + let (id, wtid, url) = &prepare_transfer().await; + let out = TxOut { + id: rand_tx_id(), + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + transfer_sent(&mut *db, *id, &out.id).await.unwrap(); + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::None + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Conflict + let (id, wtid, url) = &prepare_transfer().await; + let tx_id = rand_tx_id(); + let conflict_id = rand_tx_id(); + let out = TxOut { + id: conflict_id, + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + transfer_sent(&mut *db, *id, &tx_id).await.unwrap(); + transfer_conflict(&mut *db, &conflict_id).await.unwrap(); + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::None + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Bump fee + let (id, wtid, url) = &prepare_transfer().await; + let tx_id = rand_tx_id(); + let bump_id = rand_tx_id(); + let out = TxOut { + id: bump_id, + replaces_txid: Some(tx_id), + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + transfer_sent(&mut *db, *id, &tx_id).await.unwrap(); + transfer_bumpfee(&mut db, &bump_id, wtid).await.unwrap(); + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::None + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Recover + let (id, wtid, url) = &prepare_transfer().await; + let out = TxOut { + id: rand_tx_id(), + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::Recovered + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + + // Recover completed + let (id, wtid, url) = &prepare_transfer().await; + let out = TxOut { + id: rand_tx_id(), + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::Recovered + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Recover bump + let (id, wtid, url) = &prepare_transfer().await; + let out = TxOut { + id: rand_tx_id(), + replaces_txid: Some(rand_tx_id()), + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::Recovered + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + + // Recover bump completed + let (id, wtid, url) = &prepare_transfer().await; + let out = TxOut { + id: rand_tx_id(), + replaces_txid: Some(rand_tx_id()), + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::Recovered + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Recover sent bump + let (id, wtid, url) = &prepare_transfer().await; + let txid = rand_tx_id(); + let out = TxOut { + id: rand_tx_id(), + replaces_txid: Some(txid), + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + transfer_sent(&mut *db, *id, &txid).await.unwrap(); + assert_eq!( + sync_out(&poll, &out, &kind, false).await.unwrap(), + SyncOutResult { + id: None, + state: SyncOutState::Replaced + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::New + } + ); + + // Recover sent bump completed + let (id, wtid, url) = &prepare_transfer().await; + let txid = rand_tx_id(); + let out = TxOut { + id: rand_tx_id(), + replaces_txid: Some(txid), + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Talerable { + wtid, + url, + metadata: None, + }; + transfer_sent(&mut *db, *id, &txid).await.unwrap(); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::Replaced + } + ); + assert_eq!( + sync_out(&poll, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(*id), + state: SyncOutState::None + } + ); + + // Failure + let (id, _, _) = &prepare_transfer().await; + transfer_ignored(&mut *db, *id, "Oh nooooo").await.unwrap(); + } + + #[tokio::test] async fn bounces() { let (_, db) = setup().await; let amount = amount("KUDOS:10"); @@ -1095,34 +1546,28 @@ pub mod test { } _ => unreachable!(), } - assert_matches!( - sync_out( - &db, - &bounce_txid, - None, - &amount, - &ADDR, - &TxOutKind::Bounce(bounced_txid), - &now, - ) - .await - .unwrap(), - SyncOutResult::New + let out = TxOut { + id: bounce_txid, + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }; + let kind = TxOutKind::Bounce(bounced_txid); + assert_eq!( + sync_out(&db, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::New + } ); assert_eq!(pending_bounce(&db).await.unwrap(), None); - assert_matches!( - sync_out( - &db, - &bounce_txid, - None, - &amount, - &ADDR, - &TxOutKind::Bounce(bounced_txid), - &now, - ) - .await - .unwrap(), - SyncOutResult::None + assert_eq!( + sync_out(&db, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(1), + state: SyncOutState::None + } ); // Recovered @@ -1138,113 +1583,29 @@ pub mod test { .. } ); - assert_matches!( - sync_out( - &db, - &bounce_txid, - None, - &amount, - &ADDR, - &TxOutKind::Bounce(bounced_txid), - &now, - ) - .await - .unwrap(), - SyncOutResult::Recovered - ); - assert_matches!( - sync_out( - &db, - &bounce_txid, - None, - &amount, - &ADDR, - &TxOutKind::Bounce(bounced_txid), - &now, - ) - .await - .unwrap(), - SyncOutResult::None - ); - assert_eq!(pending_bounce(&db).await.unwrap(), None); - } - - #[tokio::test] - async fn sync_out_talerable_and_replace() { - let (mut db, poll) = setup().await; - let amount = amount("KUDOS:10"); - let now = now_sql_stable_ts(); - - // 1. Simple Sync Out - let txid = rand_tx_id(); - assert_matches!( - sync_out(&poll, &txid, None, &amount, &ADDR, &TxOutKind::Simple, &now,) - .await - .unwrap(), - SyncOutResult::New - ); - assert_matches!( - sync_out(&poll, &txid, None, &amount, &ADDR, &TxOutKind::Simple, &now,) - .await - .unwrap(), - SyncOutResult::None - ); - - // 2. Replace (Fee Bump) - assert_matches!( - sync_out( - &poll, - &rand_tx_id(), - Some(&txid), - &amount, - &ADDR, - &TxOutKind::Simple, - &now, - ) - .await - .unwrap(), - SyncOutResult::Replaced - ); - - // 3. Recover Talerable Transfer - let t = TransferRequest { + let out = TxOut { + id: bounce_txid, + replaces_txid: None, amount, - exchange_base_url: url("https://exchange.example.com"), - request_uid: HashCode::rand(), - wtid: ShortHashCode::rand(), - metadata: None, - credit_account: CLIENT.as_uri(), + credit_acc: &ADDR, + block_time: now, }; - - // Create the pending transfer - assert_matches!( - transfer(&poll, &CLIENT, &t).await.unwrap(), - TransferResult::Success(_) + let kind = TxOutKind::Bounce(bounced_txid); + assert_eq!( + sync_out(&db, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(2), + state: SyncOutState::New + } ); - - let txid = rand_tx_id(); - // Sync it out - assert_matches!( - sync_out( - &poll, - &txid, - None, - &amount, - &ADDR, - &TxOutKind::Talerable { - wtid: &t.wtid, - url: &t.exchange_base_url, - metadata: None, - }, - &now, - ) - .await - .unwrap(), - SyncOutResult::Recovered + assert_eq!( + sync_out(&db, &out, &kind, true).await.unwrap(), + SyncOutResult { + id: Some(2), + state: SyncOutState::None + } ); - - // Bump fee - bump_tx_id(&mut db, &rand_tx_id(), &t.wtid).await.unwrap(); + assert_eq!(pending_bounce(&db).await.unwrap(), None); } #[tokio::test] @@ -1262,7 +1623,7 @@ pub mod test { &amount, &ADDR, &now, - &Some(IncomingSubject::Reserve(reserve_pub.clone())), + &Some(IncomingSubject::Reserve(reserve_pub)), ) .await .unwrap(); @@ -1275,12 +1636,15 @@ pub mod test { .unwrap(); sync_out( &pool, - &txid_bounce, - None, - &amount, - &ADDR, + &TxOut { + id: txid_bounce, + replaces_txid: None, + amount, + credit_acc: &ADDR, + block_time: now, + }, &TxOutKind::Bounce(txid_bounced), - &now, + true, ) .await .unwrap(); diff --git a/depolymerizer-bitcoin/src/fail_point.rs b/depolymerizer-bitcoin/src/fail_point.rs @@ -1,31 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -pub struct Injected(&'static str); - -/// Inject random failure when 'fail' feature is used -#[allow(unused_variables)] -pub fn fail_point(msg: &'static str, prob: f32) -> Result<(), Injected> { - #[cfg(feature = "fail")] - return if rand::random::<f32>() < prob { - Err(Injected(msg)) - } else { - Ok(()) - }; - - Ok(()) -} diff --git a/depolymerizer-bitcoin/src/lib.rs b/depolymerizer-bitcoin/src/lib.rs @@ -16,19 +16,18 @@ use std::str::FromStr; use bitcoin::{Address, Amount, Network, Txid, hashes::hex::FromHex}; -use rpc::{Category, Rpc, Transaction}; -use rpc_utils::{segwit_min_amount, sender_address}; +use rpc::{Category, Transaction}; +use rpc_utils::segwit_min_amount; use segwit::{decode_segwit_msg, encode_segwit_key}; use taler_common::{api::EddsaPublicKey, config::parser::ConfigSource}; -use crate::segwit::DecodeSegWitErr; +use crate::{rpc::RpcApi, segwit::DecodeSegWitErr}; pub mod api; pub mod cli; pub mod config; pub mod db; -mod fail_point; -mod loops; +pub mod loops; pub mod payto; pub mod rpc; pub mod rpc_utils; @@ -38,7 +37,6 @@ pub mod sql; pub mod taler_utils; pub const CONFIG_SOURCE: ConfigSource = ConfigSource::simple("depolymerizer-bitcoin"); -pub const DB_SCHEMA: &str = "depolymerizer_bitcoin"; #[derive(Debug, thiserror::Error)] pub enum GetOpReturnErr { @@ -49,12 +47,12 @@ pub enum GetOpReturnErr { } /// An extended bitcoincore JSON-RPC api client who can send and retrieve metadata with their transaction -impl Rpc { +pub trait RpcApiExtended: RpcApi { /// Send a transaction with a 32B key as metadata encoded using fake segwit addresses - pub async fn send_segwit_key( + async fn send_segwit_key( &mut self, to: &Address, - amount: &Amount, + amount: Amount, metadata: &[u8; 32], ) -> rpc::Result<Txid> { let network = guess_network(to); @@ -71,12 +69,12 @@ impl Rpc { ]; let mut recipients = vec![(to, amount)]; let min = segwit_min_amount(); - recipients.extend(addresses.iter().map(|addr| (addr, &min))); + recipients.extend(addresses.iter().map(|addr| (addr, min))); self.send_many(recipients).await } /// Get detailed information about an in-wallet transaction and it's 32B metadata key encoded using fake segwit addresses - pub async fn get_tx_segwit_key( + async fn get_tx_segwit_key( &mut self, id: &Txid, ) -> Result<(Transaction, Result<EddsaPublicKey, DecodeSegWitErr>), rpc::Error> { @@ -98,7 +96,7 @@ impl Rpc { } /// Get detailed information about an in-wallet transaction and its op_return metadata - pub async fn get_tx_op_return( + async fn get_tx_op_return( &mut self, id: &Txid, ) -> Result<(Transaction, Vec<u8>), GetOpReturnErr> { @@ -118,12 +116,27 @@ impl Rpc { Ok((full, metadata)) } + /// Get the first sender address from a raw transaction + async fn sender_address(&mut self, full: &Transaction) -> rpc::Result<Address> { + let first = &full.decoded.vin[0]; + let tx = self.get_input_output(&first.txid.unwrap()).await?; + Ok(tx + .vout + .into_iter() + .find(|it| it.n == first.vout.unwrap()) + .unwrap() + .script_pub_key + .address + .unwrap() + .assume_checked()) + } + /// Bounce a transaction bask to its sender /// /// There is no reliable way to bounce a transaction as you cannot know if the addresses /// used are shared or come from a third-party service. We only send back to the first input /// address as a best-effort gesture. - pub async fn bounce( + async fn bounce( &mut self, id: &Txid, bounce_fee: &Amount, @@ -134,13 +147,15 @@ impl Rpc { assert!(detail.category == Category::Receive); let amount = detail.amount.to_unsigned().unwrap(); - let sender = sender_address(self, &full).await?; + let sender = self.sender_address(&full).await?; let bounce_amount = Amount::from_sat(amount.to_sat().saturating_sub(bounce_fee.to_sat())); // Send refund making recipient pay the transaction fees - self.send(&sender, &bounce_amount, metadata, true).await + self.send(&sender, bounce_amount, metadata, true).await } } +impl<T: RpcApi> RpcApiExtended for T {} + pub fn guess_network(address: &Address) -> Network { let addr = address.as_unchecked(); for network in [ diff --git a/depolymerizer-bitcoin/src/loops.rs b/depolymerizer-bitcoin/src/loops.rs @@ -14,7 +14,9 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use crate::{fail_point::Injected, rpc}; +use failure_injection::InjectedErr; + +use crate::rpc; pub mod analysis; pub mod watcher; @@ -29,7 +31,7 @@ pub enum LoopError { #[error("Another btc-wire process is running concurrently")] Concurrency, #[error(transparent)] - Injected(#[from] Injected), + Injected(#[from] InjectedErr), } pub type LoopResult<T> = Result<T, LoopError>; diff --git a/depolymerizer-bitcoin/src/loops/analysis.rs b/depolymerizer-bitcoin/src/loops/analysis.rs @@ -17,10 +17,10 @@ use tracing::warn; use super::LoopResult; -use crate::rpc::{ChainTipsStatus, Rpc}; +use crate::rpc::{ChainTipsStatus, RpcApi}; /// Analyse blockchain behavior and return the new confirmation delay -pub async fn analysis(rpc: &mut Rpc, current: u32, max: u32) -> LoopResult<u32> { +pub async fn analysis(rpc: &mut impl RpcApi, current: u32, max: u32) -> LoopResult<u32> { // Get biggest known valid fork let fork = rpc .get_chain_tips() diff --git a/depolymerizer-bitcoin/src/loops/watcher.rs b/depolymerizer-bitcoin/src/loops/watcher.rs @@ -20,14 +20,17 @@ use tokio::time::sleep; use tracing::error; use super::LoopResult; -use crate::{config::RpcCfg, rpc::rpc_common}; +use crate::{ + config::RpcCfg, + rpc::{Rpc, RpcApi as _}, +}; /// Wait for new block and notify arrival with postgreSQL notifications pub async fn watcher(rpc_cfg: RpcCfg, pool: PgPool) { let mut jitter = ExpoBackoffDecorr::default(); loop { let result: LoopResult<()> = async { - let mut rpc = rpc_common(&rpc_cfg).await?; + let mut rpc = Rpc::new(&rpc_cfg).await?; loop { sqlx::query("NOTIFY new_block").execute(&pool).await?; rpc.wait_for_new_block().await?; diff --git a/depolymerizer-bitcoin/src/loops/worker.rs b/depolymerizer-bitcoin/src/loops/worker.rs @@ -17,6 +17,7 @@ use std::{fmt::Write, time::SystemTime}; use bitcoin::{Amount as BtcAmount, Txid, hashes::Hash}; use depolymerizer_common::metadata::OutMetadata; +use failure_injection::fail_point; use jiff::Timestamp; use sqlx::{ Acquire, Either, PgConnection, PgPool, @@ -29,12 +30,14 @@ use tracing::{debug, error, info, trace, warn}; use super::{LoopError, LoopResult, analysis::analysis}; use crate::{ - GetOpReturnErr, + GetOpReturnErr, RpcApiExtended as _, config::WorkerCfg, - db::{self, AddIncomingResult, SyncOutResult, TxOutKind}, - fail_point::fail_point, - rpc::{self, Category, ErrorCode, ListSinceBlock, ListTransaction, Rpc, rpc_wallet}, - rpc_utils::sender_address, + db::{self, AddIncomingResult, SyncOutState, TxOut, TxOutKind}, + rpc::{ + self, Category, + ErrorCode::{self, RpcInvalidAddressOrKey}, + ListSinceBlock, ListTransaction, Rpc, RpcApi, + }, taler_utils::btc_to_taler, }; @@ -47,7 +50,12 @@ pub async fn worker_loop(mut state: WorkerCfg, pool: PgPool) { loop { let result: LoopResult<()> = async { // Connect - let rpc = &mut rpc_wallet(&state.rpc_cfg, &state.wallet_cfg).await?; + let rpc = &mut Rpc::new(&state.rpc_cfg).await?; + rpc.load_wallet(&state.wallet_cfg.name).await?; + if let Some(password) = &state.wallet_cfg.password { + rpc.unlock_wallet(password).await?; + } + let rpc = &mut rpc.wallet(&state.wallet_cfg.name); let db = &mut PgListener::connect_with(&pool).await?; // Listen to all channels @@ -104,7 +112,7 @@ pub async fn worker_loop(mut state: WorkerCfg, pool: PgPool) { // Perform analysis state.conf = analysis(rpc, state.conf, state.max_conf).await?; - worker_step(rpc, lock.as_mut(), &mut state, &mut status).await?; + worker_step(lock.as_mut(), rpc, &mut state, &mut status).await?; skip_notification = false; jitter.reset(); @@ -120,7 +128,10 @@ pub async fn worker_loop(mut state: WorkerCfg, pool: PgPool) { skip_notification = match e { LoopError::DB(_) | LoopError::Injected(_) | LoopError::Concurrency => true, LoopError::Rpc(e) => match e { - rpc::Error::Transport(_) | rpc::Error::Connect(_) => true, + rpc::Error::Transport(_) + | rpc::Error::Cookie(_, _) + | rpc::Error::Tcp(_, _) + | rpc::Error::Elapsed(_, _) => true, rpc::Error::RPC { code, .. } => code == ErrorCode::RpcWalletError, rpc::Error::Bitcoin(_) | rpc::Error::Json { .. } | rpc::Error::Null => false, }, @@ -136,7 +147,12 @@ pub async fn worker_transient(mut state: WorkerCfg, pool: PgPool) -> LoopResult< let mut status = true; // Connect - let rpc = &mut rpc_wallet(&state.rpc_cfg, &state.wallet_cfg).await?; + let rpc = &mut Rpc::new(&state.rpc_cfg).await?; + rpc.load_wallet(&state.wallet_cfg.name).await?; + if let Some(password) = &state.wallet_cfg.password { + rpc.unlock_wallet(password).await?; + } + let rpc = &mut rpc.wallet(&state.wallet_cfg.name); let mut db = pool.acquire().await?; // It is not possible to atomically update the blockchain and the database. @@ -153,19 +169,19 @@ pub async fn worker_transient(mut state: WorkerCfg, pool: PgPool) -> LoopResult< return Err(LoopError::Concurrency); }; - worker_step(rpc, lock.as_mut(), &mut state, &mut status).await?; + worker_step(lock.as_mut(), rpc, &mut state, &mut status).await?; Ok(()) } /// Synchronize local db with blockchain and perform transactions -async fn worker_step( - rpc: &mut Rpc, +pub async fn worker_step( db: &mut PgConnection, + rpc: &mut impl RpcApi, state: &mut WorkerCfg, status: &mut bool, ) -> LoopResult<()> { // Sync chain - if let Some(stuck) = sync_chain(rpc, db, state, status).await? { + if let Some(stuck) = sync_chain(db, rpc, state, status).await? { // As we are now in sync with the blockchain if a transaction has Requested status it have not been sent // Send requested debits @@ -174,9 +190,9 @@ async fn worker_step( // Bump stuck transactions for (txid, wtid) in stuck { let bump = rpc.bump_fee(&txid).await?; - fail_point("(injected) fail bump", 0.3)?; - db::bump_tx_id(&mut *db, &bump.txid, &wtid).await?; - info!(target: "worker", ">> (bump) {wtid} {txid} -> {}", bump.txid); + failure_injection::fail_point("bumpfee")?; + db::transfer_bumpfee(&mut *db, &bump.txid, &wtid).await?; + info!(target: "worker", ">> bump {wtid} {txid} -> {}", bump.txid); } // Send requested bounce @@ -187,8 +203,8 @@ async fn worker_step( /// Parse new transactions, return stuck transactions if the database is up to date with the latest mined block async fn sync_chain( - rpc: &mut Rpc, db: &mut PgConnection, + rpc: &mut impl RpcApi, state: &WorkerCfg, status: &mut bool, ) -> LoopResult<Option<Vec<(Txid, ShortHashCode)>>> { @@ -329,13 +345,13 @@ async fn sync_chain_removed( /// Sync database with an incoming confirmed transaction async fn sync_chain_incoming_confirmed( txid: &Txid, - rpc: &mut Rpc, + rpc: &mut impl RpcApi, db: &mut PgConnection, state: &WorkerCfg, ) -> Result<(), LoopError> { let (tx, metadata) = rpc.get_tx_segwit_key(txid).await?; // Store transactions in database - let debit_addr = sender_address(rpc, &tx).await?; + let debit_addr = rpc.sender_address(&tx).await?; let amount = btc_to_taler(&tx.amount, &state.currency); let time = Timestamp::from_second(tx.time as i64).unwrap(); let ty = IncomingType::reserve; @@ -347,7 +363,7 @@ async fn sync_chain_incoming_confirmed( &amount, &debit_addr, &Timestamp::from_second(tx.time as i64).unwrap(), - &Some(IncomingSubject::Reserve(reserve_pub.clone())), + &Some(IncomingSubject::Reserve(reserve_pub)), ) .await? { @@ -377,10 +393,12 @@ async fn sync_chain_incoming_confirmed( async fn sync_chain_outgoing( txid: &Txid, confirmations: i32, - rpc: &mut Rpc, + rpc: &mut impl RpcApi, db: &mut PgConnection, state: &WorkerCfg, ) -> LoopResult<Option<ShortHashCode>> { + let confirmed = confirmations >= state.conf as i32; + let chain = if confirmed { "onchain" } else { "sent" }; match rpc .get_tx_op_return(txid) .await @@ -397,54 +415,56 @@ async fn sync_chain_outgoing( metadata, } => { if confirmations < 0 { - // Handle conflicting tx - if tx.replaced_by_txid.is_none() && db::transfer_conflict(db, txid).await? { - warn!(target: "worker", ">> (conflict) {wtid} {txid} {credit_addr} {amount}"); + // Handle conflict + if db::transfer_conflict(db, txid).await? { + warn!(target: "worker", ">> conflict {wtid} {txid} {credit_addr} {amount}"); } - } else if confirmations > state.conf as i32 { + } else { + // Sync db state match db::sync_out( db, - txid, - tx.replaced_by_txid.as_ref(), - &amount, - &credit_addr, + &TxOut { + id: *txid, + replaces_txid: tx.replaces_txid, + amount, + credit_acc: &credit_addr, + block_time: created_at, + }, &TxOutKind::Talerable { wtid: &wtid, url: &url, metadata: metadata.as_deref(), }, - &created_at, + confirmed, ) .await? + .state { - SyncOutResult::New => { - info!(target: "worker", ">> (onchain) {wtid} {txid} {credit_addr} {amount}"); + SyncOutState::New => { + info!(target: "worker", ">> {chain} {wtid} {txid} {credit_addr} {amount}"); } - SyncOutResult::Replaced => { - info!( + SyncOutState::Replaced => { + warn!( target: "worker", - ">> (recovered) {wtid} {txid} -> {} {credit_addr} {amount}", - tx.replaced_by_txid.unwrap() + ">> recovered {chain} {wtid} {txid} -> {} {credit_addr} {amount}", + tx.replaces_txid.unwrap() ) } - SyncOutResult::Recovered => { - warn!(target: "worker", ">> (recovered) {wtid} {txid} {credit_addr} {amount}") + SyncOutState::Recovered => { + warn!(target: "worker", ">> recovered {chain} {wtid} {txid} {credit_addr} {amount}") } - SyncOutResult::None => {} + SyncOutState::None => {} } - } else { - // TODO sync transfer sent ? - - // Check if stuck if let Some(delay) = state.bump_delay && confirmations == 0 && tx.replaced_by_txid.is_none() { + // Check stuck let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - if now - tx.time > delay as u64 { + if now - tx.time >= delay as u64 { return Ok(Some(wtid)); } } @@ -453,39 +473,42 @@ async fn sync_chain_outgoing( OutMetadata::Bounce { bounced } => { let bounced = Txid::from_byte_array(bounced); if confirmations < 0 { - // Handle conflicting tx + // Handle conflict if db::bounce_conflict(db, txid).await? { - warn!(target: "worker", "|| (conflict) {bounced} {txid}"); + warn!(target: "worker", "|| conflict {bounced} {txid}"); } - } else if confirmations > state.conf as i32 { + } else { + // Sync db state match db::sync_out( db, - txid, - tx.replaces_txid.as_ref(), - &amount, - &credit_addr, + &TxOut { + id: *txid, + replaces_txid: tx.replaces_txid, + amount, + credit_acc: &credit_addr, + block_time: created_at, + }, &TxOutKind::Bounce(bounced), - &created_at, + confirmed, ) .await? + .state { - SyncOutResult::New => { - info!(target: "worker", "|| (onchain) {bounced} {txid}") + SyncOutState::New => { + info!(target: "worker", "|| {chain} {bounced} {txid}") } - SyncOutResult::Replaced => { - info!( + SyncOutState::Replaced => { + warn!( target: "worker", - "|| (recovered) {bounced} {txid} -> {}", + "|| recovered {chain} {bounced} {txid} -> {}", tx.replaced_by_txid.unwrap() ) } - SyncOutResult::Recovered => { - warn!(target: "worker", "|| (recovered) {bounced} {txid}") + SyncOutState::Recovered => { + warn!(target: "worker", "|| recovered {chain} {bounced} {txid}") } - SyncOutResult::None => {} + SyncOutState::None => {} } - } else { - // TODO sync bounce sent ? } } } @@ -500,24 +523,41 @@ async fn sync_chain_outgoing( } /// Send a debit transaction on the blockchain, return false if no more requested transactions are found -async fn debit(db: &mut PgConnection, rpc: &mut Rpc, state: &WorkerCfg) -> LoopResult<bool> { +async fn debit( + db: &mut PgConnection, + rpc: &mut impl RpcApi, + state: &WorkerCfg, +) -> LoopResult<bool> { // We rely on the advisory lock to ensure we are the only one sending transactions if let Some((id, amount, wtid, addr, url, metadata)) = db::pending_transfer(&mut *db, &state.currency).await? { let metadata = OutMetadata::Debit { - wtid: wtid.clone(), + wtid, url, metadata, }; - let txid = rpc - .send(&addr, &amount, Some(&metadata.encode().unwrap()), false) - .await?; - fail_point("(injected) fail debit", 0.3)?; + let txid = match rpc + .send(&addr, amount, Some(&metadata.encode().unwrap()), false) + .await + { + Ok(id) => id, + + Err(e) => match &e { + rpc::Error::RPC { code, msg, .. } if *code == RpcInvalidAddressOrKey => { + let reason = format!("{code:?} {msg}"); + db::transfer_ignored(db, id, &reason).await?; + warn!(target: "worker", ">> ignored {wtid} {addr} {amount}: {reason}"); + return Ok(true); + } + _ => return Err(e.into()), + }, + }; + fail_point("debit")?; db::transfer_sent(db, id, &txid).await?; let amount = btc_to_taler(&amount.to_signed().unwrap(), &state.currency); - info!(target: "worker", ">> (sent) {wtid} {txid} {addr} {amount}"); + info!(target: "worker", ">> sent {wtid} {txid} {addr} {amount}"); Ok(true) } else { Ok(false) @@ -525,7 +565,7 @@ async fn debit(db: &mut PgConnection, rpc: &mut Rpc, state: &WorkerCfg) -> LoopR } /// Bounce a transaction on the blockchain, return false if no more requested transactions are found -async fn bounce(db: &mut PgConnection, rpc: &mut Rpc, fee: &BtcAmount) -> LoopResult<bool> { +async fn bounce(db: &mut PgConnection, rpc: &mut impl RpcApi, fee: &BtcAmount) -> LoopResult<bool> { // We rely on the advisory lock to ensure we are the only one sending transactions if let Some((id, bounced, reason)) = db::pending_bounce(&mut *db).await? { let metadata = OutMetadata::Bounce { @@ -537,13 +577,9 @@ async fn bounce(db: &mut PgConnection, rpc: &mut Rpc, fee: &BtcAmount) -> LoopRe .await { Ok(txid) => { - fail_point("(injected) fail bounce", 0.3)?; + fail_point("bounce")?; db::bounce_sent(db, id, &txid).await?; - if let Some(reason) = reason { - info!(target: "worker", "|| (sent) {bounced} {txid}: {reason}"); - } else { - info!(target: "worker", "|| (sent) {bounced} {txid}"); - } + info!(target: "worker", "|| sent {bounced} {txid}: {reason}"); } Err(err) => match err { rpc::Error::RPC { diff --git a/depolymerizer-bitcoin/src/payto.rs b/depolymerizer-bitcoin/src/payto.rs @@ -23,10 +23,7 @@ use taler_common::types::payto::{FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI pub struct BtcWallet(pub Address); const BITCOIN: &str = "bitcoin"; - -#[derive(Debug, thiserror::Error)] -#[error("missing wallet addr in path")] -pub struct MissingAddr; +const SEGMENT: &str = "wallet addr"; impl PaytoImpl for BtcWallet { fn as_uri(&self) -> PaytoURI { @@ -42,13 +39,13 @@ impl PaytoImpl for BtcWallet { )); } let Some(mut segments) = url.path_segments() else { - return Err(PaytoErr::MissingSegment("wallet addr")); + return Err(PaytoErr::MissingSegment(SEGMENT)); }; let Some(first) = segments.next() else { - return Err(PaytoErr::MissingSegment("wallet addr")); + return Err(PaytoErr::MissingSegment(SEGMENT)); }; let address = - Address::from_str(first).map_err(|e| PaytoErr::malformed_segment("wallet addr", e))?; + Address::from_str(first).map_err(|e| PaytoErr::malformed_segment(SEGMENT, e))?; Ok(Self(address.assume_checked())) } } diff --git a/depolymerizer-bitcoin/src/rpc.rs b/depolymerizer-bitcoin/src/rpc.rs @@ -45,39 +45,23 @@ use tokio::{ }; use tracing::trace; -use crate::config::{RpcAuth, RpcCfg, WalletCfg}; +use crate::config::{RpcAuth, RpcCfg}; -/// Create a rpc connection with an unlocked wallet -pub async fn rpc_wallet(config: &RpcCfg, wallet: &WalletCfg) -> Result<Rpc> { - let mut rpc = Rpc::wallet(config, &wallet.name).await?; - rpc.load_wallet(&wallet.name).await?; - if let Some(password) = &wallet.password { - rpc.unlock_wallet(password).await?; - } - Ok(rpc) -} - -/// Create a rpc connection -pub async fn rpc_common(config: &RpcCfg) -> Result<Rpc> { - Ok(Rpc::common(config).await?) -} +const RPC_VERSION: &str = "2.0"; #[derive(Debug, serde::Serialize)] struct RpcRequest<'a, T: serde::Serialize> { + jsonrpc: &'static str, method: &'a str, id: u64, params: &'a T, } #[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum RpcResponse<T> { - RpcResponse { - result: Option<T>, - error: Option<RpcError>, - id: u64, - }, - Error(CompactString), +struct RpcResponse<T> { + result: Option<T>, + error: Option<RpcError>, + id: u64, } #[derive(Debug, serde::Deserialize)] @@ -103,8 +87,12 @@ pub enum Error { method: CompactString, e: serde_json::Error, }, - #[error("connect: {0}")] - Connect(#[from] RpcConnectErr), + #[error("failed to read cookie file at '{0}': {1}")] + Cookie(String, ErrorKind), + #[error("failed to connect {0}: {1}")] + Tcp(SocketAddr, ErrorKind), + #[error("failed to connect {0}: {1}")] + Elapsed(SocketAddr, tokio::time::error::Elapsed), #[error("Null rpc, no result or error")] Null, } @@ -120,15 +108,20 @@ fn expect_null(result: Result<()>) -> Result<()> { } } +#[derive(Debug)] pub struct JsonSocket { - path: CompactString, - cookie: CompactString, sock: TcpStream, buf: Vec<u8>, } impl JsonSocket { - async fn call<T>(&mut self, method: &str, body: &impl serde::Serialize) -> Result<T> + async fn call<T>( + &mut self, + path: &str, + method: &str, + cookie: &str, + body: &impl serde::Serialize, + ) -> Result<T> where T: serde::de::DeserializeOwned, { @@ -142,10 +135,10 @@ impl JsonSocket { let body_len = buf.len(); // Write HTTP request - writeln!(buf, "POST {} HTTP/1.1\r", self.path)?; + writeln!(buf, "POST {path} HTTP/1.1\r")?; // Write headers writeln!(buf, "Accept: application/json-rpc\r")?; - writeln!(buf, "Authorization: {}\r", self.cookie)?; + writeln!(buf, "Authorization: {cookie}\r")?; writeln!(buf, "Content-Type: application/json-rpc\r")?; writeln!(buf, "Content-Length: {body_len}\r")?; // Write separator @@ -184,115 +177,144 @@ impl JsonSocket { } } -#[derive(Debug, thiserror::Error)] -pub enum RpcConnectErr { - #[error("failed to read cookie file at '{0}': {1}")] - Cookie(String, ErrorKind), - #[error("failed to connect {0}: {1}")] - Tcp(SocketAddr, ErrorKind), - #[error("failed to connect {0}: {1}")] - Elapsed(SocketAddr, tokio::time::error::Elapsed), -} - /// Bitcoin RPC connection pub struct Rpc { socket: JsonSocket, + cookie: CompactString, id: u64, } impl Rpc { /// Start a RPC connection - pub async fn common(cfg: &RpcCfg) -> std::result::Result<Self, RpcConnectErr> { - Self::new(cfg, None).await - } - - /// Start a wallet RPC connection - pub async fn wallet(cfg: &RpcCfg, wallet: &str) -> std::result::Result<Self, RpcConnectErr> { - Self::new(cfg, Some(wallet)).await - } - - async fn new(cfg: &RpcCfg, wallet: Option<&str>) -> std::result::Result<Self, RpcConnectErr> { - let path = if let Some(wallet) = wallet { - format_compact!("/wallet/{wallet}") - } else { - CompactString::const_new("/") - }; - + pub async fn new(cfg: &RpcCfg) -> std::result::Result<Self, Error> { let token = match &cfg.auth { RpcAuth::Basic(s) => s.as_bytes().to_vec(), RpcAuth::Cookie(path) => match std::fs::read(path) { Ok(content) => content, Err(e) if e.kind() == ErrorKind::IsADirectory => { let path = PathBuf::from_str(path).unwrap().join(".cookie"); - std::fs::read(&path).map_err(|e| { - RpcConnectErr::Cookie(path.to_string_lossy().to_string(), e.kind()) - })? + std::fs::read(&path) + .map_err(|e| Error::Cookie(path.to_string_lossy().to_string(), e.kind()))? } - Err(e) => return Err(RpcConnectErr::Cookie(path.clone(), e.kind())), + Err(e) => return Err(Error::Cookie(path.clone(), e.kind())), }, }; // Open connection let sock = timeout(Duration::from_secs(5), TcpStream::connect(&cfg.addr)) .await - .map_err(|e| RpcConnectErr::Elapsed(cfg.addr, e))? - .map_err(|e| RpcConnectErr::Tcp(cfg.addr, e.kind()))?; + .map_err(|e| Error::Elapsed(cfg.addr, e))? + .map_err(|e| Error::Tcp(cfg.addr, e.kind()))?; sock.set_nodelay(true).ok(); Ok(Self { id: 0, + cookie: format_compact!("Basic {}", BASE64_STANDARD.encode(&token)), socket: JsonSocket { - path, - cookie: format_compact!("Basic {}", BASE64_STANDARD.encode(&token)), sock, buf: Vec::with_capacity(16 * 1024), }, }) } - async fn call<T>(&mut self, method: &str, params: &(impl serde::Serialize + Debug)) -> Result<T> + /// Set current wallet name + pub fn wallet(&mut self, wallet: &str) -> WalletRpc<'_> { + WalletRpc { + rpc: self, + path: format_compact!("/wallet/{wallet}"), + } + } + + async fn call_rpc<T>( + &mut self, + path: &str, + method: &str, + params: &(impl serde::Serialize + Debug), + ) -> Result<T> where T: serde::de::DeserializeOwned + Debug, { trace!("RPC > {method} {params:?}"); let request = RpcRequest { + jsonrpc: RPC_VERSION, method, id: self.id, params, }; - let response: RpcResponse<T> = self.socket.call(method, &request).await?; + let response: RpcResponse<T> = self + .socket + .call(path, method, &self.cookie, &request) + .await?; trace!("RPC < {response:?}"); - match response { - RpcResponse::RpcResponse { result, error, id } => { - assert_eq!(self.id, id); - self.id += 1; - if let Some(ok) = result { - Ok(ok) - } else { - Err(match error { - Some(err) => Error::RPC { - method: method.into(), - code: err.code, - msg: err.message, - }, - None => Error::Null, - }) - } - } - RpcResponse::Error(msg) => Err(Error::Bitcoin(msg)), + let RpcResponse { result, error, id } = response; + assert_eq!(self.id, id); + self.id += 1; + if let Some(ok) = result { + Ok(ok) + } else { + Err(match error { + Some(err) => Error::RPC { + method: method.into(), + code: err.code, + msg: err.message, + }, + None => Error::Null, + }) } } +} + +impl RpcApi for Rpc { + async fn call< + T: serde::de::DeserializeOwned + Debug + Send, + A: serde::Serialize + Debug + Send, + >( + &mut self, + method: &str, + params: &A, + ) -> Result<T> { + self.call_rpc("/", method, params).await + } +} + +pub struct WalletRpc<'a> { + rpc: &'a mut Rpc, + path: CompactString, +} + +impl<'a> RpcApi for WalletRpc<'a> { + async fn call< + T: serde::de::DeserializeOwned + Debug + Send, + A: serde::Serialize + Debug + Send, + >( + &mut self, + method: &str, + params: &A, + ) -> Result<T> { + self.rpc.call_rpc(&self.path, method, params).await + } +} + +pub trait RpcApi { + async fn call< + T: serde::de::DeserializeOwned + Debug + Send, + A: serde::Serialize + Debug + Send, + >( + &mut self, + method: &str, + params: &A, + ) -> Result<T>; /* ----- Wallet management ----- */ /// Create encrypted native bitcoin wallet - pub async fn create_wallet(&mut self, name: &str, passwd: &str) -> Result<Wallet> { + async fn create_wallet(&mut self, name: &str, passwd: &str) -> Result<Wallet> { self.call("createwallet", &(name, (), (), passwd, (), true)) .await } /// Load existing wallet - pub async fn load_wallet(&mut self, name: &str) -> Result<Wallet> { + async fn load_wallet(&mut self, name: &str) -> Result<Wallet> { match self.call("loadwallet", &[name]).await { Err(Error::RPC { code: ErrorCode::RpcWalletAlreadyLoaded, @@ -305,82 +327,92 @@ impl Rpc { } /// Unlock loaded wallet - pub async fn unlock_wallet(&mut self, passwd: &str) -> Result<()> { + async fn unlock_wallet(&mut self, passwd: &str) -> Result<()> { expect_null(self.call("walletpassphrase", &(passwd, 100000000)).await) } /* ----- Wallet utils ----- */ /// Generate a new address for the current wallet - pub async fn gen_addr(&mut self) -> Result<Address> { + async fn gen_addr(&mut self) -> Result<Address> { Ok(self - .call::<Address<NetworkUnchecked>>("getnewaddress", &EMPTY) + .call::<Address<NetworkUnchecked>, _>("getnewaddress", &EMPTY) .await? .assume_checked()) } /// Get current balance amount - pub async fn get_balance(&mut self) -> Result<Amount> { + async fn get_balance(&mut self) -> Result<Amount> { let btc: f64 = self.call("getbalance", &EMPTY).await?; Ok(Amount::from_btc(btc).unwrap()) } /// Get current balance amount - pub async fn addr_info(&mut self, addr: &Address) -> Result<AddressInfo> { + async fn addr_info(&mut self, addr: &Address) -> Result<AddressInfo> { self.call("getaddressinfo", &[addr]).await } /* ----- Mining ----- */ /// Mine a certain amount of block to profit a given address - pub async fn mine(&mut self, nb: u16, address: &Address) -> Result<Vec<BlockHash>> { + async fn mine(&mut self, nb: u32, address: &Address) -> Result<Vec<BlockHash>> { self.call("generatetoaddress", &(nb, address)).await } + /// Wait for a block height + async fn wait_for_block_height(&mut self, height: u32) -> Result<Nothing> { + self.call("waitforblockheight", &[height]).await + } + /* ----- Getter ----- */ /// Get blockchain info - pub async fn get_blockchain_info(&mut self) -> Result<BlockchainInfo> { + async fn get_blockchain_info(&mut self) -> Result<BlockchainInfo> { self.call("getblockchaininfo", &EMPTY).await } /// Get mempool info - pub async fn get_mempool_info(&mut self) -> Result<MemPoolInfo> { + async fn get_mempool_info(&mut self) -> Result<MemPoolInfo> { self.call("getmempoolinfo", &EMPTY).await } + /// Get mempool entry + async fn get_mempool_entry(&mut self, id: &Txid) -> Result<MemPoolEntry> { + self.call("getmempoolentry", &[id]).await + } + /// Get blockchain info - pub async fn get_wallet_info(&mut self) -> Result<WalletInfo> { + async fn get_wallet_info(&mut self) -> Result<WalletInfo> { self.call("getwalletinfo", &EMPTY).await } /// Get chain tips - pub async fn get_chain_tips(&mut self) -> Result<Vec<ChainTips>> { + async fn get_chain_tips(&mut self) -> Result<Vec<ChainTips>> { self.call("getchaintips", &EMPTY).await } /// Get wallet transaction info from id - pub async fn get_tx(&mut self, id: &Txid) -> Result<Transaction> { + async fn get_tx(&mut self, id: &Txid) -> Result<Transaction> { self.call("gettransaction", &(id, (), true)).await } /// Get transaction inputs and outputs - pub async fn get_input_output(&mut self, id: &Txid) -> Result<InputOutput> { + async fn get_input_output(&mut self, id: &Txid) -> Result<InputOutput> { self.call("getrawtransaction", &(id, true)).await } /// Get genesis block hash - pub async fn get_genesis(&mut self) -> Result<BlockHash> { + async fn get_genesis(&mut self) -> Result<BlockHash> { self.call("getblockhash", &[0]).await } /* ----- Transactions ----- */ /// Send bitcoin transaction - pub async fn send( + async fn send( &mut self, to: &Address, - amount: &Amount, + amount: Amount, data: Option<&[u8]>, subtract_fee: bool, ) -> Result<Txid> { @@ -390,9 +422,9 @@ impl Rpc { } /// Send bitcoin transaction with multiple recipients - pub async fn send_many<'a>( + async fn send_many<'a>( &mut self, - to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, + to: impl IntoIterator<Item = (&'a Address, Amount)>, ) -> Result<Txid> { self.send_custom([], to, None, false) .await @@ -402,7 +434,7 @@ impl Rpc { async fn send_custom<'a>( &mut self, from: impl IntoIterator<Item = &'a Txid>, - to: impl IntoIterator<Item = (&'a Address, &'a Amount)>, + to: impl IntoIterator<Item = (&'a Address, Amount)>, data: Option<&[u8]>, subtract_fee: bool, ) -> Result<SendResult> { @@ -445,24 +477,24 @@ impl Rpc { } /// Bump transaction fees of a wallet debit - pub async fn bump_fee(&mut self, id: &Txid) -> Result<BumpResult> { + async fn bump_fee(&mut self, id: &Txid) -> Result<BumpResult> { self.call("bumpfee", &[id]).await } /// Abandon a pending transaction - pub async fn abandon_tx(&mut self, id: &Txid) -> Result<()> { + async fn abandon_tx(&mut self, id: &Txid) -> Result<()> { expect_null(self.call("abandontransaction", &[&id]).await) } /* ----- Watcher ----- */ /// Block until a new block is mined - pub async fn wait_for_new_block(&mut self) -> Result<Nothing> { + async fn wait_for_new_block(&mut self) -> Result<Nothing> { self.call("waitfornewblock", &[0]).await } /// List new and removed transaction since a block - pub async fn list_since_block( + async fn list_since_block( &mut self, hash: Option<&BlockHash>, confirmation: u32, @@ -477,20 +509,20 @@ impl Rpc { /* ----- Cluster ----- */ /// Try a connection to a node once - pub async fn add_node(&mut self, addr: &str) -> Result<()> { + async fn add_node(&mut self, addr: &str) -> Result<()> { expect_null(self.call("addnode", &(addr, "onetry")).await) } /// Immediately disconnects from the specified peer node. - pub async fn disconnect_node(&mut self, addr: &str) -> Result<()> { + async fn disconnect_node(&mut self, addr: &str) -> Result<()> { expect_null(self.call("disconnectnode", &(addr, ())).await) } /* ----- Control ------ */ /// Request a graceful shutdown - pub async fn stop(&mut self) -> Result<String> { - self.call("stop", &()).await + fn stop(&mut self) -> impl Future<Output = Result<String>> { + self.call("stop", &()) } } @@ -506,7 +538,7 @@ pub struct BlockchainInfo { pub verification_progress: f32, #[serde(rename = "initialblockdownload")] pub initial_block_download: bool, - pub blocks: u64, + pub blocks: u32, #[serde(rename = "bestblockhash")] pub best_block_hash: BlockHash, } @@ -517,6 +549,11 @@ pub struct MemPoolInfo { } #[derive(Clone, Debug, serde::Deserialize)] +pub struct MemPoolEntry { + pub height: u32, +} + +#[derive(Clone, Debug, serde::Deserialize)] #[serde(untagged)] pub enum Scanning { Active { duration: u64, progress: f32 }, @@ -627,6 +664,7 @@ pub struct Transaction { pub replaced_by_txid: Option<Txid>, pub details: Vec<TransactionDetail>, pub decoded: InputOutput, + //pub height: u32, } #[derive(Clone, PartialEq, Eq, serde::Deserialize, Debug)] diff --git a/depolymerizer-bitcoin/src/rpc_utils.rs b/depolymerizer-bitcoin/src/rpc_utils.rs @@ -15,9 +15,7 @@ */ use std::{path::PathBuf, str::FromStr}; -use bitcoin::{Address, Amount, Network}; - -use crate::rpc::{self, Rpc, Transaction}; +use bitcoin::{Amount, Network}; /// Default chain dir <https://github.com/bitcoin/bitcoin/blob/master/doc/files.md#data-directory-location> pub fn chain_dir(network: Network) -> &'static str { @@ -65,18 +63,3 @@ pub fn segwit_min_amount() -> Amount { // https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp Amount::from_sat(294) } - -/// Get the first sender address from a raw transaction -pub async fn sender_address(rpc: &mut Rpc, full: &Transaction) -> rpc::Result<Address> { - let first = &full.decoded.vin[0]; - let tx = rpc.get_input_output(&first.txid.unwrap()).await?; - Ok(tx - .vout - .into_iter() - .find(|it| it.n == first.vout.unwrap()) - .unwrap() - .script_pub_key - .address - .unwrap() - .assume_checked()) -} diff --git a/depolymerizer-bitcoin/src/segwit.rs b/depolymerizer-bitcoin/src/segwit.rs @@ -63,11 +63,11 @@ pub fn encode_segwit_key(hrp: Hrp, msg: &[u8; 32]) -> [String; 2] { #[derive(Debug, thiserror::Error)] pub enum DecodeSegWitErr { - #[error("There is less than 2 segwit addresses")] + #[error("there is less than 2 segwit addresses")] MissingSegWitAddress, - #[error("No addresses are sharing a common prefix")] + #[error("no addresses shares a common prefix")] NoPrefixMatch, - #[error("More than two addresses are sharing a common prefix")] + #[error("more than two addresses shares a common prefix")] PrefixCollision, #[error(transparent)] Malformed(EddsaPublicKeyError), diff --git a/depolymerizer-bitcoin/src/setup.rs b/depolymerizer-bitcoin/src/setup.rs @@ -15,29 +15,24 @@ */ use anyhow::bail; -use taler_common::{config::Config, db::pool}; +use taler_common::config::Config; use tokio::try_join; use tracing::{info, warn}; use crate::{ - DB_SCHEMA, - config::{WorkerCfg, parse_db_cfg}, - db, + config::WorkerCfg, + db::{self, pool}, payto::BtcWallet, - rpc::{Rpc, Scanning}, + rpc::{Rpc, RpcApi as _, Scanning}, }; pub async fn setup(cfg: &Config, reset: bool) -> anyhow::Result<()> { info!(target: "setup", "Check bitcoind RPC connection"); let worker_cfg = WorkerCfg::parse(cfg)?; - let mut rpc = Rpc::wallet(&worker_cfg.rpc_cfg, &worker_cfg.wallet_cfg.name).await?; + let mut rpc = Rpc::new(&worker_cfg.rpc_cfg).await?; let info = rpc.get_blockchain_info().await?; info!(target: "setup", "Running on {} chain", info.chain); - #[cfg(feature = "fail")] - if info.chain != "regtest" { - anyhow::bail!("Running with random failures is unsuitable for production"); - } let genesis_hash = rpc.get_genesis().await.unwrap(); info!(target: "setup", "Check wallet"); @@ -45,28 +40,28 @@ pub async fn setup(cfg: &Config, reset: bool) -> anyhow::Result<()> { if let Some(password) = &worker_cfg.wallet_cfg.password { rpc.unlock_wallet(password).await?; } + let rpc = &mut rpc.wallet(&worker_cfg.wallet_cfg.name); info!(target: "setup", "Check address"); let wallet: BtcWallet = cfg .section("depolymerizer-bitcoin") .parse("bitcoin wallet address", "WALLET") .require()?; - let addr_info = rpc.addr_info(&wallet.0).await?; + let addr = wallet.0; + let addr_info = rpc.addr_info(&addr).await?; if !addr_info.ismine { bail!( - "Address {} does not belong to wallet '{}'", - wallet.0, + "Address {addr} does not belong to wallet '{}'", worker_cfg.wallet_cfg.name ); } else if addr_info.iswatchonly { - bail!("Address {} is watchonly", wallet.0); + bail!("Address {addr} is watchonly"); } else if !addr_info.solvable { - bail!("Address {} is not solvable", wallet.0); + bail!("Address {addr} is not solvable"); } info!(target: "setup", "Setup database state"); - let db_cfg = parse_db_cfg(cfg)?; - let pool = pool(db_cfg.cfg, DB_SCHEMA).await?; + let pool = pool(cfg).await?; // Init status to true try_join!( diff --git a/depolymerizer-bitcoin/src/sql.rs b/depolymerizer-bitcoin/src/sql.rs @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use bitcoin::{Address, Amount as BtcAmount, Txid, address::NetworkUnchecked, hashes::Hash}; +use bitcoin::{Address, Amount as BtcAmount, address::NetworkUnchecked}; use sqlx::{Row, postgres::PgRow}; use taler_api::db::TypeHelper; use taler_common::types::{ @@ -37,11 +37,6 @@ pub fn sql_addr(row: &PgRow, idx: usize) -> sqlx::Result<Address> { .assume_checked()) } -/// Bitcoin transaction id from sql -pub fn sql_txid(row: &PgRow, idx: usize) -> sqlx::Result<Txid> { - row.try_get_map(idx, Txid::from_slice) -} - pub fn sql_payto<I: sqlx::ColumnIndex<PgRow>>( r: &PgRow, addr: I, diff --git a/depolymerizer-common/src/status.rs b/depolymerizer-common/src/status.rs @@ -15,8 +15,11 @@ */ //! Transactions status in database +use taler_common::api::wire::TransferState; + /// Debit transaction status /// -> Requested API request +/// Requested -> Ignored Invalid address /// Requested -> Sent Announced to the bitcoin network /// Sent -> Confirmed Mined in an N old block /// Confirmed -> Requested Conflicting transaction (reorg) @@ -26,12 +29,24 @@ pub enum DebitStatus { /// Debit have been requested (default) requested, + /// Debit will never be sent (e.g: address does not work for this network) + ignored, /// Debit have been announced to the bitcoin network sent, /// Debit have been mined in an N old block confirmed, } +impl Into<TransferState> for DebitStatus { + fn into(self) -> TransferState { + match self { + DebitStatus::requested | DebitStatus::sent => TransferState::pending, + DebitStatus::confirmed => TransferState::success, + DebitStatus::ignored => TransferState::permanent_failure, + } + } +} + /// Bounce transaction status /// -> Requested Credit in wrong format /// Requested -> Ignored Insufficient found @@ -40,7 +55,7 @@ pub enum DebitStatus { /// Confirmed -> Requested Conflicting transaction (reorg) #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] #[allow(non_camel_case_types)] -#[sqlx(type_name = "bounce_status")] +#[sqlx(type_name = "debit_status")] pub enum BounceStatus { /// Bounce have been requested (default) requested, diff --git a/makefile b/makefile @@ -15,8 +15,8 @@ all: build build: cargo build --release -.PHONY: install-nobuild-files -install-nobuild-files: +.PHONY: install-files +install-files: install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/config.d depolymerizer-bitcoin/depolymerizer-bitcoin.conf install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/sql depolymerizer-common/db/versioning.sql install -m 644 -D -t $(share_dir)/depolymerizer-bitcoin/sql depolymerizer-bitcoin/db/depolymerizer-bitcoin*.sql @@ -25,16 +25,15 @@ install-nobuild-files: install -D -t $(bin_dir) contrib/depolymerizer-bitcoin-dbconfig .PHONY: install -install: build install-nobuild-files +install: build install-files install -D -t $(bin_dir) target/release/depolymerizer-bitcoin .PHONY: check -check: install-nobuild-files - cargo clippy --all-targets +check: install-files cargo test .PHONY: test -test: install-nobuild-files +test: install-files RUST_BACKTRACE=true cargo run --profile dev --bin testbench -- instrumentation .PHONY: doc @@ -56,4 +55,7 @@ fmt: .PHONY: coverage-cyclos coverage: - cargo llvm-cov test -\ No newline at end of file + cargo llvm-cov clean --workspace + cargo llvm-cov test --no-clean + cargo llvm-cov run --bin btc-harness --no-clean -- logic + cargo llvm-cov report --html +\ No newline at end of file diff --git a/testbench/Cargo.toml b/testbench/Cargo.toml @@ -1,31 +0,0 @@ -[package] -name = "testbench" -version = "0.1.0" -edition.workspace = true -authors.workspace = true -homepage.workspace = true -repository.workspace = true -license-file.workspace = true - -[dependencies] -# Cli args parser -clap.workspace = true -# Bitcoin -depolymerizer-bitcoin = { path = "../depolymerizer-bitcoin" } -bitcoin.workspace = true -# Wire Gateway -ureq = { version = "3.0.0", features = ["json"] } -# terminal color -owo-colors = "4.0.0" -# Edit toml files -rust-ini = "0.21.0" -# Progress reporting -indicatif = "0.18.0" -taler-common.workspace = true -taler-test-utils.workspace = true -tokio.workspace = true -anyhow.workspace = true -tracing-subscriber.workspace = true -tracing.workspace = true -sqlx.workspace = true -url.workspace = true diff --git a/testbench/conf/bitcoin.conf b/testbench/conf/bitcoin.conf @@ -1,13 +0,0 @@ -regtest=1 -txindex=1 -maxtxfee=0.01 -fallbackfee=0.00000001 -rpcservertimeout=0 -dbcache=4 -maxmempool=5 -par=2 -rpcthreads=5 - -[regtest] -port=8345 -rpcport=18345 -\ No newline at end of file diff --git a/testbench/conf/bitcoindev.conf b/testbench/conf/bitcoindev.conf @@ -1,4 +0,0 @@ -regtest=1 -txindex=1 -rpcservertimeout=0 -rpcport=18345 -\ No newline at end of file diff --git a/testbench/conf/taler_btc.conf b/testbench/conf/taler_btc.conf @@ -1,14 +0,0 @@ -[depolymerizer-bitcoin] -CURRENCY = DEVBTC - -[depolymerizer-bitcoin-worker] -BOUNCE_FEE = DEVBTC:0.00001 -CONFIRMATION = 3 - -[depolymerizer-bitcoin-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-bitcoin-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none diff --git a/testbench/conf/taler_btc_bump.conf b/testbench/conf/taler_btc_bump.conf @@ -1,15 +0,0 @@ -[depolymerizer-bitcoin] -CURRENCY = DEVBTC - -[depolymerizer-bitcoin-worker] -BOUNCE_FEE = DEVBTC:0.00001 -CONFIRMATION = 3 -BUMP_DELAY = 5 - -[depolymerizer-bitcoin-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-bitcoin-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none -\ No newline at end of file diff --git a/testbench/conf/taler_btc_dev.conf b/testbench/conf/taler_btc_dev.conf @@ -1,17 +0,0 @@ -[depolymerizer-bitcoin] -CURRENCY = DEVBTC -NAME = Test - -[depolymerizer-bitcoin-worker] -WALLET_NAME = wire -RPC_COOKIE_FILE = testbenches/btc-regtest/bitcoin/testnet4/.cookie -RPC_BIND = 127.0.0.1:18345 -CONFIRMATION = 3 - -[depolymerizer-bitcoin-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-bitcoin-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none diff --git a/testbench/conf/taler_btc_lifetime.conf b/testbench/conf/taler_btc_lifetime.conf @@ -1,18 +0,0 @@ -[depolymerizer-bitcoin] -CURRENCY = DEVBTC - -[depolymerizer-bitcoin-worker] -BOUNCE_FEE = DEVBTC:0.00001 -CONFIRMATION = 3 -LIFETIME = 10 - -[depolymerizer-bitcoin-httpd] -LIFETIME = 10 - -[depolymerizer-bitcoin-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-bitcoin-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none diff --git a/testbench/conf/taler_eth.conf b/testbench/conf/taler_eth.conf @@ -1,14 +0,0 @@ -[depolymerizer-ethereum] -CURRENCY = DEVETH - -[depolymerizer-ethereum-worker] -BOUNCE_FEE = DEVETH:0.00001 -CONFIRMATION = 3 - -[depolymerizer-ethereum-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-ethereum-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none diff --git a/testbench/conf/taler_eth_bump.conf b/testbench/conf/taler_eth_bump.conf @@ -1,15 +0,0 @@ -[depolymerizer-ethereum] -CURRENCY = DEVETH - -[depolymerizer-ethereum-worker] -BOUNCE_FEE = DEVETH:0.00001 -CONFIRMATION = 3 -BUMP_DELAY = 5 - -[depolymerizer-ethereum-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-ethereum-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none -\ No newline at end of file diff --git a/testbench/conf/taler_eth_lifetime.conf b/testbench/conf/taler_eth_lifetime.conf @@ -1,18 +0,0 @@ -[depolymerizer-ethereum] -CURRENCY = DEVETH - -[depolymerizer-ethereum-worker] -BOUNCE_FEE = DEVETH:0.00001 -CONFIRMATION = 3 -LIFETIME = 10 - -[depolymerizer-ethereum-httpd] -LIFETIME = 10 - -[depolymerizer-ethereum-httpd-wire-gateway-api] -ENABLED = YES -AUTH_METHOD = none - -[depolymerizer-ethereum-httpd-revenue-api] -ENABLED = YES -AUTH_METHOD = none -\ No newline at end of file diff --git a/testbench/src/btc.rs b/testbench/src/btc.rs @@ -1,1102 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{ - net::{IpAddr, Ipv4Addr, SocketAddr}, - ops::{Deref, DerefMut}, - path::Path, - time::Duration, -}; - -use bitcoin::{Address, Amount}; -use depolymerizer_bitcoin::{ - CONFIG_SOURCE, - config::{RpcAuth, RpcCfg, ServeCfg, WorkerCfg}, - payto::BtcWallet, - rpc::{Category, Error, ErrorCode, Rpc}, - rpc_utils::segwit_min_amount, - taler_utils::btc_to_taler, -}; -use ini::Ini; -use taler_common::{ - api::{EddsaPublicKey, ShortHashCode}, - config::Config, - types::{ - base32::Base32, - payto::{Payto, PaytoImpl}, - }, -}; - -use crate::{ - retry, retry_opt, - utils::{ChildGuard, TalerCtx, TestCtx, cmd_redirect, patch_config, transfer, unused_port}, -}; - -pub struct BtcCtx { - btc_node: ChildGuard, - _btc_node2: ChildGuard, - common_rpc: Rpc, - common_rpc2: Rpc, - wire_rpc: Rpc, - client_rpc: Rpc, - reserve_rpc: Rpc, - wire_addr: Address, - pub client_addr: Address, - reserve_addr: Address, - worker_cfg: WorkerCfg, - serve_cfg: ServeCfg, - conf: u16, - ctx: TalerCtx, - node2_addr: String, -} - -impl Deref for BtcCtx { - type Target = TalerCtx; - - fn deref(&self) -> &Self::Target { - &self.ctx - } -} - -impl DerefMut for BtcCtx { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.ctx - } -} - -impl BtcCtx { - pub async fn config( - ctx: &TestCtx, - btc_patch: impl FnOnce(&mut Ini, &Path), - cfg_patch: impl FnOnce(&mut Ini, &Path), - ) { - // Patch configs - let port = unused_port(); - let rpc_port = unused_port(); - - patch_config( - "testbench/conf/bitcoin.conf", - ctx.dir.join("bitcoin.conf"), - |cfg| { - cfg.with_section(Some("regtest")) - .set("bind", format!("127.0.0.1:{port}")) - .set("rpcport", format!("{rpc_port}")); - btc_patch(cfg, &ctx.dir) - }, - ); - patch_config( - "testbench/conf/taler_btc.conf", - ctx.dir.join("config.conf"), - |cfg| { - cfg.with_section(Some("depolymerizer-bitcoin-worker")) - .set("RPC_BIND", format!("127.0.0.1:{rpc_port}")); - cfg_patch(cfg, &ctx.dir) - }, - ); - // Load config - let cfg = Config::load(CONFIG_SOURCE, Some(ctx.dir.join("config.conf"))).unwrap(); - let rpc_cfg = RpcCfg::parse(&cfg).unwrap(); - // Start bitcoin nodes - let _node = cmd_redirect( - "bitcoind", - &[&format!("-datadir={}", ctx.dir.to_string_lossy())], - ctx.log("bitcoind"), - ); - // Connect - retry_opt!(async { - let mut client = Rpc::common(&rpc_cfg).await?; - client.get_blockchain_info().await?; - Ok::<_, anyhow::Error>(()) - }); - } - - fn patch_btc_config(from: impl AsRef<Path>, to: impl AsRef<Path>, port: u16, rpc_port: u16) { - patch_config(from, to, |cfg| { - cfg.with_section(Some("regtest")) - .set("bind", format!("127.0.0.1:{port}")) - .set("rpcport", format!("{rpc_port}")); - }) - } - - pub async fn setup(ctx: &TestCtx, config: &str, stressed: bool) -> Self { - let mut ctx = TalerCtx::new(ctx, "depolymerizer-bitcoin", config, stressed); - - // Choose unused port - let btc_port = unused_port(); - let btc_rpc_port = unused_port(); - let btc2_port = unused_port(); - let btc2_rpc_port = unused_port(); - - // Bitcoin config - Self::patch_btc_config( - "testbench/conf/bitcoin.conf", - ctx.wire_dir.join("bitcoin.conf"), - btc_port, - btc_rpc_port, - ); - Self::patch_btc_config( - "testbench/conf/bitcoin.conf", - ctx.wire2_dir.join("bitcoin.conf"), - btc2_port, - btc2_rpc_port, - ); - patch_config(&ctx.conf, &ctx.conf, |cfg| { - cfg.with_section(Some("depolymerizer-bitcoin-worker")) - .set("RPC_BIND", format!("127.0.0.1:{btc_rpc_port}")) - .set("WALLET_NAME", "wire") - .set( - "RPC_COOKIE_FILE", - ctx.wire_dir.join("regtest/.cookie").to_string_lossy(), - ); - }); - - // Load config - let config = Config::load(CONFIG_SOURCE, Some(&ctx.conf)).unwrap(); - let cfg = WorkerCfg::parse(&config).unwrap(); - // Start bitcoin nodes - let btc_node = cmd_redirect( - "bitcoind", - &[&format!("-datadir={}", ctx.wire_dir.to_string_lossy())], - ctx.log("bitcoind"), - ); - let _btc_node2 = cmd_redirect( - "bitcoind", - &[&format!("-datadir={}", ctx.wire2_dir.to_string_lossy())], - ctx.log("bitcoind2"), - ); - - // Setup wallets - let mut common_rpc = retry_opt!(Rpc::common(&cfg.rpc_cfg)); - retry_opt!(common_rpc.get_blockchain_info()); - let node2_addr = format!("127.0.0.1:{btc2_port}"); - common_rpc.add_node(&node2_addr).await.unwrap(); - - for name in ["wire", "client", "reserve"] { - if let Err(e) = common_rpc.load_wallet(name).await { - if let Error::RPC { - code: ErrorCode::RpcWalletNotFound, - .. - } = e - { - common_rpc.create_wallet(name, "").await.unwrap(); - } else { - break; - } - } - } - let common_rpc2 = retry_opt!(Rpc::common(&RpcCfg { - addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), btc2_rpc_port), - auth: RpcAuth::Cookie( - ctx.wire2_dir - .join("regtest/.cookie") - .to_string_lossy() - .to_string(), - ), - })); - - // Generate money - let mut reserve_rpc = Rpc::wallet(&cfg.rpc_cfg, "reserve").await.unwrap(); - let mut client_rpc = Rpc::wallet(&cfg.rpc_cfg, "client").await.unwrap(); - let mut wire_rpc = Rpc::wallet(&cfg.rpc_cfg, "wire").await.unwrap(); - let reserve_addr = reserve_rpc.gen_addr().await.unwrap(); - let client_addr = client_rpc.gen_addr().await.unwrap(); - let wire_addr = wire_rpc.gen_addr().await.unwrap(); - common_rpc.mine(101, &reserve_addr).await.unwrap(); - reserve_rpc - .send(&client_addr, &(Amount::ONE_BTC * 10), None, false) - .await - .unwrap(); - common_rpc.mine(1, &reserve_addr).await.unwrap(); - - patch_config(&ctx.conf, &ctx.conf, |cfg| { - cfg.with_section(Some("depolymerizer-bitcoin")) - .set("NAME", "Exchange Owner") - .set("WALLET", wire_addr.to_string()); - }); - - let config = Config::load(CONFIG_SOURCE, Some(&ctx.conf)).unwrap(); - let serve_cfg = ServeCfg::parse(&config).unwrap(); - - // Setup & run - ctx.dbinit(); - ctx.setup(); - ctx.run().await; - - Self { - ctx, - btc_node, - common_rpc, - wire_rpc, - client_rpc, - reserve_rpc, - wire_addr, - client_addr, - reserve_addr, - conf: cfg.conf as u16, - worker_cfg: cfg, - serve_cfg, - _btc_node2, - common_rpc2, - node2_addr, - } - } - - pub fn reset_db(&mut self) { - self.ctx.reset_db(); - self.ctx.setup(); - } - - pub async fn stop_node(&mut self) { - // We need to kill bitcoin gracefully to avoid corruption - self.common_rpc.stop().await.unwrap(); - self.btc_node.0.wait().unwrap(); - } - - pub async fn cluster_deco(&mut self) { - self.common_rpc - .disconnect_node(&self.node2_addr) - .await - .unwrap(); - } - - pub async fn cluster_fork(&mut self) -> u16 { - let node1_height = self.common_rpc.get_blockchain_info().await.unwrap().blocks; - let node2_height = self.common_rpc2.get_blockchain_info().await.unwrap().blocks; - let diff = node1_height - node2_height; - self.common_rpc2 - .mine(diff as u16 + 1, &self.reserve_addr) - .await - .unwrap(); - self.common_rpc.add_node(&self.node2_addr).await.unwrap(); - diff as u16 + 2 - } - - pub async fn restart_node(&mut self, additional_args: &[&str]) { - self.stop_node().await; - self.resume_node(additional_args).await; - } - - pub async fn resume_node(&mut self, additional_args: &[&str]) { - let datadir = format!("-datadir={}", self.ctx.wire_dir.to_string_lossy()); - let mut args = vec![datadir.as_str()]; - args.extend_from_slice(additional_args); - self.btc_node = cmd_redirect("bitcoind", &args, self.ctx.log("bitcoind")); - self.common_rpc = retry_opt!(Rpc::common(&self.worker_cfg.rpc_cfg)); - self.common_rpc.add_node(&self.node2_addr).await.unwrap(); - for name in ["client", "reserve", "wire"] { - self.common_rpc.load_wallet(name).await.ok(); - } - - self.reserve_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "reserve") - .await - .unwrap(); - self.client_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "client") - .await - .unwrap(); - self.wire_rpc = Rpc::wallet(&self.worker_cfg.rpc_cfg, "wire").await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - } - - /* ----- Transaction ------ */ - - pub async fn credit(&mut self, amount: Amount, metadata: &EddsaPublicKey) { - while let Err(e) = self - .client_rpc - .send_segwit_key(&self.wire_addr, &amount, metadata) - .await - { - match e { - Error::RPC { - code: ErrorCode::RpcWalletError, - .. - } => { - self.mine(1).await; - } - _ => panic!("{e:?}"), - } - } - } - - pub async fn debit(&mut self, amount: Amount, metadata: &ShortHashCode) { - transfer( - &self.ctx.gateway_url, - metadata, - Payto::new(BtcWallet(self.client_addr.clone())).as_full_uri("name"), - &btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), - ) - .await - } - - pub async fn malformed_credit(&mut self, amount: &Amount) { - self.client_rpc - .send(&self.wire_addr, amount, None, false) - .await - .unwrap(); - } - - pub async fn reset_wallet(&mut self) { - let amount = self.wire_balance().await; - self.wire_rpc - .send(&self.client_addr, &amount, None, true) - .await - .unwrap(); - self.next_block().await; - } - - async fn abandon(rpc: &mut Rpc) { - let list = rpc.list_since_block(None, 1).await.unwrap(); - for tx in list.transactions { - if tx.category == Category::Send && tx.confirmations == 0 { - rpc.abandon_tx(&tx.txid).await.unwrap(); - } - } - } - - pub async fn abandon_wire(&mut self) { - Self::abandon(&mut self.wire_rpc).await; - } - - pub async fn abandon_client(&mut self) { - Self::abandon(&mut self.client_rpc).await; - } - - /* ----- Mining ----- */ - - async fn mine(&mut self, nb: u16) { - self.common_rpc.mine(nb, &self.reserve_addr).await.unwrap(); - } - - pub async fn next_conf(&mut self) { - self.mine(self.conf).await - } - - pub async fn next_block(&mut self) { - self.mine(1).await - } - - pub async fn mine_pending(&mut self) { - while self.common_rpc.get_mempool_info().await.unwrap().size > 0 { - self.mine(1).await - } - } - - pub async fn mine_conf(&mut self) { - self.mine_pending().await; - self.next_conf().await; - } - - /* ----- Balances ----- */ - - pub async fn client_balance(&mut self) -> Amount { - self.client_rpc.get_balance().await.unwrap() - } - - pub async fn wire_balance(&mut self) -> Amount { - self.wire_rpc.get_balance().await.unwrap() - } - - pub async fn expect_c_balance(&mut self, balance: Amount, mine: bool) { - retry!(async { - let check = balance == self.client_balance().await; - if !check && mine { - self.mine_conf().await; - } - check - }); - } - - pub async fn expect_w_balance(&mut self, balance: Amount, mine: bool) { - retry!(async { - let check = balance == self.wire_balance().await; - if !check && mine { - self.mine_conf().await; - } - check - }); - } - - pub async fn expect_w_balance_less(&mut self, balance: Amount, mine: bool) { - retry!(async { - let check = self.wire_balance().await < balance; - if !check && mine { - self.mine_conf().await; - } - check - }); - } - - /* ----- Wire Gateway ----- */ - - pub async fn expect_credits(&mut self, txs: &[(EddsaPublicKey, Amount)], mine: bool) { - let txs: Vec<_> = txs - .iter() - .map(|(metadata, amount)| { - ( - metadata.clone(), - btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), - ) - }) - .collect(); - retry!(async { - let check = self.ctx.expect_credits(&txs).await; - if !check && mine { - self.mine_conf().await; - } - check - }); - } - - pub async fn expect_debits(&mut self, txs: &[(ShortHashCode, Amount)], mine: bool) { - let txs: Vec<_> = txs - .iter() - .map(|(metadata, amount)| { - ( - metadata.clone(), - btc_to_taler(&amount.to_signed().unwrap(), &self.worker_cfg.currency), - ) - }) - .collect(); - retry!(async { - let check = self.ctx.expect_debits(&txs).await; - if !check && mine { - self.mine_conf().await; - } - check - }); - } -} - -/// Test btc-wire correctly receive and send transactions on the blockchain -pub async fn wire(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false).await; - - ctx.step("Credit"); - { - // Send transactions - let mut balance = ctx.wire_balance().await; - let mut txs = Vec::new(); - for n in 10..100 { - let metadata = EddsaPublicKey::rand(); - let amount = Amount::from_sat(n * 1000); - ctx.credit(amount, &metadata).await; - txs.push((metadata, amount)); - balance += amount; - } - ctx.expect_credits(&txs, true).await; - ctx.expect_w_balance(balance, false).await; - }; - - ctx.step("Debit"); - { - let mut balance = ctx.client_balance().await; - let mut txs = Vec::new(); - for n in 10..100 { - let metadata = Base32::rand(); - let amount = Amount::from_sat(n * 100); - balance += amount; - ctx.debit(amount, &metadata).await; - txs.push((metadata, amount)); - } - ctx.expect_debits(&txs, true).await; - ctx.expect_c_balance(balance, false).await; - } - - ctx.step("Bounce"); - { - ctx.reset_wallet().await; - let mut balance = ctx.wire_balance().await; - for n in 10..40 { - ctx.malformed_credit(&Amount::from_sat(n * 1000)).await; - balance += ctx.worker_cfg.bounce_fee; - } - ctx.expect_w_balance(balance, true).await; - } -} - -/// Check btc-wire and wire-gateway correctly stop when a lifetime limit is configured -pub async fn lifetime(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc_lifetime.conf", false).await; - ctx.step("Check lifetime"); - // Start up - retry!(async { ctx.wire_running() && ctx.gateway_running() }); - // Consume wire lifetime - for _ in 0..ctx.worker_cfg.lifetime.unwrap() { - ctx.mine(1).await; - } - retry!(async { - let check = !ctx.wire_running() && ctx.gateway_running(); - if !check { - ctx.mine(1).await; - } - check - }); - // Consume gateway lifetime - for _ in 0..=ctx.serve_cfg.lifetime.unwrap() { - ctx.debit(segwit_min_amount(), &Base32::rand()).await; - } - // End down - retry!(async { !ctx.wire_running() && !ctx.gateway_running() }); -} - -/// Check the capacity of wire-gateway and btc-wire to recover from database and node loss -pub async fn reconnect(ctx: TestCtx) { - // TODO check recover metadata - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false).await; - - let mut credits = Vec::new(); - let mut debits = Vec::new(); - - ctx.step("With DB"); - { - let metadata = EddsaPublicKey::rand(); - let amount = Amount::from_sat(42000); - ctx.credit(amount, &metadata).await; - credits.push((metadata, amount)); - ctx.mine_conf().await; - ctx.expect_credits(&credits, false).await; - }; - - ctx.step("Without DB"); - { - ctx.stop_db(); - ctx.malformed_credit(&Amount::from_sat(24000)).await; - let metadata = EddsaPublicKey::rand(); - let amount = Amount::from_sat(40000); - ctx.credit(amount, &metadata).await; - credits.push((metadata, amount)); - ctx.stop_node().await; - ctx.expect_gateway_down().await; - } - - ctx.step("Reconnect DB"); - { - ctx.resume_db(); - ctx.resume_node(&[]).await; - let metadata = Base32::rand(); - let amount = Amount::from_sat(2000); - ctx.debit(amount, &metadata).await; - debits.push((metadata, amount)); - ctx.expect_debits(&debits, true).await; - ctx.expect_credits(&credits, false).await; - } - - ctx.step("Recover DB"); - { - let balance = ctx.wire_balance().await; - ctx.reset_db(); - ctx.expect_debits(&debits, false).await; - ctx.expect_credits(&credits, false).await; - ctx.expect_w_balance(balance, false).await; - } -} - -/// Test btc-wire ability to recover from errors in correctness critical paths and prevent concurrent sending -pub async fn stress(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", true).await; - - let mut credits = Vec::new(); - let mut debits = Vec::new(); - - ctx.step("Credit"); - { - let mut balance = ctx.wire_balance().await; - for n in 10..30 { - let metadata = EddsaPublicKey::rand(); - let amount = Amount::from_sat(n * 10000); - ctx.credit(amount, &metadata).await; - credits.push((metadata, amount)); - balance += amount; - } - ctx.expect_credits(&credits, true).await; - ctx.expect_w_balance(balance, false).await; - }; - - ctx.step("Debit"); - { - let mut balance = ctx.client_balance().await; - for n in 10..30 { - let metadata = Base32::rand(); - let amount = Amount::from_sat(n * 100); - balance += amount; - ctx.debit(amount, &metadata).await; - debits.push((metadata, amount)); - } - ctx.expect_debits(&debits, true).await; - ctx.expect_c_balance(balance, false).await; - } - - ctx.step("Bounce"); - { - ctx.reset_wallet().await; - let mut balance = ctx.wire_balance().await; - for n in 10..30 { - ctx.malformed_credit(&Amount::from_sat(n * 1000)).await; - balance += ctx.worker_cfg.bounce_fee; - } - ctx.expect_w_balance(balance, true).await; - } - - ctx.step("Recover DB"); - { - let balance = ctx.wire_balance().await; - ctx.reset_db(); - ctx.expect_debits(&debits, false).await; - ctx.expect_credits(&credits, false).await; - ctx.expect_w_balance(balance, false).await; - } -} - -/// Test btc-wire ability to handle conflicting outgoing transactions -pub async fn conflict(tctx: TestCtx) { - tctx.step("Setup"); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc.conf", false).await; - - ctx.step("Conflict send"); - { - // Perform credit - let amount = Amount::from_sat(4200000); - ctx.credit(amount, &EddsaPublicKey::rand()).await; - ctx.next_conf().await; - ctx.expect_w_balance(amount, false).await; - let client = ctx.client_balance().await; - let wire = ctx.wire_balance().await; - - // Perform debit - ctx.debit(Amount::from_sat(400000), &Base32::rand()).await; - ctx.expect_w_balance_less(wire, false).await; - - // Abandon pending transaction - ctx.restart_node(&["-minrelaytxfee=0.0001"]).await; - ctx.abandon_wire().await; - ctx.expect_c_balance(client, false).await; - ctx.expect_w_balance(wire, false).await; - - // Generate conflict - ctx.debit(Amount::from_sat(500000), &Base32::rand()).await; - ctx.expect_w_balance_less(wire, false).await; - - // Resend conflicting transaction - let wire = ctx.wire_balance().await; - ctx.restart_node(&[]).await; - ctx.expect_w_balance_less(wire, true).await; - } - - ctx.step("Setup"); - drop(ctx); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc.conf", false).await; - ctx.credit(Amount::from_sat(3000000), &EddsaPublicKey::rand()) - .await; - ctx.next_block().await; - - ctx.step("Conflict bounce"); - { - // Perform bounce - let wire = ctx.wire_balance().await; - let bounce_amount = Amount::from_sat(4000000); - ctx.malformed_credit(&bounce_amount).await; - ctx.next_conf().await; - let fee = ctx.worker_cfg.bounce_fee; - ctx.expect_w_balance(wire + fee, true).await; - - // Abandon pending transaction - ctx.restart_node(&["-minrelaytxfee=0.0001"]).await; - ctx.abandon_wire().await; - ctx.expect_w_balance(wire + bounce_amount, false).await; - - // Generate conflict - ctx.debit(Amount::from_sat(50000), &Base32::rand()).await; - ctx.expect_w_balance_less(wire + bounce_amount, false).await; - - // Resend conflicting transaction - let wire = ctx.wire_balance().await; - ctx.restart_node(&[]).await; - ctx.expect_w_balance_less(wire, true).await; - } -} - -/// Test btc-wire correctness when a blockchain reorganization occurs -pub async fn reorg(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false).await; - - ctx.step("Handle reorg incoming transactions"); - { - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform credits - let before = ctx.wire_balance().await; - for n in 10..21 { - ctx.credit(Amount::from_sat(n * 10000), &EddsaPublicKey::rand()) - .await; - } - ctx.mine_conf().await; - let after = ctx.wire_balance().await; - - // Perform fork and check btc-wire hard error - ctx.expect_gateway_up().await; - let fork = ctx.cluster_fork().await; - ctx.expect_w_balance(before, false).await; - ctx.expect_gateway_down().await; - - // Recover orphaned transaction - ctx.mine(fork).await; - ctx.expect_w_balance(after, false).await; - ctx.expect_gateway_up().await; - } - - ctx.step("Handle reorg outgoing transactions"); - { - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform debits - let before = ctx.client_balance().await; - let mut after = ctx.client_balance().await; - for n in 10..21 { - let amount = Amount::from_sat(n * 100); - ctx.debit(amount, &Base32::rand()).await; - after += amount; - } - ctx.expect_c_balance(after, true).await; - - // Perform fork and check btc-wire still up - ctx.expect_gateway_up().await; - ctx.cluster_fork().await; - ctx.expect_c_balance(before, false).await; - ctx.expect_gateway_up().await; - - // Recover orphaned transaction - ctx.next_conf().await; - ctx.expect_c_balance(after, false).await; - } - - ctx.step("Handle reorg bounce"); - { - ctx.reset_wallet().await; - - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform bounce - let before = ctx.wire_balance().await; - let mut after = ctx.wire_balance().await; - for n in 10..21 { - ctx.malformed_credit(&Amount::from_sat(n * 1000)).await; - after += ctx.worker_cfg.bounce_fee; - } - ctx.expect_w_balance(after, true).await; - - // Perform fork and check btc-wire hard error - ctx.expect_gateway_up().await; - let fork = ctx.cluster_fork().await; - ctx.expect_w_balance(before, false).await; - ctx.expect_gateway_down().await; - - // Recover orphaned transaction - ctx.mine(fork).await; - ctx.expect_w_balance(after, false).await; - ctx.expect_gateway_up().await; - } -} - -/// Test btc-wire correctness when a blockchain reorganization occurs leading to past incoming transaction conflict -pub async fn hell(tctx: TestCtx) { - macro_rules! step { - ($ctx:ident, $action:expr) => { - // Loose second bitcoin node - $ctx.cluster_deco().await; - - // Perform action - $action; - - // Perform fork and check btc-wire hard error - $ctx.expect_gateway_up().await; - $ctx.cluster_fork().await; - $ctx.expect_gateway_down().await; - - // Generate conflict - $ctx.restart_node(&["-minrelaytxfee=0.001"]).await; - $ctx.abandon_client().await; - let amount = Amount::from_sat(54000); - $ctx.credit(amount, &EddsaPublicKey::rand()).await; - $ctx.expect_w_balance(amount, true).await; - - // Check btc-wire suspend operation - let bounce_amount = Amount::from_sat(34000); - $ctx.malformed_credit(&bounce_amount).await; - $ctx.next_conf().await; - $ctx.expect_w_balance(amount + bounce_amount, true).await; - $ctx.expect_gateway_down().await; - }; - } - - tctx.step("Setup"); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc.conf", false).await; - ctx.step("Handle reorg conflicting incoming credit"); - step!(ctx, { - let amount = Amount::from_sat(420000); - ctx.credit(amount, &EddsaPublicKey::rand()).await; - ctx.mine_conf().await; - ctx.expect_w_balance(amount, true).await; - }); - - drop(ctx); - tctx.step("Setup"); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc.conf", false).await; - ctx.step("Handle reorg conflicting bounce"); - step!(ctx, { - let amount = Amount::from_sat(420000); - ctx.malformed_credit(&amount).await; - ctx.mine_conf().await; - let fee = ctx.worker_cfg.bounce_fee; - ctx.expect_w_balance(fee, true).await; - }); -} - -/// Test btc-wire ability to learn and protect itself from blockchain behavior -pub async fn analysis(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false).await; - - ctx.step("Learn from reorg"); - - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform credit - let before = ctx.wire_balance().await; - ctx.credit(Amount::from_sat(42000), &EddsaPublicKey::rand()) - .await; - ctx.mine_conf().await; - let after = ctx.wire_balance().await; - - // Perform fork and check btc-wire hard error - ctx.expect_gateway_up().await; - ctx.cluster_fork().await; - ctx.expect_w_balance(before, false).await; - ctx.expect_gateway_down().await; - - // Recover orphaned transaction - ctx.mine_conf().await; - ctx.next_block().await; // Conf have changed - ctx.expect_w_balance(after, false).await; - ctx.expect_gateway_up().await; - - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform credit - let before = ctx.wire_balance().await; - ctx.credit(Amount::from_sat(42000), &EddsaPublicKey::rand()) - .await; - ctx.mine_conf().await; - - // Perform fork and check btc-wire learned from previous attack - ctx.expect_gateway_up().await; - ctx.cluster_fork().await; - ctx.expect_w_balance(before, false).await; - ctx.expect_gateway_up().await; -} - -/// Test btc-wire ability to handle stuck transaction correctly -pub async fn bumpfee(tctx: TestCtx) { - tctx.step("Setup"); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc_bump.conf", false).await; - - // Perform credits to allow wire to perform debits latter - for n in 10..13 { - ctx.credit(Amount::from_sat(n * 100000), &EddsaPublicKey::rand()) - .await; - } - ctx.mine_conf().await; - - ctx.step("Bump fee"); - { - // Perform debit - let mut client = ctx.client_balance().await; - let wire = ctx.wire_balance().await; - let amount = Amount::from_sat(40000); - ctx.debit(amount, &Base32::rand()).await; - ctx.expect_w_balance_less(wire, false).await; - - // Bump min relay fee making the previous debit stuck - ctx.restart_node(&["-minrelaytxfee=0.0001"]).await; - - // Check bump happen - client += amount; - ctx.expect_c_balance(client, true).await; - } - - ctx.step("Bump fee reorg"); - { - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform debit - let mut client = ctx.client_balance().await; - let wire = ctx.wire_balance().await; - let amount = Amount::from_sat(40000); - ctx.debit(amount, &Base32::rand()).await; - ctx.expect_w_balance_less(wire, false).await; - - // Bump min relay fee and fork making the previous debit stuck and problematic - ctx.cluster_fork().await; - ctx.restart_node(&["-minrelaytxfee=0.0001"]).await; - - // Check bump happen - client += amount; - ctx.expect_c_balance(client, true).await; - } - ctx.step("Setup"); - drop(ctx); - let mut ctx = BtcCtx::setup(&tctx, "taler_btc_bump.conf", true).await; - - // Perform credits to allow wire to perform debits latter - for n in 10..61 { - ctx.credit(Amount::from_sat(n * 100000), &EddsaPublicKey::rand()) - .await; - } - ctx.mine_conf().await; - - ctx.step("Bump fee stress"); - { - // Loose second bitcoin node - ctx.cluster_deco().await; - - // Perform debits - let client = ctx.client_balance().await; - let wire = ctx.wire_balance().await; - let mut total_amount = Amount::ZERO; - for n in 10..30 { - let amount = Amount::from_sat(n * 10000); - total_amount += amount; - ctx.debit(amount, &Base32::rand()).await; - } - ctx.expect_w_balance_less(wire - total_amount, false).await; - - // Bump min relay fee making the previous debits stuck - ctx.restart_node(&["-minrelaytxfee=0.0001"]).await; - - // Check bump happen - ctx.expect_c_balance(client + total_amount, true).await; - } -} - -/// Test btc-wire handle transaction fees exceeding limits -pub async fn maxfee(ctx: TestCtx) { - ctx.step("Setup"); - let mut ctx = BtcCtx::setup(&ctx, "taler_btc.conf", false).await; - - // Perform credits to allow wire to perform debits latter - for n in 10..31 { - ctx.credit(Amount::from_sat(n * 100000), &EddsaPublicKey::rand()) - .await; - } - ctx.mine_pending().await; - - let client = ctx.client_balance().await; - let wire = ctx.wire_balance().await; - let mut total_amount = Amount::ZERO; - - ctx.step("Too high fee"); - { - // Change fee config - ctx.restart_node(&["-maxtxfee=0.0000001", "-minrelaytxfee=0.0000001"]) - .await; - - // Perform debits - for n in 10..31 { - let amount = Amount::from_sat(n * 10000); - total_amount += amount; - ctx.debit(amount, &Base32::rand()).await; - } - ctx.mine_pending().await; - - // Check no transaction happen - ctx.expect_w_balance(wire, false).await; - ctx.expect_c_balance(client, false).await; - } - - ctx.step("Good feed"); - { - // Restore default config - ctx.restart_node(&[]).await; - - // Check transaction now have been made - ctx.expect_c_balance(client + total_amount, true).await; - } -} - -/// Test btc-wire ability to configure itself from bitcoin configuration -pub async fn config(ctx: TestCtx) { - // Connect with cookie files - ctx.step("Cookie"); - BtcCtx::config( - &ctx, - |btc, dir| { - btc.with_section(None::<&str>).set( - "rpccookiefile", - dir.join("catch_me_if_you_can").to_string_lossy(), - ); - }, - |cfg, dir| { - cfg.with_section(Some("depolymerizer-bitcoin-worker")).set( - "RPC_COOKIE_FILE", - dir.join("catch_me_if_you_can").to_string_lossy(), - ); - }, - ) - .await; - - // Connect with password - ctx.step("Password"); - BtcCtx::config( - &ctx, - |btc, _| { - btc.with_section(None::<&str>) - .set("rpcuser", "bob") - .set("rpcpassword", "password"); - }, - |cfg, _| { - cfg.with_section(Some("depolymerizer-bitcoin-worker")) - .set("RPC_AUTH_METHOD", "basic") - .set("RPC_USERNAME", "bob") - .set("RPC_PASSWORD", "password"); - }, - ) - .await; - - // Connect with token - ctx.step("Token"); - BtcCtx::config( - &ctx, - |btc, _| { - btc.with_section(None::<&str>) - .set("rpcauth", "bob:9641cec731e1fad1ded02e1d31536e44$36b8b8af0a38104997a57f017805ff56bf8963ae4a2ed40252ca0e0e070fc19e"); - }, - |cfg, _| { - cfg.with_section(Some("depolymerizer-bitcoin-worker")) - .set("RPC_AUTH_METHOD", "basic") - .set("RPC_USERNAME", "bob") - .set("RPC_PASSWORD", "password"); - }, - ).await; -} diff --git a/testbench/src/main.rs b/testbench/src/main.rs @@ -1,527 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{ - panic::UnwindSafe, - path::{Path, PathBuf}, - str::FromStr, - string::String, - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; - -use bitcoin::{Address, Txid, address::NetworkUnchecked}; -use clap::{Parser, ValueEnum}; -use depolymerizer_bitcoin::{ - CONFIG_SOURCE, DB_SCHEMA, - cli::{Command, run}, - config::{WalletCfg, WorkerCfg, parse_db_cfg}, - db, - payto::BtcWallet, - rpc::{Error, ErrorCode, Rpc, rpc_common, rpc_wallet}, - taler_utils::taler_to_btc, -}; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use owo_colors::OwoColorize; -use sqlx::PgPool; -use taler_common::{ - api::{EddsaPublicKey, HashCode, ShortHashCode, wire::TransferRequest}, - config::Config, - db::pool, - log::taler_logger, - types::{amount::amount, payto::FullPayto}, -}; -use taler_test_utils::repl::Repl; -use tokio::task::{JoinError, JoinHandle}; -use tracing::info; -use tracing_subscriber::util::SubscriberInitExt as _; -use url::Url; - -use crate::utils::{ChildGuard, LocalDb, TestCtx, cmd_redirect, patch_config, try_cmd_redirect}; - -mod btc; -mod utils; - -/// Depolymerizer instrumentation test -#[derive(clap::Parser, Debug)] -enum Testbench { - /// Start instrumentation tests for offline testing - Instrumentation { - /// With tests to run - #[clap(global = true, default_value = "")] - filters: Vec<String>, - }, - /// Start bitcoin testbench for online testing - Bitcoin { network: Network }, -} - -struct Tmp<'a> { - root: &'a Path, - filters: &'a [String], - m: MultiProgress, - start_style: ProgressStyle, - ok_style: ProgressStyle, - err_style: ProgressStyle, - tasks: Vec<( - &'static str, - JoinHandle<(Result<(), JoinError>, Duration, String)>, - )>, - db: Arc<LocalDb>, - start: Instant, -} - -impl<'a> Tmp<'a> { - async fn check<T, F>(&mut self, name: &'static str, task: T) - where - T: FnOnce(TestCtx) -> F + Send + UnwindSafe + 'static, - F: Future<Output = ()> + Send + 'static, - { - if self.filters.is_empty() || self.filters.iter().any(|f| name.starts_with(f)) { - let pb = self.m.add(ProgressBar::new_spinner()); - pb.set_style(self.start_style.clone()); - pb.set_prefix(name); - pb.set_message("Init"); - pb.enable_steady_tick(Duration::from_millis(1000)); - let ok_style = self.ok_style.clone(); - let err_style = self.err_style.clone(); - let start = self.start; - let db = self.db.clone(); - let ctx: TestCtx = TestCtx::new(self.root, name, pb.clone(), db); - self.tasks.push(( - name, - tokio::spawn(async move { - let result = tokio::spawn(task(ctx)).await; - if result.is_ok() { - pb.set_style(ok_style.clone()); - pb.finish_with_message("OK"); - } else { - pb.set_style(err_style.clone()); - pb.finish(); - } - (result, start.elapsed(), pb.message()) - }), - )); - } - } -} - -#[tokio::main] -pub async fn main() { - taler_logger(None, false).init(); - match Testbench::parse() { - Testbench::Instrumentation { filters } => instrumentation(filters).await, - Testbench::Bitcoin { network } => bitcoin(network).await, - } -} - -#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] -enum Network { - Local, - Test, -} -struct BtcEnv { - network: Network, - db: LocalDb, - bitcoind: ChildGuard, - wire_rpc: Rpc, - client_rpc: Rpc, - cfg: Config, - wire_addr: Address, - client_addr: Address, - tracked: Vec<Txid>, - pool: PgPool, -} - -impl BtcEnv { - pub async fn init(network: Network) -> Self { - let network_dir = match network { - Network::Local => "btc-local", - Network::Test => "btc-test", - }; - println!("Setup bitcoin {network:?} env in {network_dir}"); - let root_dir = PathBuf::from_str("./testbench/env") - .unwrap() - .join(network_dir); - std::fs::create_dir_all(root_dir.join("bitcoin")).unwrap(); - let root_dir = root_dir.canonicalize().unwrap(); - - // Generate bitcoind config - let cfg = match network { - Network::Local => { - " - chain=regtest - txindex=1 - rpcservertimeout=0 - fallbackfee=0.00000001 - - [regtest] - rpcport=18345 - " - } - Network::Test => { - " - chain=signet - txindex=1 - rpcservertimeout=0 - fallbackfee=0.000001 - [signet] - rpcport=18345 - " - } - }; - std::fs::write(root_dir.join("bitcoin/bitcoin.conf"), cfg).unwrap(); - let datadir = match network { - Network::Local => root_dir.join("bitcoin").join("regtest"), - Network::Test => root_dir.join("bitcoin").join("signet"), - }; - - // Start database - let db = LocalDb::new(&root_dir); - - // Generate Taler config - let taler_cfg_path = root_dir.join("taler.conf"); - patch_config( - "testbench/conf/taler_btc_dev.conf", - &taler_cfg_path, - |ini| { - ini.with_section(Some("depolymerizer-bitcoin-worker")) - .set("RPC_COOKIE_FILE", datadir.to_string_lossy()); - ini.with_section(Some("depolymerizer-bitcoindb-postgres")) - .set("CONFIG", db.postgres_uri("depolymerizer_bitcoin")); - }, - ); - - // Start node - let bitcoind = cmd_redirect( - "bitcoind", - &[&format!( - "-datadir={}", - root_dir.join("bitcoin").to_string_lossy() - )], - root_dir.join("bitcoind.log"), - ); - let cfg = Config::load(CONFIG_SOURCE, Some(&taler_cfg_path)).unwrap(); - let worker_cfg = WorkerCfg::parse(&cfg).unwrap(); - let mut rpc = retry_opt!(rpc_common(&worker_cfg.rpc_cfg)); - - for wallet in ["wire", "client"] { - loop { - let res = rpc.load_wallet(wallet).await; - if let Err(Error::RPC { code, .. }) = res { - match code { - ErrorCode::RpcInWarmup => continue, - ErrorCode::RpcWalletNotFound => { - rpc.create_wallet(wallet, "").await.unwrap(); - break; - } - _ => { - res.unwrap(); - } - } - } else { - break; - } - } - } - drop(rpc); - let mut wire_rpc = rpc_wallet( - &worker_cfg.rpc_cfg, - &WalletCfg { - name: "wire".to_string(), - password: None, - }, - ) - .await - .unwrap(); - let mut client_rpc = rpc_wallet( - &worker_cfg.rpc_cfg, - &WalletCfg { - name: "client".to_string(), - password: None, - }, - ) - .await - .unwrap(); - let wire_addr = wire_rpc.gen_addr().await.unwrap(); - let client_addr = client_rpc.gen_addr().await.unwrap(); - patch_config(&taler_cfg_path, &taler_cfg_path, |ini| { - ini.with_section(Some("depolymerizer-bitcoin")) - .set("WALLET", wire_addr.to_string()); - }); - let cfg = Config::load(CONFIG_SOURCE, Some(taler_cfg_path)).unwrap(); - - // Wait for db to start - db.wait_running(); - db.create_db("depolymerizer_bitcoin"); - - // Dbinit & setup - run(Command::Dbinit { reset: false }, &cfg).await.unwrap(); - run(Command::Setup { reset: false }, &cfg).await.unwrap(); - - let db_cg = parse_db_cfg(&cfg).unwrap(); - Self { - pool: pool(db_cg.cfg, DB_SCHEMA).await.unwrap(), - wire_addr, - client_addr, - network, - db, - bitcoind, - cfg, - wire_rpc, - client_rpc, - tracked: Vec::new(), - } - } -} - -#[derive(clap::Parser, Debug)] -#[command(no_binary_name = true)] -enum Shell { - Setup, - Reset, - ResetDb, - Sync, - Credit, - Debit, - Mine { - amount: Option<u16>, - addr: Option<Address<NetworkUnchecked>>, - }, - Exit, - Tx { - txid: Txid, - }, - Track { - txid: Txid, - }, - Untrack { - txid: Txid, - }, -} - -async fn bitcoin(network: Network) { - let mut env = BtcEnv::init(network).await; - - let mut repl = Repl::new(".history"); - loop { - let info = env.client_rpc.get_blockchain_info().await.unwrap(); - let wire_balance = env.wire_rpc.get_balance().await.unwrap(); - println!("wire {} {wire_balance}", env.wire_addr); - let client_balance = env.client_rpc.get_balance().await.unwrap(); - println!("client {} {client_balance}", env.client_addr); - for txid in &env.tracked { - match env.client_rpc.get_tx(txid).await { - Ok(info) => println!( - "{} {txid} {} {}", - "tx".cyan(), - info.amount, - info.confirmations - ), - Err(e) => println!("{} {txid} {}", "tx".cyan(), e.red()), - } - } - if let Some(cmd) = - repl.read_line(&info.chain, &format!("{:.6}", info.verification_progress)) - { - match cmd { - Shell::Setup => run(Command::Setup { reset: false }, &env.cfg) - .await - .unwrap(), - Shell::Reset => run(Command::Setup { reset: true }, &env.cfg).await.unwrap(), - Shell::ResetDb => { - run(Command::Dbinit { reset: true }, &env.cfg) - .await - .unwrap(); - run(Command::Setup { reset: false }, &env.cfg) - .await - .unwrap(); - } - Shell::Sync => run(Command::Worker { transient: true }, &env.cfg) - .await - .unwrap(), - Shell::Credit => { - let reserve_pub = EddsaPublicKey::rand(); - let amount = amount("DEVBTC:0.00011"); - let txid = env - .client_rpc - .send_segwit_key(&env.wire_addr, &taler_to_btc(&amount), &reserve_pub) - .await - .unwrap(); - env.tracked.push(txid); - info!(target: "testbench", "Credit {reserve_pub} {amount} {txid} to {}", env.wire_addr); - } - Shell::Debit => { - let creditor = FullPayto::new(BtcWallet(env.client_addr.clone()), "client"); - let wtid = ShortHashCode::rand(); - let amount = amount("DEVBTC:0.0001"); - let transfer = TransferRequest { - request_uid: HashCode::rand(), - amount, - exchange_base_url: Url::parse("https://test.com/").unwrap(), - wtid: wtid.clone(), - credit_account: creditor.as_uri(), - metadata: None, - }; - db::transfer(&env.pool, &creditor, &transfer).await.unwrap(); - info!(target: "testbench", "Debit {wtid} {amount} to {}", env.wire_addr); - } - Shell::Mine { amount, addr } => { - let amount = amount.unwrap_or(1); - let addr = addr.map(|a| a.assume_checked()); - let addr = addr.as_ref().unwrap_or(&env.client_addr); - env.client_rpc.mine(amount, addr).await.unwrap(); - } - Shell::Exit => break, - Shell::Tx { txid } => { - let info = env.client_rpc.get_tx(&txid).await.unwrap(); - info!(target: "testbench", "{txid} {} {}", info.amount, info.confirmations); - } - Shell::Track { txid } => { - env.tracked.push(txid); - } - Shell::Untrack { txid } => { - env.tracked.retain(|id| *id != txid); - } - } - } else { - break; - } - } -} - -pub async fn instrumentation(filters: Vec<String>) { - let root = PathBuf::from_str("testbench/instrumentation") - .unwrap() - .canonicalize() - .unwrap(); - std::fs::remove_dir_all(&root).ok(); - std::fs::create_dir_all(root.join("bin")).unwrap(); - - // Set panic hook - let failures = Arc::new(Mutex::new(Vec::new())); - { - let failures = failures.clone(); - std::panic::set_hook(Box::new(move |e| { - let backtrace = std::backtrace::Backtrace::force_capture(); - let info = format!("{e}\n{backtrace}"); - if let Some(id) = tokio::task::try_id() { - failures.lock().unwrap().push((id, info)); - } else { - eprintln!("Failed outside of a task:\n{info}") - } - })); - } - - // Build binaries - let p = ProgressBar::new_spinner(); - p.set_style(ProgressStyle::with_template("building {msg} {elapsed:.dim}").unwrap()); - p.enable_steady_tick(Duration::from_millis(1000)); - for name in ["depolymerizer-bitcoin"] { - build_bin(&root, &p, name, None, name); - build_bin(&root, &p, name, Some("fail"), &format!("{name}-fail")); - } - p.finish_and_clear(); - - // Run tests - let m = MultiProgress::new(); - let start_style = - ProgressStyle::with_template("{prefix:.magenta} {msg} {elapsed:.dim}").unwrap(); - let ok_style = - ProgressStyle::with_template("{prefix:.magenta} {msg:.green} {elapsed:.dim}").unwrap(); - let err_style = - ProgressStyle::with_template("{prefix:.magenta} {msg:.red} {elapsed:.dim}").unwrap(); - - let start = Instant::now(); - let db = Arc::new(LocalDb::new(&root)); - let mut tmp = Tmp { - root: &root, - filters: filters.as_slice(), - m, - start_style, - ok_style, - err_style, - tasks: Vec::new(), - db, - start, - }; - tmp.check("btc_wire", btc::wire).await; - tmp.check("btc_lifetime", btc::lifetime).await; - tmp.check("btc_reconnect", btc::reconnect).await; - tmp.check("btc_stress", btc::stress).await; - tmp.check("btc_conflict", btc::conflict).await; - tmp.check("btc_reorg", btc::reorg).await; - tmp.check("btc_hell", btc::hell).await; - tmp.check("btc_analysis", btc::analysis).await; - tmp.check("btc_bumpfee", btc::bumpfee).await; - tmp.check("btc_maxfee", btc::maxfee).await; - tmp.check("btc_config", btc::config).await; - let mut results = Vec::new(); - for (name, task) in tmp.tasks { - results.push((name, task.await.unwrap())); - } - - let len = results.len(); - - tmp.m.clear().unwrap(); - let failures = failures.lock().unwrap(); - for (name, (result, _, msg)) in &results { - if let Err(e) = result - && let Some((_, err)) = failures.iter().find(|(id, _)| *id == e.id()) - { - println!("{} {}\n{}", name.magenta(), msg.red(), err.bright_black()); - } - } - for (name, (result, time, msg)) in results { - match result { - Ok(_) => { - println!( - "{} {} {}", - name.magenta(), - "OK".green(), - format_args!("{}s", time.as_secs()).bright_black() - ); - } - Err(_) => { - println!( - "{} {} {}", - name.magenta(), - msg.red(), - format_args!("{}s", time.as_secs()).bright_black() - ); - } - } - } - println!("{} tests in {}s", len, start.elapsed().as_secs()); -} - -pub fn build_bin(root: &Path, p: &ProgressBar, name: &str, features: Option<&str>, bin_name: &str) { - p.set_message(bin_name.to_string()); - let mut args = vec!["build", "--bin", name, "--release"]; - if let Some(features) = features { - args.extend_from_slice(&["--features", features]); - } - let result = try_cmd_redirect("cargo", &args, root.join("bin/build")) - .unwrap() - .0 - .wait() - .unwrap(); - assert!(result.success()); - std::fs::rename( - format!("target/release/{name}"), - root.join("bin").join(bin_name), - ) - .unwrap(); -} diff --git a/testbench/src/utils.rs b/testbench/src/utils.rs @@ -1,571 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2022-2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{ - fmt::Display, - io::Write as _, - net::{Ipv4Addr, SocketAddrV4, TcpListener}, - ops::{Deref, DerefMut}, - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - str::FromStr, - sync::Arc, - time::Duration, -}; - -use indicatif::ProgressBar; -use ini::Ini; -use taler_common::{ - api::{ - EddsaPublicKey, ShortHashCode, - wire::{IncomingBankTransaction, IncomingHistory, OutgoingHistory, TransferRequest}, - }, - types::{amount::Amount, base32::Base32, payto::PaytoURI}, -}; -use url::Url; - -const LOG: &str = "DEBUG"; - -#[must_use] -pub async fn check_incoming(base_url: &str, txs: &[(EddsaPublicKey, Amount)]) -> bool { - let mut res = ureq::get(&format!("{base_url}history/incoming")) - .query("delta", format!("-{}", 100)) - .call() - .unwrap(); - if txs.is_empty() { - res.status() == 204 - } else { - if res.status() != 200 { - return false; - } - let history: IncomingHistory = res.body_mut().read_json().unwrap(); - - history.incoming_transactions.len() == txs.len() - && txs.iter().all(|(reserve_pub_key, taler_amount)| { - history.incoming_transactions.iter().any(|h| { - matches!( - h, - IncomingBankTransaction::Reserve { - reserve_pub, - amount, - .. - } if reserve_pub == reserve_pub_key && amount == taler_amount - ) - }) - }) - } -} - -#[must_use] -pub async fn check_gateway_down(base_url: &str) -> bool { - matches!( - ureq::get(&format!("{base_url}history/incoming")) - .query("delta", "-5") - .call(), - Err(ureq::Error::StatusCode(504 | 502)) - ) -} - -#[must_use] -pub async fn check_gateway_up(base_url: &str) -> bool { - ureq::get(&format!("{base_url}config")).call().is_ok() -} - -pub async fn transfer(base_url: &str, wtid: &[u8; 32], credit_account: PaytoURI, amount: &Amount) { - loop { - let res = ureq::post(&format!("{base_url}transfer")).send_json(TransferRequest { - request_uid: Base32::rand(), - amount: *amount, - exchange_base_url: Url::parse("https://exchange.test/").unwrap(), - wtid: Base32::from(*wtid), - credit_account: credit_account.clone(), - metadata: None, - }); - if !matches!(res, Err(ureq::Error::StatusCode(502))) { - res.unwrap(); - break; - } - } -} - -#[must_use] -pub async fn check_outgoing(base_url: &str, txs: &[(ShortHashCode, Amount)]) -> bool { - let mut res = ureq::get(format!("{base_url}history/outgoing")) - .query("delta", format!("-{}", txs.len())) - .call() - .unwrap(); - if txs.is_empty() { - res.status() == 204 - } else { - if res.status() != 200 { - return false; - } - let history: OutgoingHistory = res.body_mut().read_json().unwrap(); - - history.outgoing_transactions.len() == txs.len() - && txs.iter().all(|(wtid, amount)| { - history - .outgoing_transactions - .iter() - .any(|h| h.wtid == *wtid && &h.amount == amount) - }) - } -} -pub struct ChildGuard(pub Child); - -impl Drop for ChildGuard { - fn drop(&mut self) { - self.0.kill().ok(); - } -} - -#[track_caller] -pub fn try_cmd_redirect( - cmd: &str, - args: &[&str], - path: impl AsRef<Path>, -) -> std::io::Result<ChildGuard> { - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - - let child = Command::new(cmd) - .args(args) - .stderr(log_file.try_clone()?) - .stdout(log_file) - .stdin(Stdio::null()) - .spawn()?; - Ok(ChildGuard(child)) -} - -#[track_caller] -pub fn cmd_redirect(cmd: &str, args: &[&str], path: impl AsRef<Path>) -> ChildGuard { - try_cmd_redirect(cmd, args, path).unwrap() -} - -#[track_caller] -pub fn cmd_ok(mut child: ChildGuard, name: &str) { - let result = child.0.wait().unwrap(); - if !result.success() { - panic!("cmd {name} failed"); - } -} - -#[track_caller] -pub fn cmd_redirect_ok(cmd: &str, args: &[&str], path: impl AsRef<Path>, name: &str) { - cmd_ok(cmd_redirect(cmd, args, path), name) -} - -#[macro_export] -macro_rules! retry_opt { - ($expr:expr) => { - async { - let start = std::time::Instant::now(); - loop { - let result = $expr.await; - if result.is_err() && start.elapsed() < std::time::Duration::from_secs(30) { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - } else { - return result.unwrap(); - } - } - } - .await - }; -} - -#[macro_export] -macro_rules! retry { - ($expr:expr) => { - $crate::retry_opt! { - async { $expr.await.then_some(()).ok_or("failure") } - } - }; -} - -#[derive(Clone)] -pub struct TestCtx { - pub name: String, - pub root: PathBuf, - pub dir: PathBuf, - pub pb: ProgressBar, - pub db: Arc<LocalDb>, -} - -impl TestCtx { - pub fn new( - root: impl Into<PathBuf>, - name: impl Into<String>, - pb: ProgressBar, - db: Arc<LocalDb>, - ) -> Self { - let root = root.into(); - let name = name.into(); - // Create log dir - let dir = root.join(&name); - std::fs::create_dir_all(&dir).unwrap(); - - Self { - name, - dir, - pb, - db, - root, - } - } - - pub fn log(&self, name: &str) -> PathBuf { - self.dir.join(format!("{name}.log")) - } - - pub fn step(&self, disp: impl Display) { - self.pb.set_message(format!("{disp}")) - } - - /* ----- Database ----- */ - - pub fn stop_db(&mut self) { - self.db.stop_db(&self.name); - } - - pub fn resume_db(&mut self) { - self.db.resume_db(&self.name); - } -} - -pub struct TalerCtx { - pub wire_dir: PathBuf, - pub wire2_dir: PathBuf, - pub conf: PathBuf, - ctx: TestCtx, - pub wire_bin_path: String, - stressed: bool, - gateway: Option<ChildGuard>, - pub gateway_url: String, - wire: Option<ChildGuard>, - wire2: Option<ChildGuard>, - pub gateway_port: u16, -} - -impl TalerCtx { - pub fn new(ctx: &TestCtx, wire_name: impl Into<String>, config: &str, stressed: bool) -> Self { - // Create temporary dir - let dir = ctx.dir.clone(); - let conf = dir.join("taler.conf"); - - // Create common dirs - let wire_dir = dir.join("wire"); - let wire2_dir = dir.join("wire2"); - for dir in [&wire_dir, &wire2_dir] { - std::fs::create_dir_all(dir).unwrap(); - } - - // Find unused port - let gateway_port = unused_port(); - let gateway_url = format!("http://localhost:{gateway_port}/taler-wire-gateway/"); - - // Generate taler config from base - let wire_name = wire_name.into(); - let config = PathBuf::from_str("testbench/conf").unwrap().join(config); - let mut cfg = ini::Ini::load_from_file(config).unwrap(); - cfg.with_section(Some("exchange-accountcredentials-admin")) - .set("WIRE_GATEWAY_URL", &gateway_url); - cfg.with_section(Some(format!("{wire_name}db-postgres"))) - .set("CONFIG", ctx.db.postgres_uri(&ctx.name)); - cfg.with_section(Some(format!("{wire_name}-httpd"))) - .set("PORT", gateway_port.to_string()); - - cfg.write_to_file(&conf).unwrap(); - - Self { - ctx: ctx.clone(), - gateway_url, - wire_dir, - wire2_dir, - conf, - wire_bin_path: if stressed { - ctx.root.join(format!("bin/{wire_name}-fail")) - } else { - ctx.root.join(format!("bin/{wire_name}")) - } - .to_string_lossy() - .to_string(), - stressed, - gateway: None, - wire: None, - wire2: None, - gateway_port, - } - } - - pub fn dbinit(&self) { - self.db.wait_running(); - self.db.create_db(&self.ctx.name); - // Init db - cmd_redirect_ok( - &self.wire_bin_path, - &["-c", self.conf.to_string_lossy().as_ref(), "dbinit"], - self.log("cmd"), - "wire dbinit", - ); - } - - pub fn reset_db(&self) { - // Reset db - self.db.execute_sql( - " - FOR r IN ( - SELECT schemaname, tablename - FROM pg_tables - WHERE schemaname NOT IN ('pg_catalog', 'information_schema') - ) LOOP - EXECUTE format( - 'TRUNCATE TABLE %I.%I RESTART IDENTITY CASCADE', - r.schemaname, - r.tablename - ); - END LOOP; - ", - ); - } - - pub fn setup(&self) { - // Init db - cmd_redirect_ok( - &self.wire_bin_path, - &["-c", self.conf.to_string_lossy().as_ref(), "setup", "-r"], - self.log("cmd"), - "wire setup", - ); - } - - pub async fn run(&mut self) { - // Run gateway - self.gateway = Some(cmd_redirect( - &self.wire_bin_path, - &[ - "-c", - self.conf.to_string_lossy().as_ref(), - "-L", - LOG, - "serve", - ], - self.log("gateway"), - )); - - // Start wires - self.wire = Some(cmd_redirect( - &self.wire_bin_path, - &[ - "-c", - self.conf.to_string_lossy().as_ref(), - "-L", - LOG, - "worker", - ], - self.log("worker"), - )); - self.wire2 = self.stressed.then(|| { - cmd_redirect( - &self.wire_bin_path, - &[ - "-c", - self.conf.to_string_lossy().as_ref(), - "-L", - LOG, - "worker", - ], - self.log("worker+"), - ) - }); - - // Wait for gateway to be up - retry_opt! { - tokio::net::TcpStream::connect(SocketAddrV4::new(Ipv4Addr::LOCALHOST, self.gateway_port)) - }; - } - - /* ----- Process ----- */ - - #[must_use] - pub fn wire_running(&mut self) -> bool { - self.wire.as_mut().unwrap().0.try_wait().unwrap().is_none() - } - - #[must_use] - pub fn gateway_running(&mut self) -> bool { - self.gateway - .as_mut() - .unwrap() - .0 - .try_wait() - .unwrap() - .is_none() - } - - /* ----- Wire Gateway -----*/ - - pub async fn expect_credits(&self, txs: &[(EddsaPublicKey, Amount)]) -> bool { - check_incoming(&self.gateway_url, txs).await - } - - pub async fn expect_debits(&self, txs: &[(ShortHashCode, Amount)]) -> bool { - check_outgoing(&self.gateway_url, txs).await - } - - pub async fn expect_gateway_up(&self) { - retry!(check_gateway_up(&self.gateway_url)) - } - - pub async fn expect_gateway_down(&self) { - retry!(check_gateway_down(&self.gateway_url)) - } -} - -impl Deref for TalerCtx { - type Target = TestCtx; - - fn deref(&self) -> &Self::Target { - &self.ctx - } -} - -impl DerefMut for TalerCtx { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.ctx - } -} - -pub fn unused_port() -> u16 { - TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) - .unwrap() - .local_addr() - .unwrap() - .port() -} - -pub struct LocalDb { - cluster_dir: String, - cmd_log: String, - cluster: ChildGuard, -} - -impl LocalDb { - pub fn new(root: &Path) -> Self { - let cluster_dir = root.join("db"); - let cluster_log = root.join("postgres.log").to_string_lossy().to_string(); - let cmd_log = root.join("postgres-cmd.log").to_string_lossy().to_string(); - if !std::fs::exists(&cluster_dir).unwrap() { - // Init databases files - cmd_redirect_ok( - "initdb", - &[cluster_dir.to_string_lossy().as_ref()], - &cluster_log, - "init_db", - ); - } - // Generate database config - std::fs::write( - cluster_dir.join("postgresql.conf"), - format!( - " - listen_addresses='' - unix_socket_directories='{}' - fsync=off - synchronous_commit=off - full_page_writes=off - ", - cluster_dir.to_string_lossy().as_ref() - ), - ) - .unwrap(); - let cluster = cmd_redirect( - "postgres", - &["-D", cluster_dir.to_string_lossy().as_ref()], - &cmd_log, - ); - Self { - cluster_dir: cluster_dir.to_string_lossy().to_string(), - cmd_log, - cluster, - } - } - - pub fn postgres_uri(&self, database: &str) -> String { - format!("postgres:///{database}?host={}", self.cluster_dir) - } - - pub fn execute_sql(&self, sql: &str) -> bool { - let mut psql = ChildGuard( - Command::new("psql") - .arg(self.postgres_uri("postgres")) - .stderr(Stdio::null()) - .stdout( - std::fs::File::options() - .append(true) - .create(true) - .open(&self.cmd_log) - .unwrap(), - ) - .stdin(Stdio::piped()) - .spawn() - .unwrap(), - ); - psql.0 - .stdin - .as_mut() - .unwrap() - .write_all(sql.as_bytes()) - .unwrap(); - psql.0.wait().unwrap().success() - } - - pub fn create_db(&self, name: &str) { - self.execute_sql(&format!("CREATE DATABASE {name};")); - } - - pub fn stop_db(&self, name: &str) { - self.execute_sql(&format!( - " - UPDATE pg_database SET datallowconn=false WHERE datname='{name}'; - SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname='{name}' AND pid <> pg_backend_pid(); - " - )); - } - pub fn resume_db(&self, name: &str) { - self.execute_sql(&format!( - "UPDATE pg_database SET datallowconn=true WHERE datname='{name}';" - )); - } - - pub fn wait_running(&self) { - for _ in 0..10 { - if self.execute_sql("SELECT true") { - break; - } - std::thread::sleep(Duration::from_millis(500)) - } - } -} - -pub fn patch_config(from: impl AsRef<Path>, to: impl AsRef<Path>, patch: impl FnOnce(&mut Ini)) { - let mut cfg = ini::Ini::load_from_file(from).unwrap(); - patch(&mut cfg); - cfg.write_to_file(to).unwrap(); -}