Skip to content

Release: develop -> main#4190

Open
github-actions[bot] wants to merge 9 commits into
mainfrom
develop
Open

Release: develop -> main#4190
github-actions[bot] wants to merge 9 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

…onality (#4188)

* fix(kyc): auto-complete NationalityData from the account's known nationality

The NationalityData KYC step is a standalone entity that is only satisfied by
an explicit submission through the step machinery. A RealUnit registration
writes userData.nationality but never completes that step, and initiateStep —
unlike CONTACT_DATA (from mail) and PERSONAL_DATA (from user fields) — had no
auto-completion branch for it. The nationality captured on the registration
form was therefore asked again on the dedicated nationality step (double
capture) for every fresh RealUnit account.

Auto-complete NATIONALITY_DATA in initiateStep when the account's nationality
is already known and clean (allowed, non-residence-permit, no step errors),
mirroring the sibling CONTACT_DATA/PERSONAL_DATA auto-completes. Residence-permit
or disallowed nationalities and merged/blocked accounts are deliberately left
IN_PROGRESS so they still go through the normal nationality step, which routes
them to internal/manual review and pulls in the residence-permit follow-up step
— never a blind complete. The step result stores the same slim { id, symbol }
shape as the manual path.

* test(kyc): initialize global Config for the NationalityData auto-complete tests

The NATIONALITY_DATA branch reads Config.kyc.residencePermitCountries, and the
global Config is a module-level let that is undefined until a ConfigService is
constructed. Initialize it in beforeAll, matching the existing pattern.

* test(kyc): use absolute mock import, drop vacuous review-spy assertion

Import createCustomCountry via an absolute path (CONTRIBUTING; matches the
convention in the sibling specs and the Country entity import). Remove the
reviewNationalityData spy and its not-called assertion: the auto-complete
branch never routes through that path, so the assertion was vacuous — the
COMPLETED status and single save already cover the intended behaviour.
Blume1977 and others added 8 commits July 13, 2026 11:51
#4189)

* feat(realunit): show current REALU balance in the compliance dashboard

The customer list and the detail dossier now carry the customer's
current REALU holdings: sum of the ponder-indexer balances over all
wallet addresses of the account (share count, asset decimals applied).
The holder set is cached for 5 minutes; when the indexer or the asset
lookup is unavailable the balance is reported as unknown instead of
failing the list. New bulk user query getUsersByUserDataIds avoids
per-customer address lookups.

* docs(realunit): note the deliberate check-evidence exception in the reduced-scope header
…ompliance balances (#4191)

* fix(realunit): fail closed on empty or truncated indexer sweeps for compliance balances

Follow-up to #4189. The REALU balance shown per compliance customer is resolved from the
Ponder indexer and cached for 5 minutes; three fail-open weaknesses let a degraded indexer
response render a wrong authoritative value instead of "unknown":

- A successful-but-empty sweep (HTTP 200, no holders during resync/cold start) was cached
  and shown as a definitive 0 for real shareholders, contradicting the documented
  undefined=unresolved contract. Empty sweeps now throw and stay unresolved (not cached).
- Pagination stopped silently on a missing/repeated cursor and had no page cap, risking a
  silent undercount. It now fails closed on a stalled cursor and caps at 100 pages,
  mirroring the deuro/juice clients but throwing instead of returning a partial set
  (balance correctness over completeness).
- A single unparsable holder balance made fromWeiAmount throw and blanked the whole batch;
  malformed records are now skipped per address.

Adds tests for the empty-sweep, malformed-holder, missing-cursor and dossier-detail paths.

* fix(realunit): fail closed per member on an unparsable holder balance

Skipping a malformed balance left the affected member on a false authoritative 0 (or a
partial-sum undercount), contradicting the undefined=unresolved contract the rest of the
change enforces. Any unparsable address now marks that member's total as unresolved
(undefined) while the other members stay correct.

Adds tests for member-level isolation, the no-partial-sum case, and the 100-page cap guard.

* test(realunit): isolate the page-cap and repeated-cursor pagination guards

The page-cap test used empty pages, so its undefined result was equally produced by the
empty-sweep guard; it now returns non-empty foreign holders so only the page-cap guard can
yield undefined. Adds a test for the repeated-cursor stall, asserting it is caught on the
second page rather than only after the 100-page cap.

* fix(realunit): treat an empty-string holder balance as unresolved, not a false 0

The truthiness guard (`if (!raw) continue`) swallowed an empty-string balance the same way
as an absent address, masking an unusable indexer value as an authoritative 0. Now only a
truly absent address short-circuits; a present-but-empty balance flows through toShareCount
and fails closed (member -> unresolved).

Also adds a positive test for cursor-advance + cross-page holder aggregation.

* style(realunit): satisfy prettier in the balance spec

format:check flagged the object-literal cast in the page-cap test; prettier 3 formats it as
`({...}) as HoldersDto`.
…nt KYC lift (#4195)

* test(realunit): cover the registration forward self-heal and idempotent KYC lift

Two gaps remained after the forward-outside-transaction change:
- self-heal on retry: a persist failure after a successful (idempotent-upsert)
  POST returns false and leaves no blocking row, so the client retry re-POSTs
  and completes. The load-bearing self-heal property was only asserted in prose.
- the in-lock idempotent outcome (a concurrent completion of the same wallet)
  must still lift the caller's KYC level so its buy/sell gate opens; the existing
  idempotent test used a level-999 user and never exercised the lift.

* test(realunit): correct the self-heal assertion rationale

The registerUser POST runs unconditionally outside the persist transaction, so
its call count does not prove the retry was not short-circuited. Attribute the
self-heal to the second attempt persisting a COMPLETED row instead.
…structural broadcast boundary (all chains) (#4181)

* fix(payout): designate before broadcast to prevent double payout on restart

Payout strategies broadcast on-chain before persisting any state, so a
restart between the broadcast and the save leaves the order re-selectable
by the 30s cron and triggers a second broadcast (double payout).

Persist PAYOUT_DESIGNATED before the broadcast (mirrors the Bitcoin path)
in the EVM, Solana, Tron, Cardano, ICP, Arkade, Spark and Lightning
strategies. The !payoutTxId guard leaves the TX_SPEEDUP/expired-retry path
untouched so nonce-reuse stays intact; the catch stays fail-closed and
never auto-rolls-back. Guard speedupTransaction on PAYOUT_PENDING. No
migration needed (PAYOUT_DESIGNATED already exists).

* fix(payout): guard speedup to replacement-capable chains, extract designate helper

Address review: speedupTransaction now rejects chains without nonce-reuse
(supportsSpeedup=false, EVM-only) and when TX_SPEEDUP is disabled, so a manual
speedup never broadcasts a second, independent transaction (double payout).

Extract the designate-before-broadcast block into a shared PayoutStrategy
helper to remove the 8-fold duplication and the EVM-specific comment that was
misleading for the non-EVM chains.

* test(payout): use toHaveBeenCalled matchers per repo convention

* test(payout): cover the reboot double-spend case across all strategies

Exhaustive tests for the designate-before-broadcast fix: all 8 payout
strategies x the variant matrix (success = designate persisted before the
broadcast; broadcast-throws stays PAYOUT_DESIGNATED with no second broadcast
and no rollback; payoutTxId-set skips re-designation; designate-save-throws
never reaches the broadcast; Lightning also isHealthy=false). Plus the
PayoutStrategy helper and supportsSpeedup, speedupTransaction (all guard
branches incl. not-found), the EVM retry x guard interaction (OOG fresh nonce
vs. expired nonce reuse), and the cron-level reboot guarantee (payoutOrders
skips PAYOUT_DESIGNATED, processFailedOrders escalates it to PAYOUT_UNCERTAIN).
base/payout.strategy.ts reaches 100% coverage.

* test(payout): reach 100% coverage on all changed payout files

Cover the case-adjacent code in the touched files so every changed file hits
100% statement/branch/function/line coverage: the full PayoutService cron
(doPayout, checkOrderCompletion, fee estimation, prepare/completion phases,
getRecentPayoutSentCorrelationIds, group/mail helpers, per-group error
isolation across two groups) and estimateFee / checkPayoutCompletionData for
all eight strategies (incl. the ICP input-asset and TOKEN/COIN specifics and
Lightning's undefined assetType). 225 payout tests pass on Node 20.

* fix(payout): self-heal transient pre-broadcast failures via broadcast boundary

Chain clients now throw TxBroadcastError only around the actual on-chain send
call; the payout service maps it to PayoutBroadcastException. The strategy catch
discriminates: an ambiguous broadcast error (at or after send) leaves the order
PAYOUT_DESIGNATED for fail-closed escalation, while a provably pre-broadcast
error rolls the designation back to PREPARATION_CONFIRMED so the cron self-heals
transient RPC failures — only on the first attempt and capped by
maxPreBroadcastRetries.

Restores the develop-era self-healing that the designate-before-broadcast fix
had turned into a permanent strand for transient dispatch errors (RPC timeout,
gas estimation, nonce fetch). Covers EVM, Cardano and Solana; adds an
empty-tx-hash guard inside the EVM send boundary.

* fix(payout): extend broadcast boundary to ICP and Lightning

Apply the TxBroadcastError / PayoutBroadcastException boundary to the ICP and
Lightning payout paths, so a transient pre-broadcast failure self-heals while an
ambiguous at-or-after-send failure stays PAYOUT_DESIGNATED for fail-closed
escalation.

- ICP: hoist address/amount validation out of the try so only the ledger update
  call is wrapped; a post-submit network error is ambiguous and the call carries
  no created_at_time dedup, so it stays fail-closed.
- Lightning: wrap the transport-level send in lightning-client; an in-band LND
  payment_error ("no route") remains a plain error and self-heals. Keysend
  (LN_NID) has no payment_hash for LND dedup, so its failures are wrapped
  fail-closed to prevent a double-pay on retry.
- Solana: treat an empty tx hash after send as a broadcast error (fail-closed)
  instead of letting it fall through to a pre-broadcast-style rollback.

* fix(payout): extend broadcast boundary to Bitcoin, Firo, Monero, Zano

Replace the fragile substring heuristic (e.message.includes('timeout')) in the
batch-based bitcoin path with the structural PayoutBroadcastException marker, so
the pre/post-broadcast decision no longer depends on error wording: any
at-or-after-send failure now keeps the batch PAYOUT_DESIGNATED (fail-closed)
while a provable pre-broadcast failure rolls back for self-heal.

- Bitcoin/Firo/Testnet4: wrap the actual node broadcast (Core `send` is atomic;
  Firo's sendrawtransaction / mintspark) in TxBroadcastError, mapped to
  PayoutBroadcastException in the payout services; fee-estimation stays outside.
- Monero/Zano: wrap the atomic wallet-RPC transfer, including response mapping,
  so a malformed body cannot fall through as a pre-broadcast-style rollback.

* fix(payout): extend broadcast boundary to Tron, Arkade, Spark

Tron's gateway POST and the Arkade/Spark SDK sends are atomic (sign+broadcast in
one call), so any failure from the send on is ambiguous and now surfaces as
TxBroadcastError -> PayoutBroadcastException (fail-closed). Their strategies move
from a blanket fail-closed catch to handleBroadcastError, so a provable
pre-broadcast failure (wallet resolution, decimals, leaf sync) self-heals.

- Arkade/Spark: extract the actual broadcast (wallet.sendBitcoin / wallet.transfer)
  out of the internal reconnect-and-retry wrapper, which would otherwise resend an
  already-broadcast tx on a connection/channel error -> double-spend.
- Guard every send against an empty txId (200 OK without a tx id): treat it as an
  ambiguous broadcast error instead of returning an empty id that would later roll
  the order back for re-broadcast, mirroring the Solana empty-hash guard.

* test(payout): reach 100% coverage across the payout execution domain

Cover the normal (happy-path) execution of every payout strategy and service the
broadcast-boundary fix touches or sits next to: the 40+ chain-derivation
strategies (dispatch, getters, fee asset), all payout services (completion data,
gas, nonce, expiry), the base strategies (batch send, designate/rollback,
grouping), and the payout order entity and repository. Every in-scope file is now
100% in statements, branches, functions and lines.

* fix(payout): fail-closed on empty Lightning keysend payment hash

A keysend (LN_NID) carries no invoice payment_hash, so LND cannot deduplicate a
re-broadcast and a self-healing retry would double-pay. sendTransfer returns an
empty string for a blank payment hash without throwing, which would otherwise roll
the order back to PREPARATION_CONFIRMED and re-broadcast. Guard the empty return
(and keep wrapping every keysend error) as a PayoutBroadcastException so the order
stays PAYOUT_DESIGNATED (fail-closed). Invoice payouts (LN_URL/LND_HUB) keep
self-healing, since their payment_hash lets LND drop a duplicate.
* fix(realunit): require tax residence to cover address country

Validate that addressCountry is among the declared tax residences
(swissTaxResidence covers CH; countryAndTINs covers other countries).
Always persist countryAndTINs on user_data.tin, including when personal
data already exists. Multi-residence remains allowed.

* fix(realunit): reject CH in countryAndTINs and validate multi-residence TINs

Swiss tax residence is covered only by swissTaxResidence. countryAndTINs
entries must be non-CH with a non-empty tin, including when multi-residence
is declared alongside swissTaxResidence true.

* fix(realunit): make user_data.tin updates audit-safe and non-destructive

Never clear a non-null TIN by writing null (Swiss-only submissions must
not erase prior foreign TINs). Write user_data.tin only after the
registration row is durable, and always log previous→next before the
column update so every mutation remains reconstructible.

* docs(contributing): require auditable, non-destructive DB overwrites

Document that mutating stored values is only allowed when the previous
value remains recoverable, with before→after auditability and fail-closed
behaviour when history would be lost.
* feat(country): enable Philippines onboarding

* test(country): harden Philippines onboarding migration
… hit (#4207)

* fix(aml): distinguish Scorechain provider failure from real high-risk hit

Screening failures (provider 5xx, timeout, quota, misconfiguration) were reduced to the
same boolean as a real high-risk hit and surfaced as AmlError.SCORECHAIN_HIGH_RISK, so
compliance saw Scorechain hits for transactions that were never screened (no screening
row, no PDF).

Replace the boolean channel with a ScorechainOutcome enum (Pass/HighRisk/Unavailable) and
add AmlError.SCORECHAIN_UNAVAILABLE. Both outcomes stay fail-closed (CRUCIAL -> PENDING /
generic ManualCheck, no tipping-off), but the internal classification is now distinct.

Closes #4205

* docs(aml): scope the ScorechainUnavailable rationale to the actual failure paths

A missing risk threshold makes isHighRisk throw only after the screening row and the
compliance PDF have been persisted, so the blanket claim that neither exists was wrong
for that path. Also drop a tautological assertion from both preparation specs.

* docs(aml): keep provider/infrastructure on one line in the Scorechain spec

A soft wrap right after the slash rendered as "provider/ infrastructure".
…data.tin on a rejected request (#4210)

* fix(realunit): bound the tax-residence payload and stop writing user_data.tin on a rejected request

Three defects in the tax-residence contract:

- user_data.tin is varchar(256), but neither CountryAndTin.tin nor countryAndTINs was
  length-bounded and the client allows an unbounded number of tax residences. Seven
  entries with an 11-digit TIN already serialize to 260 characters, so the UPDATE threw
  'value too long for type character varying(256)' — AFTER forwardRegistration had already
  persisted the registration, turning a completed registration into a 500 that every retry
  reproduced. Bound the input (10 entries, 64-character TIN), widen the column to
  varchar(1024) to cover that bounded maximum, and reject an oversized payload up front in
  validateRegistrationDto so it can never fail once the registration is durable.

- The idempotent branch of completeRegistration wrote user_data.tin BEFORE
  idempotentRegistrationResult rejects a mismatching signature, so a request answered with a
  400 had already mutated the column. countryAndTINs is not part of the EIP-712 envelope, so
  the body cannot describe what was actually registered either. Check the signature first and
  sync the snapshot from the durable registration row instead of the request body.

- @ValidateIf(!swissTaxResidence) disabled every validator on countryAndTINs — including
  @isarray — whenever swissTaxResidence was true, so a non-array body reached the service and
  crashed it with a TypeError (500 instead of 400). Validate whenever the field is required or
  present, and keep a defensive Array.isArray guard in the service.

Also replace the unguarded JSON.parse(user_data.tin) in the registration prefill: a malformed
legacy value took down the only endpoint through which the user could submit a corrected
declaration. It now degrades loudly (PII-free error log) to 'no known tax residences'.

* fix(realunit): trim tin without crashing on a non-string value

The @Transform(Util.trim) added to CountryAndTin.tin runs in class-transformer
before class-validator, so a non-string tin (e.g. a number) made value.trim()
throw a TypeError => HTTP 500 before @IsString could reject it as a 400 — the
exact 500-instead-of-400 regression this change set exists to remove. Trim only
actual strings and let any other type fall through to @IsString.

* style(realunit): apply prettier to the non-string-tin regression test
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