From 862c0fc9a6c03f3f3e97afa3cc75950745293220 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Mon, 23 Mar 2026 16:03:24 +0100 Subject: [PATCH 1/6] iban: add Lightning Network bank code and node ID IBAN --- src/iban.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/iban.rs b/src/iban.rs index 673236c..1e169a1 100644 --- a/src/iban.rs +++ b/src/iban.rs @@ -1,9 +1,11 @@ -use anyhow::Result; +use anyhow::{Result, bail}; /// Bank code for mainnet IBANs. const BANK_CODE_MAIN: &str = "XBTC"; /// Bank code for test-network IBANs (regtest, testnet3, testnet4, signet). const BANK_CODE_TEST: &str = "TBTC"; +/// Bank code for Lightning Network IBANs (all networks). +const BANK_CODE_LIGHTNING: &str = "LNBT"; /// Generate a deterministic IBAN from a wallet fingerprint. /// @@ -26,6 +28,23 @@ pub fn iban_from_fingerprint(fingerprint_hex: &str, country: &str, chain: &str) Ok(format!("{country}{iban_check:02}{bban}")) } +/// Generate a deterministic IBAN from a phoenixd node ID. +/// +/// Uses the first 4 bytes (8 hex chars) of the 33-byte compressed pubkey as the +/// fingerprint and always uses the `LNBT` bank code to distinguish Lightning +/// Network accounts from on-chain Bitcoin accounts. +pub fn iban_from_node_id(node_id: &str, country: &str) -> Result { + if node_id.len() < 8 { + bail!("node ID too short: {node_id}"); + } + let fp = u32::from_str_radix(&node_id[..8], 16) + .map_err(|_| anyhow::anyhow!("invalid node ID (expected hex): {node_id}"))?; + let fp_decimal = format!("{fp:010}"); + let bban = format!("{BANK_CODE_LIGHTNING}{fp_decimal}"); + let iban_check = iban_mod97_check(country, &bban)?; + Ok(format!("{country}{iban_check:02}{bban}")) +} + /// Compute IBAN check digits using ISO 13616 Mod-97 algorithm. fn iban_mod97_check(country: &str, bban: &str) -> Result { // Move country code + "00" to end, convert letters to numbers (A=10..Z=35) @@ -120,4 +139,26 @@ mod tests { let numeric = alpha_to_numeric(&format!("{}{}", &iban[4..], &iban[..4])); assert_eq!(mod97(&numeric), 1); } + + #[test] + fn node_id_uses_lnbt_bank_code() { + // ACINQ's well-known public node ID (03864ef0...) + let node_id = "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"; + let iban = iban_from_node_id(node_id, "FR").unwrap(); + assert!(iban.starts_with("FR")); + assert!(iban.contains("LNBT"), "IBAN should use LNBT bank code: {iban}"); + assert_eq!(iban.len(), 18); + let numeric = alpha_to_numeric(&format!("{}{}", &iban[4..], &iban[..4])); + assert_eq!(mod97(&numeric), 1, "IBAN {iban} should pass mod-97 check"); + } + + #[test] + fn node_id_rejects_short_input() { + assert!(iban_from_node_id("02eec7", "NL").is_err()); + } + + #[test] + fn node_id_rejects_invalid_hex() { + assert!(iban_from_node_id("zzzzzzzzzzzzzzzz", "NL").is_err()); + } } From 9a1a37f300d6fe23ae83cd2eed2b08b7689aef53 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 24 Mar 2026 09:54:13 +0100 Subject: [PATCH 2/6] accounting: track closing_balance_sats in Statement Add a closing_balance_sats field to Statement so consumers (e.g. the export summary) can report the final BTC balance without recomputing it. Include the closing sat balance in the export summary line when FIFO or mark-to-market entries are present. Co-Authored-By: Claude Opus 4.6 --- src/accounting.rs | 1 + src/commands/export.rs | 10 +++++++--- src/export/camt053.rs | 1 + src/export/mod.rs | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/accounting.rs b/src/accounting.rs index c8a6525..397d319 100644 --- a/src/accounting.rs +++ b/src/accounting.rs @@ -395,6 +395,7 @@ pub fn build_statement( opening_rate, entries, closing_balance_cents: balance_cents, + closing_balance_sats: balance_sats, opening_date, statement_date, statement_id, diff --git a/src/commands/export.rs b/src/commands/export.rs index 0ccd7f1..77e4b3a 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -249,13 +249,17 @@ pub fn run(args: ExportArgs) -> Result<()> { } // Summary + let closing_note = format!( + "{} closing balance {} sat", + &statement.statement_date, statement.closing_balance_sats, + ); if new_tx_count > 0 { if let (Some(first), Some(last)) = (first_date, last_date) { let extras = match (new_fifo_count, new_mtm_count) { (0, 0) => String::new(), - (f, 0) => format!(" ({f} FIFO gain/loss)"), - (0, m) => format!(" ({m} mark-to-market)"), - (f, m) => format!(" ({f} FIFO gain/loss, {m} mark-to-market)"), + (f, 0) => format!(" ({f} FIFO gain/loss, {closing_note})"), + (0, m) => format!(" ({m} mark-to-market, {closing_note})"), + (f, m) => format!(" ({f} FIFO gain/loss, {m} mark-to-market, {closing_note})"), }; if first == last { eprintln!("Exported {new_tx_count} transaction(s) from {first}.{extras}"); diff --git a/src/export/camt053.rs b/src/export/camt053.rs index e61ab43..0b94c2b 100644 --- a/src/export/camt053.rs +++ b/src/export/camt053.rs @@ -497,6 +497,7 @@ mod tests { }, ], closing_balance_cents: 499_985, + closing_balance_sats: 5_262_158, opening_date: "2025-01-02".to_owned(), statement_date: "2025-01-02".to_owned(), statement_id: "STMT-2025-01-02".to_owned(), diff --git a/src/export/mod.rs b/src/export/mod.rs index e178615..f093051 100644 --- a/src/export/mod.rs +++ b/src/export/mod.rs @@ -35,6 +35,8 @@ pub struct Statement { pub opening_rate: Option, pub entries: Vec, pub closing_balance_cents: i64, + /// Closing BTC balance in satoshis. + pub closing_balance_sats: i64, /// ISO date of the opening balance (= start_date or first entry date). pub opening_date: String, /// ISO date: YYYY-MM-DD From 91199a2e0ab84fe83248380d9c8571d59ad773a7 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 24 Mar 2026 09:55:57 +0100 Subject: [PATCH 3/6] import: add payment_hash and TxKind to WalletTransaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend WalletTransaction with two new fields: - payment_hash: Optional Lightning payment hash for invoice matching. - kind: TxKind enum distinguishing standard transactions from liquidity purchases that need special accounting treatment. All existing constructors set the defaults (None / TxKind::Default). No behavioral changes yet — this is purely a data-model extension. Co-Authored-By: Claude Opus 4.6 --- src/accounting.rs | 6 +++++- src/commands/export.rs | 2 ++ src/import/bitcoin_core_rpc.rs | 2 ++ src/import/mod.rs | 12 ++++++++++++ tests/regtest/wallet.rs | 2 ++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/accounting.rs b/src/accounting.rs index 397d319..2319fbc 100644 --- a/src/accounting.rs +++ b/src/accounting.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc}; use crate::exchange_rate::ExchangeRateProvider; use crate::export::{Entry, Statement, booking_date_to_date}; -use crate::import::{TxCategory, WalletTransaction}; +use crate::import::{TxCategory, TxKind, WalletTransaction}; /// Configuration for the accounting engine. pub struct AccountingConfig { @@ -519,6 +519,8 @@ mod tests { block_hash: "bb".repeat(32), address: "bc1qtest".to_owned(), label: String::new(), + payment_hash: None, + kind: TxKind::Default, } } @@ -534,6 +536,8 @@ mod tests { block_hash: "dd".repeat(32), address: "bc1qother".to_owned(), label: String::new(), + payment_hash: None, + kind: TxKind::Default, } } diff --git a/src/commands/export.rs b/src/commands/export.rs index 77e4b3a..dc2b6e6 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -747,6 +747,8 @@ mod tests { block_hash: "bb".repeat(32), address: "bc1qtest".to_owned(), label: String::new(), + payment_hash: None, + kind: crate::import::TxKind::Default, }; assert_eq!(transaction_entry_ref(&tx), "123:abcdef0123456789abcd:7"); diff --git a/src/import/bitcoin_core_rpc.rs b/src/import/bitcoin_core_rpc.rs index cf97a5a..678121a 100644 --- a/src/import/bitcoin_core_rpc.rs +++ b/src/import/bitcoin_core_rpc.rs @@ -281,6 +281,8 @@ impl TransactionSource for BitcoinCoreRpc { block_hash: tx.blockhash.clone().unwrap_or_default(), address: tx.address.clone().unwrap_or_default(), label: tx.label.clone().unwrap_or_default(), + payment_hash: None, + kind: super::TxKind::Default, }); } diff --git a/src/import/mod.rs b/src/import/mod.rs index 357544b..8643772 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -9,6 +9,15 @@ pub enum TxCategory { Receive, } +/// Distinguishes transactions that need special accounting treatment. +#[derive(Clone, Debug)] +pub enum TxKind { + /// Standard on-chain or lightning transaction. + Default, + /// Liquidity purchase fee — description is pre-formatted by the parser. + LiquidityPurchase { description: String }, +} + /// A wallet transaction with all data needed for accounting. #[derive(Clone, Debug)] pub struct WalletTransaction { @@ -24,6 +33,9 @@ pub struct WalletTransaction { pub address: String, /// Address label or transaction comment (from Bitcoin Core). pub label: String, + /// Lightning payment hash for invoice matching (hex, 32 bytes). + pub payment_hash: Option, + pub kind: TxKind, } /// Source of wallet transactions. diff --git a/tests/regtest/wallet.rs b/tests/regtest/wallet.rs index ab7c4cc..fbc2b2e 100644 --- a/tests/regtest/wallet.rs +++ b/tests/regtest/wallet.rs @@ -357,6 +357,8 @@ impl<'a> RpcWallet<'a> { block_hash: tx.blockhash.clone().unwrap_or_default(), address: tx.address.clone().unwrap_or_default(), label: tx.label.clone().unwrap_or_default(), + payment_hash: None, + kind: btc_fiat_value::import::TxKind::Default, }); } From 00ddc7c317fcbcf3df31c748d396bee10d2d00fa Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 24 Mar 2026 09:59:07 +0100 Subject: [PATCH 4/6] accounting: support Lightning metadata and fee threshold Teach the accounting engine to handle Lightning-specific fields: - Refactor format_description to support empty labels (Lightning transactions have no address) and an optional suffix for payment hash or fee notes. - Handle TxKind::LiquidityPurchase: emit as a single fee entry with pre-formatted mining/service fee breakdown. - Include payment_hash in receive and send descriptions. - Add fee_threshold_cents: fees below this threshold are folded into the parent entry description instead of emitted as separate entries. - Use "Lightning send fee" label when tx.label is "lightning_sent". - Add --fee-threshold-cents CLI flag. Co-Authored-By: Claude Opus 4.6 --- src/accounting.rs | 89 ++++++++++++++++++++++++++++++++++-------- src/commands/export.rs | 13 ++++++ tests/regtest/main.rs | 1 + 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/accounting.rs b/src/accounting.rs index 2319fbc..b800e24 100644 --- a/src/accounting.rs +++ b/src/accounting.rs @@ -27,6 +27,9 @@ pub struct AccountingConfig { pub wallet_balance_sats: Option, /// If true, warn instead of error on forward/backward balance mismatch. pub ignore_balance_mismatch: bool, + /// Fee entries below this many fiat cents are folded into the transaction + /// description instead of being emitted as separate entries. Default: 1. + pub fee_threshold_cents: i64, } /// A FIFO lot: tracks remaining satoshis and their cost basis. @@ -175,8 +178,9 @@ pub fn build_statement( let entry_ref = format_entry_ref(tx.block_height, &tx.txid, tx.vout); let full_ref = format_full_ref(&tx.block_hash, &tx.txid, tx.vout); - let label = if tx.label.is_empty() { &tx.address } else { &tx.label }; - let description = format_description(label, "Received", tx.amount_sats, rate_cents_per_btc); + let label = if tx.address.is_empty() { "" } else if tx.label.is_empty() { &tx.address } else { &tx.label }; + let hash_suffix = tx.payment_hash.as_deref().map(|h| format!("payment hash: {h}")); + let description = format_description(label, "Received", tx.amount_sats, rate_cents_per_btc, hash_suffix.as_deref()); entries.push(Entry { entry_ref, @@ -198,8 +202,39 @@ pub fn build_statement( let entry_ref = format_entry_ref(tx.block_height, &tx.txid, tx.vout); let full_ref = format_full_ref(&tx.block_hash, &tx.txid, tx.vout); - let label = if tx.label.is_empty() { &tx.address } else { &tx.label }; - let description = format_description(label, "Sent", abs_sats, rate_cents_per_btc); + // Liquidity purchase fees are emitted as a single fee entry + // with the mining/service breakdown in the description. + if let TxKind::LiquidityPurchase { ref description } = tx.kind { + entries.push(Entry { + entry_ref, + full_ref, + booking_date: booking_date.clone(), + amount_cents, + is_credit: false, + description: description.clone(), + is_fee: true, + }); + } else { + + let label = if tx.address.is_empty() { "" } else if tx.label.is_empty() { &tx.address } else { &tx.label }; + let verb = "Sent"; + // For sub-cent fees, fold the fee mention into the description so + // the description reads "... BTC @ rate + N sat fee payment hash: ..." with no + // separate fee entry in the XML output. + let fee_note_str = tx.fee_sats + .filter(|&f| { + let abs_fee = f.unsigned_abs() as i64; + abs_fee > 0 && convert_to_cents(abs_fee, rate_cents_per_btc) < config.fee_threshold_cents + }) + .map(|f| { + let abs_fee = f.unsigned_abs() as i64; + match tx.payment_hash.as_deref() { + Some(h) => format!("+ {abs_fee} sat fee payment hash: {h}"), + None => format!("+ {abs_fee} sat fee"), + } + }); + let effective_suffix = fee_note_str.or_else(|| tx.payment_hash.as_deref().map(|h| format!("payment hash: {h}"))); + let description = format_description(label, verb, abs_sats, rate_cents_per_btc, effective_suffix.as_deref()); entries.push(Entry { entry_ref, @@ -211,6 +246,8 @@ pub fn build_statement( is_fee: false, }); + } // else (not LiquidityPurchase) + // FIFO: consume lots and emit realized gain/loss if config.fifo && config.fiat_mode { let mut remaining = abs_sats; @@ -309,15 +346,20 @@ pub fn build_statement( } } - entries.push(Entry { - entry_ref: format!(":{}:{}:fee", tx.block_height, &tx.txid[..20.min(tx.txid.len())]), - full_ref: format!(":{}:{}:fee", tx.block_hash, tx.txid), - booking_date, - amount_cents: fee_cents, - is_credit: false, - description: format!("Mining fee ({} sat)", abs_fee), - is_fee: true, - }); + if fee_cents >= config.fee_threshold_cents { + entries.push(Entry { + entry_ref: format!(":{}:{}:fee", tx.block_height, &tx.txid[..20.min(tx.txid.len())]), + full_ref: format!(":{}:{}:fee", tx.block_hash, tx.txid), + booking_date, + amount_cents: fee_cents, + is_credit: false, + description: match tx.label.as_str() { + "lightning_sent" => format!("Lightning send fee ({abs_fee} sat)"), + _ => format!("Mining fee ({abs_fee} sat)"), + }, + is_fee: true, + }); + } } } } @@ -475,11 +517,17 @@ fn format_full_ref(block_hash: &str, txid: &str, vout: u32) -> String { format!("{block_hash}:{txid}:{vout}") } -fn format_description(label: &str, verb: &str, sats: i64, rate_cents_per_btc: Option) -> String { +fn format_description(label: &str, verb: &str, sats: i64, rate_cents_per_btc: Option, suffix: Option<&str>) -> String { let btc = sats as f64 / 100_000_000.0; - match rate_cents_per_btc { - Some(rate) => format!("{label} - {verb} {btc:.8} BTC @ {rate:.2}"), - None => format!("{label} - {verb} {btc:.8} BTC"), + let prefix = if label.is_empty() { String::new() } else { format!("{label} - ") }; + let rate_part = match rate_cents_per_btc { + Some(rate) => format!(" @ {rate:.2}"), + None => String::new(), + }; + let base = format!("{prefix}{verb} {btc:.8} BTC{rate_part}"); + match suffix { + Some(s) => format!("{base} {s}"), + None => base, } } @@ -557,6 +605,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let stmt = build_statement(&txs, &provider, &config).unwrap(); @@ -587,6 +636,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let stmt = build_statement(&txs, &provider, &config).unwrap(); @@ -617,6 +667,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let stmt = build_statement(&txs, &provider, &config).unwrap(); @@ -682,6 +733,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let provider = TwoRateProvider; @@ -735,6 +787,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let provider = TwoRateProvider; @@ -793,6 +846,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let provider = TwoRateProvider; @@ -862,6 +916,7 @@ mod tests { bank_name: None, wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let provider = MultiRateProvider; diff --git a/src/commands/export.rs b/src/commands/export.rs index dc2b6e6..4771923 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -38,6 +38,7 @@ options: --start-date Start date YYYY-MM-DD --bank-name Bank/institution name (default: Bitcoin Core - ) --candle Kraken candle interval (default: DEFAULT_CANDLE_MINUTES or 1440) + --fee-threshold-cents Fold fees below this threshold into the parent entry description (default: 1) --ignore-balance-mismatch Warn instead of error on forward/backward balance mismatch"; #[derive(Debug, PartialEq, Eq)] @@ -55,6 +56,7 @@ pub struct ExportArgs { pub candle_override_minutes: Option, pub bank_name: Option, pub ignore_balance_mismatch: bool, + pub fee_threshold_cents: Option, } #[derive(Debug, PartialEq, Eq)] @@ -207,6 +209,7 @@ pub fn run(args: ExportArgs) -> Result<()> { Some(wallet_balance_sats) }, ignore_balance_mismatch: args.ignore_balance_mismatch, + fee_threshold_cents: args.fee_threshold_cents.unwrap_or(1), }; let mut statement = build_statement(&transactions, &provider, &config)?; @@ -423,6 +426,7 @@ where let mut candle_minutes: Option = None; let mut bank_name: Option = None; let mut ignore_balance_mismatch = false; + let mut fee_threshold_cents: Option = None; while let Some(arg) = args.next() { match arg.as_str() { @@ -465,6 +469,10 @@ where bank_name = Some(args.next().ok_or_else(|| anyhow::anyhow!("--bank-name requires a value\n\n{usage}"))?); } "--ignore-balance-mismatch" => ignore_balance_mismatch = true, + "--fee-threshold-cents" => { + let val = args.next().ok_or_else(|| anyhow::anyhow!("--fee-threshold-cents requires a value\n\n{usage}"))?; + fee_threshold_cents = Some(val.parse::().with_context(|| format!("invalid --fee-threshold-cents: {val}"))?); + } "-h" | "--help" | "help" => bail!("{usage}"), _ => { // Handle --key=value form @@ -477,6 +485,9 @@ where "--output" => output = Some(PathBuf::from(value)), "--candle" => candle_minutes = Some(crate::common::parse_candle_interval_minutes(value, "--candle")?), "--bank-name" => bank_name = Some(value.to_owned()), + "--fee-threshold-cents" => { + fee_threshold_cents = Some(value.parse::().with_context(|| format!("invalid --fee-threshold-cents: {value}"))?); + } "--start-date" => { start_date = Some(NaiveDate::parse_from_str(value, "%Y-%m-%d") .with_context(|| format!("invalid date: {value}"))?); @@ -538,6 +549,7 @@ where candle_override_minutes: candle_minutes, bank_name, ignore_balance_mismatch, + fee_threshold_cents, }) } @@ -587,6 +599,7 @@ mod tests { candle_override_minutes: None, bank_name: None, ignore_balance_mismatch: false, + fee_threshold_cents: None, } ); } diff --git a/tests/regtest/main.rs b/tests/regtest/main.rs index c07b2b7..03fc0b9 100644 --- a/tests/regtest/main.rs +++ b/tests/regtest/main.rs @@ -204,6 +204,7 @@ fn run_salary_scenario() -> Result<()> { bank_name: Some("Bitcoin Core - accounting".to_owned()), wallet_balance_sats: None, ignore_balance_mismatch: false, + fee_threshold_cents: 1, }; let mut statement = btc_fiat_value::accounting::build_statement(&transactions, &mock_provider, &config)?; From 52af956074e3adbedaba977664bdb293e6b74643 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 24 Mar 2026 10:00:17 +0100 Subject: [PATCH 5/6] import: add phoenixd CSV parser Add a phoenixd CSV transaction source that parses the CSV format produced by `phoenix-cli exportcsv` (phoenixd v0.7.3). Supported types: lightning_received, lightning_sent, swap_out, channel_close, and liquidity_purchase. All other types are rejected with an error. Channel-opening receives (lightning_received with mining/service fees) are split into a plain receive plus a LiquidityPurchase fee entry. Fee credits are folded into the received amount. The CSV fixture contains 7 representative rows from the upstream phoenixd testnet sample database. Co-Authored-By: Claude Opus 4.6 --- src/import/mod.rs | 1 + src/import/phoenixd_csv.rs | 567 +++++++++++++++++++++++++++++ tests/fixtures/phoenixd_sample.csv | 8 + 3 files changed, 576 insertions(+) create mode 100644 src/import/phoenixd_csv.rs create mode 100644 tests/fixtures/phoenixd_sample.csv diff --git a/src/import/mod.rs b/src/import/mod.rs index 8643772..b245b9f 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -1,4 +1,5 @@ pub mod bitcoin_core_rpc; +pub mod phoenixd_csv; use anyhow::Result; diff --git a/src/import/phoenixd_csv.rs b/src/import/phoenixd_csv.rs new file mode 100644 index 0000000..0cdeac8 --- /dev/null +++ b/src/import/phoenixd_csv.rs @@ -0,0 +1,567 @@ +use std::io::Read; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use chrono::DateTime; + +use super::{TransactionSource, TxCategory, WalletTransaction}; + +/// Phoenixd CSV transaction source. +#[derive(Debug)] +pub struct PhoenixdCsv { + records: Vec, +} + +#[derive(Debug)] +struct CsvRecord { + date: String, + id: String, + tx_type: String, + amount_msat: i64, + fee_credit_msat: i64, + mining_fee_sat: i64, + service_fee_msat: i64, + payment_hash: String, + tx_id: String, +} + +impl PhoenixdCsv { + pub fn from_path(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .with_context(|| format!("failed to open CSV file {}", path.display()))?; + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| format!("failed to read CSV file {}", path.display()))?; + Self::from_str(&contents) + } + + /// Accounting balance computed from raw msat values, rounded once. + /// This avoids rounding drift from summing individually rounded sat values. + /// Matches the accounting engine's semantics: credits are income, sends + /// include fees in amount_msat, and channel-opening credit consumption is + /// reflected via fee_credit_msat. + pub fn wallet_balance_sats(&self) -> i64 { + let total_msat: i64 = self.records.iter().map(|r| { + if r.tx_type == "liquidity_purchase" { + // Standalone fee: accounting debits gross fee, ignoring + // amount_msat (which equals the fee total). + -(r.mining_fee_sat * 1000 + r.service_fee_msat) + } else { + let is_channel_opening = r.tx_type == "lightning_received" + && (r.mining_fee_sat > 0 || r.service_fee_msat > 0); + if is_channel_opening { + // Channel opening: account_balance = channel + fee_credit. + // amount_msat enters the channel, fee_credit_msat is + // consumed (negative). Sum = account balance change. + r.amount_msat + r.fee_credit_msat + } else { + // Non-channel: positive credit is income, negative + // credit doesn't occur (only on channel openings). + r.amount_msat + r.fee_credit_msat.max(0) + } + } + }).sum(); + msat_to_sat(total_msat) + } + + pub fn from_str(csv: &str) -> Result { + let mut records = Vec::new(); + let mut lines = csv.lines(); + + // Skip header + let header = lines.next().context("empty CSV file")?; + if !header.starts_with("date,") { + bail!("unexpected CSV header: {header}"); + } + + for line in lines { + if line.trim().is_empty() { + continue; + } + let fields = parse_csv_line(line)?; + if fields.len() < 9 { + bail!("expected 9 CSV fields, got {}: {line}", fields.len()); + } + + records.push(CsvRecord { + date: fields[0].clone(), + id: fields[1].clone(), + tx_type: fields[2].clone(), + amount_msat: fields[3].parse::() + .with_context(|| format!("invalid amount_msat: {}", fields[3]))?, + fee_credit_msat: fields[4].parse::() + .with_context(|| format!("invalid fee_credit_msat: {}", fields[4]))?, + mining_fee_sat: fields[5].parse::() + .with_context(|| format!("invalid mining_fee_sat: {}", fields[5]))?, + service_fee_msat: fields[6].parse::() + .with_context(|| format!("invalid service_fee_msat: {}", fields[6]))?, + payment_hash: fields[7].clone(), + tx_id: fields[8].clone(), + }); + } + + Ok(Self { records }) + } +} + +impl TransactionSource for PhoenixdCsv { + fn list_transactions(&self) -> Result> { + let mut transactions = Vec::new(); + + for rec in &self.records { + // Only accept types we have test coverage for. + match rec.tx_type.as_str() { + "lightning_received" | "lightning_sent" | "swap_out" + | "channel_close" | "liquidity_purchase" => {} + other => bail!("unsupported phoenixd transaction type: {other}"), + } + + let timestamp = DateTime::parse_from_rfc3339(&rec.date) + .with_context(|| format!("invalid date: {}", rec.date))? + .timestamp(); + + // Channel opening: a lightning_received with on-chain fees is split + // into a plain receive (zero fees) plus a liquidity purchase fee + // entry. When an automatic liquidity purchase coincides with a + // lightning receive, phoenixd rolls the fees into this row instead + // of emitting a separate liquidity_purchase row. + let is_channel_opening = rec.tx_type == "lightning_received" + && (rec.mining_fee_sat > 0 || rec.service_fee_msat > 0); + + if is_channel_opening { + let payment_hash = if !rec.payment_hash.is_empty() { + Some(rec.payment_hash.clone()) + } else { + None + }; + + // account_balance = channel_balance + fee_credit. + // Gross up by the net fee (= gross − consumed credits) to + // reconstruct the amount the customer paid. When credits + // were consumed, some of that fee was pre-funded by earlier + // fee-credit income; the gross fee debit implicitly "spends" + // those credits. If BTCPay detects a shortfall (because + // phoenixd only credited amount_msat to the invoice) and the + // customer pays the difference, that second payment appears + // as a separate lightning_received row — both credits are + // real income. + let gross_fee_msat = rec.mining_fee_sat * 1000 + rec.service_fee_msat; + let credit_consumed_msat = (-rec.fee_credit_msat).max(0); + let net_fee_msat = (gross_fee_msat - credit_consumed_msat).max(0); + let invoice_msat = rec.amount_msat + net_fee_msat; + + // 1. Lightning receive at the grossed-up amount. + transactions.push(WalletTransaction { + txid: rec.id.clone(), + vout: 0, + amount_sats: msat_to_sat(invoice_msat), + fee_sats: None, + category: TxCategory::Receive, + block_time: timestamp, + block_height: 0, + block_hash: String::new(), + address: String::new(), + label: rec.tx_type.clone(), + payment_hash, + kind: super::TxKind::Default, + }); + + // 2. Liquidity purchase fee (gross). + if let Some(fee_tx) = liquidity_fee_tx(rec, timestamp) { + transactions.push(fee_tx); + } + + continue; + } + + // Standalone liquidity purchase: amount_msat IS the fee total + // (miningFee + serviceFee) per lightning-kmp's + // `AutomaticLiquidityPurchasePayment.amount = fees`. + // The purchased inbound capacity is not included. + if rec.tx_type == "liquidity_purchase" { + if let Some(fee_tx) = liquidity_fee_tx(rec, timestamp) { + transactions.push(fee_tx); + } + continue; + } + + // Positive fee_credit_msat is income: credits are a coupon that + // has real value (it will offset a future channel-opening fee). + // Accounting balance tracks wallet sats + outstanding credits. + let effective_amount_msat = rec.amount_msat + rec.fee_credit_msat.max(0); + + // Total fee in msat (mining fee is already in sat) + let fee_msat = rec.mining_fee_sat * 1000 + rec.service_fee_msat; + + // For sends, phoenixd's amount_msat includes fees (e.g. a + // 3_000_000 sat invoice with 12_004 sat routing fee is reported as + // amount_msat = −3_012_004_000). Strip the fee so the accounting + // engine — which subtracts fee_sats separately — doesn't + // double-count. + let adjusted_msat = if effective_amount_msat < 0 && fee_msat > 0 { + effective_amount_msat + fee_msat + } else { + effective_amount_msat + }; + + let amount_sats = msat_to_sat(adjusted_msat); + let category = if amount_sats >= 0 { + TxCategory::Receive + } else { + TxCategory::Send + }; + + // Total fee in sats (mining fee is already in sat, service fee is in msat) + let total_fee_sats = rec.mining_fee_sat + msat_to_sat(rec.service_fee_msat); + let fee_sats = if total_fee_sats != 0 { + Some(total_fee_sats) + } else { + None + }; + + let label = rec.tx_type.clone(); + let payment_hash = if !rec.payment_hash.is_empty() { + Some(rec.payment_hash.clone()) + } else { + None + }; + + transactions.push(WalletTransaction { + txid: rec.id.clone(), + vout: 0, + amount_sats, + fee_sats, + category, + block_time: timestamp, + block_height: 0, + block_hash: String::new(), + address: String::new(), + label, + payment_hash, + kind: super::TxKind::Default, + }); + } + + // Already chronological from phoenixd, but sort to be safe + transactions.sort_by_key(|tx| tx.block_time); + + Ok(transactions) + } +} + +/// Build a `LiquidityPurchase` fee transaction from a CSV record's gross +/// mining and service fees. Returns `None` when both fees are zero. +/// +/// The fee is always gross (before fee-credit offset) because credits were +/// already booked as income on earlier receives. Charging the gross fee +/// here implicitly "spends" those credits. +fn liquidity_fee_tx(rec: &CsvRecord, block_time: i64) -> Option { + let gross_fee_msat = rec.mining_fee_sat * 1000 + rec.service_fee_msat; + let total_fee = msat_to_sat(gross_fee_msat); + if total_fee == 0 { + return None; + } + let mut parts = Vec::new(); + if rec.mining_fee_sat > 0 { + parts.push(format!("mining fee ({} sat)", rec.mining_fee_sat)); + } + let service_sats = msat_to_sat(rec.service_fee_msat); + if service_sats > 0 { + parts.push(format!("service fee ({service_sats} sat)")); + } + // Use tx_id when available (on-chain tx), otherwise derive from the row id. + let txid = if rec.tx_id.is_empty() { + format!("{}:fee", rec.id) + } else { + rec.tx_id.clone() + }; + Some(WalletTransaction { + txid, + vout: 0, + amount_sats: -total_fee, + fee_sats: None, + category: TxCategory::Send, + block_time, + block_height: 0, + block_hash: String::new(), + address: String::new(), + label: String::new(), + payment_hash: None, + kind: super::TxKind::LiquidityPurchase { + description: format!("Liquidity purchase {}", parts.join(" + ")), + }, + }) +} + +/// Convert millisatoshis to satoshis, rounding toward zero. +fn msat_to_sat(msat: i64) -> i64 { + if msat >= 0 { + (msat + 500) / 1000 + } else { + (msat - 500) / 1000 + } +} + +/// Parse a single CSV line handling quoted fields. +fn parse_csv_line(line: &str) -> Result> { + let mut fields = Vec::new(); + let mut pos = 0; + let bytes = line.as_bytes(); + + while pos <= bytes.len() { + if pos == bytes.len() { + // Trailing comma produced an empty final field + if !fields.is_empty() { + fields.push(String::new()); + } + break; + } + + if bytes[pos] == b'"' { + pos += 1; + let mut field = String::new(); + while pos < bytes.len() { + if bytes[pos] == b'"' { + pos += 1; + if pos < bytes.len() && bytes[pos] == b'"' { + field.push('"'); + pos += 1; + } else { + break; + } + } else { + field.push(bytes[pos] as char); + pos += 1; + } + } + fields.push(field); + if pos < bytes.len() && bytes[pos] == b',' { + pos += 1; + } + } else { + let start = pos; + while pos < bytes.len() && bytes[pos] != b',' { + pos += 1; + } + fields.push(String::from_utf8_lossy(&bytes[start..pos]).into_owned()); + if pos < bytes.len() { + pos += 1; // skip comma + } else { + break; + } + } + } + + Ok(fields) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_CSV: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/phoenixd_sample.csv" + )); + + #[test] + fn parses_csv_records() { + let source = PhoenixdCsv::from_str(SAMPLE_CSV).expect("parse CSV"); + let txs = source.list_transactions().expect("list transactions"); + + // 7 CSV rows → 8 transactions + // (channel-opening lightning_received splits into receive + fee entry) + assert_eq!(txs.len(), 8); + + // Row 1: lightning_received 500_000 msat → 500 sat + assert_eq!(txs[0].category, TxCategory::Receive); + assert_eq!(txs[0].amount_sats, 500); + assert!(txs[0].fee_sats.is_none()); + + // Row 2: swap_out -15_579_000 msat, mining fee 579 sat. + // amount_msat includes the fee; strip it so the accounting engine + // (which subtracts fee_sats) doesn't double-count. + // Adjusted: -15_579_000 + 579_000 = -15_000_000 → -15000 sat. + assert_eq!(txs[1].category, TxCategory::Send); + assert_eq!(txs[1].amount_sats, -15000); + assert_eq!(txs[1].fee_sats, Some(579)); + + // Row 3: lightning_received with fee_credit_msat=503_000 but amount=0. + // Credits are income: accounting balance = wallet sats + outstanding credits. + assert_eq!(txs[2].category, TxCategory::Receive); + assert_eq!(txs[2].amount_sats, 503); + assert!(txs[2].fee_sats.is_none()); + + // Row 4: channel_close -308_594_000 msat → -308594 sat + assert_eq!(txs[3].category, TxCategory::Send); + assert_eq!(txs[3].amount_sats, -308594); + assert!(txs[3].fee_sats.is_none()); + + // Row 5: lightning_sent -1_242_936 msat, service fee 8_936 msat. + // Adjusted: -1_242_936 + 8_936 = -1_234_000 → -1234 sat, fee 9 sat. + assert_eq!(txs[4].category, TxCategory::Send); + assert_eq!(txs[4].amount_sats, -1234); + assert_eq!(txs[4].fee_sats, Some(9)); + + // Row 6: lightning_sent -3_012_004_000 msat, service fee 12_004_000 msat. + // Adjusted: -3_012_004_000 + 12_004_000 = -3_000_000_000 → -3000000 sat, fee 12004 sat. + assert_eq!(txs[5].category, TxCategory::Send); + assert_eq!(txs[5].amount_sats, -3000000); + assert_eq!(txs[5].fee_sats, Some(12004)); + + // Row 7: channel-opening lightning_received splits into receive + fee. + // Gross up: amount_msat (300M) + net_fee (41364000) → 341364 sat. + // Fee: gross 41364 sat. + assert_eq!(txs[6].category, TxCategory::Receive); + assert_eq!(txs[6].amount_sats, 341364); + assert!(txs[6].fee_sats.is_none()); + assert_eq!(txs[6].label, "lightning_received"); + + assert_eq!(txs[7].category, TxCategory::Send); + assert_eq!(txs[7].amount_sats, -41364); // 18364 mining + 23000 service + assert!(matches!(txs[7].kind, crate::import::TxKind::LiquidityPurchase { .. })); + } + + #[test] + fn msat_to_sat_rounds_correctly() { + assert_eq!(msat_to_sat(1000), 1); + assert_eq!(msat_to_sat(1499), 1); + assert_eq!(msat_to_sat(1500), 2); + assert_eq!(msat_to_sat(-1000), -1); + assert_eq!(msat_to_sat(-1500), -2); + assert_eq!(msat_to_sat(0), 0); + assert_eq!(msat_to_sat(499), 0); + assert_eq!(msat_to_sat(500), 1); + } + + #[test] + fn rejects_bad_header() { + let csv = "wrong_header\n1,2,3"; + let err = PhoenixdCsv::from_str(csv).expect_err("should reject bad header"); + assert!(err.to_string().contains("unexpected CSV header")); + } + + #[test] + fn parses_quoted_csv_fields() { + let fields = parse_csv_line(r#"hello,"world, ""quoted""",end"#).unwrap(); + assert_eq!(fields, vec!["hello", "world, \"quoted\"", "end"]); + } + + #[test] + fn labels_and_payment_hashes() { + let source = PhoenixdCsv::from_str(SAMPLE_CSV).expect("parse CSV"); + let txs = source.list_transactions().expect("list transactions"); + + assert_eq!(txs[0].label, "lightning_received"); + assert_eq!(txs[1].label, "swap_out"); + assert_eq!(txs[3].label, "channel_close"); + assert_eq!(txs[4].label, "lightning_sent"); + + assert_eq!(txs[0].payment_hash.as_deref(), Some("462f75bff2bd054c7d1f28f3524bc6c5e1022f36369d4e0a35324674fd2b6922")); + assert_eq!(txs[4].payment_hash.as_deref(), Some("f6ce6b8bb04a6639cb93d0ec5d3ed1eb33448e60ac91e82f90ff76fbe84f36e1")); + assert!(txs[1].payment_hash.is_none()); // swap_out has no payment hash + } + + #[test] + fn rejects_legacy_types() { + let csv = "date,id,type,amount_msat,fee_credit_msat,mining_fee_sat,service_fee_msat,payment_hash,tx_id\n\ + 2024-01-01T00:00:00.000Z,a,legacy_pay_to_open,1000,0,0,0,abc,\n"; + let err = PhoenixdCsv::from_str(csv).unwrap().list_transactions().expect_err("should reject legacy type"); + assert!(err.to_string().contains("unsupported phoenixd transaction type: legacy_pay_to_open")); + } + + #[test] + fn rejects_unknown_type() { + let csv = "date,id,type,amount_msat,fee_credit_msat,mining_fee_sat,service_fee_msat,payment_hash,tx_id\n\ + 2024-01-01T00:00:00.000Z,a,fee_bumping,1000,0,0,0,,\n"; + let err = PhoenixdCsv::from_str(csv).unwrap().list_transactions().expect_err("should reject unknown type"); + assert!(err.to_string().contains("unsupported phoenixd transaction type: fee_bumping")); + } + + /// Receives accumulate fee credits, a channel opening consumes them, then a + /// send drains the balance to zero. account_balance = channel + fee_credit. + /// The receive is grossed up by the net fee (gross − credits consumed); + /// the fee entry shows the gross fee. + #[test] + fn fee_credit_drain_to_zero() { + // R1: receive 0 msat, earn 503_000 msat credit → 503 sat income. + // R2: channel opening with amount_msat=300_000_000. + // Gross fee = 18_364 sat mining + 23_000_000 msat service = 41_364_000 msat. + // Credit consumed = 503_000 msat. Net fee = 40_861_000 msat. + // Invoice (receive) = 300_000_000 + 40_861_000 = 340_861_000 → 340_861 sat. + // Fee = gross 41_364_000 → 41_364 sat. + // Account Δ = 340_861 − 41_364 = 299_497 sat. + // Accounting: 503 + 299_497 = 300_000. ✓ + // S1: send -300_000_000 msat to drain to 0. + let csv = "\ +date,id,type,amount_msat,fee_credit_msat,mining_fee_sat,service_fee_msat,payment_hash,tx_id +2024-01-01T00:00:00.000Z,r1,lightning_received,0,503000,0,0,r1hash, +2024-01-02T00:00:00.000Z,r2,lightning_received,300000000,-503000,18364,23000000,r2hash,r2txid +2024-01-03T00:00:00.000Z,s1,lightning_sent,-300000000,0,0,0,s1hash,\n"; + + let source = PhoenixdCsv::from_str(csv).unwrap(); + let txs = source.list_transactions().unwrap(); + + // Accounting balance matches: credits earned and consumed cancel out. + assert_eq!(source.wallet_balance_sats(), 0); + + // r1 → 503 sat (credit income) + // r2 → 340_861 sat receive + 41_364 sat gross fee + // s1 → −300_000 sat send + let balance: i64 = txs.iter().map(|tx| { + let fee = tx.fee_sats.unwrap_or(0).unsigned_abs() as i64; + tx.amount_sats - if tx.category == TxCategory::Send { fee } else { 0 } + }).sum(); + + assert_eq!(balance, 0, "sat balance must be zero when the actual msat balance is zero"); + } + + /// When a channel opening coincides with fee-credit consumption and a + /// BTCPay shortfall payment, both credits are real: the grossed-up + /// receive shows what the customer paid for the first invoice, and the + /// shortfall payment is genuine additional income. + #[test] + fn split_payment_channel_opening() { + // P1: earlier payments earning fee credit = 13739 sat. + // P2: channel-opening. gross_fee = 1027*1000 + 21122000 = 22149000. + // credit consumed = 13739000. net_fee = 8410000. + // receive = 3825000 + 8410000 = 12235000 → 12235 sat. + // fee = gross 22149000 → 22149 sat. + // P3: BTCPay shortfall payment, plain receive 8410 sat. + // (BTCPay saw only 3825 credited, requested 8410 more.) + // Accounting: 13739 + 12235 − 22149 + 8410 = 12235. ✓ + let csv = "\ +date,id,type,amount_msat,fee_credit_msat,mining_fee_sat,service_fee_msat,payment_hash,tx_id +2026-01-01T00:00:00.000Z,p1,lightning_received,0,13739000,0,0,hash_a, +2026-01-01T00:00:40.000Z,p2,lightning_received,3825000,-13739000,1027,21122000,hash_b,on_chain_tx +2026-01-01T00:01:20.000Z,p3,lightning_received,8410000,0,0,0,hash_c,\n"; + + let source = PhoenixdCsv::from_str(csv).unwrap(); + let txs = source.list_transactions().unwrap(); + + // P1: credit income 13739 sat. + assert_eq!(txs[0].amount_sats, 13739); + assert_eq!(txs[0].category, TxCategory::Receive); + + // P2 receive: grossed up 3825 + 8410 = 12235 sat. + assert_eq!(txs[1].amount_sats, 12235); + assert_eq!(txs[1].category, TxCategory::Receive); + + // P2 fee: gross 22149 sat. + assert_eq!(txs[2].amount_sats, -22149); + assert_eq!(txs[2].category, TxCategory::Send); + assert!(matches!(txs[2].kind, crate::import::TxKind::LiquidityPurchase { .. })); + + // P3: BTCPay shortfall payment 8410 sat. + assert_eq!(txs[3].amount_sats, 8410); + assert_eq!(txs[3].category, TxCategory::Receive); + + // Total: 13739 + 12235 − 22149 + 8410 = 12235. + assert_eq!(source.wallet_balance_sats(), 12235); + let balance: i64 = txs.iter().map(|tx| { + let fee = tx.fee_sats.unwrap_or(0).unsigned_abs() as i64; + tx.amount_sats - if tx.category == TxCategory::Send { fee } else { 0 } + }).sum(); + assert_eq!(balance, 12235, "accounting must match wallet balance"); + } +} diff --git a/tests/fixtures/phoenixd_sample.csv b/tests/fixtures/phoenixd_sample.csv new file mode 100644 index 0000000..bd665c8 --- /dev/null +++ b/tests/fixtures/phoenixd_sample.csv @@ -0,0 +1,8 @@ +date,id,type,amount_msat,fee_credit_msat,mining_fee_sat,service_fee_msat,payment_hash,tx_id +2024-03-19T21:55:49.133Z,462f75bf-f2bd-454c-bd1f-28f3524bc6c5,lightning_received,500000,0,0,0,462f75bff2bd054c7d1f28f3524bc6c5e1022f36369d4e0a35324674fd2b6922, +2024-03-19T22:43:37.109Z,a270159f-05d2-4346-af15-612357e6ea92,swap_out,-15579000,0,579,0,,eeae7f8384e2b703196405f0873d582179816ba7d27ae616cba559380ed789d2 +2024-03-19T22:45:07.577Z,204ba635-b0cd-4e7c-8604-3ade67cf11d2,lightning_received,0,503000,0,0,204ba635b0cdce7c46043ade67cf11d2f34bef65629babec29a841ec0c2341c6, +2024-03-19T23:06:41.733Z,13cf3d19-0504-4f0c-bb3e-073e3da0ff21,channel_close,-308594000,0,0,0,,5f6cb87af4ac2ece6f4e075c1e5b7c08dcb65046ef6baf497cb2774c52a9db50 +2024-04-09T14:24:19.926Z,bf907f7d-d5c6-4adf-b0cd-75837d91bb37,lightning_sent,-1242936,0,0,8936,f6ce6b8bb04a6639cb93d0ec5d3ed1eb33448e60ac91e82f90ff76fbe84f36e1, +2024-04-09T14:24:39.723Z,94bc8a03-eeea-431c-b0a2-6ca00c435dfb,lightning_sent,-3012004000,0,0,12004000,564305d9c91a4271d27338572b745cdac48221e9fb47b0209e2981b13ea682f1, +2024-10-10T12:51:52.939Z,6fd52908-a7ec-4de7-a012-c8b2fb1ecff0,lightning_received,300000000,0,18364,23000000,6fd52908a7ec4de7e012c8b2fb1ecff04e9a569f766eb0d46acfeca5940f85f5,fa1ee3f08b271456e922856ee6c34d6298ad58d34289be9337abc66971ea4220 From f405fea95b4102c6dd28e3ea505386e8ee6fc43c Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 24 Mar 2026 10:03:13 +0100 Subject: [PATCH 6/6] export: add --phoenixd-csv source with integration test Add --phoenixd-csv and --nodeid flags to the export command. When --phoenixd-csv is given, transactions are read from a phoenixd CSV export instead of Bitcoin Core RPC. The node ID is cached in .cache/phoenixd_node.txt after the first run. Refactor run() into run_bitcoin_core_source() and run_phoenixd_source() that return a common (iban, transactions, wallet_balance, descriptors, bank_name) tuple. The integration test uses real CSV rows from the upstream phoenixd testnet sample database and real 2024 Kraken EUR/BTC daily rates. CI validates the generated XML against the CAMT.053 XSD. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 + .gitignore | 3 +- README.md | 18 +- src/commands/export.rs | 143 ++++++-- tests/fixtures/README.md | 45 +++ tests/fixtures/phoenixd_sample_camt053.xml | 373 +++++++++++++++++++++ tests/fixtures/rates_2024.json | 368 ++++++++++++++++++++ tests/phoenixd.rs | 104 ++++++ 8 files changed, 1017 insertions(+), 39 deletions(-) create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/phoenixd_sample_camt053.xml create mode 100644 tests/fixtures/rates_2024.json create mode 100644 tests/phoenixd.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9964c70..bc25416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,5 @@ jobs: run: cargo test - name: Validate CAMT.053 Fixture run: xmllint --schema tests/fixtures/camt.053.001.02.xsd tests/fixtures/salary_2025_camt053.xml --noout + - name: Validate Phoenixd CAMT.053 Fixture + run: xmllint --schema tests/fixtures/camt.053.001.02.xsd tests/fixtures/phoenixd_sample_camt053.xml --noout diff --git a/.gitignore b/.gitignore index eb59da9..5190fad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ AGENTS.md .claude -# Symlink to Bitcoin Core +# Symlink to applications bitcoin-core +phoenixd diff --git a/README.md b/README.md index 1c3c210..7391d04 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,20 @@ But it does mean VWAP values can be constructed as far back as late 2013. Export wallet transactions to CAMT.053 accounting format. -Reads transactions from a Bitcoin Core wallet via JSON-RPC, converts them to a CAMT.053 XML bank statement. Supports fiat conversion at spot rates, optional FIFO realized gain/loss entries, and mark-to-market year-end reconciliation. If the output file already exists, new transactions are appended (deduplication by entry reference). +Reads transactions from a [Bitcoin Core](https://bitcoincore.org/en/download/) wallet via JSON-RPC or a [phoenixd](https://phoenix.acinq.co/server) CSV export, converts them to a CAMT.053 XML bank statement. Supports fiat conversion at spot rates, optional FIFO realized gain/loss entries, and mark-to-market year-end reconciliation. If the output file already exists, new transactions are appended (deduplication by entry reference). ```bash +# Bitcoin Core wallet (via JSON-RPC) cargo run -- export --country NL --wallet mywallet --datadir ~/.bitcoin --output statement.xml + +# Phoenixd Lightning wallet (via CSV export) +phoenix-cli exportcsv +cargo run -- export --country FR --phoenixd-csv payments.csv --nodeid 03864e... --output lightning.xml ``` +The `--nodeid` is only needed on first run; it is cached in `.cache/phoenixd_node.txt`. +Get it with `phoenix-cli getinfo`. + Key options: - `--country ` — IBAN country code, e.g. NL (required; env: `IBAN_COUNTRY`) - `--wallet ` — Bitcoin Core wallet name (auto-detected if only one wallet is loaded) @@ -81,10 +89,13 @@ Key options: - `--output ` — output file path (appends if file exists) - `--start-date ` — only include transactions from this date - `--candle ` — Kraken candle interval (`DEFAULT_CANDLE_MINUTES` or `1440` by default) +- `--fee-threshold-cents ` — fold fees below this threshold into the parent entry description (default: 1) +- `--phoenixd-csv ` — use a phoenixd CSV export as the transaction source (instead of Bitcoin Core RPC) +- `--nodeid ` — phoenixd node public key (cached after first use; find it with `phoenix-cli getinfo`) - `--datadir ` — Bitcoin Core data directory (for cookie auth) - `--chain ` — chain: main, testnet3, testnet4, signet, regtest (default: main) -The IBAN in the output is generated deterministically from the wallet's master fingerprint. The bank code is `XBTC` on mainnet, `TBTC` on test networks. The BIC in the XML servicer field is derived from the IBAN (e.g. `XBTCNL2A`). +The IBAN in the output is generated deterministically from the wallet's master fingerprint (on-chain) or the node public key (Lightning). The bank code is `XBTC` on mainnet, `TBTC` on test networks, and `LNBT` for Lightning. The BIC in the XML servicer field is derived from the IBAN (e.g. `XBTCNL2A` or `LNBTFR2A`). The generated XML conforms to the CAMT.053.001.02 schema (ISO 20022) and validates against the official XSD. Each entry contains enough information to map back to the original Bitcoin transaction (block hash, txid, vout). @@ -236,5 +247,6 @@ Licensed under the MIT License. See `LICENSE` for details. - `src/exchange_rate.rs` — ExchangeRateProvider trait and KrakenProvider - `src/export/camt053.rs` — CAMT.053 XML generation and parsing - `src/import/bitcoin_core_rpc.rs` — Bitcoin Core JSON-RPC client -- `src/iban.rs` — deterministic IBAN from wallet fingerprint +- `src/import/phoenixd_csv.rs` — phoenixd CSV export parser +- `src/iban.rs` — deterministic IBAN from wallet fingerprint or node ID - `tests/regtest/` — regtest integration test infrastructure diff --git a/src/commands/export.rs b/src/commands/export.rs index 4771923..27c1839 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -2,7 +2,7 @@ use std::env; use std::collections::HashSet; use std::fs::File; use std::io::BufWriter; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use chrono::{Duration, NaiveDate}; @@ -15,10 +15,11 @@ use crate::export::camt053::Camt053ParseResult; use crate::exchange_rate::KrakenProvider; use crate::export::camt053::Camt053Exporter; use crate::export::AccountingExporter; -use crate::iban::iban_from_fingerprint; +use crate::iban::{iban_from_fingerprint, iban_from_node_id}; use crate::import::WalletTransaction; use crate::import::TransactionSource; use crate::import::bitcoin_core_rpc::BitcoinCoreRpc; +use crate::import::phoenixd_csv::PhoenixdCsv; pub const SUBCOMMAND_NAME: &str = "export"; @@ -39,6 +40,8 @@ options: --bank-name Bank/institution name (default: Bitcoin Core - ) --candle Kraken candle interval (default: DEFAULT_CANDLE_MINUTES or 1440) --fee-threshold-cents Fold fees below this threshold into the parent entry description (default: 1) + --phoenixd-csv Use a Phoenixd CSV export as the transaction source + --nodeid Phoenixd node public key (from: phoenix-cli getinfo); cached in .cache/phoenixd_node.txt --ignore-balance-mismatch Warn instead of error on forward/backward balance mismatch"; #[derive(Debug, PartialEq, Eq)] @@ -56,6 +59,8 @@ pub struct ExportArgs { pub candle_override_minutes: Option, pub bank_name: Option, pub ignore_balance_mismatch: bool, + pub phoenixd_csv: Option, + pub node_id: Option, pub fee_threshold_cents: Option, } @@ -83,37 +88,12 @@ enum ExistingMergeMode { } pub fn run(args: ExportArgs) -> Result<()> { - // Resolve wallet name: use provided, or auto-detect the single loaded wallet - let wallet = match args.wallet { - Some(w) => w, - None => { - let rpc_url = crate::import::bitcoin_core_rpc::rpc_url_for_chain(&args.chain)?; - let cookie_path = crate::import::bitcoin_core_rpc::cookie_path(&args.datadir, &args.chain); - let cookie = std::fs::read_to_string(&cookie_path) - .with_context(|| format!("failed to read cookie file at {}", cookie_path.display()))?; - let wallets = BitcoinCoreRpc::list_wallets(&rpc_url, &cookie)?; - match wallets.len() { - 0 => bail!("no wallets loaded; specify --wallet"), - 1 => wallets.into_iter().next().unwrap(), - n => bail!("{n} wallets loaded ({}) — specify --wallet", wallets.join(", ")), - } - } - }; - - let rpc = BitcoinCoreRpc::new(&wallet, &args.datadir, &args.chain)?; - let fingerprint = rpc.get_fingerprint()?; - let iban = iban_from_fingerprint(&fingerprint, &args.country, &args.chain)?; - eprintln!("Virtual IBAN: {iban}"); - - let mut transactions = rpc.list_transactions()?; - let wallet_balance_sats = rpc.get_balance()?; - - // Collect receive addresses and fetch matching watch-only descriptors - let receive_addresses: std::collections::HashSet = transactions.iter() - .filter(|tx| tx.category == crate::import::TxCategory::Receive) - .map(|tx| tx.address.clone()) - .collect(); - let descriptors = rpc.get_receive_descriptors(&receive_addresses)?; + let (iban, mut transactions, wallet_balance_sats, descriptors, bank_name_default) = + if let Some(ref csv_path) = args.phoenixd_csv { + run_phoenixd_source(csv_path, &args)? + } else { + run_bitcoin_core_source(&args)? + }; let app_config = AppConfig::from_env()?; let candle_minutes = resolve_candle_minutes( @@ -182,7 +162,7 @@ pub fn run(args: ExportArgs) -> Result<()> { .map(|plan| plan.existing_entry_refs.clone()) .unwrap_or_default(); - let bank_name = Some(args.bank_name.unwrap_or_else(|| format!("Bitcoin Core - {wallet}"))); + let bank_name = Some(args.bank_name.unwrap_or(bank_name_default)); let config = AccountingConfig { fiat_mode: args.fiat_mode, @@ -203,10 +183,11 @@ pub fn run(args: ExportArgs) -> Result<()> { wallet_balance_sats: if existing_plan .as_ref() .is_some_and(|plan| plan.merge_mode == ExistingMergeMode::Prepend) + || transactions.is_empty() { None } else { - Some(wallet_balance_sats) + wallet_balance_sats }, ignore_balance_mismatch: args.ignore_balance_mismatch, fee_threshold_cents: args.fee_threshold_cents.unwrap_or(1), @@ -283,6 +264,84 @@ pub fn run(args: ExportArgs) -> Result<()> { Ok(()) } +/// Return type for the two source functions: +/// (iban, transactions, wallet_balance_sats, descriptors, bank_name_default) +type SourceResult = (String, Vec, Option, Vec, String); + +fn run_bitcoin_core_source(args: &ExportArgs) -> Result { + let wallet = match &args.wallet { + Some(w) => w.clone(), + None => { + let rpc_url = crate::import::bitcoin_core_rpc::rpc_url_for_chain(&args.chain)?; + let cookie_path = crate::import::bitcoin_core_rpc::cookie_path(&args.datadir, &args.chain); + let cookie = std::fs::read_to_string(&cookie_path) + .with_context(|| format!("failed to read cookie file at {}", cookie_path.display()))?; + let wallets = BitcoinCoreRpc::list_wallets(&rpc_url, &cookie)?; + match wallets.len() { + 0 => bail!("no wallets loaded; specify --wallet"), + 1 => wallets.into_iter().next().unwrap(), + n => bail!("{n} wallets loaded ({}) — specify --wallet", wallets.join(", ")), + } + } + }; + + let rpc = BitcoinCoreRpc::new(&wallet, &args.datadir, &args.chain)?; + let fingerprint = rpc.get_fingerprint()?; + let iban = iban_from_fingerprint(&fingerprint, &args.country, &args.chain)?; + eprintln!("Virtual IBAN: {iban}"); + + let transactions = rpc.list_transactions()?; + let wallet_balance_sats = Some(rpc.get_balance()?); + + let receive_addresses: std::collections::HashSet = transactions.iter() + .filter(|tx| tx.category == crate::import::TxCategory::Receive) + .map(|tx| tx.address.clone()) + .collect(); + let descriptors = rpc.get_receive_descriptors(&receive_addresses)?; + + let bank_name_default = format!("Bitcoin Core - {wallet}"); + + Ok((iban, transactions, wallet_balance_sats, descriptors, bank_name_default)) +} + +fn phoenixd_node_cache_path() -> std::path::PathBuf { + std::path::PathBuf::from(crate::exchange_rate::CACHE_DIR).join("phoenixd_node.txt") +} + +fn run_phoenixd_source(csv_path: &Path, args: &ExportArgs) -> Result { + let source = PhoenixdCsv::from_path(csv_path)?; + let wallet_balance_sats = source.wallet_balance_sats(); + let transactions = source.list_transactions()?; + + // Resolve node ID: CLI flag > cache file + let node_id = match &args.node_id { + Some(id) => { + let path = phoenixd_node_cache_path(); + std::fs::create_dir_all(crate::exchange_rate::CACHE_DIR).ok(); + std::fs::write(&path, id) + .with_context(|| format!("failed to cache node ID at {}", path.display()))?; + id.clone() + } + None => { + let path = phoenixd_node_cache_path(); + std::fs::read_to_string(&path) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!( + "no node ID available; pass --nodeid (find it with: phoenix-cli getinfo)" + ))? + } + }; + + let iban = iban_from_node_id(&node_id, &args.country)?; + eprintln!("Virtual IBAN: {iban}"); + + let bank_name_default = format!("Phoenixd - {node_id}"); + + Ok((iban, transactions, Some(wallet_balance_sats), vec![], bank_name_default)) +} + fn quote_currency_from_pair(pair: &str) -> String { if pair.len() >= 3 { pair[pair.len() - 3..].to_uppercase() @@ -426,6 +485,8 @@ where let mut candle_minutes: Option = None; let mut bank_name: Option = None; let mut ignore_balance_mismatch = false; + let mut phoenixd_csv: Option = None; + let mut node_id: Option = None; let mut fee_threshold_cents: Option = None; while let Some(arg) = args.next() { @@ -468,6 +529,12 @@ where "--bank-name" => { bank_name = Some(args.next().ok_or_else(|| anyhow::anyhow!("--bank-name requires a value\n\n{usage}"))?); } + "--phoenixd-csv" => { + phoenixd_csv = Some(PathBuf::from(args.next().ok_or_else(|| anyhow::anyhow!("--phoenixd-csv requires a value\n\n{usage}"))?)); + } + "--nodeid" => { + node_id = Some(args.next().ok_or_else(|| anyhow::anyhow!("--nodeid requires a value\n\n{usage}"))?); + } "--ignore-balance-mismatch" => ignore_balance_mismatch = true, "--fee-threshold-cents" => { let val = args.next().ok_or_else(|| anyhow::anyhow!("--fee-threshold-cents requires a value\n\n{usage}"))?; @@ -485,6 +552,8 @@ where "--output" => output = Some(PathBuf::from(value)), "--candle" => candle_minutes = Some(crate::common::parse_candle_interval_minutes(value, "--candle")?), "--bank-name" => bank_name = Some(value.to_owned()), + "--phoenixd-csv" => phoenixd_csv = Some(PathBuf::from(value)), + "--nodeid" => node_id = Some(value.to_owned()), "--fee-threshold-cents" => { fee_threshold_cents = Some(value.parse::().with_context(|| format!("invalid --fee-threshold-cents: {value}"))?); } @@ -549,6 +618,8 @@ where candle_override_minutes: candle_minutes, bank_name, ignore_balance_mismatch, + phoenixd_csv, + node_id, fee_threshold_cents, }) } @@ -599,6 +670,8 @@ mod tests { candle_override_minutes: None, bank_name: None, ignore_balance_mismatch: false, + phoenixd_csv: None, + node_id: None, fee_threshold_cents: None, } ); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..c1e3263 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,45 @@ +# Test fixtures + +## camt.053.001.02.xsd + +The ISO 20022 CAMT.053.001.02 XML schema, used by CI to validate `salary_2025_camt053.xml` with `xmllint`. + +## coinbase_cache.json + +A cache of proof-of-work solutions for regtest coinbase transactions. Generated and updated automatically by the integration test (`cargo test --test regtest`) when new blocks are mined via IPC. Committed so subsequent runs skip the CPU-intensive mining step. + +## kraken_xbteur_2026-03-21_trades.csv + +Real XBT/EUR trade data for 2026-03-21 fetched from the Kraken public trade history API (format: `timestamp,price,volume`). Used by the `cache_rates` unit test to verify that the VWAP-candle builder reproduces the expected hourly candles from real market data. Fetched with `tmp/fetch_trades.py`. + +## mock_rates_2025.json + +Deterministic mock EUR/BTC daily prices for the full year 2025, seeded with a fixed RNG. Generated automatically by the integration test on first run if not present, then committed for repeatability. Provides the exchange-rate input for the `salary_scenario_2025` integration test. + +## phoenixd_sample.csv + +A subset of real CSV rows exported by phoenixd v0.7.3 from the upstream [sample testnet database](https://github.com/ACINQ/phoenixd/blob/v0.7.3/src/commonTest/resources/sampledbs/v3/phoenix.testnet.03be9f.db). The full 770-row export was generated by running the upstream [`CsvExportTestsCommon`](https://github.com/ACINQ/phoenixd/blob/v0.7.3/src/commonTest/kotlin/fr/acinq/phoenixd/db/CsvExportTestsCommon.kt) test, then trimmed to 7 representative rows covering: `lightning_received` (plain, fee-credit, and channel-opening), `lightning_sent`, `swap_out`, and `channel_close`. + +The CSV column layout and `type` enum values are defined in [`WalletPaymentCsvWriter.kt`](https://github.com/ACINQ/phoenixd/blob/v0.7.3/src/commonMain/kotlin/fr/acinq/phoenixd/csv/WalletPaymentCsvWriter.kt). The parser rejects any type not in the whitelist (`lightning_received`, `lightning_sent`, `swap_out`, `channel_close`, `liquidity_purchase`). + +### Regenerating + +Requires Java 21. From the phoenixd submodule (or a fresh clone at the same tag): + +```sh +cd phoenixd +# Patch the test to write to a known output path: +sed -i 's|Path(testdir, "export.csv")|Path("/tmp/phoenixd_export.csv")|' \ + src/commonTest/kotlin/fr/acinq/phoenixd/db/CsvExportTestsCommon.kt +./gradlew jvmTest --tests '*CsvExportTestsCommon*' --no-daemon +# /tmp/phoenixd_export.csv now contains the full export (770 rows). +# Select representative rows and copy to the fixture location. +``` + +## rates_2024.json + +Real EUR/BTC daily VWAP rates for all 366 days of 2024, extracted from Kraken `XXBTZEUR` 1440-minute candles. Indexed by day-of-year (element 0 = Jan 1). Used by the `phoenixd_csv_export_produces_valid_camt053` integration test. + +## salary_2025_camt053.xml + +A complete CAMT.053.001.02 XML bank statement for a simulated 12-month salary scenario in 2025, generated by the integration test. Used by CI as a schema-validation target (`xmllint`) and serves as a human-readable example of the tool's output. Regenerate it by running `cargo test --test regtest`. diff --git a/tests/fixtures/phoenixd_sample_camt053.xml b/tests/fixtures/phoenixd_sample_camt053.xml new file mode 100644 index 0000000..ef0469d --- /dev/null +++ b/tests/fixtures/phoenixd_sample_camt053.xml @@ -0,0 +1,373 @@ + + + + + STMT-2024-10-10 + 2024-10-10T00:00:00 + + 1 + true + + + + STMT-2024-10-10 + 1 + 2024-10-10T00:00:00 + + + FR12LNBT0059133680 + + EUR + + + LNBTFR2A + Phoenixd + + + + + + + OPBD + + + 0.00 + CRDT +
+
2024-03-19
+ +
+ + + + CLBD + + + 1970.28 + DBIT +
+
2024-10-10
+ +
+ + 0:462f75bf-f2bd-454c-b:0 + 0.29 + CRDT + BOOK + + 2024-03-19T21:55:49 + + + 2024-03-19T21:55:49 + + + + PMNT + + RCDT + OTHR + + + + + + + Received 0.00000500 BTC @ 58773.66 payment hash: 462f75bff2bd054c7d1f28f3524bc6c5e1022f36369d4e0a35324674fd2b6922 + + + + :462f75bf-f2bd-454c-bd1f-28f3524bc6c5:0 + + + 0:a270159f-05d2-4346-a:0 + 8.82 + DBIT + BOOK + + 2024-03-19T22:43:37 + + + 2024-03-19T22:43:37 + + + + PMNT + + ICDT + OTHR + + + + + + + Sent 0.00015000 BTC @ 58773.66 + + + + :a270159f-05d2-4346-af15-612357e6ea92:0 + + + :0:a270159f-05d2-4346-a:fee + 0.34 + DBIT + BOOK + + 2024-03-19T22:43:37 + + + 2024-03-19T22:43:37 + + + + PMNT + + ICDT + OTHR + + + + + + + Mining fee (579 sat) + + + + ::a270159f-05d2-4346-af15-612357e6ea92:fee + + + 0:204ba635-b0cd-4e7c-8:0 + 0.30 + CRDT + BOOK + + 2024-03-19T22:45:07 + + + 2024-03-19T22:45:07 + + + + PMNT + + RCDT + OTHR + + + + + + + Received 0.00000503 BTC @ 58773.66 payment hash: 204ba635b0cdce7c46043ade67cf11d2f34bef65629babec29a841ec0c2341c6 + + + + :204ba635-b0cd-4e7c-8604-3ade67cf11d2:0 + + + 0:13cf3d19-0504-4f0c-b:0 + 181.37 + DBIT + BOOK + + 2024-03-19T23:06:41 + + + 2024-03-19T23:06:41 + + + + PMNT + + ICDT + OTHR + + + + + + + Sent 0.00308594 BTC @ 58773.66 + + + + :13cf3d19-0504-4f0c-bb3e-073e3da0ff21:0 + + + 0:bf907f7d-d5c6-4adf-b:0 + 0.80 + DBIT + BOOK + + 2024-04-09T14:24:19 + + + 2024-04-09T14:24:19 + + + + PMNT + + ICDT + OTHR + + + + + + + Sent 0.00001234 BTC @ 64578.80 payment hash: f6ce6b8bb04a6639cb93d0ec5d3ed1eb33448e60ac91e82f90ff76fbe84f36e1 + + + + :bf907f7d-d5c6-4adf-b0cd-75837d91bb37:0 + + + :0:bf907f7d-d5c6-4adf-b:fee + 0.01 + DBIT + BOOK + + 2024-04-09T14:24:19 + + + 2024-04-09T14:24:19 + + + + PMNT + + ICDT + OTHR + + + + + + + Lightning send fee (9 sat) + + + + ::bf907f7d-d5c6-4adf-b0cd-75837d91bb37:fee + + + 0:94bc8a03-eeea-431c-b:0 + 1937.36 + DBIT + BOOK + + 2024-04-09T14:24:39 + + + 2024-04-09T14:24:39 + + + + PMNT + + ICDT + OTHR + + + + + + + Sent 0.03000000 BTC @ 64578.80 payment hash: 564305d9c91a4271d27338572b745cdac48221e9fb47b0209e2981b13ea682f1 + + + + :94bc8a03-eeea-431c-b0a2-6ca00c435dfb:0 + + + :0:94bc8a03-eeea-431c-b:fee + 7.75 + DBIT + BOOK + + 2024-04-09T14:24:39 + + + 2024-04-09T14:24:39 + + + + PMNT + + ICDT + OTHR + + + + + + + Lightning send fee (12004 sat) + + + + ::94bc8a03-eeea-431c-b0a2-6ca00c435dfb:fee + + + 0:6fd52908-a7ec-4de7-a:0 + 188.41 + CRDT + BOOK + + 2024-10-10T12:51:52 + + + 2024-10-10T12:51:52 + + + + PMNT + + RCDT + OTHR + + + + + + + Received 0.00341364 BTC @ 55192.80 payment hash: 6fd52908a7ec4de7e012c8b2fb1ecff04e9a569f766eb0d46acfeca5940f85f5 + + + + :6fd52908-a7ec-4de7-a012-c8b2fb1ecff0:0 + + + 0:fa1ee3f08b271456e922:0 + 22.83 + DBIT + BOOK + + 2024-10-10T12:51:52 + + + 2024-10-10T12:51:52 + + + + PMNT + + ICDT + OTHR + + + + + + + Liquidity purchase mining fee (18364 sat) + service fee (23000 sat) + + + + :fa1ee3f08b271456e922856ee6c34d6298ad58d34289be9337abc66971ea4220:0 + +
+
+
diff --git a/tests/fixtures/rates_2024.json b/tests/fixtures/rates_2024.json new file mode 100644 index 0000000..88764bf --- /dev/null +++ b/tests/fixtures/rates_2024.json @@ -0,0 +1,368 @@ +[ + 39070.26, + 41229.36, + 39530.09, + 39928.04, + 39957.9, + 40108.4, + 40358.06, + 41786.22, + 42565.87, + 41971.41, + 43096.86, + 40359.5, + 39242.0, + 38940.87, + 39002.95, + 39463.11, + 39237.02, + 38398.94, + 37839.91, + 38202.35, + 38297.92, + 37147.84, + 36104.59, + 36747.48, + 36768.9, + 37926.8, + 38552.56, + 38983.94, + 39330.14, + 40083.24, + 39619.21, + 39224.4, + 39710.84, + 39942.49, + 39681.59, + 39931.13, + 39997.86, + 40350.31, + 41711.9, + 43536.65, + 43835.95, + 44492.73, + 45630.74, + 46055.74, + 47869.82, + 48500.42, + 48398.58, + 47568.58, + 48139.5, + 48414.59, + 48068.01, + 47462.57, + 47633.08, + 47123.53, + 47320.08, + 47696.59, + 48929.63, + 52034.75, + 55669.51, + 57552.06, + 57373.6, + 57166.9, + 57584.52, + 60569.92, + 59674.06, + 60931.02, + 61550.52, + 62294.53, + 62521.83, + 63384.73, + 65353.55, + 65297.05, + 66666.87, + 65469.58, + 62627.8, + 61909.36, + 61419.57, + 62177.63, + 58773.66, + 59236.33, + 61088.11, + 59435.89, + 59957.66, + 60851.79, + 63815.22, + 64919.93, + 64477.17, + 65518.56, + 64838.94, + 64891.79, + 65536.7, + 64554.04, + 61597.9, + 61266.3, + 62090.3, + 62086.9, + 63118.7, + 64177.1, + 66039.5, + 64578.8, + 64028.6, + 65647.9, + 64307.4, + 60518.5, + 61072.4, + 61224.8, + 59208.5, + 57973.3, + 58547.4, + 59861.7, + 60546.6, + 61055.8, + 62168.3, + 62191.9, + 61121.0, + 59724.3, + 59838.4, + 58982.4, + 59478.4, + 58343.9, + 57092.1, + 54049.9, + 54552.0, + 56675.9, + 59108.0, + 59400.6, + 59538.2, + 59044.7, + 57802.1, + 57431.2, + 57280.3, + 56680.6, + 56916.9, + 58035.9, + 57370.2, + 59278.9, + 60530.0, + 61076.7, + 61604.1, + 61549.3, + 63292.8, + 64961.8, + 64368.8, + 62799.6, + 62804.5, + 63920.2, + 63440.0, + 63832.0, + 62600.0, + 62569.5, + 63191.7, + 62454.3, + 62391.2, + 62465.6, + 63693.4, + 64289.2, + 65406.4, + 65313.6, + 65105.7, + 64324.0, + 64489.5, + 64684.5, + 62589.5, + 63669.5, + 62497.8, + 61935.9, + 61859.2, + 62109.0, + 61560.5, + 60545.6, + 60594.6, + 61026.1, + 59809.0, + 60098.8, + 59853.2, + 56869.9, + 57302.3, + 57423.9, + 57361.7, + 57079.9, + 56838.7, + 57484.2, + 58627.3, + 58043.3, + 56012.8, + 53881.5, + 50871.7, + 52805.5, + 52699.6, + 52176.9, + 53221.3, + 53976.1, + 53728.9, + 52584.0, + 53805.0, + 55361.1, + 57951.8, + 58746.5, + 59504.7, + 59039.0, + 60047.0, + 61445.5, + 61711.1, + 61865.4, + 60973.0, + 61026.6, + 59462.8, + 61979.3, + 62811.0, + 62414.1, + 63293.0, + 61340.3, + 60743.2, + 58981.2, + 58367.0, + 55965.3, + 54255.1, + 48225.4, + 51133.1, + 51643.6, + 53668.5, + 55659.1, + 55740.1, + 54752.1, + 54011.1, + 54620.5, + 54573.8, + 52778.3, + 53231.7, + 53857.5, + 54195.1, + 53000.1, + 54229.1, + 53867.0, + 54664.7, + 55874.1, + 57267.6, + 57295.1, + 56952.9, + 54932.9, + 53243.6, + 54242.5, + 53374.5, + 53464.5, + 52656.7, + 52854.4, + 53117.0, + 51452.4, + 51017.8, + 49424.5, + 49066.8, + 49104.5, + 50683.9, + 51753.3, + 51464.4, + 52637.0, + 53165.0, + 54106.7, + 53980.3, + 52427.3, + 53845.8, + 54095.9, + 56389.7, + 56656.8, + 56554.0, + 56432.5, + 57079.7, + 57098.0, + 56987.4, + 57842.9, + 58880.8, + 58912.2, + 58843.2, + 57215.5, + 56339.0, + 55395.5, + 54932.6, + 55985.8, + 56487.6, + 56974.1, + 57726.3, + 56921.9, + 56375.7, + 55192.8, + 56496.5, + 57591.1, + 57241.5, + 59654.2, + 60724.4, + 62098.3, + 61858.6, + 62778.0, + 62790.4, + 63031.6, + 62504.7, + 62254.1, + 61506.9, + 62417.3, + 62263.3, + 62043.2, + 62646.1, + 63742.9, + 66482.4, + 66493.1, + 65693.5, + 64368.7, + 64229.9, + 63259.5, + 62600.5, + 63576.1, + 68967.4, + 70090.4, + 71120.5, + 71240.6, + 73975.5, + 78893.6, + 81983.1, + 84933.3, + 84723.1, + 84943.1, + 86223.3, + 85360.6, + 86219.8, + 87407.9, + 89026.1, + 92368.9, + 94518.0, + 93886.5, + 92481.5, + 91526.8, + 88275.3, + 90145.4, + 90393.8, + 92040.8, + 91329.8, + 91699.7, + 91316.0, + 90627.2, + 92198.2, + 95598.5, + 94073.3, + 94363.8, + 94344.1, + 92602.6, + 91771.3, + 94953.0, + 96253.0, + 96101.1, + 96665.4, + 98082.9, + 100290.6, + 101543.7, + 98978.0, + 95989.7, + 91511.1, + 94272.1, + 91900.1, + 90997.8, + 92757.1, + 94744.0, + 92589.4, + 91385.9, + 90720.1, + 90426.8, + 89405.1, + 90840.8 +] diff --git a/tests/phoenixd.rs b/tests/phoenixd.rs new file mode 100644 index 0000000..1814426 --- /dev/null +++ b/tests/phoenixd.rs @@ -0,0 +1,104 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use btc_fiat_value::accounting::{AccountingConfig, build_statement}; +use btc_fiat_value::export::camt053::Camt053Exporter; +use btc_fiat_value::exchange_rate::ExchangeRateProvider; +use btc_fiat_value::iban::iban_from_node_id; +use btc_fiat_value::import::TransactionSource; +use btc_fiat_value::import::phoenixd_csv::PhoenixdCsv; + +/// ACINQ's public node ID — a real, publicly known Lightning node key used as +/// a stable test input for deterministic IBAN and XML generation. +const ACINQ_NODE_ID: &str = + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"; + +/// Daily real EUR/BTC rates for 2024 from tests/fixtures/rates_2024.json, +/// indexed by day-of-year. Extracted from Kraken XXBTZEUR 1440-minute candles. +struct RateProvider2024 { + rates: Vec, +} + +impl RateProvider2024 { + fn load() -> Result { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/rates_2024.json"); + let data = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let rates: Vec = serde_json::from_str(&data)?; + Ok(Self { rates }) + } +} + +impl ExchangeRateProvider for RateProvider2024 { + fn get_vwap(&self, timestamp: i64, _interval_minutes: u32) -> Result { + let dt = chrono::DateTime::::from_timestamp(timestamp, 0) + .context("invalid timestamp")?; + let start_of_2024 = chrono::NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + let day_index = (dt.signed_duration_since(start_of_2024).num_days()).max(0) as usize; + let rate = self.rates.get(day_index).copied() + .with_context(|| format!("no rate for day index {day_index}"))?; + Ok(rate) + } +} + +/// Export `phoenixd_sample.csv` to CAMT.053, persist the result as +/// `tests/fixtures/phoenixd_sample_camt053.xml`, then assert structural +/// invariants. CI validates the committed file against the official XSD. +#[test] +fn phoenixd_csv_export_produces_valid_camt053() -> Result<()> { + let csv_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/phoenixd_sample.csv"); + let csv = std::fs::read_to_string(&csv_path)?; + + let source = PhoenixdCsv::from_str(&csv)?; + let balance_sats = source.wallet_balance_sats(); + let mut transactions = source.list_transactions()?; + + // Sort by block_time so the statement is in chronological order. + transactions.sort_by_key(|tx| tx.block_time); + + let iban = iban_from_node_id(ACINQ_NODE_ID, "FR")?; + + let config = AccountingConfig { + fiat_mode: true, + mark_to_market: false, + fifo: false, + currency: "EUR".to_owned(), + account_iban: iban, + candle_interval_minutes: 1440, + start_date: None, + opening_balance_cents: 0, + bank_name: Some("Phoenixd".to_owned()), + wallet_balance_sats: Some(balance_sats), + ignore_balance_mismatch: false, + fee_threshold_cents: 1, + }; + + let statement = build_statement(&transactions, &RateProvider2024::load()?, &config)?; + + let mut output = Vec::new(); + btc_fiat_value::export::AccountingExporter::write(&Camt053Exporter, &statement, &mut output)?; + let xml = String::from_utf8(output)?; + + // Persist so CI can run xmllint over it. + let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/phoenixd_sample_camt053.xml"); + std::fs::write(&fixture_path, &xml)?; + + // Structural assertions. + assert!(xml.contains("urn:iso:std:iso:20022:tech:xsd:camt.053.001.02")); + assert!(xml.contains(""), "expected IBAN element"); + assert!(xml.contains("LNBT"), "expected LNBT bank code in IBAN"); + assert!(xml.contains("EUR"), "expected EUR currency"); + + let entry_count = xml.matches("").count(); + assert!(entry_count > 0, "expected at least one entry"); + eprintln!("Generated {entry_count} entries; saved to {}", fixture_path.display()); + + Ok(()) +}