use crate::conservative_promote::{clamped_promote_year_saving, filed_basis_for};
use crate::conventions::{round_cents, TaxDate, SATS_PER_BTC};
use crate::event::{EventPayload, LedgerEvent};
use crate::identity::EventId;
use crate::optimize::{persistability, Persistability};
use crate::price::PriceProvider;
use crate::project::fold::fold;
use crate::project::resolve::{resolve, Op};
use crate::project::{in_force_methods, project, ProjectionConfig};
use crate::state::{Disposal, LedgerState, Removal, RemovalKind, Term};
use crate::tax::{compute_tax_year, Carryforward, TaxOutcome, TaxProfile, TaxTables};
use crate::{BasisSource, LotMethod, Usd, WalletId};
use std::collections::BTreeSet;
pub fn tranche_dip_advisory(disposal: &Disposal) -> Option<String> {
let lines: Vec<String> = disposal
.legs
.iter()
.filter(|l| l.basis_source == BasisSource::EstimatedConservative)
.map(|l| {
format!(
"Conservative-filing dip — {sat} sat of undocumented BTC (estimated acquired by {acq}) \
disposed on {date} at ${basis:.2} basis as filed, reporting ${gain:.2} gain. If you can \
substantiate a higher basis for these units, recording it lowers the reported gain \
(never below the amount you can document).",
sat = l.sat,
acq = l.acquired_at,
date = disposal.disposed_at,
basis = l.basis,
gain = l.gain,
)
})
.collect();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
pub fn method_inversion_advisory(
state: &LedgerState,
wallet: &WalletId,
method: LotMethod,
) -> Option<String> {
if method == LotMethod::Hifo {
return None;
}
let has_tranche = state.lots.iter().any(|l| {
l.wallet == *wallet
&& l.remaining_sat > 0
&& l.basis_source == BasisSource::EstimatedConservative
});
let has_documented = state.lots.iter().any(|l| {
l.wallet == *wallet
&& l.remaining_sat > 0
&& l.basis_source != BasisSource::EstimatedConservative
});
if has_tranche && has_documented {
Some(format!(
"Method-inversion warning — the in-force lot method for this wallet is {method:?} (not HIFO). \
Under it a disposal can draw a conservative-filing unit (at its as-filed basis) before your \
documented higher-basis units, maximizing the reported gain. Electing HIFO would draw the \
highest-basis units first — set it forward with `btctax config --set-forward-method hifo` \
(which binds 2025+ disposals); a forward election cannot change a PRE-2025-dated disposal, so \
for those elect HIFO as the pre-2025 method instead."
))
} else {
None
}
}
pub fn self_custody_nudge(state: &LedgerState) -> Option<String> {
let has_exchange_tranche = state.lots.iter().any(|l| {
l.remaining_sat > 0
&& l.basis_source == BasisSource::EstimatedConservative
&& matches!(l.wallet, WalletId::Exchange { .. })
});
if has_exchange_tranche {
Some(
"Self-custody nudge — undocumented (conservative-filing) units are held at an exchange. \
Holding your oldest / no-records units in self-custody keeps own-books specific \
identification available indefinitely (a broker's own-books identification is insufficient \
from 2027). Also consider electing HIFO so a disposal draws your highest-basis documented \
units before the conservative-filing units: `btctax config --set-forward-method hifo`."
.to_string(),
)
} else {
None
}
}
pub fn basis_methodology(state: &LedgerState, year: i32) -> Option<String> {
let mut items: Vec<String> = Vec::new();
let mut any_promoted = false;
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
for l in d
.legs
.iter()
.filter(|l| l.basis_source == BasisSource::EstimatedConservative)
{
let term = match l.term {
Term::LongTerm => "long-term",
Term::ShortTerm => "short-term",
};
let disclosure = if state.promoted_origins.contains(&l.lot_id.origin_event_id) {
any_promoted = true;
let clamp = if l.basis >= l.proceeds {
", limited so as not to report a loss"
} else {
""
};
format!(
" \u{2014} basis estimated at the minimum daily closing price over the attested \
acquisition window (Cohan){clamp}"
)
} else {
String::new()
};
items.push(format!(
" \u{2022} {sat} sat of undocumented BTC, estimated acquired by {acq} (the conservative \
window-end date), disposed on {date}, filed at ${basis:.2} basis ({term} holding \
period){disclosure}.",
sat = l.sat,
acq = l.acquired_at,
date = d.disposed_at,
basis = l.basis,
));
}
}
if items.is_empty() {
return None;
}
let promoted_clause = if any_promoted {
", or \u{2014} for a unit whose tranche has been promoted (noted on its line) \u{2014} the \
conservative estimated basis floor itself"
} else {
""
};
let mut out = format!(
"Basis methodology disclosure (conservative filing) \u{2014} tax year {year}\n\n\
For the units below, the actual cost basis could not be substantiated from available records, \
so a conservative estimate was filed \u{2014} the basis filed for each unit is shown on its \
line (the IRS `$0` fallback for unprovable basis, which cannot understate gain). A `>$0` amount \
reflects a documented on-chain fee basis re-homed onto that unit under \u{00a7}1011{promoted_clause}. \
Each unit's holding period is derived from its estimated acquisition date and reported as \
computed, never assumed. If records are later reconstructed, a higher documented basis may be \
substantiated \u{2014} lowering the reported gain, never below the amount that can be \
documented.\n\n"
);
out.push_str(&items.join("\n"));
Some(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Coverage {
Full,
Partial,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowRef {
pub min: Usd,
pub coverage: Coverage,
}
pub fn window_reference(
prices: &dyn PriceProvider,
start: TaxDate,
end: TaxDate,
) -> Option<WindowRef> {
let mut min: Option<Usd> = None;
let mut covered: u64 = 0;
let mut total: u64 = 0;
let mut day = start;
while day <= end {
total += 1;
if let Some(px) = prices.usd_per_btc(day) {
covered += 1;
min = Some(min.map_or(px, |m| if px < m { px } else { m }));
}
match day.next_day() {
Some(d) => day = d,
None => break, }
}
min.map(|m| WindowRef {
min: m,
coverage: if covered == total {
Coverage::Full
} else {
Coverage::Partial
},
})
}
pub fn tranche_broker_specific_id_advisory(
wallet: &WalletId,
sale_date: TaxDate,
selection_made: TaxDate,
) -> Option<String> {
match persistability(wallet, sale_date, selection_made) {
Persistability::ForbiddenBroker2027 => Some(format!(
"Broker specific-ID warning — this {year} disposal draws undocumented BTC held at an \
exchange (broker). From 2027, own-books specific identification is INSUFFICIENT at a \
broker (the Notices 2025-7/2026-20 own-books transitional relief runs only through \
2026-12-31): to specifically identify units the broker must be given the identification by \
the time of sale, otherwise the sale falls back to FIFO. To keep own-books specific-ID for \
no-records units, hold them in self-custody.",
year = sale_date.year(),
)),
_ => None,
}
}
fn tax_total(
events: &[LedgerEvent],
state: &LedgerState,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<Usd> {
match compute_tax_year(events, state, year, profile, tables) {
TaxOutcome::Computed(r) => Some(r.total_federal_tax_attributable),
TaxOutcome::NotComputable(_) => None,
}
}
#[allow(clippy::too_many_arguments)]
fn overpayment_delta_one(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
tranche_id: &EventId,
reference: Usd,
baseline: Usd,
) -> Usd {
if reference <= Usd::ZERO {
return Usd::ZERO; }
let mut res = resolve(events, prices, config);
let mut swapped = false;
for eff in res.timeline.iter_mut() {
if eff.id != *tranche_id || !matches!(eff.id, EventId::Decision { .. }) {
continue;
}
if let Op::Acquire(a) = &mut eff.op {
if a.basis_source == BasisSource::EstimatedConservative {
a.usd_cost = round_cents(reference * Usd::from(a.sat) / Usd::from(SATS_PER_BTC));
swapped = true;
}
}
}
if !swapped {
return Usd::ZERO; }
let with_state = fold(res, prices, config);
match tax_total(events, &with_state, year, profile, tables) {
Some(with_tax) => (baseline - with_tax).max(Usd::ZERO), None => Usd::ZERO,
}
}
#[allow(clippy::too_many_arguments)]
pub fn overpayment_delta(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
refs: &[(EventId, Usd)],
) -> Usd {
let baseline = match tax_total(
events,
&project(events, prices, config),
year,
profile,
tables,
) {
Some(t) => t,
None => return Usd::ZERO,
};
refs.iter()
.map(|(id, reference)| {
overpayment_delta_one(
events, prices, config, year, profile, tables, id, *reference, baseline,
)
})
.sum()
}
#[allow(clippy::too_many_arguments)]
pub fn overpayment_nudge_lines(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
if profile.is_none()
|| !events
.iter()
.any(|e| matches!(e.payload, EventPayload::DeclareTranche(_)))
{
return lines;
}
let baseline = match tax_total(
events,
&project(events, prices, config),
year,
profile,
tables,
) {
Some(t) => t,
None => return lines,
};
let mut any = false;
for e in events {
let EventPayload::DeclareTranche(t) = &e.payload else {
continue;
};
if state.promoted_origins.contains(&e.id) {
lines.push(format!(
"Promote status — the {ws}\u{2013}{we} tranche is already promoted to a filed basis \
floor; its basis is filed, so the overpayment nudge and the promote funnel no longer \
apply to it.",
ws = t.window_start,
we = t.window_end,
));
continue;
}
let Some(wr) = window_reference(prices, t.window_start, t.window_end) else {
continue; };
let delta = overpayment_delta_one(
events, prices, config, year, profile, tables, &e.id, wr.min, baseline,
);
if delta > Usd::ZERO {
any = true;
let mut line = format!(
"Overpayment nudge — reconstructing this {ws}\u{2013}{we} tranche and importing the \
records could save ~${saving} of federal tax this year, at the cost of a documented \
basis an examiner can question.",
ws = t.window_start,
we = t.window_end,
saving = delta.round_dp(0),
);
if wr.coverage == Coverage::Partial {
line.push_str(
" (Partial-window estimate: some days in the window had no price data, so the true \
saving may differ.)",
);
}
lines.push(line);
}
if let Ok(cf) = filed_basis_for(prices, t.sat, t.window_start, t.window_end) {
let clamped = clamped_promote_year_saving(
events,
prices,
config,
&e.id,
cf.filed_basis,
year,
profile,
tables,
);
if clamped > Usd::ZERO {
lines.push(format!(
"Promote-tranche funnel — promoting this {ws}\u{2013}{we} tranche to its filed \
window-low floor (${floor:.2}) could save ~${saving} of federal tax this year, \
quoted on the CLAMPED promoted gain (a sale below the floor files $0 gain, never a \
loss the promote cannot file): `btctax reconcile promote-tranche`.",
ws = t.window_start,
we = t.window_end,
floor = cf.filed_basis,
saving = clamped.round_dp(0),
));
}
}
}
if any {
lines.push(
"If any of these coins were inherited, their basis is reconstructable by law from the \
date-of-death fair market value \u{2014} no cost records needed (\u{00a7}1014(a); the \
holding period is automatically long-term, \u{00a7}1223(9))."
.to_string(),
);
if state
.lots
.iter()
.any(|l| l.basis_source == BasisSource::EstimatedConservative && l.remaining_sat > 0)
{
lines.push(format!(
"This figure covers only the conservative-filing units disposed in {year}; undisposed \
tranche units remain."
));
}
}
lines
}
#[allow(clippy::too_many_arguments)]
pub fn tranche_report_advisory(
state: &LedgerState,
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
if let Some(a) = tranche_dip_advisory(d) {
lines.push(a);
}
if let Some(w) = d
.legs
.iter()
.find(|l| l.basis_source == BasisSource::EstimatedConservative)
.map(|l| &l.wallet)
{
if let Some(a) = tranche_broker_specific_id_advisory(w, d.disposed_at, d.disposed_at) {
lines.push(a);
}
}
}
let tranche_wallets: Vec<WalletId> = state
.lots
.iter()
.filter(|l| l.remaining_sat > 0 && l.basis_source == BasisSource::EstimatedConservative)
.map(|l| l.wallet.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if !tranche_wallets.is_empty() {
if let Ok(as_of) = time::Date::from_calendar_date(year, time::Month::December, 31) {
let methods = in_force_methods(events, prices, config, as_of, &tranche_wallets);
for (w, m) in tranche_wallets.iter().zip(methods) {
if let Some(a) = method_inversion_advisory(state, w, m.method) {
lines.push(a);
}
}
}
}
if let Some(a) = self_custody_nudge(state) {
lines.push(a);
}
lines.extend(overpayment_nudge_lines(
events, state, prices, config, year, profile, tables,
));
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Promote,
Void,
}
fn carryforward_out_of(
events: &[LedgerEvent],
state: &LedgerState,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<Carryforward> {
match compute_tax_year(events, state, year, profile, tables) {
TaxOutcome::Computed(r) => Some(r.carryforward_out),
TaxOutcome::NotComputable(_) => None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn promote_prior_year_advisory(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
promote_id: &EventId,
direction: Direction,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
current: i32,
) -> Vec<String> {
let with_state = project(events, prices, config);
let without_events: Vec<LedgerEvent> = events
.iter()
.filter(|e| e.id != *promote_id)
.cloned()
.collect();
let without_state = project(&without_events, prices, config);
let (new_state, old_state) = match direction {
Direction::Promote => (&with_state, &without_state),
Direction::Void => (&without_state, &with_state),
};
let mut years: BTreeSet<i32> = BTreeSet::new();
for st in [&with_state, &without_state] {
for d in &st.disposals {
years.insert(d.disposed_at.year());
}
for r in &st.removals {
years.insert(r.removed_at.year());
}
}
years.retain(|y| *y < current);
let verb = match direction {
Direction::Promote => "Promoting this tranche",
Direction::Void => "Voiding this promotion",
};
let mut lines: Vec<String> = Vec::new();
for y in years {
let disp = |st: &LedgerState| -> Vec<Disposal> {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == y)
.cloned()
.collect()
};
let rem = |st: &LedgerState, k: RemovalKind| -> Vec<Removal> {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == k)
.cloned()
.collect()
};
let disp_changed = disp(new_state) != disp(old_state);
let don_changed =
rem(new_state, RemovalKind::Donation) != rem(old_state, RemovalKind::Donation);
let gift_changed = rem(new_state, RemovalKind::Gift) != rem(old_state, RemovalKind::Gift);
if !(disp_changed || don_changed || gift_changed) {
continue;
}
let gain = |st: &LedgerState| -> Usd {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == y)
.flat_map(|d| &d.legs)
.map(|l| l.gain)
.sum()
};
let ded = |st: &LedgerState| -> Usd {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == RemovalKind::Donation)
.filter_map(|r| r.claimed_deduction)
.sum()
};
let gift_basis = |st: &LedgerState| -> Usd {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == RemovalKind::Gift)
.flat_map(|r| &r.legs)
.map(|l| l.basis)
.sum()
};
let dgain = gain(new_state) - gain(old_state);
let dded = ded(new_state) - ded(old_state);
let dgift = gift_basis(new_state) - gift_basis(old_state);
let dtax = match (
tax_total(events, new_state, y, profile, tables),
tax_total(events, old_state, y, profile, tables),
) {
(Some(a), Some(b)) => Some(a - b),
_ => None,
};
let tax_sign = match dtax {
Some(d) => d.cmp(&Usd::ZERO),
None => (dgain - dded).cmp(&Usd::ZERO),
};
let mut frags: Vec<String> = Vec::new();
if disp_changed {
if dgain != Usd::ZERO {
frags.push(format!(
"{verb} changes year {y}'s reported capital gain/loss by ~${g} (a HIFO reorder of that \
year's disposals).",
g = dgain.abs().round_dp(0),
));
} else {
frags.push(format!(
"{verb} changes the Form 8949 acquisition date and holding-period detail of year {y}'s \
disposals (the reported gain is unchanged)."
));
}
}
if don_changed {
if dded != Usd::ZERO {
frags.push(format!(
"{verb} changes year {y}'s §170(e) charitable deduction by ~${d}.",
d = dded.abs().round_dp(0),
));
} else {
frags.push(format!(
"{verb} changes the Form 8283 donee and acquisition date records of year {y}'s \
donation(s) (the deduction amount is unchanged)."
));
}
}
match dtax {
Some(d) if d != Usd::ZERO => frags.push(format!(
"Its computed federal tax for {y} changes by ~${}.",
d.abs().round_dp(0),
)),
None if (disp_changed && dgain != Usd::ZERO) || (don_changed && dded != Usd::ZERO) => frags
.push(format!(
"Its federal tax for {y} is not separately computable here (no table/profile/blocked)."
)),
_ => {}
}
if disp_changed || don_changed {
match tax_sign {
std::cmp::Ordering::Less => frags.push(format!(
"This LOWERS year {y}'s tax; if {y} was already filed, claiming the reduction requires a \
Form 1040-X for {y} (Form 8275 attached), and any refund is limited by the §6511 \
statute of limitations (generally 3 years from filing / 2 years from payment)."
)),
std::cmp::Ordering::Greater => frags.push(format!(
"This RAISES year {y}'s tax; if {y} was already filed, correcting it requires a Form \
1040-X for {y} (Form 8275 attached) reporting additional tax, plus interest."
)),
std::cmp::Ordering::Equal => frags.push(format!(
"If year {y} was already filed, the corrected Form 8949/8283 detail belongs on an \
amended return for {y} (the tax itself is unchanged)."
)),
}
}
if (disp_changed && dgain != Usd::ZERO) || (don_changed && dded != Usd::ZERO) {
let cf_quote = match (
carryforward_out_of(events, new_state, y, profile, tables),
carryforward_out_of(events, old_state, y, profile, tables),
) {
(Some(n), Some(o)) if n.short != o.short || n.long != o.long => format!(
" (year {y}'s §1212(b) carryforward-out changes by short ~${s}, long ~${l})",
s = (n.short - o.short).abs().round_dp(0),
l = (n.long - o.long).abs().round_dp(0),
),
_ => String::new(),
};
frags.push(format!(
"Because year {y}'s net capital gain/loss or charitable deduction changed, its §1212(b) \
capital-loss carryforward and its §170(d) charitable carryover into later years may shift \
too, so the carryover-linked lines of later filed years may also require amendment{cf_quote}."
));
}
if gift_changed {
if dgift != Usd::ZERO {
frags.push(format!(
"{verb} changes the §1015 carryover basis passed to the donee for year {y}'s gift(s) by \
~${g} — donee-basis documentation only; the donor's own Form 1040 for {y} is \
unaffected, so no amended return is required.",
g = dgift.abs().round_dp(0),
));
} else {
frags.push(format!(
"{verb} changes the Form 8283 donee and acquisition date records of year {y}'s gift(s) \
(the §1015 carryover basis is unchanged) — donee-basis documentation only; the donor's \
own Form 1040 for {y} is unaffected."
));
}
}
lines.push(frags.join(" "));
}
lines
}
fn live_promote_ids(events: &[LedgerEvent], state: &LedgerState) -> Vec<EventId> {
let voided = voided_decision_targets(events);
events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::PromoteTranche(p)
if !voided.contains(&e.id) && state.promoted_origins.contains(&p.target) =>
{
Some(e.id.clone())
}
_ => None,
})
.collect()
}
fn voided_decision_targets(events: &[LedgerEvent]) -> BTreeSet<EventId> {
events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
_ => None,
})
.collect()
}
fn live_declare_ids(events: &[LedgerEvent], state: &LedgerState) -> Vec<EventId> {
let voided = voided_decision_targets(events);
events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::DeclareTranche(_)
if !voided.contains(&e.id) || state.promoted_origins.contains(&e.id) =>
{
Some(e.id.clone())
}
_ => None,
})
.collect()
}
fn decision_changed_years(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
decision_id: &EventId,
current: i32,
) -> BTreeSet<i32> {
let mut config = *config;
config.pseudo_reconcile = false;
let config = &config;
let with_state = project(events, prices, config);
let without_events: Vec<LedgerEvent> = events
.iter()
.filter(|e| e.id != *decision_id)
.cloned()
.collect();
let without_state = project(&without_events, prices, config);
let mut years: BTreeSet<i32> = BTreeSet::new();
for st in [&with_state, &without_state] {
for d in &st.disposals {
years.insert(d.disposed_at.year());
}
for r in &st.removals {
years.insert(r.removed_at.year());
}
}
years.retain(|y| *y < current);
years
.into_iter()
.filter(|&y| {
let disp = |st: &LedgerState| -> Vec<Disposal> {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == y)
.cloned()
.collect()
};
let rem = |st: &LedgerState, k: RemovalKind| -> Vec<Removal> {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == k)
.cloned()
.collect()
};
let disp_changed = disp(&with_state) != disp(&without_state);
let don_changed = rem(&with_state, RemovalKind::Donation)
!= rem(&without_state, RemovalKind::Donation);
let gift_changed =
rem(&with_state, RemovalKind::Gift) != rem(&without_state, RemovalKind::Gift);
disp_changed || don_changed || gift_changed
})
.collect()
}
pub fn flagged_years(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
_tables: &dyn TaxTables,
cfg: &ProjectionConfig,
current: i32,
) -> BTreeSet<i32> {
let mut years: BTreeSet<i32> = BTreeSet::new();
for promote_id in live_promote_ids(events, state) {
years.extend(decision_changed_years(
events,
prices,
cfg,
&promote_id,
current,
));
}
for declare_id in live_declare_ids(events, state) {
years.extend(decision_changed_years(
events,
prices,
cfg,
&declare_id,
current,
));
}
years
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{DeclareTranche, VoidDecisionEvent};
use time::macros::{date, datetime};
use time::UtcOffset;
fn dec_ev(seq: u64, payload: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: datetime!(2026-01-01 0:00 UTC),
original_tz: UtcOffset::UTC,
wallet: None,
payload,
}
}
fn tranche_payload() -> EventPayload {
EventPayload::DeclareTranche(DeclareTranche {
sat: 40_000_000,
wallet: WalletId::SelfCustody { label: "w".into() },
window_start: date!(2016 - 01 - 01),
window_end: date!(2016 - 03 - 31),
})
}
#[test]
fn live_declare_ids_keeps_a_tranche_whose_void_the_engine_held_inert() {
let tranche = EventId::decision(1);
let events = vec![
dec_ev(1, tranche_payload()),
dec_ev(
3,
EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: tranche.clone(),
}),
),
];
let mut in_force = LedgerState::default();
in_force.promoted_origins.insert(tranche.clone());
assert_eq!(
live_declare_ids(&events, &in_force),
vec![tranche.clone()],
"a tranche the resolver kept in force must stay in the DFW-D11 export union"
);
let dropped = LedgerState::default();
assert!(
live_declare_ids(&events, &dropped).is_empty(),
"an actually-applied void must still drop its tranche — the fix must not admit everything"
);
}
}