use btctax_core::event::*;
use btctax_core::identity::*;
use btctax_core::price::StaticPrices;
use btctax_core::project::{project, ProjectionConfig};
use proptest::prelude::*;
use rust_decimal::Decimal;
use time::macros::{datetime, offset};
fn wal_a() -> WalletId {
WalletId::Exchange {
provider: "cb".into(),
account: "m".into(),
}
}
fn wal_b() -> WalletId {
WalletId::SelfCustody {
label: "cold".into(),
}
}
#[derive(Debug, Clone)]
enum Step {
Acquire {
sat: i64,
cents: i64,
},
Dispose {
sat: i64,
cents: i64,
},
SelfXfer {
sat: i64,
fee: i64,
},
}
fn arb_step() -> impl Strategy<Value = Step> {
prop_oneof![
(1_000i64..5_000_000, 1i64..2_000_000)
.prop_map(|(sat, cents)| Step::Acquire { sat, cents }),
(1_000i64..5_000_000, 1i64..2_000_000)
.prop_map(|(sat, cents)| Step::Dispose { sat, cents }),
(1_000i64..5_000_000, 0i64..500).prop_map(|(sat, fee)| Step::SelfXfer { sat, fee }),
]
}
fn build(steps: &[Step]) -> Vec<LedgerEvent> {
let mut evs = Vec::new();
let (mut seq, ts) = (0u64, datetime!(2025-03-01 00:00:00 UTC));
for (i, s) in steps.iter().enumerate() {
match s {
Step::Acquire { sat, cents } => evs.push(LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(format!("A{i}"))),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(wal_a()),
payload: EventPayload::Acquire(Acquire {
sat: *sat,
usd_cost: Decimal::new(*cents, 2),
fee_usd: Decimal::ZERO,
basis_source: BasisSource::ExchangeProvided,
}),
}),
Step::Dispose { sat, cents } => evs.push(LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(format!("D{i}"))),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(wal_a()),
payload: EventPayload::Dispose(Dispose {
sat: *sat,
usd_proceeds: Decimal::new(*cents, 2),
fee_usd: Decimal::ZERO,
kind: DisposeKind::Sell,
}),
}),
Step::SelfXfer { sat, fee } => {
let (out_ref, in_ref) = (format!("O{i}"), format!("I{i}"));
evs.push(LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(out_ref.clone())),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(wal_a()),
payload: EventPayload::TransferOut(TransferOut {
sat: *sat,
fee_sat: Some(*fee),
dest_addr: None,
txid: None,
}),
});
evs.push(LedgerEvent {
id: EventId::import(Source::Swan, SourceRef::new(in_ref.clone())),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(wal_b()),
payload: EventPayload::TransferIn(TransferIn {
sat: *sat,
src_addr: None,
txid: None,
}),
});
seq += 1;
evs.push(LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: datetime!(2026-01-01 00:00:00 UTC),
original_tz: offset!(+00:00),
wallet: None,
payload: EventPayload::TransferLink(TransferLink {
out_event: EventId::import(Source::Coinbase, SourceRef::new(out_ref)),
in_event_or_wallet: TransferTarget::InEvent(EventId::import(
Source::Swan,
SourceRef::new(in_ref),
)),
}),
});
}
}
}
evs
}
fn arb_events() -> impl Strategy<Value = Vec<LedgerEvent>> {
prop::collection::vec(arb_step(), 1..8).prop_map(|s| build(&s))
}
fn arb_events_no_pending_basis() -> impl Strategy<Value = Vec<LedgerEvent>> {
let step = prop_oneof![
(1_000i64..5_000_000, 1i64..2_000_000)
.prop_map(|(sat, cents)| Step::Acquire { sat, cents }),
(1_000i64..5_000_000, 0i64..500).prop_map(|(sat, fee)| Step::SelfXfer { sat, fee }),
];
prop::collection::vec(step, 1..8).prop_map(|s| build(&s))
}
proptest! {
#[test]
fn conservation_holds_when_no_uncovered(evs in arb_events()) {
let st = project(&evs, &StaticPrices::default(), &ProjectionConfig::default());
let r = btctax_core::conservation_report(&st);
if !r.has_uncovered {
prop_assert_eq!(r.sigma_in, r.sigma_disposed + r.sigma_removed + r.sigma_held + r.sigma_fee_sats + r.sigma_pending);
prop_assert!(r.balanced);
}
}
#[test]
fn no_negative_remainders_ever(evs in arb_events()) {
let st = project(&evs, &StaticPrices::default(), &ProjectionConfig::default());
prop_assert!(st.lots.iter().all(|l| l.remaining_sat >= 0));
prop_assert!(st.holdings_by_wallet.values().all(|&h| h >= 0));
}
#[test]
fn sigma_lot_basis_conserved_through_feed_self_transfers(evs in arb_events_no_pending_basis()) {
let st = project(&evs, &StaticPrices::default(), &ProjectionConfig::default());
let acquired: Decimal = evs.iter().filter_map(|e| match &e.payload {
EventPayload::Acquire(a) => Some(a.usd_cost + a.fee_usd), _ => None,
}).sum();
let remaining: Decimal = st.lots.iter().map(|l| l.usd_basis).sum();
prop_assert_eq!(acquired, remaining);
}
}
#[test]
fn golden_kat_cross_boundary_conservation() {
use rust_decimal_macros::dec;
let wal_a = WalletId::Exchange {
provider: "cb".into(),
account: "m".into(),
};
let wal_b = WalletId::SelfCustody {
label: "cold".into(),
};
fn imp(
src_ref: &str,
ts: time::OffsetDateTime,
wallet: Option<WalletId>,
p: EventPayload,
) -> LedgerEvent {
LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(src_ref)),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet,
payload: p,
}
}
fn dec_ev(seq: u64, ts: time::OffsetDateTime, p: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: None,
payload: p,
}
}
let wa = Some(wal_a.clone());
let wb = Some(wal_b.clone());
let buy = imp(
"BUY",
datetime!(2024-06-01 00:00:00 UTC),
wa.clone(),
EventPayload::Acquire(Acquire {
sat: 200_000,
usd_cost: dec!(100.00),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
);
let income = imp(
"INCOME",
datetime!(2025-02-01 00:00:00 UTC),
wa.clone(),
EventPayload::Income(Income {
sat: 10_000,
usd_fmv: Some(dec!(5.00)),
fmv_status: FmvStatus::ExchangeProvided,
kind: IncomeKind::Interest,
business: false,
}),
);
let xfer_out = imp(
"XOUT",
datetime!(2025-03-01 00:00:00 UTC),
wa.clone(),
EventPayload::TransferOut(TransferOut {
sat: 80_000,
fee_sat: Some(200),
dest_addr: None,
txid: None,
}),
);
let xfer_in = imp(
"XIN",
datetime!(2025-03-01 00:00:00 UTC),
wb.clone(),
EventPayload::TransferIn(TransferIn {
sat: 80_000,
src_addr: None,
txid: None,
}),
);
let link = dec_ev(
1,
datetime!(2025-03-02 00:00:00 UTC),
EventPayload::TransferLink(TransferLink {
out_event: EventId::import(Source::Coinbase, SourceRef::new("XOUT")),
in_event_or_wallet: TransferTarget::InEvent(EventId::import(
Source::Coinbase,
SourceRef::new("XIN"),
)),
}),
);
let gift_out_tx = imp(
"GOUT",
datetime!(2025-04-01 00:00:00 UTC),
wb.clone(),
EventPayload::TransferOut(TransferOut {
sat: 40_000,
fee_sat: None,
dest_addr: None,
txid: None,
}),
);
let gift_dec = dec_ev(
2,
datetime!(2025-04-02 00:00:00 UTC),
EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: EventId::import(Source::Coinbase, SourceRef::new("GOUT")),
as_: OutflowClass::GiftOut,
principal_proceeds_or_fmv: dec!(20.00),
fee_usd: None,
donee: None,
}),
);
let sell = imp(
"SELL",
datetime!(2025-05-01 00:00:00 UTC),
wb.clone(),
EventPayload::Dispose(Dispose {
sat: 30_000,
usd_proceeds: dec!(15.00),
fee_usd: dec!(0),
kind: DisposeKind::Sell,
}),
);
let events = vec![
buy,
income,
xfer_out,
xfer_in,
link,
gift_out_tx,
gift_dec,
sell,
];
let st = project(
&events,
&StaticPrices::default(),
&ProjectionConfig::default(),
);
assert_eq!(st.disposals.len(), 1, "should have exactly one disposal");
assert!(!st.disposals[0].fee_mini_disposition);
let disp_sat: i64 = st.disposals[0].legs.iter().map(|l| l.sat).sum();
assert_eq!(disp_sat, 30_000);
assert_eq!(st.removals.len(), 1, "should have exactly one removal");
let rem_sat: i64 = st.removals[0].legs.iter().map(|l| l.sat).sum();
assert_eq!(rem_sat, 40_000);
assert_eq!(
st.income_recognized.len(),
1,
"should have exactly one income record"
);
assert_eq!(st.income_recognized[0].sat, 10_000);
assert_eq!(st.income_recognized[0].usd_fmv, dec!(5.00));
assert_eq!(st.holdings_by_wallet[&wal_a], 129_800, "wal_a holdings");
assert_eq!(st.holdings_by_wallet[&wal_b], 10_000, "wal_b holdings");
assert!(
!st.blockers
.iter()
.any(|b| b.kind == btctax_core::state::BlockerKind::UncoveredDisposal),
"unexpected UncoveredDisposal blocker"
);
let r = btctax_core::conservation_report(&st);
assert_eq!(r.sigma_in, 210_000, "sigma_in");
assert_eq!(r.sigma_disposed, 30_000, "sigma_disposed");
assert_eq!(r.sigma_removed, 40_000, "sigma_removed");
assert_eq!(r.sigma_fee_sats, 200, "sigma_fee_sats");
assert_eq!(r.sigma_pending, 0, "sigma_pending");
assert_eq!(r.sigma_held, 139_800, "sigma_held");
assert!(!r.has_uncovered, "has_uncovered must be false");
assert!(r.balanced, "conservation_report must be balanced");
assert_eq!(
r.sigma_in,
r.sigma_disposed + r.sigma_removed + r.sigma_held + r.sigma_fee_sats + r.sigma_pending,
"FR9 identity"
);
}