Release: develop -> main#4302
Merged
Merged
Conversation
…tical audit) (#4300)
github-actions
Bot
requested review from
TaprootFreak and
davidleomay
as code owners
July 21, 2026 14:22
…n-chain wei-exact ingestion (#4280, #4287) (#4288) * feat(ledger): store the native quantity as exact integer base units (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). * feat(ledger): guard toBaseUnits against out-of-domain magnitude; tighten 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. * feat(ledger): enforce the same-asset native invariant on exact base units (#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). * feat(ledger): reconcile and mark-to-market on exact base units (#4280) 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. * feat(ledger): backfill transit counter base units for same-currency transfers (#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. * style(ledger): apply prettier formatting to accounting specs * fix(ledger): scope exact base-unit transit cumulation to single-asset 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. * fix(ledger): skip exact base-unit assertion for mixed-decimals same-ticker 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. * style(ledger): reformat base-unit migrations to the repo 2-space + semicolon 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. * fix(ledger): treat null-decimals asset legs as ambiguous scope in the native invariant * refactor(shared): extract reusable exact base-units transformer (#4287 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). * feat(payin): capture exact on-chain base units at ingestion (#4287 stage 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. * feat(ledger): book captured exact base units verbatim into on-chain legs (#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). * feat(payout): capture exact on-chain send amount at EVM broadcast (#4287 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). * refactor(ledger): harden on-chain exact base-unit capture to mirror the 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. * feat(shared): add reusable EVM exact swap/bridge base-unit capture helpers (#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. * feat(accounting): add nullable exact base-unit columns to swap/bridge 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). * feat(trading,dex,bridge): capture exact on-chain swap/bridge base units 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. * feat(ledger): book captured swap/bridge base units verbatim into the 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. * feat(shared): add exact fromBaseUnits inverse for non-EVM base-unit capture (#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. * feat(solana): capture exact on-chain base units at deposit + withdrawal (#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. * feat(monero): capture exact atomic-unit base units at deposit (#4287 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. * feat(zano): capture exact atomic-unit base units at deposit (#4287 stage 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. * feat(blockchain): expose exact on-chain gas fee as integer wei on the 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). * feat(payout,ledger): book the EVM withdrawal gas fee wei-exact on the 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. * feat(dex,ledger): book the DfxDex swap gas fee wei-exact on the network-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. * feat(buy-crypto): add exact base-unit columns alongside float amounts (#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. * feat(buy-crypto): propagate crypto_input base units into inputAmountBaseUnits (#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. * feat(buy-crypto): store on-chain-broadcast base units for output + network-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. * feat(asset): set native-coin decimals for SOL/XMR/ZANO to activate exact 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. * feat(buy-fiat): add exact base-unit column alongside float input amount (#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. * feat(buy-fiat): propagate crypto_input base units into inputAmountBaseUnits (#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. * feat(ref-reward): add exact base-unit column alongside float output amount (#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. * feat(ref-reward): propagate payout_order base units into outputAmountBaseUnits (#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. * feat(payin): capture exact on-chain forward gas fee wei for EVM coin 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). * feat(payout): capture exact non-EVM payout gas fee base units for Solana 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. * fix(core): serialize bigint base-unit fields as decimal strings 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). * test(payout): align Solana/Cardano completion specs with fee base-units 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. * test(buy-crypto): cover exact base-unit propagation in BuyCryptoOutService 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. * style: restore original CRLF line endings in base-client and monero strategy 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. * test(accounting): cover exact base-unit zero-crossing in ledger reconciliation
…inancial-log validity (#4295) * feat(auth): allow the Debug role to retry uncertain payouts and set financial-log validity Both endpoints serve forensic work that the Debug role already performs, but were gated on Admin alone. The retry endpoint's precondition is an attestation that the payout is absent on-chain — establishing that is exactly what the Debug role's read-only tooling is for, so requiring a second role to state the finding split the verification from the confirmation. The retry endpoint stays narrowly bounded: it takes an order id and moves it from PAYOUT_UNCERTAIN to PREPARATION_CONFIRMED. It cannot alter the amount, the asset or the destination address, so widening it does not create a path to move funds anywhere new. setFinancialLogValidity is a bulk update whose valid flag feeds accounting baselines (ledger cutover snapshot selection, equity-parity reconciliation), and it previously wrote no audit trail at all. Since it is now reachable by a more widely granted role, it logs who set which value, over which filter window, and how many rows changed — mirroring the audit line retryUncertainPayout already writes. Deliberately unchanged: doPayout, checkOrderCompletion and speedupTransaction stay Admin-only, as does PUT /log/:id, which can overwrite arbitrary log content rather than just the valid flag. * fix(log): persist the pre-state of a financial-log validity sweep before updating CONTRIBUTING requires that a mutation stay reconstructible from the DB alone, with the previous value recorded before the column update and the update skipped if the audit write fails. The stdout log added in the previous commit satisfied neither: it held only an aggregate count and never reached the database. This matters because the repository predicate is 'valid IS DISTINCT FROM :target', which also matches rows where valid IS NULL — 320 such FinancialDataLog rows exist in production. Once swept they were indistinguishable from rows that previously carried the opposite boolean. setFinancialLogValidity now resolves the exact change set first, persists an audit row carrying the affected ids grouped by their previous validity (true/false/null) together with the account, target value, every filter bound and the caller's reference, and only then runs the update. The audit write is not caught, so a failure leaves the column untouched. An empty change set writes nothing and returns affected 0. The change set and the update share one private condition builder, so the audited rows and the updated rows cannot drift apart. SetFinancialLogValidityDto gains a mandatory reference, mirroring the verification reference the payout retry endpoint already requires — the audit answered who, what and which range, but not why. Also simplifies both guards to RoleGuard(UserRole.DEBUG): the role hierarchy already lists ADMIN and SUPER_ADMIN as super-roles of DEBUG, so naming ADMIN was redundant and suggested that removing it would lock admins out. * fix(log): bind the validity update to the audited rows The previous commit claimed the shared condition builder kept the audited and the updated rows in step. That was wrong: a shared predicate guarantees the same condition, not the same data. The change-set select, the audit insert and the update ran as three separate autocommit statements. Since 'to' is optional the window is open-ended, and FinancialDataLog rows are inserted by cron roughly every minute. A row inserted between the select and the update satisfied 'valid IS DISTINCT FROM :target', was flipped along, and appeared in no audit — reintroducing exactly the unreconstructible overwrite this work set out to remove. The update now takes the audited ids and filters on them, batched by 100 like the cleanup path, summing the affected counts. 'valid IS DISTINCT FROM :target' is kept so rows already matching the target stay no-ops. Audited and updated rows are therefore the same set by construction rather than by assumption. Verified against a real Postgres: with a row inserted mid-sweep the unbound predicate matches 3 rows while the id-bound update touches exactly the 2 audited ones. Further review findings addressed: - The audit recorded 'affected' from the change-set size while the response returned the update result — two different numbers under one name. The audit field is now 'auditedRows', and a divergence between the update result and the audited count is logged as an error rather than passing silently. - A sweep is capped at 10000 rows; a full sweep would otherwise match ~35000 rows in production and put every id into a multi-megabyte audit record. Over the cap the request fails before anything is written. - The change set is read with getRawMany instead of hydrating full entities, matching the cleanup path. - Dropped a 'valid ?? null' that silently coerced a missing column into the null bucket, which would have made the audit claim every row was previously null instead of failing loudly. - The audit subsystem is now a shared constant and is protected in both generic log paths: cleanup skips it, and PUT /log/:id refuses to modify it. * fix(log): run the validity sweep in one locked transaction and close the remaining audit gaps Review follow-ups, all from the same root concern: the audit must describe exactly what happened, and nothing outside the audited endpoint may touch what it describes. Atomicity. The change set, the audit insert and the batched update now run inside a single transaction, and the change-set select takes a pessimistic write lock. Two gaps close with it: a concurrent writer can no longer change an audited row's validity between select and update, which would have left the recorded pre-state describing a value the row no longer had while the affected count still matched; and a batch failing midway no longer leaves earlier batches committed under an audit record claiming the full sweep. Verified against a real Postgres: FOR UPDATE composes with the ORDER BY and LIMIT, and a rollback drops the audit row together with the update. Reach. The change-set query is capped one row above the sweep limit, so an over-broad request is rejected without materialising the whole history and casting every message to jsonb first. Fabrication and second routes. The audit subsystem is now refused by POST /log, which would otherwise let a caller invent evidence of a sweep that never ran. PUT /log/:id additionally refuses to change validity on a FinancialDataLog row — that is the very mutation this endpoint makes auditable, and leaving the generic route open would have kept an unaudited path to it. Message and category edits there are untouched. Correctness details. UpdateResult.affected is number | null | undefined; the previous cast let a missing count poison the sum as NaN and trip the divergence check forever, so a missing count now fails loudly. The batch sum uses Util.sum instead of a hand-rolled reduce. Both financial subsystem names are shared constants rather than repeated literals. Adds log.repository.spec.ts covering what the service tests mock away: the cleanup guard on audit rows, the 100-row batching with per-batch id binding, and the missing-count throw. Full suite green (4413 tests); removing the four new guards fails exactly four tests. * refactor(log): enrol the sweep repository via new LogRepository(manager) and finish the constant Two consistency follow-ups from review. The change-set and update methods no longer take an EntityManager parameter — a shape no other repository in the codebase uses. Instead the service re-creates the repository on the transaction manager (new LogRepository(manager)), the pattern bank-tx.service already uses, and the methods build their queries through this.createQueryBuilder as usual. Behaviour is unchanged; the queries still run on the transactional manager. The remaining 'FinancialDataLog' literals in the repository now use the shared FINANCIAL_DATA_LOG_SUBSYSTEM constant, so the commit message's earlier claim actually holds for the whole file rather than one line of it. Full suite green (4413 tests); removing the four guards still fails exactly four tests.
* feat(bank): add Bank Frick as a second virtual-IBAN provider Introduce a VibanProvider abstraction so personal-IBAN issuance is no longer hardcoded to Yapeal/CHF, and wire Bank Frick's VBAN API in behind it to issue EUR personal IBANs. Frick's create -> activation-approval lifecycle is fully encapsulated and fail-closed (a vIBAN is only ever persisted active on a confirmed ACTIVE state), and is opt-in via FRICK_VBAN_BASE_URL, so there is no behaviour change when it is unset. Rename the Yapeal-specific virtual_iban.yapealAccountUid column to the provider-neutral providerAccountRef. Closes #4247 * fix(bank): harden Frick vIBAN response validation and complete config surface - validate and canonicalize the returned vban (IBAN length/shape) before it is persisted as the virtual_iban IBAN, and require the createdAt/createdBy response fields, so a malformed response fails closed at the API boundary instead of at DB insert after the vIBAN is already live at the bank - expose FRICK_VBAN_BASE_URL across the deploy config (bicep param + env mapping, dev/loc/prd parameter files, .env.example, operations docs), empty and off by default - drop the redundant default parameters on the shared signed-request helper
TaprootFreak
approved these changes
Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automatic Release PR
This PR was automatically created after changes were pushed to develop.
Commits: 1 new commit(s)
Checklist