Skip to content

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

Open
TaprootFreak wants to merge 8 commits into
developfrom
feat/realunit-ocp-pay
Open

feat(realunit): add swap-only and OCP pay-flow workflow endpoints#3819
TaprootFreak wants to merge 8 commits into
developfrom
feat/realunit-ocp-pay

Conversation

@TaprootFreak
Copy link
Copy Markdown
Collaborator

@TaprootFreak TaprootFreak commented Jun 3, 2026

Summary

RealUnit Phase 2 pay flow (Baustein 1+2, backend, Option 2 "Backend-Wrap"). A wallet pays at an Open CryptoPay (OCP) POS by (1) swapping REALU→ZCHF keeping the ZCHF in the user's own wallet, then (2) paying that ZCHF to the OCP recipient via the existing public /v1/lnurlp/* payment-link flow. The app cannot build ERC-20 calldata or serialize a signed raw tx locally, so the backend builds the unsigned txs and reconstructs/submits the signed hex — exactly like the existing RealUnit sell flow. These are workflow endpoints (the backend orchestrates), not N client calls.

Endpoints added (/v1/realunit/*, same guard set as the sell flow: JWT + RoleGuard(USER) + UserActiveGuard)

  • PUT /swap/:id/unsigned-transaction{ swap } — builds the REALU transferAndCall swap tx without the deposit sweep, so the ZCHF proceeds land in the connected wallet. Broadcast via the existing PUT /sell/:id/broadcast.
  • PUT /pay/unsigned-transaction (body { paymentLinkId, quoteId }) → { unsignedTx, tokenAddress, recipient, amountWei, chainId } — resolves recipient + exact amount from the payment-link/quote service (same source the lnurlp callback uses) and builds the unsigned ZCHF ERC-20 transfer to the DFX deposit address.
  • PUT /pay/submit (body extends the sell broadcast DTO with { paymentLinkId, quoteId }) → { txId } — reconstructs the signed hex and submits it into the existing lnurlp tx settlement path, where DFX validates recipient/amount/min-fee, broadcasts, and settles the OCP quote.
  • GET /pay/:id/status{ status } — OCP payment status via the lnurlp wait path.

Design / reuse

  • Extracted shared helpers buildSwapUnsignedTransaction and reconstructSignedTransaction from the existing sell flow (DRY) — the swap-only and OCP-submit paths reuse them; no on-chain logic is duplicated.
  • The OCP wrap depends on LnUrlForwardService (ForwardingModule) and drives the existing lnurlpCallbackForward / txHexForward / waitForPayment machinery. Recipient + amount are parsed from the EVM payment-request URI the quote activation returns — the single source the lnurlp cb uses.
  • DTOs + @ApiProperty docs; the OCP submit DTO extends the sell broadcast DTO. No entities/columns changed → no migration.

Tests

Added jest service specs for createSwapUnsignedTransaction, createOcpPayUnsignedTransaction, submitOcpPay, and getOcpPayStatus (swap-only omits the deposit leg, EVM URI parsing + happy/error paths, signed-hex reconstruction + forwarding, status mapping). Full suite green.

Pair-PR

Phase 2 Baustein 1+2 backend for RealUnitCH/app#666. The app PR (QR scanner + OCP pay flow consuming these endpoints) follows within one week per the API-capability pair-PR discipline.

Testnet support (DEV/LOC) — OCP pay is now testable on Sepolia

OCP pay is now exercisable end-to-end on the Sepolia testnet on non-PRD (DEV/LOC). The payment-link engine already supported Sepolia at the config level (PaymentLinkBlockchains includes it on non-PRD; BlockchainRegistryService.getEvmClient has a Sepolia case; doEvmHexPayment is chain-agnostic) — the only gaps were three switch statements missing a case Blockchain.SEPOLIA (PaymentBalanceService.getDepositAddress, PaymentQuoteService.executeHexPayment, PaymentRequestMapper.toPaymentRequest), each falling through to a default throw. Sepolia was added to the EVM group of all three, and to the PaymentLinkEvmHexBlockchains set the RealUnit guard checks. On DEV/LOC the RealUnit token blockchain resolves to Sepolia, so the full OCP pay flow (swap-only + PUT /pay/*) is now testable on the testnet.

PRD-safe by construction: on PRD, TestBlockchains includes Blockchain.SEPOLIA, so PaymentLinkBlockchains filters Sepolia out. No PRD payment-link can offer Sepolia, so the new EVM case Blockchain.SEPOLIA lines are unreachable on PRD and OCP remains mainnet + L2 there (Ethereum/Arbitrum/Optimism/Base/Gnosis/Polygon/BSC).

Hardening (review round 2)

  • Nonce collision fixed: the OCP pay-tx nonce is now derived from the pending block tag, so a still-pending swap tx (broadcast via PUT /sell/:id/broadcast) is counted and the two txs cannot collide on the same nonce. EvmClient.getTransactionCount gained an optional, typed block-tag param (default latest, no behaviour change for existing callers).
  • Method guard backed by the PaymentLinkEvmHexBlockchains constant mirroring the engine's supported EVM-hex set. Sepolia is included on non-PRD (see "Testnet support" above), so the guard passes for the RealUnit flow on DEV/LOC and the OCP pay endpoints proceed; it still fails fast with a typed BadRequestException for any genuinely-unsupported method instead of letting a deep, opaque Invalid method bubble up from the shared payment-link engine.
  • parseEvmPaymentRequest hardened: cross-checks the URI token contract against the expected ZCHF asset and validates recipient/amount, throwing a typed BadRequestException on mismatch or malformed input instead of a raw BigNumber.from throw.
  • Auth semantics documented: the JWT (USER guard) is an access gate, not an ownership check — an OCP quote is a POS payment payable by whoever holds the quote; the downstream lnurlp path re-validates recipient/amount/min-fee server-side.
  • Swagger error responses (@ApiBadRequestResponse / @ApiNotFoundResponse) completed across the OCP endpoints; import ordering fixed; specs extended for the pending-nonce, token-mismatch, and malformed-URI paths, plus a payment-link engine spec covering the new Sepolia EVM routes and RealUnit specs asserting the OCP flow now proceeds on Sepolia (non-PRD).

Design-gap fix (review round 3): IBAN-free SWAP quote + broadcast

The swap-only path above consumes a SWAP-type TransactionRequest, but the only way to obtain one was PUT /sell (getSellPaymentInfo), whose DTO requires a fiat IBAN (@IsDfxIban(IbanType.SELL)) and creates a fiat Sell route + payout. Requiring a DFX sell bank account to do a pure REALU→ZCHF swap whose proceeds stay in the user's wallet (to then pay at an OCP/SPAR POS) is semantically wrong and a hard UX blocker — so the swap-only path was not usable end to end. Two entry points close the gap:

  • PUT /v1/realunit/swap (body { amount } XOR { targetAmount }, no iban/currency — target is always ZCHF) → { id, uid, routeId, timestamp, amount, estimatedAmount, targetAsset, fees, min/maxVolume(Target), ethBalance, requiredGasEth, isValid, error }. Creates a genuine TransactionRequestType.SWAP request whose id feeds PUT /swap/:id/unsigned-transaction.
  • PUT /v1/realunit/swap/:id/broadcast{ txHash } — dedicated swap broadcast for clean /swap/* semantics (the app no longer has to call a /sell/* route for a swap), reusing a shared private broadcastSignedTransaction helper extracted from broadcastSellTransaction (no duplication).

How the SWAP request is created, and why it is limit-exempt by design

getSwapPaymentInfo (realunit.service.ts) gates on the same registration + KYC Level 30 access checks as sell (these decide who may use the RealUnit features and are enforced here), then delegates to the existing crypto Swap-route machinerySwapService.createSwapPaymentInfo(userId, { sourceAsset: REALU, targetAsset: ZCHF, amount, targetAmount, exactPrice: false }, /* includeTx */ false) — which creates the request via transactionRequestService.create(TransactionRequestType.SWAP, ...). The ZCHF estimate is anchored to the live on-chain brokerbot sell price (same as sell). includeTx=false so no DFX-custody deposit tx is built — on-chain execution stays the already-built user-signed brokerbot tx (buildSwapUnsignedTransaction); the swap-route deposit address is unused.

KYC trading limits do NOT apply to this swap — and this is intentional, not a bypass. Trading limits are enforced at the fiat boundary (buy/sell); a REALU → ZCHF swap is a crypto → crypto, self-custody, on-chain Aktionariat-brokerbot action that DFX only relays. The existing non-fiat RealUnit carve-out in TransactionHelper.getLimits returns Number.MAX_VALUE for every RealUnit transaction that is not selling-REALU-for-fiat, so QuoteError.LIMIT_EXCEEDED can never fire for this pair. The earlier round mapped that (unreachable) limit error to a KycLevelRequiredException — that was dead, misleading code, and has been removed. The DTO still surfaces any genuine quote error (e.g. min/max volume) via its isValid/error fields so the app keeps a pre-tap API-as-authority signal; we simply no longer fake a limit → KYC-level mapping.

We reuse KycContext.REALUNIT_SELL for the swap gating (the swap is economically a sale of REALU for ZCHF and the required-steps set is identical); no new enum value or migration is introduced.

  • No migration: reuses TransactionRequest (the SWAP type already exists) and the Swap route entity — no entity/column changes.
  • Tests added/updated: swap quote happy path (IBAN-free, returns request id + ZCHF estimate anchored to the on-chain price), DTO carries no iban field, a (hypothetical) limit signal does not throw a KYC-level error — the swap is limit-exempt by design and the DTO isValid/error are surfaced instead, registration + KYC-30 required (gating unchanged and still asserted), swap broadcast reconstructs the signed hex and returns txHash. Full suite green (lint 0/0, type-check clean, build ok).

Updated the earlier section's "broadcast via PUT /sell/:id/broadcast" references to the new PUT /swap/:id/broadcast.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

⚠️ Unverified Commits (5)

The following commits are not signed/verified:

  • 9219b15 feat(realunit): add swap-only and OCP pay-flow workflow endpoints (TaprootFreak)
  • 4609dbd fix(realunit): harden OCP pay flow (pending nonce, fail-fast on unsupported method, URI validation) (TaprootFreak)
  • 74f93f0 feat(realunit): add IBAN-free REALU -> ZCHF swap quote and broadcast endpoints (TaprootFreak)
  • 1aff2a4 fix(realunit): make REALU -> ZCHF swap quote honest about limit-exemption (TaprootFreak)
  • aebfe50 Fix stale swap comments: drop limit-enforced wording and use swap broadcast endpoint (TaprootFreak)
How to sign commits
# SSH signing (recommended)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

# Re-sign last commit
git commit --amend -S --no-edit
git push --force-with-lease

@TaprootFreak TaprootFreak marked this pull request as ready for review June 3, 2026 19:27
@TaprootFreak TaprootFreak requested a review from davidleomay as a code owner June 3, 2026 19:27
@TaprootFreak TaprootFreak marked this pull request as draft June 3, 2026 19:30
@TaprootFreak TaprootFreak marked this pull request as ready for review June 3, 2026 20:30
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
…ported 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
…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.
…tion

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.
…nt 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
…ting

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.
…n-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).
@TaprootFreak
Copy link
Copy Markdown
Collaborator Author

Validated end-to-end on Sepolia testnet against a local API instance (LOC, Postgres, real EVM layer — no mocks): swap-only REALU→ZCHF tx 0xab91cdf13107b9dd6f32245b694d38e38dd73697814b3451acbb9cfdfdf08400 (ZCHF kept in user wallet) and full OCP pay settlement tx 0x78a2d98ae183761c71f4aa509cd87503e6adc9842a5c9bbf1ea23739a3b68fd2 (2.0 ZCHF ERC-20 transfer validated + broadcast by the engine to the EVM deposit address, quote → TxMempool, activation Closed). The Sepolia enablement (incl. fee + activation switch cases) is exercised by these runs; PRD remains unaffected (Sepolia is filtered from PaymentLinkBlockchains on PRD).

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