feat(realunit): add swap-only and OCP pay-flow workflow endpoints#3819
Open
TaprootFreak wants to merge 8 commits into
Open
feat(realunit): add swap-only and OCP pay-flow workflow endpoints#3819TaprootFreak wants to merge 8 commits into
TaprootFreak wants to merge 8 commits into
Conversation
|
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.
7d123df to
aebfe50
Compare
This was referenced Jun 4, 2026
…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).
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). |
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.
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 REALUtransferAndCallswap tx without the deposit sweep, so the ZCHF proceeds land in the connected wallet. Broadcast via the existingPUT /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-20transferto 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
buildSwapUnsignedTransactionandreconstructSignedTransactionfrom the existing sell flow (DRY) — the swap-only and OCP-submit paths reuse them; no on-chain logic is duplicated.LnUrlForwardService(ForwardingModule) and drives the existinglnurlpCallbackForward/txHexForward/waitForPaymentmachinery. Recipient + amount are parsed from the EVM payment-request URI the quote activation returns — the single source the lnurlpcbuses.@ApiPropertydocs; the OCP submit DTO extends the sell broadcast DTO. No entities/columns changed → no migration.Tests
Added jest service specs for
createSwapUnsignedTransaction,createOcpPayUnsignedTransaction,submitOcpPay, andgetOcpPayStatus(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 (
PaymentLinkBlockchainsincludes it on non-PRD;BlockchainRegistryService.getEvmClienthas a Sepolia case;doEvmHexPaymentis chain-agnostic) — the only gaps were three switch statements missing acase Blockchain.SEPOLIA(PaymentBalanceService.getDepositAddress,PaymentQuoteService.executeHexPayment,PaymentRequestMapper.toPaymentRequest), each falling through to adefaultthrow. Sepolia was added to the EVM group of all three, and to thePaymentLinkEvmHexBlockchainsset 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,
TestBlockchainsincludesBlockchain.SEPOLIA, soPaymentLinkBlockchainsfilters Sepolia out. No PRD payment-link can offer Sepolia, so the new EVMcase Blockchain.SEPOLIAlines are unreachable on PRD and OCP remains mainnet + L2 there (Ethereum/Arbitrum/Optimism/Base/Gnosis/Polygon/BSC).Hardening (review round 2)
pendingblock tag, so a still-pending swap tx (broadcast viaPUT /sell/:id/broadcast) is counted and the two txs cannot collide on the same nonce.EvmClient.getTransactionCountgained an optional, typed block-tag param (defaultlatest, no behaviour change for existing callers).PaymentLinkEvmHexBlockchainsconstant 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 typedBadRequestExceptionfor any genuinely-unsupported method instead of letting a deep, opaqueInvalid methodbubble up from the shared payment-link engine.parseEvmPaymentRequesthardened: cross-checks the URI token contract against the expected ZCHF asset and validates recipient/amount, throwing a typedBadRequestExceptionon mismatch or malformed input instead of a rawBigNumber.fromthrow.@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 wasPUT /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 }, noiban/currency— target is always ZCHF) →{ id, uid, routeId, timestamp, amount, estimatedAmount, targetAsset, fees, min/maxVolume(Target), ethBalance, requiredGasEth, isValid, error }. Creates a genuineTransactionRequestType.SWAPrequest whoseidfeedsPUT /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 privatebroadcastSignedTransactionhelper extracted frombroadcastSellTransaction(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 machinery —SwapService.createSwapPaymentInfo(userId, { sourceAsset: REALU, targetAsset: ZCHF, amount, targetAmount, exactPrice: false }, /* includeTx */ false)— which creates the request viatransactionRequestService.create(TransactionRequestType.SWAP, ...). The ZCHF estimate is anchored to the live on-chain brokerbot sell price (same as sell).includeTx=falseso 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.getLimitsreturnsNumber.MAX_VALUEfor every RealUnit transaction that is not selling-REALU-for-fiat, soQuoteError.LIMIT_EXCEEDEDcan never fire for this pair. The earlier round mapped that (unreachable) limit error to aKycLevelRequiredException— that was dead, misleading code, and has been removed. The DTO still surfaces any genuine quote error (e.g. min/max volume) via itsisValid/errorfields 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_SELLfor 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.TransactionRequest(theSWAPtype already exists) and theSwaproute entity — no entity/column changes.id+ ZCHF estimate anchored to the on-chain price), DTO carries noibanfield, a (hypothetical) limit signal does not throw a KYC-level error — the swap is limit-exempt by design and the DTOisValid/errorare surfaced instead, registration + KYC-30 required (gating unchanged and still asserted), swap broadcast reconstructs the signed hex and returnstxHash. Full suite green (lint 0/0, type-check clean, build ok).