Skip to content

feat(ledger): native-first exact amounts — exact base-unit ledger + on-chain wei-exact ingestion (#4280, #4287)#4288

Merged
TaprootFreak merged 42 commits into
developfrom
feat/onchain-wei-exact-ingestion
Jul 21, 2026
Merged

feat(ledger): native-first exact amounts — exact base-unit ledger + on-chain wei-exact ingestion (#4280, #4287)#4288
TaprootFreak merged 42 commits into
developfrom
feat/onchain-wei-exact-ingestion

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

One PR: native-first exact amounts

Consolidated into a single PR (per request). This carries the whole exact-amounts work built so far, additively and backward-compatibly.

Included

A. Native-first exact base-unit ledger (#4280) — the exact-integer ledger_leg.amountBaseUnits column, populated for ASSET + same-currency TRANSIT counter legs; §7 reconciliation + mark-to-market compute on exact base units for single-asset accounts (native-float fallback for shared/bridge/mixed-decimals accounts); assertNativeBalance enforces the per-asset base-unit invariant (exact 0n) for single-asset same-currency transfers, with the fiat/mixed-decimals/null-decimals soft fallbacks. Float amount untouched.

B. On-chain wei-exact ingestion — Stages 1-2 of #4287 — the raw on-chain integer is captured before any parseFloat: EVM deposits from rawContract.rawValue, BTC deposits via string-scaled satoshi; EVM withdrawals capture the exact broadcast integer (sentBaseUnits mirrors the client toWeiAmount/parseEther); Stage 2 extends the same capture to swap/bridge source legs at broadcast. Persisted on crypto_input/payout_order (and the swap/bridge source entities) as nullable amountBaseUnits and booked verbatim into the ledger leg (else derived). Reusable transformer moved to src/shared/models/base-units.transformer.ts (fromDecimalString = exact decimal-string → integer, never via float). Strictly additive/fail-open; the exact path is string/BigInt-pure (no float leak).

C. Exchange-sourced amounts — Stage 3 of #4287 (investigated → no code) — exchange-sourced amounts (exchange_tx: ccxt Kraken/Binance/MEXC + Scrypt) are ≤8 dp at the source, so the existing base units are already source-exact — no plumbing added (capturing the raw string would produce identical base units = fake precision). ccxt types every amount as Num = JS float (ccxt/js/src/base/types.d.ts:92) and its default number handler is Number, so the raw response string is collapsed to a float before DFX reads .amount (exchange-tx.mapper.ts:24,42,66); the reported quantity is at market precision ≤8 dp for every traded asset. Exchange legs take the float path toBaseUnits(amount, min(decimals,8)) (ledger-booking.service.ts:399), whose toFixed(8) reproduces a ≤8-dp value exactly. This is the honest source ceiling for exchange legs — only the on-chain side (18-dp wei) had a finer source to preserve. Full evidence in issue #4287.

D. Non-EVM >8-dp + exact fee legs — Stage 3 of #4287 (the stages 1-2 fail-open deferrals, now closed) — the on-chain deferrals previously listed are now captured wei/atomic-exact (additive, fail-open, string/BigInt-pure; the exchange-source track in Part C is a separate, code-free investigation under the same #4287 umbrella):

  • Solana (9 dp): deposit — the Tatum webhook amount is an exact whole-unit decimal string, scaled straight into crypto_input.amountBaseUnits (no float step); withdrawal — SolanaUtil.toBroadcastBaseUnits captures the exact broadcast lamports/token integer (coin@9 / token@decimals guard, mirroring the EVM coin@18 rule) into payout_order.amountBaseUnits.
  • Monero (12 dp) & Zano (12 dp): deposit — the raw atomic-unit integer (piconero / Zano AU) is captured before the client's float collapse and re-scaled to the asset via the new string-pure fromBaseUnits; Zano sums several same-asset receive entries exactly. (Withdrawals stay float-derived — see deferrals.)
  • EVM gas-fee legs: getTxActualFeeBaseUnits (gasUsed·effectiveGasPrice, + exact L1 data fee on Base/Optimism/Citrea) supplies the exact gas wei, booked verbatim on the EVM payout distinct network-fee leg and the DfxDex third-asset gas leg (fee-fold suppression mirrors stage 1).

Scope & safety

  • Additive & backward-compatible: the float amount columns and all existing API/DTO fields are unchanged; exact values are added alongside. No wire-format break.
  • Fail-open: any leg/asset without an exact source value falls back to the existing derived path.
  • Documented deferrals (epic Epic: exact (float-free) monetary amounts — wei-exact on-chain, source-exact everywhere #4287, honest — the ledger derives from the ≤8-dp float): (1) Monero/Zano withdrawalsBitcoinBasedStrategy.aggregatePayout rounds every payout to 8 dp before broadcast, so no >8-dp integer exists to capture; (2) Monero/Zano deposits above the JSON safe-integer range (the atomic integer is already corrupted by JSON.parse in the RPC response — realistic amounts are far below); (3) the trading/arbitrage network fee — booked CHF-only, there is no native ASSET fee leg to make exact; (4) the EVM coin-payout folded gas fee and any same-asset folded fee (fee asset == swap/target/payout asset) — the override is suppressed by design because the leg's native quantity then diverges from the captured amount; (5) the deposit forward fee (crypto_input seq1) — the exact wei IS available (getTxActualFee in the EVM send flow) but capturing it needs plumbing through the send-strategy broadcast closure across ~8 strategies + a new column, deferred as a focused follow-up; (6) non-EVM payout/swap fees (Solana/Tron/Cardano/ICP/Spark/Arkade) — GAP-2 is EVM-scoped; (7) the eventual removal of the float shadow columns (needs a coordinated frontend release). Full evidence in issue Epic: exact (float-free) monetary amounts — wei-exact on-chain, source-exact everywhere #4287.

Verification

Local (m5me, Node 20): type-check, lint, format, the accounting + payin + payout + blockchain suites all green; migrations validated on PostgreSQL 16; DI graph boots. Adversarial review passes on both parts (ledger correctness + conformity; ingestion float-leak + regression) — findings addressed, latent assumptions hardened to fail-open. Stage 3 (exchange) is an investigation outcome with no code change (see part C).

Supersedes #4282 (its commits are included here).

…phase 1, #4280)

Native-first exactness. The enforced per-tx invariant is CHF (amountChfSum=0, exact
bigint cents), while the native amount was a double-precision float rounded to 8 dp -
so native quantities had weaker guarantees than the CHF side. Phase 1 adds an exact,
additive record of the native quantity as integer base units (amount x 10^asset.decimals,
e.g. satoshi/wei), mirroring the exact-integer amountChfCents model.

- ledger_leg.amountBaseUnits: numeric column (arbitrary precision - 18-decimal wei of a
  large balance exceeds bigint) via a fail-loud baseUnitsTransformer (string <-> bigint).
- toBaseUnits scales the <=8-dp native amount to base units via string math (no JS 2^53
  overflow, no float-error amplification beyond the source precision).
- the booking service resolves asset decimals (cached AssetService) and sets it for every
  asset-backed leg; fiat/TRANSIT/EQUITY/EXPENSE legs stay null (CHF-exact via amountChfCents).
- migration backfills existing legs to the same value (verified on Postgres, incl. wei-scale
  and the 0.1-ETH float-error case).

Purely additive: the float amount and the CHF close are untouched. Groundwork for threading
source-side base units end-to-end (later phases).
…ten the base-unit null rule

|amount| >= 1e21 would make toFixed emit exponential notation and throw an opaque BigInt SyntaxError inside the booking tx; fail loud with a clear ledger error instead (unreachable in practice, defensive). Also restate the rule precisely: amountBaseUnits is null unless the account asset defines decimals (not a crypto-vs-fiat test) so a later phase keys per-asset.
…nits (#4280)

Populate the TRANSIT counter of a same-currency internal transfer with exact
base units derived from the paired asset leg (TRANSIT accounts carry no assetId,
so their decimals come from the single asset in the same tx), so a conserving
pair cancels to 0n. Turn the soft checkNativeBalance into a throwing
assertNativeBalance: a non-zero per-tx same-asset base-unit sum is now
fail-closed. Retain the escapes for scopes with no base-unit-bearing legs (fiat
CHF transfers, sub-rappen bank rounding, crypto without decimals).
Sum amountBaseUnits and convert back to native once (÷10^decimals) for
decimals-bearing accounts in the §7 reconciliation balance and the §5.3
mark-to-market balance, so accumulated 8dp float noise never reaches the
tolerance comparison or a revaluation; keep the raw float SUM(amount) fallback
where an account has no decimals or its legs are not all valued. Detect a closed
transit residual and its open-since zero-crossing on the exact integer sum when
the residual is homogeneous, float otherwise.
…ransfers (#4280)

Companion to 1784600000000: fill amountBaseUnits for the TRANSIT counter of an
existing same-currency internal-transfer tx, deriving decimals from the paired
asset leg (single distinct decimals, unambiguous), so the pair sums to 0 in base
units exactly as the booking service now writes for new transfers. Idempotent
and scoped to still-null assetId-less transit legs; fiat and cross-asset txs are
left untouched. Verified on a throwaway Postgres 16 with synthetic data.
… accounts

The §7.4 transit-age check gated the exact integer base-unit cumulation only on every leg carrying base units. A shared TRANSIT/bridge account (no assetId) keyed by currency ticker can accrue same-ticker legs at different decimals across chains (USDT 6dp on ETH/Tron, 18dp on BSC); summing those base units is incommensurable and mis-places openResidualSince, causing false or missed LEDGER_TRANSIT_OVERDUE alarms. Take the exact path only for a single-asset account (account.assetId != null => one decimals) and fall back to native-float cumulation otherwise, mirroring the existing nativeBalanceFromRow fallback for no-decimals accounts. Adds a mixed-decimals bridge regression test plus a genuinely-stuck-residual test.
…icker transfers

assertNativeBalance grouped by currency and summed per-assetId-scaled base units with no single-decimals guard, then threw fail-closed. A same-ticker cross-chain ASSET<->ASSET transfer (USDT-ETH 6dp + USDT-BSC 18dp) that conserves natively has a non-zero base-unit sum (1e8 - 1e20) and would wrongly wedge a legitimate booking. Take the base-unit throw path only when the scope ASSET legs share exactly one decimals (mirroring populateTransferCounterBaseUnits); otherwise retain the pre-PR soft native-float tolerance check (log-only) so the old safety net is not lost. Adds a cross-chain same-ticker conservation test.
…micolon convention

The two ledger base-unit migrations used 4-space indentation and omitted semicolons; every other migration/*.js uses 2-space indentation with semicolons. Whitespace-only reformat, no SQL or logic change.
…stage 1)

Move baseUnitsTransformer + toBaseUnits from the accounting subdomain to a shared, entity-free module and add fromDecimalString: an EXACT decimal-string to integer base-units conversion that never touches a JS number, so an on-chain amount captured at ingestion keeps full 18-dp (wei) precision. The accounting entities/services keep their import path via a thin re-export (no import cycle).
…age 1)

Persist the raw integer base units (wei/satoshi) of a deposit/withdrawal ALONGSIDE the existing float amount, additive and nullable. crypto_input + payout_order get a nullable numeric amountBaseUnits column (+ migration, no backfill). PayInEntry carries the value as an exact integer STRING captured BEFORE the parseFloat collapse: the EVM (Alchemy) path exposes the exact decimal via EvmUtil.fromWeiAmountString and the Bitcoin path scales the BTC decimal by string, both funnelled through the shared fromDecimalString into crypto_input.amountBaseUnits. Chains with no raw integer, and any malformed value, fall back to null (fail-open); the float amount is unchanged.
…egs (#4287 stage 1)

Add an optional explicit amountBaseUnits override to LedgerLegInput; when present, populateBaseUnits books it VERBATIM instead of deriving from the <=8-dp float, so the deposit ASSET leg (crypto-input consumer) and the withdrawal wallet leg (payout-order consumer) stay wei-exact end-to-end. The payout override applies only when no payout-asset fee is folded into the leg (else it would not match order.amount). Reversals negate the stored exact base units so a correction exactly undoes the original. Legs without an override derive from the float exactly as before (fail-open).
 stage 1)

Complete the withdrawal path (was null plumbing). At EVM payout broadcast the exact integer that actually left custody = toWeiAmount(order.amount, asset.decimals) — the same string/BigNumber conversion evm-client uses to build the tx value, at the asset full base-unit resolution. For an >8-dp asset (every EVM 18-dp coin/token) this differs from the derived value because toBaseUnits caps precision at 8 dp; capturing it into payout_order.amountBaseUnits lets the ledger book the withdrawal wallet leg wei-exact. For a =<8-dp asset (BTC and other UTXO/6-dp) the derived value already equals the on-chain integer, so no capture is needed. Additive/fail-open: unknown decimals leave it null and the ledger derives from the float. The wallet-leg override still yields to a folded payout-asset fee (native quantity then exceeds order.amount).
…he broadcast (#4287 stage 1)

Two latent assumptions (inert with todays data) made robust so a misconfigured or future asset can never silently store a wrong exact integer. (1) EVM payout: a native coin is always broadcast via parseEther/18 regardless of asset.decimals, so sentBaseUnits now captures a coin ONLY at 18 dp and fails open to null for a coin whose configured decimals differ (rather than persisting a value diverging from the sent amount); a token still scales with token.decimals. The captured integer is now tautologically equal to what evm-client broadcasts. (2) Bitcoin register: the toFixed to satoshi exactness holds only for a <=8-dp, <=~21M-supply asset, so the capture is now guarded on <=8 decimals and fails open otherwise, degrading to derive instead of a silent off-by-a-base-unit value if reused for a larger/higher-precision UTXO asset. BTC path unchanged. Additive/fail-open throughout.
@TaprootFreak TaprootFreak changed the title feat(ledger): capture on-chain amounts as exact base units at ingestion (#4287 stage 1) feat(ledger): native-first exact amounts — exact base-unit ledger + on-chain wei-exact ingestion (#4280, #4287) Jul 20, 2026
@TaprootFreak
TaprootFreak changed the base branch from feat/ledger-native-base-units-phase1 to develop July 20, 2026 11:44
…lpers (#4287 stage 2)

Extend the stage-1 exact-capture toolkit for on-chain swaps and bridges. EvmUtil.toBroadcastBaseUnits scales a DFX-computed EVM amount to the exact integer base units at the resolution evm-client BROADCASTS it (toWeiAmount), guarding a native coin to 18 dp (parseEther) and failing open to null on unknown/incompatible decimals — the swap/bridge analogue of stage-1's payout sentBaseUnits. EvmUtil.toBaseUnitsFromRaw normalises a raw on-chain wei-like value to an exact bigint for a confirmed deposit/arrival. evm-client gains getSwapResultBaseUnits: the exact raw output integer of a swap from the same output-transfer log getSwapResult reads (getSwapResult now derives its float from it, mathematically unchanged). Fail-open throughout.
… source entities (#4287 stage 2)

Persist the raw integer base units (wei) of the on-chain swap/bridge legs ALONGSIDE the existing float amounts, additive and nullable. trading_order gains amountIn/amountOutBaseUnits, liquidity_order gains swap/targetAmountBaseUnits, liquidity_management_order gains outputAmountBaseUnits (all numeric <-> JS bigint via the shared baseUnitsTransformer). One additive migration adds the five columns with no historical backfill: legacy rows, non-EVM chains and unavailable legs stay NULL and the ledger derives from the <=8-dp float (fail-open), so the float amounts and every existing behaviour are untouched. Verified on a throwaway Postgres 16 (columns add/drop cleanly, 18-dp wei round-trips exactly).
…ts at broadcast (#4287 stage 2)

Populate the new exact columns where the on-chain integer is genuinely available. Trading arbitrage: input = order.amountIn at the broadcast resolution, output = the raw swap-output wei (getSwapResultBaseUnits). DfxDex purchase (EVM-only PurchaseStrategy): swap input at broadcast, target output the raw swap wei — kept only when purchased() booked the on-chain output verbatim (not the reference==target override). Bridges: dEURO bridge-in captures the 1:1 broadcast wei in the booked target decimals; LayerZero deposit captures the raw arrival transfer wei. Every capture is additive and fail-open (null -> the ledger derives). The <=8-dp L2 bridges (Base/Arbitrum/Optimism/Polygon, floored to 8 dp), non-EVM chains and fee legs are honest deferrals: their float derivation is already exact or no finer on-chain truth exists.
…on-chain legs (#4287 stage 2)

Pass each captured exact integer into the stage-1 amountBaseUnits override so the on-chain legs book wei-exact. Trading and DfxDex legs are signed to match the leg amount (Dr +, Cr -); a same-asset fee folded into a leg drops that leg's override (its native quantity then diverges from the captured swap amount), mirroring stage-1 payout. These swap txs are cross-asset and carry CHF fee/plug legs, so they stay out of assertNativeBalance's same-currency ASSET/TRANSIT throw scope. The bridge books a SAME-currency ASSET+TRANSIT pair — exactly that scope — so the captured wei is booked on BOTH legs (wallet +X / TRANSIT -X); overriding only the wallet would leave the TRANSIT counter float-derived and a >8-dp mismatch would fail-closed-throw. Tests cover >8-dp exactness, fail-open, sign/fee-fold, and the bridge same-currency invariant.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Stage 2 — on-chain SWAP & BRIDGE legs wei-exact

Extends the Stage-1 wei-exact pattern (deposits/withdrawals) to the on-chain swap and bridge legs. Strictly additive + fail-open throughout; no existing float behaviour changes.

Exact-available (captured and booked verbatim into the Stage-1 amountBaseUnits override):

  • trading_order (§4.9 arbitrage swap, EVM): amountOut = raw on-chain swap-output transfer wei (EvmClient.getSwapResultBaseUnits); amountIn = broadcast-exact (EvmUtil.toBroadcastBaseUnits).
  • liquidity_order (§4.8a DfxDex purchase, EVM): targetAmount = raw swap wei (kept only when purchased() booked the on-chain output, not the reference==target override); swapAmount = broadcast-exact.
  • liquidity_management_order bridge (§4.8): LayerZero deposit = raw arrival transfer wei; dEURO bridge-in = 1:1 broadcast wei in the booked-target decimals. Booked on BOTH bridge legs (wallet +X / TRANSIT −X) so the same-currency ASSET+TRANSIT pair nets to 0n and passes assertNativeBalance (overriding only the wallet leg would leave the TRANSIT counter float-derived → a >8-dp mismatch would fail-closed-throw).

Honest deferrals (fail-open → the ledger derives from the ≤8-dp float; no precision faked):

  • EVM L2 bridges (Base/Arbitrum/Optimism/Polygon): outputAmount is floored to 8 dp before broadcast (evm-l2-bridge.adapter Util.floor(..., 8)) → the float derivation is already wei-exact, nothing finer to capture.
  • Clementine bridge / Boltz (BTC, ≤8 dp): derived value already exact.
  • LayerZero withdraw completion: only the deposit side is captured (transfer asset == booked target); the withdraw release is on Ethereum while the booked target is the rule target → decimals alignment not guaranteed → deferred.
  • Fee/gas legs (trading & DEX network-fee): no finer on-chain truth used; booked from float.
  • Non-EVM DEX & DfxDex SELL: PurchaseStrategy is EVM-only (non-EVM uses NoPurchaseStrategy); the SELL path has no active caller → fail-open.

Verified on m5me (Node 20): tsc/eslint/prettier clean; affected suites green (evm.util, evm-client, ledger-booking, trading/liquidity-order-dex/liquidity-mgmt consumers — 10 suites / 304 tests); migration up/down on a throwaway Postgres 16 (5 nullable numeric columns, 18-dp wei round-trips exactly); DI boot reaches the JwtStrategy env wall. CI on PR #4288 green (Build and test + migration-check + CodeQL).

…apture (#4287 stage 3)

The non-EVM ingestion paths (Solana lamports, Monero piconero, Zano atomic units) expose the on-chain amount at the chain OWN native scale, not the DFX asset scale. fromBaseUnits renders an integer base-unit quantity as the exact whole-unit decimal STRING purely via string + BigInt (never a JS number), the inverse of fromDecimalString; a capture path then re-scales that string to the asset scale via fromDecimalString, decoupling the two scales and failing open (loud) when the asset cannot represent the value. Trailing zeros are trimmed so a value representable at a coarser asset scale still round-trips.
…al (#4287 stage 3)

Solana is 9-dp (lamports), beyond the ledger 8-dp float derivation. Deposit: the Tatum address webhook delivers the amount as an EXACT whole-unit decimal string, scaled straight into crypto_input.amountBaseUnits via the shared fromDecimalString (no float step) before Number(dto.amount) collapses it. Withdrawal: SolanaUtil.toBroadcastBaseUnits (the Solana analogue of EvmUtil.toBroadcastBaseUnits) scales order.amount at the exact broadcast resolution the client uses (toLamportAmount -> toWeiAmount) into payout_order.amountBaseUnits after the send; a native coin is broadcast at the lamports scale (9) regardless of configured decimals, so a coin is captured only at 9 dp and fails open otherwise, never persisting a value diverging from the sent amount. Additive + fail-open throughout; the columns already exist from stage 1.
…stage 3)

Monero is 12-dp (piconero), beyond the ledger 8-dp float derivation. The wallet-RPC get_transfers delivers each amount as a raw atomic-unit integer that the client immediately collapses to a float XMR via auToXmr. Capture the EXACT whole-unit XMR decimal string from that raw integer BEFORE the collapse (fromBaseUnits, string-pure) and scale it into crypto_input.amountBaseUnits at the asset scale via the shared fromDecimalString, recovering the 9th-12th decimals. Only a safe-integer atomic value is captured — a piconero count above 2^53 is already corrupted by JSON.parse in the RPC response, so above that the field stays null and the ledger derives from the float (fail-open). Withdrawals stay float-derived: BitcoinBasedStrategy.aggregatePayout rounds every Monero payout to 8 dp before broadcast, so there is no >8-dp integer to capture. Additive; the column already exists from stage 1.
…age 3)

Zano is up to 12-dp (native coin + per-token decimal_point), beyond the ledger 8-dp float derivation. The wallet-RPC transfer history delivers each receive employed-entry as a raw atomic-unit integer that the client collapses to a float via fromAuAmount. Resolve each entry native decimals once (coin ZANO_DECIMALS, else the token decimal_point) and capture the EXACT whole-unit decimal string from the raw integer BEFORE the collapse (fromBaseUnits, string-pure). A tx may credit the same asset via several receive entries, so the register strategy sums their exact base units at the asset scale into crypto_input.amountBaseUnits; if any entry lacks an exact value (atomic beyond 2^53, already corrupted by JSON.parse) or the asset is unknown, the whole sum stays null and the ledger derives from the float (fail-open). Withdrawals stay float-derived: aggregatePayout rounds every payout to 8 dp before broadcast. Additive; the column already exists from stage 1.
… EVM clients (#4287 stage 3)

The on-chain gas fee is an exact wei integer available from the tx receipt. Add getTxActualFeeBaseUnits to EvmClient (gasUsed * effectiveGasPrice) and override it on the L2 clients that fold in an L1 data fee: BaseClient / OptimismClient (l1Fee + l2Fee from the L2 receipt) and CitreaBaseClient (l1DiffSize * l1FeeRate + gasUsed * effectiveGasPrice via raw RPC). getTxActualFee now derives its float from getTxActualFeeBaseUnits via fromWeiAmount, so the two stay tautologically consistent and existing behaviour is unchanged. This lets the network-fee legs book the gas wei-exact instead of the <=8-dp float derivation (the 18-dp native coin the gas is paid in loses precision otherwise).
… network-fee leg (#4287 stage 3)

Capture the EXACT on-chain gas-fee wei of an EVM payout (payoutFeeAmount) at completion via getTxActualFeeBaseUnits and persist it into a new nullable payout_order.payoutFeeAmountBaseUnits column (+ migration, no backfill). PayoutTxStatus.complete carries feeBaseUnits (fail-open null on any capture error). The payout-order consumer books that exact wei verbatim on the DISTINCT native network-fee leg, negated for the credit — but ONLY when the leg is the un-aggregated payout-asset fee alone; once a preparation fee in the SAME asset is summed into the leg its native quantity diverges from the captured payout-fee wei and it falls back to the float derivation (mirrors the stage-1 wallet-leg fee-fold suppression). The payout tx carries LIABILITY/EXPENSE legs so it is out of assertNativeBalance same-currency throw scope. recordPayoutFee stays 3-arg (the base units are set directly on completion) so no existing fee-completion spec changes. Additive + fail-open; migration verified on Postgres 16.
…rk-fee leg (#4287 stage 3)

Capture the EXACT on-chain gas-fee wei of a DfxDex swap (liquidity_order.feeAmount) via getTxActualFeeBaseUnits and persist it into a new nullable liquidity_order.feeAmountBaseUnits column (+ migration, no backfill). dex-evm.service.getSwapResult returns feeAmountBaseUnits (fail-open null on any capture error); the purchase strategy sets it alongside recordFee (which stores feeAmount verbatim, so the captured wei always matches). The liquidity-order-dex consumer books that exact wei verbatim on the un-folded THIRD-asset (native EVM gas) fee leg, negated for the credit — the typical case where gas != swap/target asset. The folded swap/target fee branches keep deriving from the float (addToLeg already drops those overrides). The swap tx is cross-asset with CHF fee legs, so it stays out of assertNativeBalance same-currency throw scope. Additive + fail-open; migration verified on Postgres 16.
…#4287 stage 4)

Add three nullable, additive numeric columns to buy_crypto holding the EXACT integer
base units of the buy amounts, ALONGSIDE the untouched float columns:
- inputAmountBaseUnits: from crypto_input on-chain deposit (stage 1)
- outputAmountBaseUnits: from payout_order on-chain broadcast (stage 1)
- networkStartAmountBaseUnits: from network-start payout_order on-chain broadcast (stage 1)

Schema + migration only; population wired in follow-up commits. numeric maps to a JS
bigint via baseUnitsTransformer. Nullable, no backfill, fail-open null.
…aseUnits (#4287 stage 4)

For a crypto->crypto buy, copy the linked crypto_input's captured on-chain base units
(stage 1) into buy_crypto.inputAmountBaseUnits at creation, so the exact deposit amount
survives the <=8-dp float collapse in `inputAmount`. The existing float `inputAmount`
write is unchanged. Fail-open null for fiat buys and legacy/uncaptured deposits.

Tests: exactness beyond 8 dp (18-dp wei propagated verbatim) and fail-open null when the
crypto_input carries no base units; existing float column asserted unchanged.
…twork-start amounts (#4287 stage 4)

At payout completion, propagate the linked payout_order's EXACT broadcast base units
(stage 1) into buy_crypto:
- outputAmountBaseUnits: the amount actually delivered on-chain (main payout)
- networkStartAmountBaseUnits: the network-start fee actually paid out on-chain

checkOrderCompletion now also returns payoutAmountBaseUnits as a STRING (never a bigint,
since the payout controller JSON-serialises this result); the buy-crypto out service
converts it back to a bigint for the numeric columns, fail-open null when uncaptured. The
existing float writes (outputAmount, networkStartAmount) and every other consumer of
checkOrderCompletion are unchanged.

Tests: complete() stores exact delivered base units beyond 8 dp (18-dp wei verbatim) and
fails open to null when none provided; isComplete/status unchanged.
…act base-unit capture (#4287 stage 3)

The Solana/Monero/Zano register strategies scale the exact on-chain amount to
crypto_input.amountBaseUnits via fromDecimalString(amountExact, asset.decimals).
These three native coins had asset.decimals NULL, so that call returned undefined
(fail-open) and the ledger instead derived an 8-dp-capped value from the float,
dropping the 9th-12th on-chain decimal. Migration 1784600000007 sets the native
scale (SOL=9, XMR=12, ZANO=12) so the >8-dp exact capture runs.

NULL was unset data, not a coded assumption: the only automated decimals populator
(EvmDecimalsService) covers EVM chains only, so non-EVM native coins were never
populated. The float amount, deposit-detection and min-deposit paths use each
chain's own fixed scale (auToXmr 10^12, ZANO_DECIMALS 12, lamports 9), never
asset.decimals, so the change is additive: only the exact amountBaseUnits column
is affected and booked float amounts are unchanged. The migration is idempotent
(WHERE decimals IS NULL) and identifies each coin precisely by name + blockchain +
type = Coin.

Adds a strategy-level test proving each chain now yields a >8-dp exact
amountBaseUnits and fails open when decimals are unset.
…nt (#4287 stage 4)

Add a nullable, additive numeric column to buy_fiat holding the EXACT integer base units of
the sold crypto's on-chain deposit amount, ALONGSIDE the untouched float column:
- inputAmountBaseUnits: from the crypto_input on-chain deposit (stage 1)

The sell flow's OUTPUT is a fiat bank transfer (no on-chain base units) and the CHF/fee
columns keep their own exact model (amountChfCents), so the deposit is the only crypto leg
with an exact upstream integer. Schema + migration only; population wired in a follow-up
commit. numeric maps to a JS bigint via baseUnitsTransformer. Nullable, no backfill,
fail-open null.
…eUnits (#4287 stage 4)

For a sell, copy the linked crypto_input's captured on-chain base units (stage 1) into
buy_fiat.inputAmountBaseUnits at creation, so the exact deposited crypto amount survives the
<=8-dp float collapse in `inputAmount`. The existing float `inputAmount` write is unchanged.
Fail-open null for legacy/uncaptured deposits.

Tests: exactness beyond 8 dp (18-dp wei propagated verbatim) and fail-open null when the
crypto_input carries no base units; existing float column asserted unchanged.
…mount (#4287 stage 4)

Add a nullable, additive numeric column to ref_reward holding the EXACT integer base units
of the referral reward payout, ALONGSIDE the untouched float column:
- outputAmountBaseUnits: from the REF_PAYOUT payout_order on-chain broadcast (stage 1)

Schema + migration only; population wired in the follow-up commit. numeric maps to a JS
bigint via baseUnitsTransformer. Nullable, no backfill, fail-open null.
…BaseUnits (#4287 stage 4)

At payout completion, propagate the linked REF_PAYOUT payout_order's EXACT broadcast base
units (stage 1) into ref_reward.outputAmountBaseUnits, so the referral reward actually
delivered on-chain survives the <=8-dp float collapse in `outputAmount`. complete() takes the
value (fail-open null when the payout/row did not capture it); the ref-reward-out service
converts the JSON-serialised string from checkOrderCompletion back to a bigint. The existing
float `outputAmount` write and every other consumer are unchanged.

Tests: complete() stores exact delivered base units beyond 8 dp (18-dp wei verbatim) and
fails open to null when none provided; txId/status/isComplete/outputAmount unchanged.
…forwards (#4287)

Close the deposit-forward network-fee exactness deferral. At OUTPUT confirmation the
forward tx is mined, so its exact gasUsed*effectiveGasPrice wei is read via
EvmClient.getTxActualFeeBaseUnits and persisted on a new nullable column
crypto_input.forwardFeeAmountBaseUnits (numeric, baseUnitsTransformer), atomically with the
FORWARD_CONFIRMED transition. The accounting consumer books it verbatim on the seq1
network-fee wallet leg, superseding the estimate-derived float for the native fee leg.

Scoped to EVM COIN forwards, where the native gas coin IS the forwarded/seq1-booked asset
at 18-dp wei (18-dp guard mirrors the payout coin@18 guard). Token forwards (gas in a
different asset than the seq1 leg's deposit token), non-EVM chains, legacy rows and any
capture error keep the column null and the ledger derives from the float (fail-open).

Strictly additive: the estimate float forwardFeeAmount/CHF and every existing booking are
untouched; nullable column, no historical backfill. The fee tx carries a CHF EXPENSE leg,
so it is cross-currency and assertNativeBalance early-returns (never in its throw scope).
…ana and Cardano (#4287)

Close the non-EVM payout-fee exactness deferral where an exact on-chain fee integer exists.
Solana exposes the tx fee as raw lamports (meta.fee) and Cardano as raw lovelace
(transaction.fee); both are read verbatim as bigint via new getTxActualFeeBaseUnits client
methods and surfaced through getPayoutCompletionData. The payout strategy persists them on
the existing payout_order.payoutFeeAmountBaseUnits, guarded to the native fee asset's scale
(SOL @ 9 dp lamports, ADA @ 6 dp lovelace) so a mis-scaled asset fails open to null. The
accounting consumer already books payoutFeeAmountBaseUnits verbatim on the native fee leg
(exercised by SPL/CNT token payouts, where the fee coin is a distinct leg from the payout
token); coin payouts fold the same-asset fee and suppress the override, unchanged.

Strictly additive + fail-open: the float payoutFeeAmount/CHF and existing bookings are
untouched; any capture error, a mis-scaled/unconfigured fee asset, or a non-Solana/Cardano
chain keeps the column null and the ledger derives from the float. No new column, no
migration, no backfill. Honest deferrals remain for Tron (native TRX decimals not yet
activated), ICP (fixed transfer-fee constant, not a per-tx on-chain integer) and the
zero/near-zero-fee L2s; swaps are EVM-only, so there is no non-EVM swap fee.
The native-first exactness base-unit columns (#4287) are bigint. Several admin
endpoints return the raw entity, so once such a row is populated JSON.stringify
throws "Do not know how to serialize a BigInt" and the request 500s. Register a
global BigInt toJSON in the bootstrap polyfills (loaded before app creation) that
renders a bigint as its exact decimal string; the values can exceed 2^53, so a
string is the only lossless wire form. The DB transformers are unaffected (they
serialize via .toString()/Number, not JSON).
…ts tuple

getPayoutCompletionData now returns [isComplete, payoutFee, feeBaseUnits]; the
Solana and Cardano service specs still asserted the two-element tuple and were the
red suites. Assert the exact fee base-units on the complete case and null on the
incomplete case, mirroring the payout strategy base spec.
…rvice

Guard that checkCompletion propagates the payout order exact broadcast base units
verbatim into the delivered output and the network-start fee, and fails open to
null when uncaptured.
…trategy

Both files had been rewritten wholesale by line-ending normalization, drowning
the intended additions in diff noise. Restore develops CRLF so only the
getTxActualFeeBaseUnits / amountBaseUnits changes remain.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Review complete — ready for merge

Native-first exact-amounts, delivered as one PR. Every amount that has a finer real truth than the 8-dp float is now stored and booked as exact integer base units, end to end; CHF stays exact via amountChfCents. Strictly additive and backward-compatible — no float column, computation, threshold, or API wire format changed.

What is now exact

  • On-chain crypto — wei/satoshi/atomic-exact at capture: EVM & BTC deposits/withdrawals (Stage 1), swaps & bridges (Stage 2), Solana/Monero/Zano deposits + Solana withdrawal (Stage 3), with SOL/XMR/ZANO native decimals activated (9/12/12). Captured string/BigInt-pure before any parseFloat; booked verbatim into the ledger leg via the LedgerLegInput.amountBaseUnits override.
  • Exchange amounts: investigated — source-exact at ≤8 dp already (ccxt delivers ≤8 dp); no fake plumbing.
  • Gas/network fees: EVM (gasUsed·effectiveGasPrice + L2/Citrea overrides), EVM deposit-forward fee, and non-EVM payout fees (Solana, Cardano) booked exact on the fee leg.
  • Operational propagation: buy_crypto (in/out/network-start), buy_fiat (input), ref_reward (output) copy the exact upstream integer verbatim (never re-derived from the float).
  • Ledger core (Ledger native-first exactness: make native asset quantities an exact, enforced first-class invariant (not a reconciled float) #4280): §7 reconciliation + mark-to-market compute on exact base units for single-asset accounts (native-float fallback for shared/bridge/mixed-decimals/legacy-null); assertNativeBalance enforces the per-asset base-unit invariant for single-currency same-asset transfers.

Safety

  • Additive & fail-open throughout: any leg/asset without an exact source falls back to the existing 8-dp-derived path; nothing new throws into live ingestion/payout/booking (except the tightly-scoped native invariant).
  • No historical backfill — new bookings only; legacy rows stay NULL.
  • BigInt.prototype.toJSON added (bigint base-unit fields serialize as exact decimal strings; the raw-entity admin endpoints stay 500-safe).

Verification & review

  • CI fully green (Build and test, review, CodeQL, Migration immutability, Analyze).
  • Two adversarial review passes per stage plus a final whole-PR sweep (float-leak, assertNativeBalance false-throw, propagation scale, reconciliation gating, CHF invariant, conformity/regression). All confirmed findings fixed: the stale Solana/Cardano completion specs, the bigint-JSON serialization, the missing BuyCryptoOutService test, and diff-noise line endings.
  • All commits authored TaprootFreak, SSH-signed (verified), no history rewrite.

Documented deferrals / non-goals

  • Monero/Zano withdrawals are rounded to 8 dp before broadcast, so no finer on-chain integer exists (derived = exact-to-sent); a few non-EVM/folded fee legs derive; >2^53 Monero deposits fail-open.
  • The float-format conversion of already-exact CHF/fiat operational columns was intentionally not done — it yields no accuracy gain (those values are correct, and the accounting CHF is exact via cents).
  • Removing the float shadow columns is out of scope — it would break the API and needs a coordinated frontend release.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 20, 2026 21:44
@TaprootFreak
TaprootFreak marked this pull request as draft July 21, 2026 06:49
@TaprootFreak
TaprootFreak marked this pull request as ready for review July 21, 2026 06:54
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Re-review of the final fix commits (43d0587, 98bdbf3, bbac306, f06d9b4) complete — the two adversarial passes now cover HEAD. All prior findings closed: the bigint toJSON polyfill is strictly additive (native JSON.stringify on a bigint always threw, so no working consumer could regress — it only turns former 500s into lossless decimal strings), the Solana/Cardano completion specs match the real 3-tuple, the BuyCryptoOutService test is non-vacuous, and the line-ending noise is reverted. CI green, all 40 commits verified. Ready for review/merge.

@github-actions

Copy link
Copy Markdown

❌ Security: 1 critical vulnerabilities

@TaprootFreak
TaprootFreak marked this pull request as draft July 21, 2026 07:56
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

CI fully green after merging the latest develop (clean auto-merge, merge commit 31e7df6, verified) — this pulls in the #4300 tar override so the critical npm audit gate now passes. All gates green: Build and test, review, CodeQL, Migration immutability, Analyze. Re-marking ready for review/merge.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 21, 2026 14:45
@TaprootFreak
TaprootFreak merged commit 7d540e6 into develop Jul 21, 2026
8 checks passed
@TaprootFreak
TaprootFreak deleted the feat/onchain-wei-exact-ingestion branch July 21, 2026 15:25
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