Skip to content

feat(ledger): book crypto-native fees natively (exchange-tx, trading-order, payout-order) (#4277)#4279

Open
TaprootFreak wants to merge 2 commits into
developfrom
fix/ledger-native-fee-booking
Open

feat(ledger): book crypto-native fees natively (exchange-tx, trading-order, payout-order) (#4277)#4279
TaprootFreak wants to merge 2 commits into
developfrom
fix/ledger-native-fee-booking

Conversation

@TaprootFreak

Copy link
Copy Markdown
Collaborator

Addresses #4277 and its two sibling sites. Supersedes #4278 (exchange-tx-only draft, folded in here).

The anti-pattern

A crypto-native fee (a real BNB commission / on-chain gas) is booked into the ledger only via a persisted, 2-dp-rounded *AmountChf field. When that CHF value is sub-cent it rounds to 0.00 and the native outflow is lost — no leg is booked. The ledger books in integer CHF cents, so a sub-cent fee cannot survive as CHF; it can only be preserved by booking the underlying native amount (× its mark), exactly as payout-order.consumer.appendDistinctFeeLegs already does for distinct fee assets.

A repo-wide audit found three sites (fiat/CHF-denominated 2-dp values are correct and were not touched). All three are fixed here with the same conservative template — native leg only for a distinct fee asset with a bootstrapped account and a historical mark, else fall back to today's CHF behaviour, so no tx that books today starts deferring.

The three fixes

  1. exchange-tx.consumer — ccxt venue fee. New appendCcxtFeeLeg books the fee against ASSET/{exchange}/{feeCurrency} (mark-valued). Deeper finding: the quote-spread plug (residualChf = -Σ amountChf) makes total spread-{exchange} = quoteChf − baseChf independent of feeAmountChf, so today the venue fee is algebraically neutralized on every ccxt fill (not just sub-cent ones) — neither recognized as a cost nor tracked natively (§7 reconciliation only observes the resulting BNB drift, so nothing is silently corrupted). This fix recognizes the fee as spread-{exchange} expense on all ccxt trades and starts native fee-asset tracking → the highest-reach of the three. Scrypt's market-spread branch is untouched.
  2. trading-order.consumer — arbitrage gas. New appendNetworkFeeLegs books the on-chain gas (txFeeAmount, native) against the swap chain's native COINassetService.getNativeAsset(assetIn.blockchain), the exact asset the persisted txFeeAmountChf was valued from — as a Cr ASSET/{coin} alongside the unchanged CHF EXPENSE/network-fee Dr; the arbitrage plug simply shrinks by the gas CHF (no double-count). Injects AssetService (SharedModule; DI verified). Low blast radius.
  3. payout-order.consumer — distinct-fee gate. appendDistinctFeeLegs no longer early-returns when the persisted CHF sum is 0; it gates only the CHF EXPENSE/network-fee leg on feeChf and still builds the per-distinct-asset native legs, so a sub-cent distinct fee keeps its native outflow. Non-throwing + mark-gated at feeChf===0 → no new deferral/fail-stop. Lowest blast radius (the native machinery already existed and was tested on the feeChf!==0 path). The payout-asset fee fold and the feeChf!==0 path are unchanged.

No new account names, no CoA/bootstrap change, no migration; forward-only. Sign-aware off the signed native amount (a rebate flips Cr→Dr).

Materiality & blast radius

The recovered amounts are immaterial (~tens of CHF over ~20 months for exchange-tx; unquantified but plausibly larger for trading-order L1 gas; narrowest for payout-order). The exchange-tx change has real semantic reach — it re-prices the venue fee as recognized expense on all ccxt trades, starts native BNB tracking, nudges the §7 Binance/BNB + equity-parity baselines toward correct (§7 is a pure observer → no double-correction), and triggers a bounded reverse+rebook of the recently-synced trade window on first deploy. trading-order/payout-order are low-reach. Merge decision (accepting the exchange-tx reach) stays with the maintainer.

Verification (Node 20)

prettier / eslint / type-check / build clean · DI graph resolves (incl. the new AssetService dependency) · full accounting suite 787/787 with 10 new tests:

  • exchange-tx (3): native distinct-asset fee; sub-cent fee preserved natively; CHF fallback when the fee account is missing.
  • trading-order (5): native gas leg + arbitrage-plug shrink (no double-count); sub-cent gas preserved; fallbacks for gas-coin-equals-swap-side / no account / no mark.
  • payout-order (2): distinct native leg booked at feeChf===0; conservative skip for an unvaluable distinct fee asset at feeChf===0.

The existing fee tests now also exercise the CHF fallback / unchanged paths as regression guards (e.g. the feeChf!==0 distinct-fee-asset defer test stays green).

…4277)

Draft for evaluation — the honest recommendation is hold/won't-fix given an
immaterial benefit against a high semantic blast radius (see the PR description).

The ccxt Trade branch books the venue fee as a CHF-only spread leg valued at the
pre-2dp-rounded feeAmountChf, so a sub-cent fee (a Binance micro-fill's BNB
commission) rounds to 0 and no leg is booked. And because the quote-spread plug
neutralizes it, the fee has zero net P&L effect on every ccxt fill and its native
outflow is never tracked. Mirror payout-order appendDistinctFeeLegs: book the fee
against its native ASSET/{exchange}/{feeCurrency} account (mark-valued) so the
native outflow survives even when its CHF value rounds to 0. Conservative: only for
a distinct fee asset (not base/quote) with a bootstrapped account and a historical
mark, else fall back to today's CHF spread leg — no trade that books today defers.
#4277)

Extends native-fee booking to the two sibling sites found by the #4277 audit,
matching the exchange-tx appendCcxtFeeLeg template:

- trading-order: the on-chain arbitrage gas (txFeeAmount, native) is booked against
  its chain's native COIN asset (assetService.getNativeAsset(assetIn.blockchain) -
  the exact asset the persisted txFeeAmountChf was valued from), Cr ASSET/{coin}
  alongside the unchanged CHF EXPENSE/network-fee Dr, so a sub-cent gas fee's native
  outflow survives. Conservative: distinct coin only, non-throwing account, mark-gated
  -> no new deferral. Injects AssetService.
- payout-order: appendDistinctFeeLegs no longer early-returns when the persisted CHF
  sum is 0 - it gates only the CHF EXPENSE leg on feeChf and still builds the
  per-distinct-asset native legs, so a sub-cent distinct fee keeps its native outflow.
  Non-throwing / mark-gated at feeChf===0 -> no new deferral or fail-stop.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Reviewed across two dimensions (conformance/quality + logic/correctness), each finding adversarially verified — zero confirmed findings.

  • Logic/correctness: clean across all three sites (native leg balances, distinct-asset guard prevents double-count, conservative gates add no new deferral, DI sound).
  • Conformance: three raised items, all refuted on verification as non-required style nits — a defensibly-accurate "persisted CHF-only" header (the R2-5 payload is correct; the native leg is documented one line down), a deliberate guard-shape difference between the two new helpers (the proposed swap wasn't even NaN-equivalent), and the #4277: test-title prefix (no precedent for issue-number tags; uniform by design).

CI green (Build and test, review, CodeQL, analyze). Verified on Node 20: prettier / eslint / type-check / build, DI graph resolves (incl. the new AssetService dependency), full accounting suite 787/787 with 10 added tests.

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.

1 participant