Release: develop -> main#4256
Merged
Merged
Conversation
…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
github-actions
Bot
requested review from
TaprootFreak and
davidleomay
as code owners
July 17, 2026 15:23
…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
approved these changes
Jul 20, 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