Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ target/
AGENTS.md
.claude

# Symlink to Bitcoin Core
# Symlink to applications
bitcoin-core
phoenixd

18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CC>` — IBAN country code, e.g. NL (required; env: `IBAN_COUNTRY`)
- `--wallet <name>` — Bitcoin Core wallet name (auto-detected if only one wallet is loaded)
Expand All @@ -81,10 +89,13 @@ Key options:
- `--output <file>` — output file path (appends if file exists)
- `--start-date <YYYY-MM-DD>` — only include transactions from this date
- `--candle <minutes>` — Kraken candle interval (`DEFAULT_CANDLE_MINUTES` or `1440` by default)
- `--fee-threshold-cents <n>` — fold fees below this threshold into the parent entry description (default: 1)
- `--phoenixd-csv <file>` — use a phoenixd CSV export as the transaction source (instead of Bitcoin Core RPC)
- `--nodeid <id>` — phoenixd node public key (cached after first use; find it with `phoenix-cli getinfo`)
- `--datadir <path>` — Bitcoin Core data directory (for cookie auth)
- `--chain <name>` — 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).

Expand Down Expand Up @@ -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
96 changes: 78 additions & 18 deletions src/accounting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +27,9 @@ pub struct AccountingConfig {
pub wallet_balance_sats: Option<i64>,
/// 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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
});
}
}
}
}
Expand Down Expand Up @@ -395,6 +437,7 @@ pub fn build_statement(
opening_rate,
entries,
closing_balance_cents: balance_cents,
closing_balance_sats: balance_sats,
opening_date,
statement_date,
statement_id,
Expand Down Expand Up @@ -474,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<f64>) -> String {
fn format_description(label: &str, verb: &str, sats: i64, rate_cents_per_btc: Option<f64>, 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,
}
}

Expand Down Expand Up @@ -518,6 +567,8 @@ mod tests {
block_hash: "bb".repeat(32),
address: "bc1qtest".to_owned(),
label: String::new(),
payment_hash: None,
kind: TxKind::Default,
}
}

Expand All @@ -533,6 +584,8 @@ mod tests {
block_hash: "dd".repeat(32),
address: "bc1qother".to_owned(),
label: String::new(),
payment_hash: None,
kind: TxKind::Default,
}
}

Expand All @@ -552,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();
Expand Down Expand Up @@ -582,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();
Expand Down Expand Up @@ -612,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();
Expand Down Expand Up @@ -677,6 +733,7 @@ mod tests {
bank_name: None,
wallet_balance_sats: None,
ignore_balance_mismatch: false,
fee_threshold_cents: 1,
};

let provider = TwoRateProvider;
Expand Down Expand Up @@ -730,6 +787,7 @@ mod tests {
bank_name: None,
wallet_balance_sats: None,
ignore_balance_mismatch: false,
fee_threshold_cents: 1,
};

let provider = TwoRateProvider;
Expand Down Expand Up @@ -788,6 +846,7 @@ mod tests {
bank_name: None,
wallet_balance_sats: None,
ignore_balance_mismatch: false,
fee_threshold_cents: 1,
};

let provider = TwoRateProvider;
Expand Down Expand Up @@ -857,6 +916,7 @@ mod tests {
bank_name: None,
wallet_balance_sats: None,
ignore_balance_mismatch: false,
fee_threshold_cents: 1,
};

let provider = MultiRateProvider;
Expand Down
Loading
Loading