feat(ledger): native-first exact amounts — exact base-unit ledger + on-chain wei-exact ingestion (#4280, #4287)#4288
Conversation
…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.
… native invariant
…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.
…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.
Stage 2 — on-chain SWAP & BRIDGE legs wei-exactExtends 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
Honest deferrals (fail-open → the ledger derives from the ≤8-dp float; no precision faked):
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.
Review complete — ready for mergeNative-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 What is now exact
Safety
Verification & review
Documented deferrals / non-goals
|
|
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 |
❌ Security: 1 critical vulnerabilities |
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.amountBaseUnitscolumn, 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);assertNativeBalanceenforces the per-asset base-unit invariant (exact0n) for single-asset same-currency transfers, with the fiat/mixed-decimals/null-decimals soft fallbacks. Floatamountuntouched.B. On-chain wei-exact ingestion — Stages 1-2 of #4287 — the raw on-chain integer is captured before any
parseFloat: EVM deposits fromrawContract.rawValue, BTC deposits via string-scaled satoshi; EVM withdrawals capture the exact broadcast integer (sentBaseUnitsmirrors the clienttoWeiAmount/parseEther); Stage 2 extends the same capture to swap/bridge source legs at broadcast. Persisted oncrypto_input/payout_order(and the swap/bridge source entities) as nullableamountBaseUnitsand booked verbatim into the ledger leg (else derived). Reusable transformer moved tosrc/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 asNum= JS float (ccxt/js/src/base/types.d.ts:92) and its default number handler isNumber, 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 pathtoBaseUnits(amount, min(decimals,8))(ledger-booking.service.ts:399), whosetoFixed(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):
crypto_input.amountBaseUnits(no float step); withdrawal —SolanaUtil.toBroadcastBaseUnitscaptures the exact broadcast lamports/token integer (coin@9 / token@decimals guard, mirroring the EVM coin@18 rule) intopayout_order.amountBaseUnits.fromBaseUnits; Zano sums several same-asset receive entries exactly. (Withdrawals stay float-derived — see deferrals.)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
amountcolumns and all existing API/DTO fields are unchanged; exact values are added alongside. No wire-format break.BitcoinBasedStrategy.aggregatePayoutrounds 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 byJSON.parsein 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_inputseq1) — the exact wei IS available (getTxActualFeein 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).