Skip to content

fix(ledger): scope native-balance check to single-currency transfers#4268

Merged
TaprootFreak merged 2 commits into
developfrom
fix/ledger-native-balance-single-currency-gate
Jul 18, 2026
Merged

fix(ledger): scope native-balance check to single-currency transfers#4268
TaprootFreak merged 2 commits into
developfrom
fix/ledger-native-balance-single-currency-gate

Conversation

@TaprootFreak

Copy link
Copy Markdown
Collaborator

Closes #4267.

Problem

checkNativeBalance is meant — per its own docstring — to sanity-check pure same-asset transfers (all legs ASSET/TRANSIT of one currency): then Σ native must be 0. But it only gated on onlyAssetTransit and then looped per currency, never checking the tx is single-currency.

A cross-asset trade is also all-ASSET/TRANSIT. It normally carries a non-asset spread/fee leg (EXPENSE/INCOME) → onlyAssetTransit=false → check skipped. But a fee-less micro-fill — feeAmountChf rounds to 0 and the base↔quote CHF mark residual is within the rounding tolerance → no spread/fee leg — collapses to a bare 2-leg all-ASSET tx. The check then ran and flagged each currency's (necessarily non-zero) trade delta as an "imbalance". That delta is the traded amount, not a fee → the log is spurious.

All 46 post-cutover native imbalance … (programming error) lines reconcile to 24 committed txs: 22 cross-asset micro-fills flagged in both currencies (44 lines) + 2 fiat settlements (2 lines). Bookings committed correctly throughout — the CHF invariant (amountChfSum = 0) is enforced; this is diagnostic-only.

Fix

  1. Single-currency gate (if (byCurrency.size !== 1) return;) — restores the exact scope the docstring already describes and removes the 44 trade-driven false positives. A second currency means a cross-asset trade, where the per-currency native delta is the trade itself and never 0 (§2.3 Major R9-2).
  2. Judge the residual in CHF at the group mark, not natively — mirroring the §7 reconciliation unit-fix (native journal↔feed diff MUST be valued in CHF before comparing against the tolerance). A bare native tolerance flags sub-rappen fiat rounding (a fiat bank leg carries the unrounded output at >2 dp — the 2 remaining lines) yet is ~52'000× too loose for BTC. An unvalued tx (no mark) falls back to the native tolerance so a real imbalance is never silently passed. A genuine single-currency imbalance is still flagged, now reported valued in CHF.

No behavioural or data change to bookings; the native amount persisted for a leg is untouched.

Design note

An alternative for point 2 was to keep the native comparison and special-case fiat currencies (round the fiat leg's native amount to 2 dp, or widen the tolerance for a fiat-ticker allow-list). Valuing in CHF was preferred because it (a) needs no hard-coded fiat list, (b) handles both fiat and crypto with one mechanism, and (c) is exactly the unit treatment the ledger already applies in §7 reconciliation.

Tests

Added to ledger-booking.service.spec.ts:

  • fee-less cross-asset trade (two currencies) → not flagged;
  • sub-rappen fiat rounding on a single-currency CHF settlement → not flagged;
  • a material single-currency native imbalance → still flagged, with its CHF value reported.

Existing native-imbalance and value-boundary cases stay green (33/33).

Verification (Node 20)

prettier clean · eslint clean · type-check pass · build pass · jest ledger-booking.service.spec 33/33 · DI graph resolves (boot reaches JwtStrategy env, i.e. past DI).

checkNativeBalance is meant — per its own docstring — to sanity-check pure
same-asset transfers (all legs ASSET/TRANSIT of ONE currency): then Σ native
must be 0. It only gated on onlyAssetTransit and then looped per currency,
never checking the tx is single-currency. A fee-less cross-asset micro-fill
(feeAmountChf rounds to 0 and the base/quote CHF mark residual is within
tolerance → no spread/fee leg) collapses to a bare 2-leg all-ASSET tx and was
flagged in both currencies, for a per-currency delta that is the trade itself,
not an imbalance — 44 of the 46 spurious post-cutover lines.

Add the missing single-currency gate to restore the documented scope, and
judge the residual in CHF at the group mark instead of natively (mirroring the
§7 reconciliation unit-fix): a bare native tolerance flags sub-rappen fiat
rounding — a fiat bank leg carries the unrounded output at >2 dp — yet is far
too loose for BTC. That removes the remaining 2 buy_fiat lines while still
catching a genuine single-currency imbalance, now reported valued in CHF. An
unvalued tx falls back to the native tolerance so a real imbalance is never
silently passed. Diagnostic-only: bookings and the CHF invariant are unchanged.
On the mark=0 fallback the CHF value is 0 by construction; print "unvalued (mark 0)" so the residual is not mistaken for a negligible valued amount during triage. Diagnostic text only.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Reviewed across two dimensions (conformance/quality + logic/correctness), two passes to zero findings.

  • Pass 1 raised one diagnostic-wording item: on the unvalued fallback path (mark 0) the residual was logged as 0 CHF @ mark 0, which can read as negligible during triage. Fixed in 3bb9c53 — it now prints unvalued (mark 0). The check still fires, so a real imbalance is never silently passed.
  • Pass 2 clean.

CI green (Build and test, review, CodeQL, analyze). Verified locally on Node 20: prettier, eslint, type-check, build, and the ledger-booking suite (33/33, incl. the three added cases). Diagnostic-only change — bookings and the amountChfSum = 0 invariant are untouched.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 18, 2026 15:48
@TaprootFreak
TaprootFreak merged commit 6b2b48d into develop Jul 18, 2026
7 checks passed
@TaprootFreak
TaprootFreak deleted the fix/ledger-native-balance-single-currency-gate branch July 18, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ledger checkNativeBalance: false-positive "native imbalance" on fee-less cross-asset trades (missing single-currency gate)

1 participant