Skip to content

Release: develop -> main#4323

Merged
TaprootFreak merged 4 commits into
mainfrom
develop
Jul 22, 2026
Merged

Release: develop -> main#4323
TaprootFreak merged 4 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

* feat(gs): allow ledger_account and ledger_leg on /gs/debug

Add both double-entry-ledger tables to the DebugAllowedColumns allowlist so
they are queryable through the structured /gs/debug admin endpoint (Debug role).
Non-sensitive columns only (ids, FK ids, timestamps, enum/status, numeric
amounts, booleans, deterministic account name). ledger_account.name can embed a
counterparty institution name for untracked-bank suspense accounts, identical to
the value already exposed via bank_tx.bankName.

* docs(gs): name both counterparty-name sources in ledger_account allowlist comment

The counterparty institution name embedded in untracked-bank SUSPENSE account
names comes from bank.name (matched untracked bank) or bank_tx.bankName (no
match) — both already on the /gs/debug allowlist. The comment previously named
only bank_tx.bankName.
)

* feat(realunit): add swap-only and OCP pay-flow workflow endpoints

Add RealUnit Phase 2 pay flow: swap REALU->ZCHF keeping the proceeds in
the user wallet, then pay that ZCHF to an Open CryptoPay recipient via the
public lnurlp payment-link flow.

- swap-only: PUT /realunit/swap/:id/unsigned-transaction builds the REALU
  transferAndCall swap tx without the deposit sweep (extracted shared
  buildSwapUnsignedTransaction / reconstructSignedTransaction helpers from
  the sell flow)
- OCP pay: PUT /realunit/pay/unsigned-transaction resolves recipient and
  amount from the payment-link quote and builds the unsigned ZCHF ERC-20
  transfer; PUT /realunit/pay/submit reconstructs the signed hex and submits
  it into the existing lnurlp tx settlement path; GET /realunit/pay/:id/status
  exposes the payment status
- reuse RealUnitBlockchainService, EvmUtil, payment-link/quote services and
  the sell flow guard set; no entity/column changes

* fix(realunit): harden OCP pay flow (pending nonce, fail-fast on unsupported method, URI validation)

- derive the OCP pay-tx nonce from the pending block tag so a still-pending
  swap tx is counted and the two txs do not collide on the same nonce; add an
  optional block-tag param to EvmClient.getTransactionCount
- fail fast with a typed BadRequestException on both OCP endpoints when the
  resolved payment method is unsupported by the payment-link engine (Sepolia on
  DEV/LOC) instead of letting a deep `Invalid method` error bubble up; add the
  PaymentLinkEvmHexBlockchains constant mirroring the engine's supported set
- cross-check the URI token contract against the ZCHF asset and validate the
  recipient/amount in parseEvmPaymentRequest, throwing a typed error on mismatch
  or malformed input
- document the JWT access-gate (not ownership) semantics on the OCP endpoints
- add Swagger error responses and a nonce-ordering note to the OCP endpoints
- fix import ordering and extend specs for the new error paths

* feat(realunit): add IBAN-free REALU -> ZCHF swap quote and broadcast endpoints

The OCP pay flow's PUT /swap/:id/unsigned-transaction consumes a SWAP-type
TransactionRequest, but the only way to obtain one was PUT /sell, which requires
a fiat IBAN and creates a Sell route + payout. Requiring a sell bank account to
do a pure REALU -> ZCHF swap whose proceeds stay in the user wallet (to pay at an
OCP/SPAR POS) is semantically wrong and blocked the swap-only path end to end.

Add the missing entry points:

- PUT /v1/realunit/swap: IBAN-free swap quote. Creates a TransactionRequestType.SWAP
  request via SwapService.createSwapPaymentInfo (REALU -> ZCHF), with no fiat IBAN,
  Sell route or payout. Same registration + KYC Level 30 gating as sell, and KYC
  trading limits stay enforced: a quote over the limit surfaces QuoteError.LIMIT_EXCEEDED,
  translated into the same KYC-Level-50 requirement the sell path throws. The ZCHF
  estimate is anchored to the live on-chain brokerbot sell price. Input mirrors the sell
  DTO's amount XOR targetAmount pattern but drops iban/currency (target is always ZCHF).
- PUT /v1/realunit/swap/:id/broadcast: dedicated swap broadcast for clean OCP-flow
  semantics, reusing a shared private reconstruct/broadcast helper extracted from
  broadcastSellTransaction (no duplication).

The on-chain execution stays the already-built user-signed brokerbot mechanism; this
only adds the IBAN-free quote/request-creation and broadcast entry points. No entity or
column changes, so no migration is needed (reuses TransactionRequest + Swap route).

Update the swap unsigned-transaction and OCP pay ApiOperations to reference the new
swap quote (step 0) and swap broadcast, and document the swap flow in CONTRIBUTING.md.
Add jest specs: swap quote happy path, IBAN not required, limit-exceeded surfaces a
typed error, swap broadcast returns txHash.

* fix(realunit): make REALU -> ZCHF swap quote honest about limit-exemption

The swap quote's QuoteError.LIMIT_EXCEEDED -> KYC-Level-50 mapping was
dead code: TransactionHelper.getLimits returns Number.MAX_VALUE for any
RealUnit transaction that is not selling-REALU-for-fiat, so the limit
error can never fire for this crypto -> crypto pair.

Remove the misleading limit-to-KYC-level throw and document why the swap
is limit-exempt by design: KYC trading limits are enforced at the fiat
boundary (buy/sell), whereas a REALU -> ZCHF swap is a self-custody,
on-chain brokerbot action. Keep the registration and KYC Level 30 access
gates (those are enforced) and keep surfacing genuine quote errors (e.g.
min/max volume) via the DTO isValid/error fields.

Update the Swagger error docs, CONTRIBUTING swap row and the swap spec to
reflect the limit-exempt behavior instead of implying limit enforcement.

* Fix stale swap comments: drop limit-enforced wording and use swap broadcast endpoint

* test(realunit): cover OCP swap/pay branches and evm getTransactionCount to 100%

Add diff-coverage for every changed executable line and branch in the
OCP pay flow:
- getSwapPaymentInfo: asset-not-found, shares<=0, brokerbot-query
  rejection, missing-id and brokerbot-null estimate fallbacks
- broadcast: broadcast-error and no-tx-hash failure paths
- createSwapUnsignedTransaction: missing REALU contract and decimals
  fallback to 18
- createOcpPayUnsignedTransaction: missing ZCHF contract and
  insufficient-gas paths
- EvmClient.getTransactionCount: default latest tag and pending tag
- thin controller delegations for the six OCP endpoints

* feat(payment-link): support Open CryptoPay on Sepolia for non-PRD testing

Add Blockchain.SEPOLIA to the EVM groups of the three payment-link
switch statements that previously fell through to the default throw
(getDepositAddress, executeHexPayment, PaymentRequestMapper) and to the
PaymentLinkEvmHexBlockchains set so the RealUnit OCP guard recognizes it.

This makes the RealUnit OCP pay flow testable end-to-end on the Sepolia
testnet on non-PRD (DEV/LOC). It stays PRD-safe: on PRD, TestBlockchains
includes Sepolia, so PaymentLinkBlockchains filters it out and no PRD
payment-link can offer Sepolia, leaving the new EVM cases unreachable
there. The RealUnit assertPaymentLinkSupportsMethod guard is kept (still
fails fast for genuinely-unsupported methods) and now passes for Sepolia.

Update the now-stale mainnet-only comments and flip the RealUnit Sepolia
fail-fast specs to assert the flow now proceeds on the testnet. Add a
payment-link engine spec covering the new Sepolia EVM routes.

* fix(payment-link): add Sepolia to payment-link fee calculation for non-PRD OCP

The Sepolia OCP enablement (64685ea) added case Blockchain.SEPOLIA to the
getDepositAddress, executeHexPayment and PaymentRequestMapper switches, but
missed two further EVM-mainnet switches the OCP hex-payment flow traverses:

- PaymentLinkFeeService.calculateFee: the EVM group fell through to undefined
  for Sepolia, so updateFees cached no fee, getMinFee(SEPOLIA) returned
  undefined and createTransferAmount dropped the Sepolia transfer-amount.
  getTransferAmountFor(SEPOLIA, ZCHF) then threw 'Invalid method or asset' at
  OCP quote activation.
- PaymentActivationService.createBlockchainRequest: the EVM/deposit-address
  group omitted Sepolia, so activation would hit the default invalid-method
  throw even once the fee was available.

Add case Blockchain.SEPOLIA to both EVM-mainnet groups (same minimal additive
pattern; getEvmClient(SEPOLIA) and getDepositAddress already support Sepolia).
PRD-safe: Sepolia is filtered out of PaymentLinkBlockchains on PRD, so these
cases stay unreachable there. Extend payment-link-sepolia.spec to cover both
new cases (calculateFee/getMinFee return a real gas price; createBlockchainRequest
routes Sepolia to the EVM deposit-address branch).

* fix(realunit): await registration gate on swap quote

hasRegistrationForWallet is async; without await the Promise is always
truthy and the registration gate never rejects. Align the swap path with
sell/buy and update the swap-quote specs to the current gate exceptions.

* style(evm): format evm-client.spec.ts after getTransactionCount tests

* fix(realunit): harden OCP pay flow (status endpoint, ZCHF coverage, swap gate, fail-closed price)

- getOcpPayStatus reads the payment via a status-independent lookup
  (getMostRecentPayment), so a completed OCP payment returns its terminal
  status instead of a 404 once it is no longer PENDING.
- the OCP pay tx now requires the sender's on-chain ZCHF to cover the amount
  before it is built and again before submit, so the merchant payment can no
  longer be marked complete while the funding REALU->ZCHF swap is still unmined.
- createSwapUnsignedTransaction/broadcastSwapTransaction enforce type===SWAP,
  the REALU->ZCHF asset binding and the RealUnit registration + KYC30 gate on
  every step, closing the swap-tx gate bypass and BUY/SELL type confusion.
- the swap quote fails closed with PriceSourceUnavailableException (via
  withPriceSourceGuard) instead of silently reusing the off-chain estimate when
  the brokerbot price source is down.
- build the swap-tx nonce from the pending block tag (consistent with the pay
  tx), reject swaps that round down to zero shares, add a gas buffer to the pay
  tx, and parse the swap :id via ParseIntPipe.

* style(realunit): apply prettier to the swap/pay test spec

* fix(realunit): tighten OCP pay coverage checks and swap broadcast

- compare the ZCHF balance wei-exact via getTokenBalanceWei instead of a lossy decimal float
- gate swap broadcast: validate the signed payload (to/chainId/value/data) against the request and complete it so the replay guard takes effect
- check ETH coverage against the buffered maxFeePerGas rather than the raw gas price
- reject malformed unsigned transactions with 400 in reconstructSignedTransaction

* fix(realunit): reject a swap broadcast whose signer is not the request user

Validate parsedTx.from against the request wallet in broadcastSwapTransaction, so a KYC'd user cannot relay a transaction signed by a different wallet to complete their own swap request.

* fix(realunit): apply conformity review fixes to the OCP-pay flow

- return 503 (ServiceUnavailableException) instead of 400 when the server-configured blockchain is unsupported
- fail loud on missing REALU/ZCHF assets, a missing ZCHF contract address, and missing EIP-1559 fee fields (drop the silent zero-fee fallback)
- require @isnotempty on the XOR swap-amount fields and type them optional
- align the OCP status Swagger doc with the status-independent lookup; drop an unreachable error-code example
- name the includeTx flag, fix section-comment style and the sepolia spec imports

* fix(realunit): guard missing ZCHF asset in createOcpPayUnsignedTransaction and sharpen the fee-field test

- add the !zchfAsset NotFoundException guard in createOcpPayUnsignedTransaction, matching the sibling build/submit paths (a missing asset returned 500 instead of 404)
- assert the legacy type-0 unsigned tx is rejected via the specific reconstruct failure; proven to change behavior if the EIP-1559 fee-field guard is removed

* fix(realunit): apply fresh-review fixes to the OCP-pay flow

- return 400 instead of an uncaught 500 when a broadcast swap tx carries an invalid signature
- make the swap quote consistent with the tx build: report the floored share amount and mark the quote invalid when it rounds below one share
- fix two dto import orderings and the pay-dto section-comment style

* chore(realunit): drop two unused kyc imports flagged by the code-quality bot

* chore(realunit): order the pay dto import and drop an unused test variable

* fix(realunit): claim the swap request atomically before broadcasting

Two concurrent broadcasts carrying validly signed transactions with different
nonces but the same server-derived payload could both pass the isComplete check
and payloadMatches, running the REALU to ZCHF swap twice. Claim the request with
a conditional update right before sending, and release the claim on every
broadcast failure path so a retry stays possible.

* Revert "fix(realunit): claim the swap request atomically before broadcasting"

This reverts commit 05f042e.
The debug allowlist blocked the entire setting.value column, so non-secret operational settings (e.g. minTotalBalanceChf) could not be read. Genuine secrets live in the Vault, not the setting table. Add value to the setting allowlist (columns + jsonbColumns) and update the rationale comment. No overlap with GsRestrictedColumns, so the module-load invariant still holds.
…ght bucket (#4316)

* fix(log): retire arrived Scrypt EUR deposits from the toScrypt in-flight bucket

getUnmatchedSenders retired a bank sender only when a receiver shared its reference id (txId/remittanceInfo). Arrived Scrypt EUR deposits (status ok) carry an empty txId, so they never entered receiverRefs and could never retire their bank_tx sender, leaving it in the toScrypt in-flight pending bucket for up to 7 days and double-counting funds already at Scrypt (inflated balancesTotal.totalBalanceChf).

Add a deterministic amount+date fallback pass for receivers without a usable reference: match on currency + amount (1% / min 1.0 tolerance) + date (7-day window), consuming each receiver once, selected by smallest amount diff then date diff then id so the result is independent of DB row order. Use externalCreated as the canonical ExchangeTx date and guard non-finite amounts.

Refs #4311

* chore(log): apply prettier formatting to new getUnmatchedSenders tests

* fix(log): assign arrived deposits to their exact-amount sender globally

The pass-2 fallback matched each sender to its own best receiver in date order, so an earlier near-match sender could consume a receiver that a later sender matched exactly, mis-attributing which transfer arrived (bucket off by up to the tolerance). Replace the per-sender greedy with a global candidate-pair assignment: build all in-tolerance (sender, receiver) pairs, sort by amount diff then date diff then ids, and consume front-first so exact matches win regardless of input order.

* fix(log): use maximum-cardinality matching so no arrived deposit is stranded

The cost-sorted greedy assignment was not maximum-cardinality: a sender's only in-tolerance receiver could be claimed by a competitor that had an alternative, leaving a genuinely-arrived sender counted (residual over-count). Replace it with Kuhn's augmenting-path bipartite matching, with per-sender cost-ordered candidates and cost-ordered sender processing, so it retires the maximum set of arrived senders while still preferring exact matches.
@TaprootFreak
TaprootFreak merged commit 09e49a3 into main Jul 22, 2026
11 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.

1 participant