Skip to content

Release: develop -> main#4302

Merged
TaprootFreak merged 4 commits into
mainfrom
develop
Jul 22, 2026
Merged

Release: develop -> main#4302
TaprootFreak merged 4 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

…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
TaprootFreak merged commit a6c35e6 into main Jul 22, 2026
12 checks passed
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