Release: develop -> main#3790
Merged
Merged
Conversation
…eaker (#3789) * fix(payout): harden BTC amount serialization and add retry circuit-breaker PR #3729 fixed only the fee_rate field. The amounts field in send/sendmany RPC is parsed with ParseFixedPoint(decimals=8) and silently rejects values with more precision (e.g. 0.30000000000000004 from JS float arithmetic) via "Invalid amount" (error code -3). On 2026-05-29 two BTC BuyCrypto payouts stalled for 43 min and 8 min in stochastic retry loops before random floating-point rolls let them through (Orders 106888, 106891). This change applies three complementary hardenings: * Quantize every payout amount to 8 decimals in PayoutBitcoinService.sendUtxoToMany before the RPC call and reject NaN/Infinity/non-positive amounts with a structured InvalidPayoutAmountException, so a bad input fails fast with a clear error instead of being lost as an opaque "Invalid amount" inside Bitcoin Core. fee_rate is re-rounded to 3 decimals as defense-in-depth. * Persist a retry counter, last error and last-attempt timestamp on payout_order (new migration AddPayoutOrderRetryTracking) so operators can see which orders are chronically failing instead of inferring it from log pattern matching. * Fire an operator alert via the existing ErrorMonitoringMail pattern as soon as a payout group has failed 5+ times (~2.5 min of silent retry). suppressRecurring + 1h debounce keep the inbox quiet on long incidents. Tracking + alert live in BitcoinBasedStrategy.send() so they cover every UTXO-derived chain that inherits the base; the amount sanitization is deliberately scoped to BTC. * fix(payout): address review feedback on retry circuit-breaker * notification options: drop suppressRecurring, keep debounce 1h — the entity short-circuits `suppressRecurring || isDebounced` so combining both would silence every retry forever instead of giving 1 alert/group/hour * correlationId sorts ids before joining — Postgres findBy has no ORDER BY, so an unordered group would generate a fresh correlationId each retry and defeat the debounce * min → max threshold semantic — a fresh order joining a stuck group should not silence the alert; the underlying RPC error fails the whole sendmany call, so any single order over threshold is signal worth surfacing * guard empty orders array — Math.min/max on [] yields ±Infinity which would otherwise crash the alert path on orders[0].asset.name * tighten the quantization test — assert the post-round value is observably distinct from the un-rounded raw and lies on the 8-decimal grid * InvalidPayoutAmountException sets this.name so logs identify the class * rename recordPayoutFailure parameter `error: string` → `message: string` to match what it actually receives * move trackPayoutFailure + sendRecurringPayoutFailureAlert under the existing //*** HELPER METHODS ***// section header for consistency * fix(payout): update threshold-constant comment to reference debounce (not suppressRecurring)
Custody assets (e.g. Scrypt/USDT) with price rules had their approxPrice updated but no asset_price rows written, so historical price lookups in exchange spread fee calculations failed with "No price found".
* Route Scrypt EUR via USDT instead of BTC Re-point Rule 313 (Scrypt/EUR redundancy) from Action 261 (Scrypt sell-if-deficit BTC) to Action 233 (Scrypt sell USDT), matching the existing CHF route (Rule 312). Remove the now unreferenced Action 261. Scrypt's BTC/EUR pricing is structurally worse than its USDT pairs (~0.6% spread vs ~0.13%). Incident on 2026-05-21 saw a 570k EUR buy_crypto routed to Scrypt BTC/EUR at +0.41% premium over Kraken VWAP because Rule 210 (Binance USDT refill) was Inactive. Routing EUR via USDT eliminates the costly BTC fill. Rule 314 (Scrypt/BTC withdraw) is retained as cleanup path for any residual BTC sitting on Scrypt. Closes #3739 * feat(lm): remove BTC purchase mechanism on Scrypt Structurally remove the ability to acquire BTC via Scrypt. Scrypt's BTC/EUR spreads are materially worse than its USDT/EUR pairs (~0.6% vs ~0.13%). BTC acquisition must route through Binance USDT. - Drop the `sell-if-deficit` command from ScryptAdapter (enum entry, command registration, completion check, param validation, the `sellIfDeficit` method itself and its param helpers) - Drop now-unused constructor dependencies and imports (LiquidityManagementRuleRepository, LiquidityBalanceRepository, BuyCryptoService, PriceCurrency, forwardRef/Inject) - Add structural guards in `sell()` (tradeAsset === 'BTC') and `buy()` (targetAsset.dexName === 'BTC') that throw OrderNotProcessableException before any balance/price logic — no future LM rule misconfiguration can re-introduce BTC acquisition via Scrypt Relates to #3739 * test: add CI check for MSSQL syntax in post-PSQL migrations Scans new migration files for common MSSQL patterns (dbo., IDENTITY_INSERT, TOP N, NVARCHAR, DATETIME2, GETDATE, bracket quoting) that would fail on PostgreSQL. Runs as part of npm test in the PR CI pipeline. * fix(migration): rewrite RouteScryptEurViaUsdt for PostgreSQL Replace MSSQL syntax with PostgreSQL equivalents: - Remove dbo. schema prefix - Remove IDENTITY_INSERT ON/OFF (SERIAL columns accept explicit ids) --------- Co-authored-by: David May <david.leo.may@gmail.com>
…t found (#3792) When a prepare tx (ETH gas top-up) is never broadcast or dropped from the mempool, the pay-in stays in Preparing status forever — checkPreparation returns false each cycle because getTransactionReceipt returns null for a non-existent tx, but there is no timeout to recover. Reset individual stale pay-ins (not the whole group) after 1h: if a pay-in has been Preparing for over 1 hour, reset it to Acknowledged so the next cycle retries. If any pay-in in a group was reset, skip the group for this cycle — the reset pay-ins will be re-grouped on the next run. Observed on crypto_input 432892 (buy_fiat 68232): ZCHF token forward on Ethereum stuck for 3 days because Alchemy returned 503 during the original prepare, the tx hash was stored but never mined.
* [NOTASK] fix inProgress step bug * [NOTASK] Refactoring
* fix(kyc): enforce unique constraint on nullable columns in kyc_step and user_data indexes PostgreSQL treats NULL != NULL in unique indexes, so the composite unique index on kyc_step (userData, name, type, sequenceNumber) silently allowed duplicate FinancialData steps when type was NULL. Similarly, the user_data unique index was missing a NULL filter on the nationality column. - Add NULLS NOT DISTINCT to kyc_step unique index (PG 15+) - Add nationalityId IS NOT NULL to user_data unique index WHERE clause * fix(kyc): remove migration comment from entity * fix: use NULLS NOT DISTINCT on both indexes instead of WHERE clause for nationality
davidleomay
approved these changes
Jun 1, 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