Skip to content

Release: develop -> main#3790

Merged
davidleomay merged 6 commits into
mainfrom
develop
Jun 1, 2026
Merged

Release: develop -> main#3790
davidleomay merged 6 commits into
mainfrom
develop

Conversation

@github-actions
Copy link
Copy Markdown

@github-actions github-actions Bot commented Jun 1, 2026

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

…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)
davidleomay and others added 5 commits June 1, 2026 10:22
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 davidleomay merged commit c1f5b31 into main Jun 1, 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.

3 participants