Skip to content

Release: develop -> main#4256

Merged
TaprootFreak merged 19 commits into
mainfrom
develop
Jul 20, 2026
Merged

Release: develop -> main#4256
TaprootFreak merged 19 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

…transition (#4236)

* fix(payout): make the payout designation an atomic conditional state transition

The designation introduced for the designate-before-broadcast protection was a
plain full-entity save with no status precondition. The payout cron lock times
out after 1800s while a run may still be executing, so two overlapping runs
could both select the same PreparationConfirmed orders: a stale run could
re-broadcast an order already broadcast by a newer run and overwrite its
persisted PayoutPending/payoutTxId state.

Designation is now a conditional UPDATE (WHERE status = PreparationConfirmed);
a caller that loses the race skips the order entirely. On the bitcoin-family
batch path, designation runs before aggregation and only the winning orders
are dispatched, failure-tracked and saved.

Closes #4232

* fix(payout): handle claim errors fail-safe and make retry rollback and escalation conditional

Review follow-ups: the conditional designation criteria move into a shared
claimForBroadcast helper that also absorbs UPDATE errors (an unclaimed order
stays re-selectable - escalating or rolling back an order this run never
owned would reintroduce the stale-save class). The OOG retry release and the
processFailedOrders escalation become conditional transitions as well, since
both could overwrite a concurrently persisted PayoutPending/payoutTxId via
full-entity saves. Escalation mails now cover only actually escalated orders.

* style: format payout strategies and service spec

* test: fix loser-exclusion assertion (untouched order has no lastError)

* chore: remove stray scratch file

* fix(payout): make the failed-order escalation resilient and overlap-aware

Review follow-ups: processFailedOrders now skips orders designated within the
last minute (an overlapping run is broadcasting them right now - escalating
would alert on an order that resolves itself moments later), isolates
per-order escalation failures instead of stranding already-escalated orders
unmailed, and keeps the alert retryable: if the mail fails, the escalated
orders are conditionally reverted to PayoutDesignated so the next cycle
re-attempts escalation and mail. The mail lists only orders this run actually
escalated.

* test: empty-tx-id regression asserts no entity save under conditional designation

* fix(payout): drop the dead pendingInvestigation helper and pin escalation call counts

Review follow-ups: the conditional escalation removed the helper's only
production caller; the success-path tests now pin the exact number of
conditional updates (a revert after a successful mail would add calls), and
the escalation comment states the alert-delivery residuals accurately
(retryable up to the notification persistence; transport-level delivery is
owned by the notification subsystem).

* test: pin the escalation update count in the intended success test
TaprootFreak and others added 2 commits July 17, 2026 20:37
…ot the incoming one (#4261)

The bank_tx consumer resolved a BUY_CRYPTO_RETURN's owed CHF from tx.buyCrypto,
which is the inverse of buy_crypto.bankTx — the incoming funding payment, always
undefined for a return row. The charged-back buy_crypto hangs off
buy_crypto.chargebackBankTx (tx.buyCryptoChargeback), so every return threw
'without buyCrypto.amountInChf', froze the bank_tx watermark and stalled all
later bank_tx bookings — regardless of pricing.

Read tx.buyCryptoChargeback in buyCryptoOwedChf and load the relation in both the
forward and content-scan batch loaders. The incoming BUY_CRYPTO path keeps
reading tx.buyCrypto, which is correct there. The return still closes the owed the
forward path opened — no skip, no accounting change.

The spec masked the bug by wiring the return mock's buyCrypto field directly; the
production row has buyCrypto undefined and buyCryptoChargeback set. All return
cases now wire buyCryptoChargeback, plus a regression test with buyCrypto undefined.
#4258)

Since the prod cutover the same-asset native-imbalance error fires in
batches (USDT/BTC/CHF/BNB/XMR, fee-shaped amounts), but the line carries
no reference — the producing consumer cannot be identified from logs, and
the ledger tables are not debug-queryable. Attach sourceType/sourceId/seq
and the per-leg amounts so every occurrence names its producer; the
missing-fee-leg fix can then target the right consumer.
…e-broadcast protection (#4237)

The expired-retry path re-entered doPayout with payoutTxId still set, assuming
nonce reuse would replace the pending tx. For an expired tx the hash has
vanished, getTxNonce resolves undefined and dispatch draws a fresh nonce - the
retry was an independent second transaction outside the designate-before-
broadcast protection. It now goes through the shared rollbackBroadcastForRetry
release like the out-of-gas path, so a crash window stays closed and an
ambiguous failure escalates instead of silently looping.

The release also records every replaced tx hash in a new append-only
releasedPayoutTxIds column (migration included), so a PayoutUncertain
investigation can still reconstruct the vanished hash from the DB after the
retry nulls payoutTxId. The column is exposed via the debug allowlist.

Closes #4229
…rs and cap their retries (#4238)

* fix(payout): classify provably-pre-broadcast bitcoin-family send errors and cap their retries

Since the fail-closed broadcast boundary, every send-RPC failure on the
bitcoin-family path escalates to PayoutUncertain within 30s - including error
classes that provably had no broadcast (connection-establishment failures,
insufficient-funds/wallet-locked RPC errors, the Monero unlocked-balance race)
and previously self-healed. At the same time the maxPreBroadcastRetries cap
did not apply to this path at all, so a deterministic pre-broadcast failure
could loop every 30s forever.

Send-boundary errors are now classified: connection-establishment syscall
failures and a narrow allowlist of deterministic pre-funding wallet RPC codes
(cited from the official sources) stay plain errors and roll back for capped
auto-retry; everything ambiguous keeps failing closed as TxBroadcastError.
Zano stays fully fail-closed (codes not confirmable). The rollback path now
respects the cap: orders over it keep PayoutDesignated and escalate.

Closes #4230

* style: format tx-broadcast.error.spec.ts

* fix(payout): classify parsed RPC errors independent of HTTP status, harden the cap partition

Review follow-ups: bitcoind delivers in-band JSON-RPC errors of single
requests over HTTP 500, so the blanket http-response override made the
bitcoin/firo allowlist unreachable in production. The classifier now walks
only client-attached links (cause/error, never raw response payloads): a
parsed numeric RPC code classifies by the allowlist regardless of transport
status, while bare transport errors carry no parsed code and stay fail-closed
by construction. Client tests now model the real transport shape (rejected
HTTP 500 with parsed error body). The cap partition routes a misconfigured
(NaN) cap loudly into the fail-closed branch, Zano passes an explicit empty
allowlist, and the merged empty-tx-id guards are preserved unchanged.

* style: format classifier spec and monero/zano clients

* fix: restore the dedicated empty-tx-hash guard on the zano transfer boundary

* style: drop stray blank line in zano transfer guard

* test(payout): cover the NaN cap partition and align the firo raw-broadcast guard

Review follow-ups: the NaN-cap routing now has regression coverage (no
rollback, loud warn for all orders), the duplicate weaker empty-hash tests
from before the rebase are removed in favor of the stronger merged variants,
the dead TxBroadcastError re-throw in the monero catch is gone, and the firo
sendrawtransaction empty-txid guard throws TxBroadcastError directly like its
sister guards (with a test).

* style: format bitcoin-based strategy spec

* fix(payout): qualify unreachable codes by connect phase and fail the classifier closed

Second-reviewer follow-ups: EHOSTUNREACH/ENETUNREACH can surface as socket
soft-errors on an ESTABLISHED connection (ICMP unreachable at the
retransmission timeout) - only the connect-phase variant is provably
pre-broadcast, so the classifier now requires syscall === 'connect' for these
two codes. RPC_IN_WARMUP (-28, rejected by the dispatcher before execution)
joins the bitcoin/firo allowlists so node restarts self-heal as the PR
promises. The classifier itself now defaults closed if walking an exotic
error shape throws, and the firo mint variable keeps its precise name.

* test(payout): adjust bitcoin cap-test save counts for the conditional designation

After the atomic designation merged to develop, designatePayout claims via
repo.update instead of a full-entity save, so the pre-broadcast cap tests see
one fewer save per order (failure tracking and rollback only).
…ut orders (#4240)

* feat(payout): add a verification-gated admin retry for uncertain payout orders

PayoutUncertain is the intended fail-closed outcome of every ambiguous
broadcast failure, but there was no API path to bring an investigated order
(verified: no transaction was ever broadcast) back into the payout flow - the
only option was a direct DB status reset.

POST /payout/retry (admin) accepts an order in PayoutUncertain without a
payoutTxId, requires an explicit confirmation flag plus a verification
reference, and resets the order via an atomic conditional transition to
PreparationConfirmed - so it re-enters the regular cron flow under the full
designate-before-broadcast protection instead of calling doPayout directly.
The action is logged with actor, before-state and reference; the order's
failure history is deliberately kept until the next successful broadcast.

Closes #4231

* style: format payout service spec

* fix(payout): restore the pre-broadcast retry budget on manual retry

An order that escalated via the retry cap still carries retryCount at the
cap; without a reset, the first transient pre-broadcast error of the manual
retry skips both rollback and failure recording and silently re-escalates to
PayoutUncertain within one cron cycle - with the escalation mail typically
suppressed as recurring. The conditional transition now resets retryCount;
lastError/lastAttemptDate stay as history and the audit log records the old
count.
… duplicates (#4248)

* fix(dex): persist purchase orders before the swap and guard in-flight duplicates

The liquidity purchase dispatched the swap before the order was ever saved: a
crash in that window left no row at all, so the upstream caller re-issued the
purchase and swapped a second time - and nothing prevented duplicate purchase
orders, the (context, correlationId) index is non-unique.

The order row is now persisted first as the in-flight marker, and a partial
unique index (isComplete = false AND type = 'Purchase') rejects a concurrent
duplicate purchase at INSERT, before anything reaches the chain (scoped so
reservations and historical rows stay out). EvmClient.doSwap gets an exact
broadcast boundary: only send failures wrap as TxBroadcastError and keep the
row in-flight; provable pre-broadcast errors cancel the row and preserve the
existing retry semantics. The tx id is persisted immediately after dispatch,
and stranded in-flight purchases alert after 15 minutes (no auto-cancel).

Part 2 of #4192

* test: wire the id through the liquidity-order mock factory

* fix(dex): keep estimation reverts pre-broadcast and make correlation readers deterministic

Review follow-ups: doSwap now estimates the gas limit explicitly before the
broadcast boundary - estimation reverts (including the routine slippage
revert) stay plain pre-broadcast errors, preserving the slippage handling and
the retry self-healing. The single-row correlation readers return the newest
row so a persisted cancelled attempt cannot shadow the live retry row. The
stranded-purchase alert now covers rows with a dispatched-but-never-completed
txId as well (two labeled lists, no eager loads), the entity comment no
longer misstates TypeORM's partial-index capability, and the doSwap boundary
has dedicated tests.

* style: object select syntax and typed error list in the stranded-purchase alert

* fix(dex): match the slippage revert reason chain and exclude ready orders from stranded alerts

Review follow-up: moving the gas estimation before the broadcast boundary
changed the ethers error shape - a direct estimateGas revert surfaces the
reason at e.reason, while the previous send-time estimation nested it at
e.error.reason. The slippage check now walks the whole reason chain so
PriceSlippageException is still raised and the retry self-heals. The
stranded-purchase alert additionally requires isReady = false, so healthy
but slow in-flight buys (ready, awaiting batch completion) no longer produce
false operator alarms. Adds a dex-evm.service slippage-mapping spec and uses
the realistic ethers error shape in the doSwap test.

* style: format dex-evm service spec
…h window) (#4249)

* fix(payin): designate EVM sends before broadcasting (double-send crash window)

The EVM forwarding/return path broadcast first and persisted afterwards; the
minute cron re-selects on status + outTxId IS NULL, so a crash between send
and save re-broadcast the whole group. Mirrors the payout designation fix:
a persisted Sending marker is written per group member before the broadcast,
provably pre-broadcast errors restore the captured statuses for auto-retry,
ambiguous broadcast failures keep Sending, and a cron escalation moves
stranded entries to SendUncertain with one monitoring mail. The EIP-7702
delegation path gets the same barrier plus a proper broadcast boundary around
the relayer send. No migration needed (status column is varchar).

Part 1 of #4192

* style: format payin service and token strategy specs

* test: type the payin save mock resolver

* test: import the entity type for the payin save mock

* test: type the remaining payin save mock resolvers

* test: type the block-body payin save mock as well

* test: type the payin service save mock resolver

* fix(payin): fail closed after a tx id exists and harden the send escalation

Review follow-ups: the restore path now only fires while no tx id was
obtained - a plain persistence error after a successful broadcast keeps the
group in Sending (and logs the tx id) instead of re-arming it for a second
broadcast. The stranded-send escalation is age-gated (ten minutes; the two
send crons run concurrently and must not escalate each other's live work)
and escalates via atomic conditional transitions instead of stale full-entity
saves that could overwrite a concurrently persisted outTxId; the mail lists
only actually escalated entries, logs an error and reaches the liquidity
recipients. Refund paths reject Sending/SendUncertain pay-ins, the finance
log keeps both statuses in its pending set, and the impossible
mixed-group narrative in comments/fixtures is gone.

* style: format payin token strategy

* refactor(payin): shared in-flight-send guard constant and conformity cleanups

Review follow-ups: the in-flight/uncertain guard lives as an exported
constant next to CryptoInputSettledStatus and both refund guards use it with
a message in the house style; the orphaned sendUncertain() helper (escalation
writes conditionally) is removed; spec imports are sorted and the token
strategy spec header describes the file again.
… accounting (#4252)

* feat(bank): wire Bank Frick custody assets into equity, liquidity and accounting

Bank Frick was active as a bank rail but had no linked custody Asset, so its cash
was invisible to equity/FinanceLog and the safety kill-switch, its balance was
never refreshed by liquidity management, its bank_tx booked to SUSPENSE, and
pending-input-amount matching returned zero for Frick credits.

Add a Blockchain.FRICK member and two dedicated Frick custody Assets (EUR/CHF),
linked to the active Frick bank rows via bank.assetId, and register observe-only
liquidity-management rules (context 'Bank Frick') so the existing per-bank balance
adapter refreshes Frick balances without ever triggering a fund-moving action.
Add a Frick arm to every blockchain switch/exhaustive map that previously listed
only Olkypay/Yapeal (pendingInputAmount, blockchainToBankName, BANK_BLOCKCHAINS,
the EUR-bank log aggregation, and the exhaustive Blockchain maps).

The data migration is prod-only and idempotent (local/dev obtain the Frick custody
assets from the seed). It fails loud if a required price source is missing or if an
active Frick bank row is left unlinked. Customer-facing deposit routing and Frick's
fallback sendPriority are unchanged.

* fix(bank): capture Bank Frick in the EUR-bank reconciliation and refine migration docs

Bank Frick was added to the EUR-bank asset filter but not to the EUR-bank IBAN
set that gates the Scrypt reconciliation, so a Frick<->Scrypt EUR transfer was
zeroed on the Frick asset row and never picked up on the aggregate row — it
silently vanished from the pending computation. Add the active Frick EUR bank's
IBAN to that set and cover it with a regression test.

Also cross-reference the operations runbook in the new migration's docblock
(this migration performs the custody-asset link retroactively) and scope the
idempotency note to up().
* fix(payin): fail closed on ambiguous non-EVM sends (Monero, Zano, Cardano, Lightning)

Ports the designate-before-broadcast boundary from #4249 (EVM) to the non-EVM pay-in send strategies. A failure at or after the broadcast now keeps the pay-in in the non-reselectable SENDING status (escalated to SEND_UNCERTAIN by the cron) instead of re-broadcasting a fresh transfer on the next run, which could pay a customer twice.

Adds a shared sendWithBroadcastBoundary helper in SendStrategy used by the bitcoin-based, Monero, Zano, Cardano and Lightning send loops, and makes the Lightning completion lookup fail-safe so a post-broadcast lookup error cannot mask a completed send.

Completes the non-EVM part of #4192.

* fix(payin): extend the fail-closed boundary to ICP, Solana and Tron sends

The ICP, Solana and Tron pay-in send strategies share Cardano's staged PREPARED send loop and carried the same pre-broadcast double-send window; route them through sendWithBroadcastBoundary like the other non-EVM chains. Also sort the PayInRepository import.
… race) (#4262)

* fix(accounting): log the content-change scan gate-block at verbose, not error

The §4.7 G-a gate-block is a DESIGNED self-healing retry signal (a late-settling row's opening is not yet booked), but runContentChangeScan logged every thrown error uniformly at ERROR and re-scanned the same head-of-line row every cron cycle — spamming ~120 ERROR/60min on prod after the cutover. Throw a typed LedgerGateBlockedError for the gate-block and log only that at verbose; every other throw stays a genuine scan error at error (never swallowed). No behavioural change to the retry/cursor semantics.

* fix(accounting): stop the crypto_input content-change scan from error-spamming on unbookable rows

Two by-design states were logged at ERROR every cron cycle: (1) an asset-less crypto_input (a FAILED pay-in, never settled) hit walletAsset() and threw 'has no asset' on every content-change scan — now skipped (buildSeq0Input returns undefined) when non-settled, while a settled asset-less row still fails loud; (2) a crypto_input with no buyFiat/buyCrypto product and not a payment logged its legitimate seq0 skip at error — now verbose. No functional change to what gets booked.

* fix(auth): sign the winner in on a concurrent sign-up address race instead of a 500

A check-then-act race on a brand-new address let two concurrent sign-ups both pass the existence check and both reach createUser → the loser hit SQLSTATE 23505 on user.address UNIQUE, surfaced as a raw 500 and logged raw duplicate-key noise. Consolidate the guard into doSignUp (scoped to the createUser call), keyed on error.code==='23505', so every wallet-signature caller (authenticate, signUp, alby, lnurl) reloads and signs the winner in; any other error still propagates. Removes the now-redundant catch on authenticate.

* fix: address PR review — scope sign-up 23505 guard to the address, rename gate-block exception

1) doSignUp: reload by address before signing the winner in, so a 23505 on the address UNIQUE routes to signIn while a 23505 on any OTHER user unique (e.g. ref) rethrows the original error instead of a misleading NotFound. Adds a non-address-23505 rethrow test and asserts the winner receives a real accessToken.

2) Rename LedgerGateBlockedError -> LedgerGateBlockedException to match the repo's *Exception convention (the file was already .exception.ts).
…econciliation (#4257)

* fix(bank): attribute Bank Frick CHF bank-to-Scrypt transfers in the reconciliation

Symmetric to the EUR fix: the CHF side of the log-job Scrypt reconciliation
hardcoded the Yapeal CHF IBAN, so a Bank Frick CHF bank_tx into or out of Scrypt
was dropped from the pending computation (Bank Frick CHF is a newly-active
fallback account).

Widen the two CHF Scrypt bank_tx filters to include the Bank Frick CHF IBAN, so
a Frick CHF bank-to-Scrypt transfer attributes to the Frick/CHF asset and a Frick
CHF credit can settle a previously-unmatched pending withdrawal. Yapeal CHF stays
bit-identical when there is no Frick activity, and each bank_tx attributes to
exactly one asset (per-row IBAN match), so there is no double-count.

The Scrypt-withdrawal attribution target is deliberately left hardcoded to the
Yapeal CHF IBAN: an exchange-tx carries no field identifying the destination
bank, so making that target per-asset would tautologically match every CHF bank
asset and double-count the same withdrawal. Leaving it keeps the total counted
exactly once.

* docs(log): clarify the CHF Scrypt attribution comment
…f hardcoding Yapeal (#4254)

BuyFiat.refreshFee() hardcoded the sell-side charged fee's bankOut to
IbanBankName.YAPEAL, so bank-scoped Fee rows for Olkypay or Bank Frick could
never match on a sell even when those banks execute the payout.

Extract the payout-bank selection out of getPayoutAccount() into a reusable,
side-effect-free FiatOutputService.selectPayoutBank() (single source of truth;
getPayoutAccount() becomes a thin wrapper with unchanged behaviour) and call it
from refreshFee() to resolve bankOut dynamically.

Fail-closed: an unresolved payout bank is passed as undefined, which can only
under-match a bank-scoped Fee, never apply one scoped to a bank that is not
actually paying out.
The bank-balance monitoring observer aggregated only Olkypay and Yapeal
balances, so Bank Frick balance drift was not monitored. Add a getFrick()
helper mirroring getYapeal(), gated by frickService.isAvailable() in fetch()
and reusing the existing BankFrickService.getBalances() (the same source the
liquidity balance adapter already uses). A missing available balance fails
loud rather than silently substituting the booked balance.
…4268)

* fix(ledger): scope native-balance check to single-currency transfers

checkNativeBalance is meant — per its own docstring — to sanity-check pure
same-asset transfers (all legs ASSET/TRANSIT of ONE currency): then Σ native
must be 0. It only gated on onlyAssetTransit and then looped per currency,
never checking the tx is single-currency. A fee-less cross-asset micro-fill
(feeAmountChf rounds to 0 and the base/quote CHF mark residual is within
tolerance → no spread/fee leg) collapses to a bare 2-leg all-ASSET tx and was
flagged in both currencies, for a per-currency delta that is the trade itself,
not an imbalance — 44 of the 46 spurious post-cutover lines.

Add the missing single-currency gate to restore the documented scope, and
judge the residual in CHF at the group mark instead of natively (mirroring the
§7 reconciliation unit-fix): a bare native tolerance flags sub-rappen fiat
rounding — a fiat bank leg carries the unrounded output at >2 dp — yet is far
too loose for BTC. That removes the remaining 2 buy_fiat lines while still
catching a genuine single-currency imbalance, now reported valued in CHF. An
unvalued tx falls back to the native tolerance so a real imbalance is never
silently passed. Diagnostic-only: bookings and the CHF invariant are unchanged.

* fix(ledger): label unvalued native-imbalance residual instead of "0 CHF"

On the mark=0 fallback the CHF value is 0 by construction; print "unvalued (mark 0)" so the residual is not mistaken for a negligible valued amount during triage. Diagnostic text only.
…ned last-mark lookup (#4275)

* fix(ledger): recover feedless-crypto cutover owed openings via a widened last-mark lookup

A cutover owed / manual-debt opening whose asset carries no mark in the 2-day preload
window (a delisted/feedless crypto) fails loud and wedges the whole cutover in its
5-minute retry loop. Yet the asset was necessarily priced when the <=90d-old owed row was
created, so its last finite mark <= snapshot sits within a 90d (buy_crypto) / 365d
(manual-debt) window the 2d preload and the 5d getLatestMark bridge do not reach.

Add LedgerMarkService.getMarkAtWidened(assetId, asOf, lookbackDays) - read-only, lazy,
memoized, reusing the canonical preload -> getMarkAt path - and use it as a `?? fallback`
in openBuyCryptoOwed / openManualDebt. The opening is then valued at outputAmount x
last-mark and booked as fixed CHF on the EXISTING CHF bucket (LIABILITY/{bucket}, assetId
NULL, needsMark false), byte-identical to a priced owed opening, so the unchanged
payout_order / bank_tx discharge closes it cent-exact to 0 - no per-asset account, no
settlement-code change. A truly-never-priced asset still yields undefined -> fail-loud
(deferred, alarmed).

* fix(ledger): evict getMarkAtWidened cache on a rejected preload

The per-window preload promise was memoized before it resolved and never evicted on failure. A transient DB error on the widened read would cache a rejected promise under the pinned-snapshot key, so every 5-min cron retry re-threw without re-reading -> the cutover would stay wedged until a process restart even after the DB recovered. Evict the key on rejection so the next run re-reads.
…ndow self-heals (#4281)

* fix(ledger): expire the widened last-mark memo so a still-feedless window self-heals

getMarkAtWidened (#4275) memoized its per-window preload on the process-lifetime
LedgerMarkService with no TTL — only a rejected load was evicted. A *successful*
preload whose immutable LedgerMarkCache lacks the asset (a still-feedless/delisted
mark) was therefore cached forever. Because the (from,to) key is byte-stable across
every 5-min cutover cron retry (the snapshot date is pinned), each retry re-read the
same stale-empty cache -> fail-loud -> the cutover wedged until a process restart,
defeating the "retry once the feed is back" recovery #4275 documents (asymmetry vs
the TTL'd latestMarks bridge).

Give widenedCaches the same self-healing TTL as latestMarks: a stale entry (incl. a
successful-but-empty one) expires after WIDENED_MARK_TTL_MS so a later retry re-reads
and can pick up a backfilled historical mark; within one run (seconds) the memo still
dedupes several feedless rows sharing the window. Liveness-only fix — no
financial-correctness impact (fail-loud stays safe).

Follows up #4275 (#4270).

* fix(ledger): address review nits on the widened-mark TTL

- guard the rejected-preload eviction by loadedAt so a late-rejecting stale load
  can never delete a fresher entry a later retry installed (a widened window now
  gets replaced after the TTL, so unconditional delete-by-key was newly unsafe);
  net effect was only an extra read, never a misbooking, but tighten it anyway.
- make the Date.now spy restore fail-safe via a describe-level afterEach so a
  failing assertion cannot leak the mock (the jest config has no restoreMocks).

No behaviour change on the happy/transient-failure/self-heal paths.
…gle DTO (#4166)

* fix(history): require auth for transaction list and redact public single DTO

List and export endpoints no longer accept wallet address as sole credential.
Callers must use JWT or CoinTracking API-key headers; userAddress is an optional
ownership-scoped filter. Unauthenticated single-tx lookups return a reduced DTO
without bank/chargeback targets, fee breakdowns, or external ids so status links
keep working without leaking private financial data.

* fix(history): drop unused UserService from HistoryService

* fix(history): close residual leaks in the public transaction DTO

Gate the input and output tx identifiers on their payment method: a fiat leg
reuses the same DTO fields for a private bank reference (outputTxId carries
bankTx.remittanceInfo on a sell), so an unauthenticated status link handed out
the remittance reference of the payout.

Strip the chargeback identifiers unconditionally. The hash resolves to the
chargeback target on a block explorer, and a chargeback may go to an address
other than the input sender, so it can disclose an address no other public
field does.

Add PublicTransactionReasonMap, an exhaustively typed fail-closed allow list,
and only disclose an AML reason it classifies as operational. Hiding the reason
alone is not enough: KYC_REQUIRED is produced by nothing but private reasons,
so the state discloses the reason on its own - mask it to the neutral sibling
the same AML branch returns for any other pending check.

Restore instanceof UserData in isAccountSubject. Reading the address getter
dereferences the organization relation and throws for organization accounts
that do not have it loaded.

Drop the silent `?? []` fallback so a missing users relation fails loud instead
of quietly dropping the staking history from an export.

* fix(history): stop REALUNIT tenant staff getting blanket full-transaction access

isStaffFullAccess() lumped UserRole.REALUNIT into the DFX-staff full-access set, so
canViewFullTransaction short-circuited to true for any REALUNIT JWT before any
ownership check — exposing every customer's private banking/compliance fields
(chargeback IBAN, deposit address, fees, external ids) on GET /transaction/single,
across the tenant boundary that every other RealUnit route scopes via
RealUnitScopeService. REALUNIT is an isolated external tenant, not DFX staff.

Restrict isStaffFullAccess to the SUPPORT hierarchy; RealUnit staff now receive the
public DTO here (customer-scoped full access, if ever needed, belongs on the
RealUnit-scoped routes, not this public status endpoint). Test locks in the denial.
@TaprootFreak
TaprootFreak merged commit 2872215 into main Jul 20, 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.

2 participants