From 8fbb09ed02405879017ccc44041e47d1ce41e580 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:33:17 +0200 Subject: [PATCH 1/4] feat(gs): allow ledger_account and ledger_leg on /gs/debug (#4320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/subdomains/generic/gs/dto/gs.dto.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index bd58ccefc2..ba40f6f32b 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -776,6 +776,30 @@ export const DebugAllowedColumns: Record = { language: { columns: ['id', 'created', 'updated', 'enable', 'foreignName', 'name', 'symbol'], }, + ledger_account: { + // Double-entry ledger chart-of-accounts (monitoring-only). Account names are deterministic system + // labels (e.g. 'Frick/EUR', 'LIABILITY/...'); the only non-fixed part is the counterparty + // institution name embedded in untracked-bank SUSPENSE account names, sourced from bank.name or + // bank_tx.bankName — both already exposed on the /gs/debug allowlist. No customer PII / secrets. + columns: ['id', 'created', 'updated', 'active', 'assetId', 'currency', 'name', 'type'], + }, + ledger_leg: { + // Individual debit/credit legs of a ledger transaction. Numeric amounts + FK ids for traversal + // (txId -> ledger_tx, accountId -> ledger_account); no PII / secrets / free-form text. + columns: [ + 'id', + 'created', + 'updated', + 'accountId', + 'amount', + 'amountBaseUnits', + 'amountChf', + 'amountChfCents', + 'needsMark', + 'priceChf', + 'txId', + ], + }, limit_request: { // Workflow / decision metadata only. No `fundOriginText` (free-form) and no `recipientMail`. columns: [ From cfae42315dba6ab954f16580a2a71eb229e7929d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:59:55 +0200 Subject: [PATCH 2/4] feat(realunit): add swap-only and OCP pay-flow workflow endpoints (#3819) * 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 (64685ea4) 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 05f042eb63a930d847874f4eaba6ed67604d0a1d. --- CONTRIBUTING.md | 3 + .../shared/evm/__tests__/evm-client.spec.ts | 29 + .../blockchain/shared/evm/evm-client.ts | 13 +- .../dto/payment-request.mapper.ts | 1 + .../core/payment-link/enums/index.ts | 261 +++-- .../__tests__/payment-link-sepolia.spec.ts | 245 ++++ .../services/payment-activation.service.ts | 1 + .../services/payment-balance.service.ts | 1 + .../services/payment-link-fee.service.ts | 1 + .../services/payment-quote.service.ts | 1 + .../__tests__/realunit.controller.spec.ts | 117 ++ .../__tests__/realunit.service.spec.ts | 1001 ++++++++++++++++- .../controllers/realunit.controller.ts | 122 ++ .../realunit/dto/realunit-pay.dto.ts | 160 +++ .../supporting/realunit/realunit.module.ts | 4 + .../supporting/realunit/realunit.service.ts | 574 +++++++++- 16 files changed, 2358 insertions(+), 176 deletions(-) create mode 100644 src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts create mode 100644 src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef9a5a0417..862168563f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -984,6 +984,9 @@ The RealUnit purchase and sale flows historically lived under `/v1/realunit/brok | `PUT /v1/realunit/sell/:id/broadcast` | Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no `readContract` | | `PUT /v1/realunit/transfer` | Persists a wallet-to-wallet (W2W) transfer intent and returns the EIP-7702 delegation data to sign. Limit-exempt (on-chain REALU→REALU self-custody movement). | No — prepares the gasless transfer | | `PUT /v1/realunit/transfer/:id/confirm` | Relays the user-signed EIP-7702 delegation for the stored transfer request; DFX pays gas from the dedicated W2W gas wallet (`REALUNIT_W2W_GAS_WALLET_*`), never the Sell/OTC relayer | No `readContract` — relays the user-authorized ERC20 transfer | +| `PUT /v1/realunit/swap` | IBAN-free REALU → ZCHF swap quote — creates a `TransactionRequestType.SWAP` request (proceeds stay in the user wallet, no fiat Sell route/payout). Gated by RealUnit registration + KYC Level 30 (who may use the feature). **Limit-exempt by design**: KYC trading limits apply at the fiat boundary (buy/sell), but this is a crypto → crypto, self-custody, on-chain swap, so the non-fiat RealUnit carve-out in `TransactionHelper.getLimits` means `QuoteError.LIMIT_EXCEEDED` never fires for this pair. Anchors the ZCHF estimate against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | +| `PUT /v1/realunit/swap/:id/unsigned-transaction` | Builds the REALU `transferAndCall` swap tx WITHOUT the deposit sweep (ZCHF lands in the user wallet) | No — builds calldata only | +| `PUT /v1/realunit/swap/:id/broadcast` | Submits the user-signed swap EIP-1559 transaction to the network | No — broadcast only, no `readContract` | Operational consequences: diff --git a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts index 641468794d..21e1b058b1 100644 --- a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts +++ b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts @@ -285,3 +285,32 @@ describe('EvmClient - broadcast boundary', () => { }); }); }); + +describe('EvmClient - getTransactionCount block tag', () => { + // Call the real method body with a stubbed provider so we can assert the block-tag + // forwarding without constructing a live EvmClient (same isolation pattern as the + // broadcast-boundary suite above). + const proto = EvmClient.prototype as any; + + it('defaults to the latest (mined) nonce when no block tag is given', async () => { + const getTransactionCount = jest.fn().mockResolvedValue(5); + const client = Object.create(EvmClient.prototype); + client.provider = { getTransactionCount }; + + const result = await proto.getTransactionCount.call(client, '0xabc'); + + expect(result).toBe(5); + expect(getTransactionCount).toHaveBeenCalledWith('0xabc', 'latest'); + }); + + it('forwards the pending block tag to count still-pending mempool txs', async () => { + const getTransactionCount = jest.fn().mockResolvedValue(7); + const client = Object.create(EvmClient.prototype); + client.provider = { getTransactionCount }; + + const result = await proto.getTransactionCount.call(client, '0xabc', 'pending'); + + expect(result).toBe(7); + expect(getTransactionCount).toHaveBeenCalledWith('0xabc', 'pending'); + }); +}); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 796aa37201..ff17698aee 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -144,6 +144,12 @@ export abstract class EvmClient extends BlockchainClient { return evmTokenBalances[0]?.balance ?? 0; } + async getTokenBalanceWei(asset: Asset, address?: string): Promise { + const owner = address ?? this.walletAddress; + const contract = this.getERC20ContractForDex(asset.chainId); + return contract.balanceOf(owner); + } + async getTokenBalances(assets: Asset[], address?: string): Promise { const owner = address ?? this.walletAddress; const evmTokenBalances: BlockchainTokenBalance[] = []; @@ -178,8 +184,11 @@ export abstract class EvmClient extends BlockchainClient { return block.timestamp; } - async getTransactionCount(address: string): Promise { - return this.provider.getTransactionCount(address); + // Defaults to the `latest` (mined) nonce. Pass `'pending'` to also count still-pending txs in the + // mempool, which is required when a follow-up tx is built before a prior tx of the same sender is mined + // (otherwise both would reuse the same nonce and collide). + async getTransactionCount(address: string, blockTag: ethers.providers.BlockTag = 'latest'): Promise { + return this.provider.getTransactionCount(address, blockTag); } protected async getTokenGasLimitForAsset(token: Asset): Promise { diff --git a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts index 5d0ae47222..02099520e8 100644 --- a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts +++ b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts @@ -13,6 +13,7 @@ export class PaymentRequestMapper { return this.toLnurlpInvoice(paymentActivation); case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/core/payment-link/enums/index.ts b/src/subdomains/core/payment-link/enums/index.ts index 21edde88b1..3e499402d3 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -1,123 +1,138 @@ -import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; - -export enum PaymentLinkStatus { - ACTIVE = 'Active', - INACTIVE = 'Inactive', - UNASSIGNED = 'Unassigned', -} - -export enum PaymentLinkPaymentStatus { - PENDING = 'Pending', - COMPLETED = 'Completed', - CANCELLED = 'Cancelled', - EXPIRED = 'Expired', -} - -export enum PaymentQuoteStatus { - ACTUAL = 'Actual', - CANCELLED = 'Cancelled', - EXPIRED = 'Expired', - - TX_RECEIVED = 'TxReceived', - TX_CHECKBOT = 'TxCheckbot', - TX_MEMPOOL = 'TxMempool', - TX_BLOCKCHAIN = 'TxBlockchain', - TX_COMPLETED = 'TxCompleted', - TX_FAILED = 'TxFailed', -} - -export const PaymentQuoteTxStates = [ - PaymentQuoteStatus.TX_RECEIVED, - PaymentQuoteStatus.TX_CHECKBOT, - PaymentQuoteStatus.TX_MEMPOOL, - PaymentQuoteStatus.TX_BLOCKCHAIN, - PaymentQuoteStatus.TX_COMPLETED, -]; - -export const PaymentQuoteFinalStates = [ - PaymentQuoteStatus.CANCELLED, - PaymentQuoteStatus.EXPIRED, - PaymentQuoteStatus.TX_CHECKBOT, - PaymentQuoteStatus.TX_MEMPOOL, - PaymentQuoteStatus.TX_BLOCKCHAIN, - PaymentQuoteStatus.TX_COMPLETED, - PaymentQuoteStatus.TX_FAILED, -]; - -export enum PaymentActivationStatus { - OPEN = 'Open', - CLOSED = 'Closed', -} - -export enum PaymentLinkPaymentMode { - SINGLE = 'Single', - MULTIPLE = 'Multiple', -} - -export enum PaymentStandard { - OPEN_CRYPTO_PAY = 'OpenCryptoPay', - LIGHTNING_BOLT11 = 'LightningBolt11', - PAY_TO_ADDRESS = 'PayToAddress', -} - -export enum C2BPaymentProvider { - BINANCE_PAY = Blockchain.BINANCE_PAY, - KUCOIN_PAY = Blockchain.KUCOIN_PAY, -} - -export enum C2BPaymentStatus { - WAITING = 'WAITING', - PENDING = 'PENDING', - COMPLETED = 'COMPLETED', - FAILED = 'FAILED', - REFUNDED = 'REFUNDED', -} - -export enum StickerType { - CLASSIC = 'Classic', - BITCOIN_FOCUS = 'BitcoinFocus', -} - -export enum PaymentLinkMode { - SINGLE = 'Single', - MULTIPLE = 'Multiple', - PUBLIC = 'Public', -} - -export enum StickerQrMode { - CUSTOMER = 'Customer', - POS = 'Pos', -} - -export enum PaymentMerchantStatus { - CREATED = 'Created', - PENDING = 'Pending', - PROCESSED = 'Processed', -} - -// Blockchains where the payer broadcasts the tx themselves and submits the resulting txId. -// The API marks the quote `TX_MEMPOOL` as soon as the txId is submitted, without waiting -// for on-chain confirmation. This is by design — accepting mempool transactions is the -// core feature of this payment flow (block-time waits are too slow for point-of-sale). -// -// Opt-in on the merchant side via `PaymentLinkConfig.minCompletionStatus = TX_MEMPOOL` -// (the default). Merchants who need stronger guarantees can set `TX_BLOCKCHAIN` to require -// on-chain confirmation before the payment auto-completes. -// -// Risk profile: accepting pre-confirmation enables tx-replacement attacks (the payer can -// broadcast a conflicting tx before a block confirms). The feature is scoped to physical -// point-of-sale: the fraudster has to be on the merchant's premises to walk off with -// goods, which makes the attack high-effort and locally traceable. For remote/high-value -// payments merchants should require `TX_BLOCKCHAIN`. -// -// We deliberately do not call the chain's node to verify the txId here, because none of -// the providers we use today expose mempool transactions consistently (own Monero/Zano -// daemons do; Tatum-backed Tron/Cardano do not), and a node-side check that returns -// "not found" for legitimately-broadcast-but-not-yet-propagated txs would break the -// feature. The only validation done is structural (txId format) — see `doTxIdPayment`. -export const UnverifiedTxIdBlockchains = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.TRON, Blockchain.CARDANO]; - -// Blockchains where user broadcasts tx and sends txId, API verifies tx confirmation -export const VerifiedTxIdBlockchains = [Blockchain.SOLANA, Blockchain.INTERNET_COMPUTER]; - -export const TxIdBlockchains = [...UnverifiedTxIdBlockchains, ...VerifiedTxIdBlockchains]; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; + +export enum PaymentLinkStatus { + ACTIVE = 'Active', + INACTIVE = 'Inactive', + UNASSIGNED = 'Unassigned', +} + +export enum PaymentLinkPaymentStatus { + PENDING = 'Pending', + COMPLETED = 'Completed', + CANCELLED = 'Cancelled', + EXPIRED = 'Expired', +} + +export enum PaymentQuoteStatus { + ACTUAL = 'Actual', + CANCELLED = 'Cancelled', + EXPIRED = 'Expired', + + TX_RECEIVED = 'TxReceived', + TX_CHECKBOT = 'TxCheckbot', + TX_MEMPOOL = 'TxMempool', + TX_BLOCKCHAIN = 'TxBlockchain', + TX_COMPLETED = 'TxCompleted', + TX_FAILED = 'TxFailed', +} + +export const PaymentQuoteTxStates = [ + PaymentQuoteStatus.TX_RECEIVED, + PaymentQuoteStatus.TX_CHECKBOT, + PaymentQuoteStatus.TX_MEMPOOL, + PaymentQuoteStatus.TX_BLOCKCHAIN, + PaymentQuoteStatus.TX_COMPLETED, +]; + +export const PaymentQuoteFinalStates = [ + PaymentQuoteStatus.CANCELLED, + PaymentQuoteStatus.EXPIRED, + PaymentQuoteStatus.TX_CHECKBOT, + PaymentQuoteStatus.TX_MEMPOOL, + PaymentQuoteStatus.TX_BLOCKCHAIN, + PaymentQuoteStatus.TX_COMPLETED, + PaymentQuoteStatus.TX_FAILED, +]; + +export enum PaymentActivationStatus { + OPEN = 'Open', + CLOSED = 'Closed', +} + +export enum PaymentLinkPaymentMode { + SINGLE = 'Single', + MULTIPLE = 'Multiple', +} + +export enum PaymentStandard { + OPEN_CRYPTO_PAY = 'OpenCryptoPay', + LIGHTNING_BOLT11 = 'LightningBolt11', + PAY_TO_ADDRESS = 'PayToAddress', +} + +export enum C2BPaymentProvider { + BINANCE_PAY = Blockchain.BINANCE_PAY, + KUCOIN_PAY = Blockchain.KUCOIN_PAY, +} + +export enum C2BPaymentStatus { + WAITING = 'WAITING', + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + REFUNDED = 'REFUNDED', +} + +export enum StickerType { + CLASSIC = 'Classic', + BITCOIN_FOCUS = 'BitcoinFocus', +} + +export enum PaymentLinkMode { + SINGLE = 'Single', + MULTIPLE = 'Multiple', + PUBLIC = 'Public', +} + +export enum StickerQrMode { + CUSTOMER = 'Customer', + POS = 'Pos', +} + +export enum PaymentMerchantStatus { + CREATED = 'Created', + PENDING = 'Pending', + PROCESSED = 'Processed', +} + +// EVM blockchains the payment-link engine accepts for signed-hex payments (PaymentRequestMapper + +// PaymentQuoteService.executeHexPayment). Includes the Sepolia testnet so Open CryptoPay is testable on +// non-PRD (DEV/LOC); on PRD Sepolia is filtered out of PaymentLinkBlockchains (via TestBlockchains), so no +// PRD payment-link can offer it and these EVM cases stay unreachable there. +export const PaymentLinkEvmHexBlockchains = [ + Blockchain.ETHEREUM, + Blockchain.SEPOLIA, + Blockchain.ARBITRUM, + Blockchain.OPTIMISM, + Blockchain.BASE, + Blockchain.GNOSIS, + Blockchain.POLYGON, + Blockchain.BINANCE_SMART_CHAIN, +]; + +// Blockchains where the payer broadcasts the tx themselves and submits the resulting txId. +// The API marks the quote `TX_MEMPOOL` as soon as the txId is submitted, without waiting +// for on-chain confirmation. This is by design — accepting mempool transactions is the +// core feature of this payment flow (block-time waits are too slow for point-of-sale). +// +// Opt-in on the merchant side via `PaymentLinkConfig.minCompletionStatus = TX_MEMPOOL` +// (the default). Merchants who need stronger guarantees can set `TX_BLOCKCHAIN` to require +// on-chain confirmation before the payment auto-completes. +// +// Risk profile: accepting pre-confirmation enables tx-replacement attacks (the payer can +// broadcast a conflicting tx before a block confirms). The feature is scoped to physical +// point-of-sale: the fraudster has to be on the merchant's premises to walk off with +// goods, which makes the attack high-effort and locally traceable. For remote/high-value +// payments merchants should require `TX_BLOCKCHAIN`. +// +// We deliberately do not call the chain's node to verify the txId here, because none of +// the providers we use today expose mempool transactions consistently (own Monero/Zano +// daemons do; Tatum-backed Tron/Cardano do not), and a node-side check that returns +// "not found" for legitimately-broadcast-but-not-yet-propagated txs would break the +// feature. The only validation done is structural (txId format) — see `doTxIdPayment`. +export const UnverifiedTxIdBlockchains = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.TRON, Blockchain.CARDANO]; + +// Blockchains where user broadcasts tx and sends txId, API verifies tx confirmation +export const VerifiedTxIdBlockchains = [Blockchain.SOLANA, Blockchain.INTERNET_COMPUTER]; + +export const TxIdBlockchains = [...UnverifiedTxIdBlockchains, ...VerifiedTxIdBlockchains]; diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts new file mode 100644 index 0000000000..95b9c64eb8 --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts @@ -0,0 +1,245 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ethers } from 'ethers'; +import * as ConfigModule from 'src/config/config'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; +import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; +import { TxValidationService } from 'src/integration/blockchain/shared/services/tx-validation.service'; +import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TransferInfo } from 'src/subdomains/core/payment-link/dto/payment-link.dto'; +import { PaymentRequestMapper } from 'src/subdomains/core/payment-link/dto/payment-request.mapper'; +import { PaymentActivation } from 'src/subdomains/core/payment-link/entities/payment-activation.entity'; +import { PaymentLinkPayment } from 'src/subdomains/core/payment-link/entities/payment-link-payment.entity'; +import { PaymentQuote } from 'src/subdomains/core/payment-link/entities/payment-quote.entity'; +import { PaymentQuoteStatus } from 'src/subdomains/core/payment-link/enums'; +import { PaymentQuoteRepository } from 'src/subdomains/core/payment-link/repositories/payment-quote.repository'; +import { C2BPaymentLinkService } from 'src/subdomains/core/payment-link/services/c2b-payment-link.service'; +import { PaymentActivationService } from 'src/subdomains/core/payment-link/services/payment-activation.service'; +import { PaymentBalanceService } from 'src/subdomains/core/payment-link/services/payment-balance.service'; +import { PaymentLinkFeeService } from 'src/subdomains/core/payment-link/services/payment-link-fee.service'; +import { PaymentQuoteService } from 'src/subdomains/core/payment-link/services/payment-quote.service'; +import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; +import { PayoutFiroService } from 'src/subdomains/supporting/payout/services/payout-firo.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; + +// Sepolia is an allowed payment-link chain on non-PRD (PaymentLinkBlockchains includes it; TestBlockchains is +// empty off PRD). These specs lock in that the engine routes Sepolia through the EVM handlers — the new +// `case Blockchain.SEPOLIA` lines — instead of falling through to the default throw. +describe('Payment-link engine - Sepolia routing', () => { + describe('PaymentBalanceService.getDepositAddress', () => { + let service: PaymentBalanceService; + + const evmDepositAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [PaymentBalanceService], + }) + .useMocker(() => createMock()) + .compile(); + + service = module.get(PaymentBalanceService); + // set the EVM deposit address directly (onModuleInit would derive it from a configured seed) + service['evmDepositAddress'] = evmDepositAddress; + }); + + it('returns the EVM deposit address for Sepolia (same as the mainnet EVM chains)', () => { + expect(service.getDepositAddress(Blockchain.SEPOLIA)).toBe(evmDepositAddress); + expect(service.getDepositAddress(Blockchain.ETHEREUM)).toBe(evmDepositAddress); + }); + }); + + describe('PaymentLinkFeeService.calculateFee / getMinFee', () => { + let service: PaymentLinkFeeService; + let blockchainRegistryService: BlockchainRegistryService; + + const sepoliaGasPrice = ethers.BigNumber.from(1_500_000_000); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + PaymentLinkFeeService, + { provide: BlockchainRegistryService, useValue: createMock() }, + { provide: PayoutBitcoinService, useValue: createMock() }, + { provide: PayoutFiroService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(PaymentLinkFeeService); + blockchainRegistryService = module.get(BlockchainRegistryService); + + const evmClient = createMock(); + jest.spyOn(evmClient, 'getRecommendedGasPrice').mockResolvedValue(sepoliaGasPrice); + jest.spyOn(blockchainRegistryService, 'getEvmClient').mockReturnValue(evmClient); + }); + + it('routes Sepolia to the EVM gas-price client and caches a real fee (not undefined)', async () => { + // calculateFee is private; exercise it through the public updateFees → getMinFee path + const fee = await ( + service as unknown as { calculateFee: (blockchain: Blockchain) => Promise } + ).calculateFee(Blockchain.SEPOLIA); + + expect(blockchainRegistryService.getEvmClient).toHaveBeenCalledWith(Blockchain.SEPOLIA); + expect(fee).toBe(+sepoliaGasPrice); + expect(fee).not.toBeUndefined(); + }); + + it('getMinFee returns the cached Sepolia gas-price after updateFees', async () => { + jest.spyOn(ConfigModule, 'GetConfig').mockReturnValue({ + environment: ConfigModule.Environment.DEV, + } as ReturnType); + + await service.updateFees(); + + await expect(service.getMinFee(Blockchain.SEPOLIA)).resolves.toBe(+sepoliaGasPrice); + }); + }); + + describe('PaymentActivationService.createBlockchainRequest', () => { + let service: PaymentActivationService; + let paymentBalanceService: PaymentBalanceService; + let cryptoService: CryptoService; + + const evmDepositAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'; + const paymentRequest = 'ethereum:0xToken@11155111/transfer?address=0xRecipient&uint256=1'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [PaymentActivationService], + }) + .useMocker((token) => { + if (token === LightningService) { + const lightningService = createMock(); + jest.spyOn(lightningService, 'getDefaultClient').mockReturnValue(createMock()); + return lightningService; + } + return createMock(); + }) + .compile(); + + service = module.get(PaymentActivationService); + paymentBalanceService = module.get(PaymentBalanceService); + cryptoService = module.get(CryptoService); + + jest.spyOn(paymentBalanceService, 'getDepositAddress').mockReturnValue(evmDepositAddress); + jest.spyOn(cryptoService, 'getPaymentRequest').mockResolvedValue(paymentRequest); + jest + .spyOn(service as unknown as { getAssetByInfo: () => Promise }, 'getAssetByInfo') + .mockResolvedValue({} as Asset); + }); + + it('routes Sepolia to the EVM deposit-address branch (not the default invalid-method throw)', async () => { + const transferInfo: TransferInfo = { + method: Blockchain.SEPOLIA, + asset: 'ZCHF', + amount: 1, + } as TransferInfo; + + const result = await ( + service as unknown as { + createBlockchainRequest: ( + payment: PaymentLinkPayment, + transferInfo: TransferInfo, + expirySec: number, + quote: PaymentQuote, + ) => Promise<{ paymentRequest: string; paymentHash?: string }>; + } + ).createBlockchainRequest({} as PaymentLinkPayment, transferInfo, 60, new PaymentQuote()); + + expect(paymentBalanceService.getDepositAddress).toHaveBeenCalledWith(Blockchain.SEPOLIA); + expect(result.paymentRequest).toBe(paymentRequest); + }); + }); + + describe('PaymentQuoteService.executeHexPayment', () => { + let service: PaymentQuoteService; + + function createActualQuote(): PaymentQuote { + const quote = new PaymentQuote(); + quote.uniqueId = 'quote-sepolia-1'; + quote.status = PaymentQuoteStatus.ACTUAL; + quote.activations = null; + return quote; + } + + beforeEach(async () => { + const paymentQuoteRepo = createMock(); + jest.spyOn(paymentQuoteRepo, 'findOne').mockResolvedValue(createActualQuote()); + jest.spyOn(paymentQuoteRepo, 'save').mockImplementation(async (q) => q as PaymentQuote); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + PaymentQuoteService, + { provide: PaymentQuoteRepository, useValue: paymentQuoteRepo }, + { provide: BlockchainRegistryService, useValue: createMock() }, + { provide: AssetService, useValue: createMock() }, + { provide: PricingService, useValue: createMock() }, + { provide: PaymentLinkFeeService, useValue: createMock() }, + { provide: C2BPaymentLinkService, useValue: createMock() }, + { provide: PaymentBalanceService, useValue: createMock() }, + { provide: TxValidationService, useValue: createMock() }, + { provide: InternetComputerService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(PaymentQuoteService); + }); + + it('routes Sepolia to the EVM hex handler (not the default throw)', async () => { + const doEvmHexPayment = jest + .spyOn(service as unknown as { doEvmHexPayment: (method: Blockchain) => Promise }, 'doEvmHexPayment') + .mockResolvedValue(undefined); + + // use `tx` (not `hex`) so the checkbot sign-verification branch is skipped and the method switch is reached + const transferInfo: TransferInfo = { + method: Blockchain.SEPOLIA, + tx: '0xTxHash', + quoteUniqueId: 'quote-sepolia-1', + } as TransferInfo; + + const quote = await service.executeHexPayment(transferInfo); + + expect(doEvmHexPayment).toHaveBeenCalledTimes(1); + expect(doEvmHexPayment.mock.calls[0][0]).toBe(Blockchain.SEPOLIA); + // the default branch records a TX_FAILED on the quote; the EVM route must not have failed it + expect(quote.status).not.toBe(PaymentQuoteStatus.TX_FAILED); + }); + }); + + describe('PaymentRequestMapper.toPaymentRequest', () => { + beforeAll(() => { + (ConfigModule as Record).Config = { url: () => 'https://example.com' }; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('routes Sepolia to the EVM payment-link payment (not the default throw)', () => { + const activation = { + method: Blockchain.SEPOLIA, + paymentRequest: 'ethereum:0xToken@11155111/transfer?address=0xRecipient&uint256=1', + expiryDate: new Date('2026-06-04T00:00:00.000Z'), + payment: { uniqueId: 'pl-payment-1' }, + } as unknown as PaymentActivation; + + const result = PaymentRequestMapper.toPaymentRequest(activation); + + expect(result).toMatchObject({ + blockchain: Blockchain.SEPOLIA, + uri: activation.paymentRequest, + expiryDate: activation.expiryDate, + }); + }); + }); +}); diff --git a/src/subdomains/core/payment-link/services/payment-activation.service.ts b/src/subdomains/core/payment-link/services/payment-activation.service.ts index 41b1ec3cb1..dd5bd9b967 100644 --- a/src/subdomains/core/payment-link/services/payment-activation.service.ts +++ b/src/subdomains/core/payment-link/services/payment-activation.service.ts @@ -178,6 +178,7 @@ export class PaymentActivationService { case Blockchain.MONERO: case Blockchain.ZANO: case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index 7d1aec0b67..e303cdd9a5 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -129,6 +129,7 @@ export class PaymentBalanceService implements OnModuleInit { getDepositAddress(method: Blockchain): string | undefined { switch (method) { case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index 290fe88ad0..a6a7f48cdf 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -69,6 +69,7 @@ export class PaymentLinkFeeService implements OnModuleInit { return 0; case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index 71f65965e0..41f8ef5f58 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -382,6 +382,7 @@ export class PaymentQuoteService { try { switch (transferInfo.method) { case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts index 6858390a0d..ad61329415 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts @@ -1,4 +1,11 @@ import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { + RealUnitOcpPayDto, + RealUnitOcpPaySubmitDto, + RealUnitSwapDto, +} from 'src/subdomains/supporting/realunit/dto/realunit-pay.dto'; +import { RealUnitSellBroadcastDto } from 'src/subdomains/supporting/realunit/dto/realunit-sell.dto'; import { RealUnitController } from '../controllers/realunit.controller'; // Thin controllers in this codebase delegate straight to the service; these specs assert the W2W transfer @@ -48,3 +55,113 @@ describe('RealUnitController (W2W transfer)', () => { }); }); }); + +// Thin controllers in this codebase delegate straight to the service; these specs assert the OCP pay-flow +// endpoints wire the JWT/params/body through to the right service call (the service logic itself is covered +// by realunit.service.spec.ts). +describe('RealUnitController (OCP pay flow)', () => { + let controller: RealUnitController; + + const realunitService = { + getSwapPaymentInfo: jest.fn(), + createSwapUnsignedTransaction: jest.fn(), + broadcastSwapTransaction: jest.fn(), + createOcpPayUnsignedTransaction: jest.fn(), + submitOcpPay: jest.fn(), + getOcpPayStatus: jest.fn(), + }; + const userService = { getUser: jest.fn() }; + + const jwt = { user: 42, address: '0xUser' } as JwtPayload; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new RealUnitController( + realunitService as never, // realunitService + {} as never, // balancePdfService (unused) + userService as never, // userService + {} as never, // swissQrService (unused) + {} as never, // pricingService (unused) + ); + }); + + describe('getSwapPaymentInfo', () => { + it('loads the user with kyc/country relations and delegates to the service', async () => { + const user = { id: 42 }; + const dto = { amount: 10 } as RealUnitSwapDto; + userService.getUser.mockResolvedValue(user); + realunitService.getSwapPaymentInfo.mockResolvedValue({ id: 99 }); + + const result = await controller.getSwapPaymentInfo(jwt, dto); + + expect(userService.getUser).toHaveBeenCalledWith(42, { userData: { kycSteps: true, country: true } }); + expect(realunitService.getSwapPaymentInfo).toHaveBeenCalledWith(user, dto); + expect(result).toEqual({ id: 99 }); + }); + }); + + describe('getSwapUnsignedTransaction', () => { + it('delegates to the service with the parsed numeric id', async () => { + realunitService.createSwapUnsignedTransaction.mockResolvedValue({ swap: '0xabc' }); + + const result = await controller.getSwapUnsignedTransaction(jwt, 7); + + expect(realunitService.createSwapUnsignedTransaction).toHaveBeenCalledWith(42, 7); + expect(result).toEqual({ swap: '0xabc' }); + }); + }); + + describe('broadcastSwapTransaction', () => { + it('delegates to the service with the parsed id and broadcast dto', async () => { + const dto = { unsignedTx: '0x', r: '0x', s: '0x', v: 27 } as RealUnitSellBroadcastDto; + realunitService.broadcastSwapTransaction.mockResolvedValue({ txHash: '0xhash' }); + + const result = await controller.broadcastSwapTransaction(jwt, 7, dto); + + expect(realunitService.broadcastSwapTransaction).toHaveBeenCalledWith(42, 7, dto); + expect(result).toEqual({ txHash: '0xhash' }); + }); + }); + + describe('getOcpPayUnsignedTransaction', () => { + it('delegates to the service with the jwt address, payment-link id and quote id', async () => { + const dto = { paymentLinkId: 'pl_abc', quoteId: 'quote_xyz' } as RealUnitOcpPayDto; + realunitService.createOcpPayUnsignedTransaction.mockResolvedValue({ unsignedTx: '0x' }); + + const result = await controller.getOcpPayUnsignedTransaction(jwt, dto); + + expect(realunitService.createOcpPayUnsignedTransaction).toHaveBeenCalledWith('0xUser', 'pl_abc', 'quote_xyz'); + expect(result).toEqual({ unsignedTx: '0x' }); + }); + }); + + describe('submitOcpPay', () => { + it('delegates to the service with the submit dto', async () => { + const dto = { + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx: '0x', + r: '0x', + s: '0x', + v: 27, + } as RealUnitOcpPaySubmitDto; + realunitService.submitOcpPay.mockResolvedValue({ txId: '0xTxId' }); + + const result = await controller.submitOcpPay(dto); + + expect(realunitService.submitOcpPay).toHaveBeenCalledWith(dto); + expect(result).toEqual({ txId: '0xTxId' }); + }); + }); + + describe('getOcpPayStatus', () => { + it('delegates to the service with the payment-link id', async () => { + realunitService.getOcpPayStatus.mockResolvedValue({ status: PaymentLinkPaymentStatus.COMPLETED }); + + const result = await controller.getOcpPayStatus('pl_abc'); + + expect(realunitService.getOcpPayStatus).toHaveBeenCalledWith('pl_abc'); + expect(result).toEqual({ status: PaymentLinkPaymentStatus.COMPLETED }); + }); + }); +}); diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 0ece66fa03..11a5047693 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ConflictException, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Wallet } from 'ethers'; +import { ethers, Wallet } from 'ethers'; import { verifyTypedData } from 'ethers/lib/utils'; import { request } from 'graphql-request'; import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; @@ -12,6 +12,7 @@ import { Eip7702DelegationService, TransactionRevertedException, } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { FaucetRequestService } from 'src/subdomains/core/faucet-request/services/faucet-request.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; @@ -21,7 +22,11 @@ import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { LanguageService } from 'src/shared/models/language/language.service'; import { HttpService } from 'src/shared/services/http.service'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; +import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; @@ -103,7 +108,7 @@ jest.mock('src/config/config', () => ({ GetConfig: jest.fn(() => ({ blockchain: { realunit: { - brokerbotAddress: '0xBrokerbotAddress', + brokerbotAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', graphUrl: 'https://mock-ponder.example.com', api: { url: 'https://mock-api.example.com', key: 'mock-key' }, bank: { @@ -184,6 +189,7 @@ describe('RealUnitService', () => { let eip7702DelegationService: jest.Mocked; let transactionRequestService: jest.Mocked; let sellService: jest.Mocked; + let swapService: jest.Mocked; let userService: jest.Mocked; let userDataService: jest.Mocked; let httpService: jest.Mocked; @@ -195,7 +201,20 @@ describe('RealUnitService', () => { let buyService: jest.Mocked; let supportIssueService: jest.Mocked; let transferRequestRepo: jest.Mocked; - let sepoliaClient: { getNativeCoinBalanceForAddress: jest.Mock; getTokenBalance: jest.Mock; getTxReceipt: jest.Mock }; + let sepoliaClient: { + chainId: number; + getTransactionCount: jest.Mock; + getRecommendedGasPrice: jest.Mock; + getNativeCoinBalanceForAddress: jest.Mock; + sendSignedTransaction: jest.Mock; + getTokenBalance: jest.Mock; + getTokenBalanceWei: jest.Mock; + getTxReceipt: jest.Mock; + }; + let evmClient: typeof sepoliaClient; + let lnUrlForwardService: jest.Mocked; + let paymentLinkPaymentService: jest.Mocked; + let faucetRequestService: jest.Mocked; const realuAsset = createCustomAsset({ id: 1, @@ -228,7 +247,17 @@ describe('RealUnitService', () => { aktionariatManager = { transaction: jest.fn(async (cb: any) => cb(aktionariatTxManager)), }; - sepoliaClient = { getNativeCoinBalanceForAddress: jest.fn(), getTokenBalance: jest.fn(), getTxReceipt: jest.fn() }; + sepoliaClient = { + chainId: 11155111, + getTransactionCount: jest.fn(), + getRecommendedGasPrice: jest.fn(), + getNativeCoinBalanceForAddress: jest.fn(), + sendSignedTransaction: jest.fn(), + getTokenBalance: jest.fn().mockResolvedValue(1_000_000), + getTokenBalanceWei: jest.fn().mockResolvedValue(ethers.BigNumber.from('1000000000000000000000000')), + getTxReceipt: jest.fn(), + }; + evmClient = sepoliaClient; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -271,6 +300,12 @@ describe('RealUnitService', () => { getById: jest.fn(), }, }, + { + provide: SwapService, + useValue: { + createSwapPaymentInfo: jest.fn(), + }, + }, { provide: Eip7702DelegationService, useValue: { @@ -293,14 +328,15 @@ describe('RealUnitService', () => { { provide: RealUnitDevService, useValue: { simulatePaymentForRequest: jest.fn() } }, { provide: SwissQRService, useValue: {} }, { provide: FeeService, useValue: {} }, - { provide: FaucetRequestService, useValue: {} }, - { provide: EthereumService, useValue: {} }, + { provide: FaucetRequestService, useValue: { resetFaucet: jest.fn() } }, + { provide: EthereumService, useValue: { getDefaultClient: jest.fn().mockReturnValue(sepoliaClient) } }, { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(sepoliaClient), }, }, + { provide: AktionariatRegistrationRepository, useValue: { @@ -333,6 +369,20 @@ describe('RealUnitService', () => { update: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), }, }, + { + provide: LnUrlForwardService, + useValue: { + lnurlpCallbackForward: jest.fn(), + txHexForward: jest.fn(), + waitForPayment: jest.fn(), + }, + }, + { + provide: PaymentLinkPaymentService, + useValue: { + getMostRecentPayment: jest.fn(), + }, + }, ], }).compile(); @@ -342,6 +392,7 @@ describe('RealUnitService', () => { eip7702DelegationService = module.get(Eip7702DelegationService); transactionRequestService = module.get(TransactionRequestService); sellService = module.get(SellService); + swapService = module.get(SwapService); userService = module.get(UserService); userDataService = module.get(UserDataService); httpService = module.get(HttpService); @@ -351,6 +402,9 @@ describe('RealUnitService', () => { buyService = module.get(BuyService); supportIssueService = module.get(SupportIssueService); transferRequestRepo = module.get(RealUnitTransferRequestRepository); + lnUrlForwardService = module.get(LnUrlForwardService); + paymentLinkPaymentService = module.get(PaymentLinkPaymentService); + faucetRequestService = module.get(FaucetRequestService); }); afterEach(() => { @@ -361,7 +415,7 @@ describe('RealUnitService', () => { it('should call assetService.getAssetByQuery for REALU and ZCHF', async () => { assetService.getAssetByQuery.mockResolvedValueOnce(realuAsset).mockResolvedValueOnce(zchfAsset); blockchainService.getBrokerbotInfo.mockResolvedValue({ - brokerbotAddress: '0xBrokerbotAddress', + brokerbotAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', tokenAddress: realuAsset.chainId, baseCurrencyAddress: zchfAsset.chainId, pricePerShare: 100, @@ -393,7 +447,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', undefined, @@ -407,7 +461,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(BrokerbotCurrency.EUR); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', BrokerbotCurrency.EUR, @@ -417,7 +471,7 @@ describe('RealUnitService', () => { it('should return the result from blockchainService', async () => { assetService.getAssetByQuery.mockResolvedValueOnce(realuAsset).mockResolvedValueOnce(zchfAsset); const expected = { - brokerbotAddress: '0xBrokerbotAddress', + brokerbotAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', tokenAddress: '0xRealuChainId', baseCurrencyAddress: '0xZchfChainId', pricePerShare: 100, @@ -483,12 +537,15 @@ describe('RealUnitService', () => { }); expect(result.txHash).toBe(mockTxHash); - expect(blockchainService.getBrokerbotSellPrice).toHaveBeenCalledWith('0xBrokerbotAddress', 10); + expect(blockchainService.getBrokerbotSellPrice).toHaveBeenCalledWith( + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + 10, + ); expect(eip7702DelegationService.executeBrokerBotSellForRealUnit).toHaveBeenCalledWith( userAddress, realuAsset, '0xZchfChainId', - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', depositAddress, 10, BigInt('995000000000000000000'), @@ -847,6 +904,737 @@ describe('RealUnitService', () => { }); }); + // Valid EVM addresses (checksummed) for the serialization / encoding paths + const userAddress = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + const realuContract = '0x5FbDB2315678afecb367f032d93F642f64180aa3'; + const zchfContract = '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512'; + const dfxDepositAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'; + + const realuTxAsset = createCustomAsset({ + id: 1, + name: 'REALU', + blockchain: Blockchain.SEPOLIA, + type: AssetType.TOKEN, + chainId: realuContract, + decimals: 0, + }); + + const zchfTxAsset = createCustomAsset({ + id: 2, + name: 'ZCHF', + blockchain: Blockchain.SEPOLIA, + type: AssetType.TOKEN, + chainId: zchfContract, + decimals: 18, + }); + + describe('createSwapUnsignedTransaction', () => { + const mockRequest = { + id: 1, + isValid: true, + amount: 10, + routeId: 5, + type: TransactionRequestType.SWAP, + sourceId: realuTxAsset.id, + targetId: zchfTxAsset.id, + user: { address: userAddress, userData: { kycLevel: KycLevel.LEVEL_30 } }, + }; + + beforeEach(() => { + evmClient.getTransactionCount.mockResolvedValue(7); + evmClient.getRecommendedGasPrice.mockResolvedValue(ethers.BigNumber.from(1_000_000_000)); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(true); + }); + + it('should build the swap tx without a deposit leg', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + const result = await service.createSwapUnsignedTransaction(42, 1); + + expect(Object.keys(result)).toEqual(['swap']); + const parsed = ethers.utils.parseTransaction(result.swap); + expect(parsed.to?.toLowerCase()).toBe(realuTxAsset.chainId.toLowerCase()); + expect(parsed.nonce).toBe(7); + // brokerbot is not queried for a deposit amount in the swap-only flow + expect(blockchainService.getBrokerbotSellPrice).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException if request is not valid', async () => { + transactionRequestService.getOrThrow.mockResolvedValue({ ...mockRequest, isValid: false } as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + await expect(service.createSwapUnsignedTransaction(42, 1)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException if ETH balance is insufficient for gas', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(0); + + await expect(service.createSwapUnsignedTransaction(42, 1)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException if the REALU asset has no contract address', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery + .mockResolvedValueOnce(createCustomAsset({ id: realuTxAsset.id, name: 'REALU', chainId: undefined } as any)) + .mockResolvedValueOnce(zchfTxAsset); + + await expect(service.createSwapUnsignedTransaction(42, 1)).rejects.toThrow(BadRequestException); + }); + + it('should default REALU decimals to 18 when the asset has no decimals set', async () => { + // decimals null/undefined exercises the `?? 18` fallback in buildSwapUnsignedTransaction + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + const noDecimalsAsset = createCustomAsset({ + id: realuTxAsset.id, + name: 'REALU', + chainId: realuContract, + decimals: undefined, + } as any); + assetService.getAssetByQuery.mockResolvedValueOnce(noDecimalsAsset).mockResolvedValueOnce(zchfTxAsset); + + const result = await service.createSwapUnsignedTransaction(42, 1); + + // request amount 10 -> 10 shares encoded with 18 decimals = 10e18 + const parsed = ethers.utils.parseTransaction(result.swap); + const iface = new ethers.utils.Interface([ + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', + ]); + const [, value] = iface.decodeFunctionData('transferAndCall', parsed.data); + expect(value.toString()).toBe(ethers.utils.parseUnits('10', 18).toString()); + }); + + it('should throw NotFoundException if the REALU asset is not found', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(undefined as any).mockResolvedValueOnce(zchfTxAsset); + + await expect(service.createSwapUnsignedTransaction(42, 1)).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if the ZCHF asset is not found', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(undefined as any); + + await expect(service.createSwapUnsignedTransaction(42, 1)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSwapPaymentInfo', () => { + const walletAddress = '0x4444444444444444444444444444444444444444'; + + function buildUser(opts: { kycLevel?: number } = {}): any { + return { + id: 42, + address: walletAddress, + userData: { + kycLevel: opts.kycLevel ?? KycLevel.LEVEL_30, + }, + }; + } + + const swapInfo = { + id: 99, + uid: 'MOCK-UID', + routeId: 7, + timestamp: new Date('2026-06-03T00:00:00.000Z'), + amount: 10, + estimatedAmount: 950, + fees: { dfx: 1, network: 0.5, total: 1.5 } as any, + minVolume: 1, + maxVolume: 1000, + minVolumeTarget: 95, + maxVolumeTarget: 95000, + isValid: true, + error: undefined, + }; + + beforeEach(() => { + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(true); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + evmClient.getRecommendedGasPrice.mockResolvedValue(ethers.BigNumber.from(1_000_000_000)); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); + blockchainService.getBrokerbotSellPrice.mockResolvedValue({ zchfAmountWei: BigInt('960000000000000000000') }); + transactionRequestService.updateEstimatedAmount = jest.fn(); + }); + + it('should create an IBAN-free SWAP quote (no iban/Sell route) and return the request id + ZCHF estimate', async () => { + swapService.createSwapPaymentInfo.mockResolvedValue(swapInfo as any); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any); + + expect(result.id).toBe(99); + expect(result.uid).toBe('MOCK-UID'); + expect(result.routeId).toBe(7); + expect(result.targetAsset).toBe('ZCHF'); + expect(result.isValid).toBe(true); + + // SWAP quote is created via the IBAN-free SwapService path (REALU -> ZCHF), NOT the Sell path + expect(sellService.getById).not.toHaveBeenCalled(); + const [, dto] = swapService.createSwapPaymentInfo.mock.calls[0]; + expect(dto.sourceAsset.name).toBe('REALU'); + expect(dto.targetAsset.name).toBe('ZCHF'); + // the DTO carries no iban field — the IBAN-free contract + expect('iban' in dto).toBe(false); + + // estimated ZCHF is anchored to the live on-chain brokerbot price + expect(result.estimatedAmount).toBe(960); + expect(transactionRequestService.updateEstimatedAmount).toHaveBeenCalledWith(99, 960); + }); + + it('should NOT throw a KYC-level error on a trading-limit signal — the swap is limit-exempt by design', async () => { + // KYC trading limits are enforced at the fiat boundary (buy/sell). A REALU -> ZCHF swap is a crypto -> + // crypto self-custody on-chain action, so the non-fiat RealUnit carve-out in TransactionHelper.getLimits + // means QuoteError.LIMIT_EXCEEDED can never fire for this pair. Even on a (hypothetical) limit signal the + // service must surface the DTO error rather than map it to a KYC level. + swapService.createSwapPaymentInfo.mockResolvedValue({ + ...swapInfo, + isValid: false, + error: QuoteError.LIMIT_EXCEEDED, + } as any); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 100000 } as any); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(QuoteError.LIMIT_EXCEEDED); + }); + + it('should require RealUnit registration', async () => { + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(false); + + await expect(service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any)).rejects.toBeInstanceOf( + RegistrationRequiredException, + ); + expect(swapService.createSwapPaymentInfo).not.toHaveBeenCalled(); + }); + + it('should require KYC Level 30', async () => { + await expect( + service.getSwapPaymentInfo(buildUser({ kycLevel: KycLevel.LEVEL_20 }), { amount: 10 } as any), + ).rejects.toBeInstanceOf(KycLevelRequiredException); + expect(swapService.createSwapPaymentInfo).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if the REALU asset is not found', async () => { + assetService.getAssetByQuery.mockReset(); + assetService.getAssetByQuery.mockResolvedValueOnce(undefined as any).mockResolvedValueOnce(zchfTxAsset); + + await expect(service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any)).rejects.toThrow(NotFoundException); + expect(swapService.createSwapPaymentInfo).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if the ZCHF asset is not found', async () => { + assetService.getAssetByQuery.mockReset(); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(undefined as any); + + await expect(service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any)).rejects.toThrow(NotFoundException); + expect(swapService.createSwapPaymentInfo).not.toHaveBeenCalled(); + }); + + it('should keep the SwapService estimate (no brokerbot anchor) when shares floor to 0', async () => { + // amount < 1 floors to 0 shares: the brokerbot price is not queried and the estimate stays as-is + swapService.createSwapPaymentInfo.mockResolvedValue({ ...swapInfo, amount: 0.4, estimatedAmount: 0.38 } as any); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 0.4 } as any); + + expect(blockchainService.getBrokerbotSellPrice).not.toHaveBeenCalled(); + expect(result.amount).toBe(0); + expect(result.estimatedAmount).toBe(0.38); + expect(result.isValid).toBe(false); + expect(result.error).toBe(QuoteError.AMOUNT_TOO_LOW); + expect(transactionRequestService.updateEstimatedAmount).not.toHaveBeenCalled(); + }); + + it('should floor fractional REALU shares while preserving a valid swap quote', async () => { + swapService.createSwapPaymentInfo.mockResolvedValue({ ...swapInfo, amount: 10.9, isValid: true } as any); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 10.9 } as any); + + expect(result.amount).toBe(10); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + expect(blockchainService.getBrokerbotSellPrice).toHaveBeenCalledWith(expect.any(String), 10); + }); + + it('should throw PriceSourceUnavailableException when the brokerbot price query fails', async () => { + swapService.createSwapPaymentInfo.mockResolvedValue(swapInfo as any); + blockchainService.getBrokerbotSellPrice.mockRejectedValue(new Error('rpc down')); + + await expect(service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any)).rejects.toBeInstanceOf( + PriceSourceUnavailableException, + ); + expect(transactionRequestService.updateEstimatedAmount).not.toHaveBeenCalled(); + }); + + it('should keep the SwapService estimate when the request has no id (brokerbot anchor skipped)', async () => { + // swapPaymentInfo.id falsy -> the `brokerbotResult && swapPaymentInfo.id` guard is false + swapService.createSwapPaymentInfo.mockResolvedValue({ ...swapInfo, id: 0 } as any); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any); + + expect(result.estimatedAmount).toBe(950); + expect(transactionRequestService.updateEstimatedAmount).not.toHaveBeenCalled(); + }); + }); + + describe('broadcastSwapTransaction', () => { + const signerWallet = new ethers.Wallet('0x' + '11'.repeat(32)); + + const mockRequest = { + id: 1, + isValid: true, + amount: 10, + routeId: 7, + type: TransactionRequestType.SWAP, + sourceId: realuTxAsset.id, + targetId: zchfTxAsset.id, + user: { address: signerWallet.address, userData: { kycLevel: KycLevel.LEVEL_30 } }, + }; + + const erc677Interface = new ethers.utils.Interface([ + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', + ]); + const swapData = erc677Interface.encodeFunctionData('transferAndCall', [ + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', // brokerbotAddress from the GetConfig mock + ethers.BigNumber.from(10), // mockRequest.amount = 10 shares, realuTxAsset.decimals = 0 + '0x', + ]); + + let txFields: any; + let unsignedTx: string; + let broadcastDto: { unsignedTx: string; r: string; s: string; v: number }; + + beforeAll(async () => { + txFields = { + type: 2, + chainId: 11155111, + nonce: 7, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(350_000), + to: realuContract, + value: ethers.BigNumber.from(0), + data: swapData, + accessList: [], + }; + unsignedTx = ethers.utils.serializeTransaction(txFields); + const fullySignedTx = await signerWallet.signTransaction(txFields); + const { r, s, v } = ethers.utils.parseTransaction(fullySignedTx); + broadcastDto = { unsignedTx, r: r!, s: s!, v: v! }; + }); + + beforeEach(() => { + jest.spyOn(service, 'hasRegistrationForWallet').mockResolvedValue(true); + }); + + it('should reconstruct the signed hex, broadcast it and return the txHash', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + evmClient.sendSignedTransaction.mockResolvedValue({ response: { hash: '0xSwapTxHash' } }); + + const result = await service.broadcastSwapTransaction(42, 1, broadcastDto); + + expect(result.txHash).toBe('0xSwapTxHash'); + expect(evmClient.sendSignedTransaction).toHaveBeenCalledTimes(1); + expect(evmClient.sendSignedTransaction.mock.calls[0][0]).toMatch(/^0x/); + expect(transactionRequestService.complete).toHaveBeenCalledWith(1); + }); + + it('should throw BadRequestException if the request is not valid', async () => { + transactionRequestService.getOrThrow.mockResolvedValue({ ...mockRequest, isValid: false } as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(BadRequestException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when the broadcast returns an error', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + evmClient.sendSignedTransaction.mockResolvedValue({ error: { message: 'nonce too low' } }); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(BadRequestException); + expect(faucetRequestService.resetFaucet).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when the broadcast returns no transaction hash', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + evmClient.sendSignedTransaction.mockResolvedValue({ response: {} }); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(BadRequestException); + expect(faucetRequestService.resetFaucet).not.toHaveBeenCalled(); + }); + + it('rejects a second swap broadcast for the same request after the first one completed it (replay guard)', async () => { + assetService.getAssetByQuery.mockImplementation((query: any) => + Promise.resolve(query.name === 'ZCHF' ? zchfTxAsset : realuTxAsset), + ); + evmClient.sendSignedTransaction.mockResolvedValue({ response: { hash: '0xSwapTxHash' } }); + + const requestFixture = { ...mockRequest, isComplete: false }; + transactionRequestService.complete.mockImplementation(async (id: number) => { + if (id === requestFixture.id) requestFixture.isComplete = true; + }); + transactionRequestService.getOrThrow.mockImplementation(async () => ({ ...requestFixture }) as any); + + // 1st broadcast: request not yet complete -> succeeds and marks it complete via complete() + const first = await service.broadcastSwapTransaction(42, 1, broadcastDto); + expect(first.txHash).toBe('0xSwapTxHash'); + expect(transactionRequestService.complete).toHaveBeenCalledWith(requestFixture.id); + expect(requestFixture.isComplete).toBe(true); + + // 2nd broadcast: getOrThrow now reflects isComplete=true BECAUSE complete() set it on the fixture + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(ConflictException); + expect(evmClient.sendSignedTransaction).toHaveBeenCalledTimes(1); // not called again on the replay + }); + + it('throws BadRequestException when the signed swap tx targets an unexpected contract/calldata', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + const badUnsignedTx = ethers.utils.serializeTransaction({ + type: 2, + chainId: 11155111, + nonce: 7, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(350_000), + to: '0x000000000000000000000000000000000000dEaD', // wrong recipient contract + value: ethers.BigNumber.from(0), + data: '0x12345678', + accessList: [], + }); + + await expect( + service.broadcastSwapTransaction(42, 1, { ...broadcastDto, unsignedTx: badUnsignedTx }), + ).rejects.toThrow(BadRequestException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when the signed swap tx has correct to/chainId/value but mismatched calldata', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + // Correct to/chainId/value, but different transferAndCall amount so expectedData mismatches + const mismatchedData = erc677Interface.encodeFunctionData('transferAndCall', [ + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + ethers.BigNumber.from(99), // wrong shares amount vs mockRequest.amount = 10 + '0x', + ]); + const mismatchedCalldataTx = ethers.utils.serializeTransaction({ + type: 2, + chainId: 11155111, + nonce: 7, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(350_000), + to: realuContract, + value: ethers.BigNumber.from(0), + data: mismatchedData, + accessList: [], + }); + + await expect( + service.broadcastSwapTransaction(42, 1, { ...broadcastDto, unsignedTx: mismatchedCalldataTx }), + ).rejects.toThrow(BadRequestException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException (not a raw 500) when unsignedTx is syntactically malformed', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + await expect( + service.broadcastSwapTransaction(42, 1, { ...broadcastDto, unsignedTx: '0xdeadbeef' }), + ).rejects.toThrow(BadRequestException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('maps a signed transaction parse failure to BadRequestException', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + const parseTransaction = ethers.utils.parseTransaction; + const parseTransactionSpy = jest.spyOn(ethers.utils, 'parseTransaction'); + parseTransactionSpy + .mockImplementationOnce((rawTransaction) => parseTransaction(rawTransaction)) + .mockImplementationOnce(() => { + throw new Error('invalid signature'); + }); + + try { + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow( + 'Invalid signed transaction', + ); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + } finally { + parseTransactionSpy.mockRestore(); + } + }); + + it('throws BadRequestException when the signed swap tx sender does not match the request user address (foreign wallet)', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + const foreignWallet = new ethers.Wallet('0x' + '22'.repeat(32)); + const foreignSignedTx = await foreignWallet.signTransaction(txFields); + const { r, s, v } = ethers.utils.parseTransaction(foreignSignedTx); + + await expect(service.broadcastSwapTransaction(42, 1, { unsignedTx, r, s, v })).rejects.toThrow( + BadRequestException, + ); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if the REALU asset is not found', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(undefined as any).mockResolvedValueOnce(zchfTxAsset); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(NotFoundException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if the ZCHF asset is not found', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(undefined as any); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(NotFoundException); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + + it('throws the specific "Invalid unsigned transaction" fee-field-guard failure (not merely a generic BadRequestException) when unsignedTx is a legacy type-0 tx without EIP-1559 fee fields', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValueOnce(realuTxAsset).mockResolvedValueOnce(zchfTxAsset); + + // Legacy type-0 uses gasPrice only — parseTransaction yields undefined maxFeePerGas/maxPriorityFeePerGas, + // which trips the fee-field guard inside reconstructSignedTransaction BEFORE the downstream + // payload/sender-match check ever runs. That guard's own Error message is caught by + // reconstructSignedTransaction's catch-all and remapped to 'Invalid unsigned transaction' — so THAT is + // the specific, discriminating signal to assert on here. + // Proof this is sharp, not incidental: reusing broadcastDto's r/s/v (a valid signature for the ORIGINAL + // type-2 payload) against this legacy payload means that if the fee-field guard did NOT fire first, + // execution would fall through to the payload/sender-match check and throw a DIFFERENT message + // ('...does not match the expected request payload') instead — so the two failure modes are + // distinguishable and this assertion cannot pass for the wrong reason. + const legacyUnsignedTx = ethers.utils.serializeTransaction({ + type: 0, + chainId: 11155111, + nonce: 7, + gasPrice: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(350_000), + to: realuContract, + value: ethers.BigNumber.from(0), + data: swapData, + }); + + await expect( + service.broadcastSwapTransaction(42, 1, { ...broadcastDto, unsignedTx: legacyUnsignedTx }), + ).rejects.toThrow('Invalid unsigned transaction'); + expect(evmClient.sendSignedTransaction).not.toHaveBeenCalled(); + }); + }); + + // The engine-touching OCP specs run under PRD (→ Ethereum) to exercise the mainnet branch. A dedicated + // block below asserts that on the LOC/Sepolia branch the method guard now PASSES (Sepolia is a supported + // payment-link EVM method on non-PRD), so the OCP pay flow is testable end-to-end on the testnet. + describe('createOcpPayUnsignedTransaction', () => { + const amountWei = '5000000000000000000'; + + beforeAll(() => { + mockEnvironment = 'prd'; + }); + + afterAll(() => { + mockEnvironment = 'loc'; + }); + + beforeEach(() => { + evmClient.getTransactionCount.mockResolvedValue(3); + evmClient.getRecommendedGasPrice.mockResolvedValue(ethers.BigNumber.from(1_000_000_000)); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); + }); + + it('should activate the quote, parse the EVM uri and build the ZCHF transfer tx', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.ETHEREUM, + uri: `ethereum:${zchfTxAsset.chainId}@1/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + + const result = await service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz'); + + expect(lnUrlForwardService.lnurlpCallbackForward).toHaveBeenCalledWith('pl_abc', { + method: Blockchain.ETHEREUM, + asset: 'ZCHF', + quote: 'quote_xyz', + }); + expect(result.recipient).toBe(dfxDepositAddress); + expect(result.amountWei).toBe(amountWei); + expect(result.tokenAddress).toBe(zchfTxAsset.chainId); + + const parsed = ethers.utils.parseTransaction(result.unsignedTx); + expect(parsed.to?.toLowerCase()).toBe(zchfTxAsset.chainId.toLowerCase()); + expect(parsed.nonce).toBe(3); + }); + + it('should derive the pay-tx nonce from the pending block tag (avoids collision with a still-pending swap tx)', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + + await service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz'); + + expect(evmClient.getTransactionCount).toHaveBeenCalledWith(userAddress, 'pending'); + }); + + it('should throw BadRequestException if the EVM uri token contract does not match the ZCHF asset', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${realuContract}@11155111/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if the EVM uri amount is malformed', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=${dfxDepositAddress}&uint256=not-a-number`, + hint: '', + }); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if the EVM uri recipient is not a valid address', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=0xNotAnAddress&uint256=${amountWei}`, + hint: '', + }); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if the quote returns no EVM payment request', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ pr: 'lnbc...' } as any); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if the EVM uri is missing recipient or amount', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=${dfxDepositAddress}`, + hint: '', + }); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if the ZCHF asset has no contract address', async () => { + assetService.getAssetByQuery.mockResolvedValue(createCustomAsset({ name: 'ZCHF', chainId: undefined } as any)); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + expect(lnUrlForwardService.lnurlpCallbackForward).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException if ETH balance is insufficient for gas', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.ETHEREUM, + uri: `ethereum:${zchfTxAsset.chainId}@1/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(0); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw ConflictException if ZCHF balance is insufficient (swap not yet settled)', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.ETHEREUM, + uri: `ethereum:${zchfTxAsset.chainId}@1/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + evmClient.getTokenBalanceWei.mockResolvedValueOnce(ethers.BigNumber.from(0)); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + ConflictException, + ); + }); + + it('should reject a wallet whose ETH balance covers the base gas cost but not the buffered maxFeePerGas (F5)', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.ETHEREUM, + uri: `ethereum:${zchfTxAsset.chainId}@1/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + // gasPrice=1e9, gasLimit=100_000 -> base requirement 0.0001 ETH, buffered (x1.2) requirement 0.00012 ETH + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(0.00011); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should build the pay tx when ETH balance is just above the buffered maxFeePerGas requirement (F5) and the tx carries the buffered fee', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.ETHEREUM, + uri: `ethereum:${zchfTxAsset.chainId}@1/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + // gasPrice=1e9, gasLimit=100_000 -> buffered (x1.2) requirement 0.00012 ETH; use a balance just above it + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(0.000121); + + const result = await service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz'); + + const parsed = ethers.utils.parseTransaction(result.unsignedTx); + const expectedBufferedMaxFeePerGas = ethers.BigNumber.from(1_000_000_000).mul(120).div(100); + expect(parsed.maxFeePerGas?.toString()).toBe(expectedBufferedMaxFeePerGas.toString()); + }); + }); + describe('confirmPaymentReceived (REALU scoping)', () => { // mockEnvironment stays at its 'loc' default so confirmPaymentReceived takes the DEV // simulation path (devService.simulatePaymentForRequest), never the PRD payAndAllocate path. @@ -939,6 +1727,97 @@ describe('RealUnitService', () => { }); }); + describe('submitOcpPay', () => { + beforeAll(() => { + mockEnvironment = 'prd'; + }); + + afterAll(() => { + mockEnvironment = 'loc'; + }); + + it('should reconstruct the signed hex and forward it into the lnurlp tx path', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.txHexForward.mockResolvedValue({ txId: '0xTxId' }); + + const signerWallet = new ethers.Wallet('0x' + '11'.repeat(32)); + const payAmountWei = ethers.BigNumber.from('5000000000000000000'); + const txFields = { + type: 2, + chainId: 1, + nonce: 1, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(100_000), + to: zchfTxAsset.chainId, + value: ethers.BigNumber.from(0), + data: EvmUtil.encodeErc20Transfer(dfxDepositAddress, payAmountWei), + accessList: [], + }; + const unsignedTx = ethers.utils.serializeTransaction(txFields); + const fullySignedTx = await signerWallet.signTransaction(txFields); + const { r, s, v } = ethers.utils.parseTransaction(fullySignedTx); + + const result = await service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx, + r, + s, + v, + }); + + expect(result.txId).toBe('0xTxId'); + expect(lnUrlForwardService.txHexForward).toHaveBeenCalledWith( + 'pl_abc', + expect.objectContaining({ method: Blockchain.ETHEREUM, asset: 'ZCHF', quote: 'quote_xyz' }), + ); + expect(lnUrlForwardService.txHexForward.mock.calls[0][1].hex).toMatch(/^0x/); + }); + + it('should throw BadRequestException (not a raw 500) when the signed tx data is not a valid ERC-20 transfer', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + + const signerWallet = new ethers.Wallet('0x' + '11'.repeat(32)); + const txFields = { + type: 2, + chainId: 1, + nonce: 1, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(100_000), + to: zchfTxAsset.chainId, + value: ethers.BigNumber.from(0), + data: '0x12345678', // garbage selector — not the ERC-20 transfer() selector + accessList: [], + }; + const unsignedTx = ethers.utils.serializeTransaction(txFields); + const fullySignedTx = await signerWallet.signTransaction(txFields); + const { r, s, v } = ethers.utils.parseTransaction(fullySignedTx); + + await expect( + service.submitOcpPay({ paymentLinkId: 'pl_abc', quoteId: 'quote_xyz', unsignedTx, r, s, v }), + ).rejects.toThrow(BadRequestException); + expect(lnUrlForwardService.txHexForward).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if the ZCHF asset is not found', async () => { + assetService.getAssetByQuery.mockResolvedValue(undefined as any); + + await expect( + service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx: '0x', + r: '0x', + s: '0x', + v: 27, + }), + ).rejects.toThrow(NotFoundException); + expect(lnUrlForwardService.txHexForward).not.toHaveBeenCalled(); + }); + }); + describe('W2W transfer', () => { const senderAddress = '0x1111111111111111111111111111111111111111'; const recipientAddress = '0x2222222222222222222222222222222222222222'; @@ -1469,6 +2348,104 @@ describe('RealUnitService', () => { }); }); + describe('getOcpPayStatus', () => { + it('should map the most recent payment status', async () => { + paymentLinkPaymentService.getMostRecentPayment.mockResolvedValue({ + status: PaymentLinkPaymentStatus.COMPLETED, + } as any); + + const result = await service.getOcpPayStatus('pl_abc'); + + expect(result).toEqual({ status: PaymentLinkPaymentStatus.COMPLETED }); + expect(paymentLinkPaymentService.getMostRecentPayment).toHaveBeenCalledWith('pl_abc'); + }); + }); + + describe('assertPaymentLinkSupportsMethod (private guard)', () => { + it('throws ServiceUnavailableException (not BadRequestException) for an unsupported token blockchain', () => { + jest.spyOn(service as any, 'tokenBlockchain', 'get').mockReturnValue(Blockchain.BITCOIN); + + expect(() => + (service as unknown as { assertPaymentLinkSupportsMethod: () => void }).assertPaymentLinkSupportsMethod(), + ).toThrow(ServiceUnavailableException); + + expect(() => + (service as unknown as { assertPaymentLinkSupportsMethod: () => void }).assertPaymentLinkSupportsMethod(), + ).not.toThrow(BadRequestException); + }); + }); + + // On LOC/DEV the token blockchain resolves to Sepolia. Sepolia is a supported payment-link EVM method on + // non-PRD, so the method guard passes and both OCP pay endpoints proceed into the payment-link engine + // (OCP is testable end-to-end on the testnet). + describe('OCP pay supported on non-PRD testnet (Sepolia)', () => { + const amountWei = '5000000000000000000'; + + beforeEach(() => { + evmClient.getTransactionCount.mockResolvedValue(3); + evmClient.getRecommendedGasPrice.mockResolvedValue(ethers.BigNumber.from(1_000_000_000)); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); + }); + + it('createOcpPayUnsignedTransaction passes the method guard and activates the Sepolia quote', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ + expiryDate: new Date(), + blockchain: Blockchain.SEPOLIA, + uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + hint: '', + }); + + const result = await service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz'); + + expect(lnUrlForwardService.lnurlpCallbackForward).toHaveBeenCalledWith('pl_abc', { + method: Blockchain.SEPOLIA, + asset: 'ZCHF', + quote: 'quote_xyz', + }); + expect(result.recipient).toBe(dfxDepositAddress); + expect(result.amountWei).toBe(amountWei); + }); + + it('submitOcpPay passes the method guard and forwards the hex with the Sepolia method', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.txHexForward.mockResolvedValue({ txId: '0xTxId' }); + + const signerWallet = new ethers.Wallet('0x' + '11'.repeat(32)); + const payAmountWei = ethers.BigNumber.from('5000000000000000000'); + const txFields = { + type: 2, + chainId: 11155111, + nonce: 1, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1), + gasLimit: ethers.BigNumber.from(100_000), + to: zchfTxAsset.chainId, + value: ethers.BigNumber.from(0), + data: EvmUtil.encodeErc20Transfer(dfxDepositAddress, payAmountWei), + accessList: [], + }; + const unsignedTx = ethers.utils.serializeTransaction(txFields); + const fullySignedTx = await signerWallet.signTransaction(txFields); + const { r, s, v } = ethers.utils.parseTransaction(fullySignedTx); + + const result = await service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx, + r, + s, + v, + }); + + expect(result.txId).toBe('0xTxId'); + expect(lnUrlForwardService.txHexForward).toHaveBeenCalledWith( + 'pl_abc', + expect.objectContaining({ method: Blockchain.SEPOLIA, asset: 'ZCHF', quote: 'quote_xyz' }), + ); + }); + }); + describe('completeRegistrationForWalletAddress (idempotency)', () => { const walletAddress = '0x1111111111111111111111111111111111111111'; const userDataId = 42; diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 7884ec432e..8cc23bdadf 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -63,6 +63,16 @@ import { RealUnitConfirmAktionariatDto, RealUnitConfirmAktionariatQueryDto, } from '../dto/realunit-confirm-aktionariat.dto'; +import { + RealUnitOcpPayDto, + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, + RealUnitSwapUnsignedTransactionDto, +} from '../dto/realunit-pay.dto'; import { RealUnitBalancePdfDto, RealUnitMultiReceiptPdfDto, @@ -721,6 +731,118 @@ export class RealUnitController { return this.realunitService.confirmTransfer(jwt.user, id, dto); } + // --- OCP Pay-Flow Endpoints --- // + // Phase 2 pay flow: swap REALU -> ZCHF keeping the ZCHF in the user wallet, then pay that ZCHF to an + // Open CryptoPay recipient via the public lnurlp payment-link flow. The backend orchestrates the steps + // (workflow endpoints) since the app cannot build EVM calldata or settle the OCP quote locally. + + @Put('swap') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get swap quote for an IBAN-free REALU -> ZCHF swap (proceeds stay in the user wallet)', + description: + 'Creates a SWAP-type transaction request for a REALU -> ZCHF swap WITHOUT a fiat IBAN, Sell route or payout, so the ZCHF proceeds stay in the connected wallet (to then pay at an OCP/SPAR POS). Same registration + KYC Level 30 gating as sell. KYC trading limits do NOT apply: they are enforced at the fiat boundary (buy/sell), whereas this is a crypto -> crypto self-custody on-chain swap (limit-exempt by design). Step 0 of the OCP pay flow: feed the returned `id` into `PUT /swap/:id/unsigned-transaction`. Requires KYC Level 30 and RealUnit registration.', + }) + @ApiOkResponse({ type: RealUnitSwapPaymentInfoDto }) + @ApiBadRequestResponse({ + description: 'KYC Level 30 required, registration missing, or invalid swap amount (min/max volume)', + }) + async getSwapPaymentInfo( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitSwapDto, + ): Promise { + const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } }); + return this.realunitService.getSwapPaymentInfo(user, dto); + } + + @Put('swap/:id/unsigned-transaction') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get unsigned REALU -> ZCHF swap transaction (proceeds stay in the user wallet)', + description: + 'Builds the REALU transferAndCall swap transaction WITHOUT the deposit sweep, so the ZCHF proceeds land in the connected wallet. Step 1 of the OCP pay flow (obtain the request `id` from `PUT /swap` first); broadcast the signed transaction via `PUT /swap/:id/broadcast`.', + }) + @ApiParam({ name: 'id', description: 'Transaction request ID' }) + @ApiOkResponse({ type: RealUnitSwapUnsignedTransactionDto }) + @ApiBadRequestResponse({ description: 'Invalid request or insufficient ETH for gas' }) + async getSwapUnsignedTransaction( + @GetJwt() jwt: JwtPayload, + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.realunitService.createSwapUnsignedTransaction(jwt.user, id); + } + + @Put('swap/:id/broadcast') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Broadcast a signed REALU -> ZCHF swap transaction', + description: + 'Broadcasts the user-signed EIP-1559 swap transaction (from `PUT /swap/:id/unsigned-transaction`) to the network. Step 1b of the OCP pay flow; afterwards request the OCP pay transaction via `PUT /pay/unsigned-transaction`.', + }) + @ApiParam({ name: 'id', description: 'Transaction request ID' }) + @ApiOkResponse({ description: 'Transaction broadcast', schema: { properties: { txHash: { type: 'string' } } } }) + @ApiBadRequestResponse({ description: 'Invalid signed transaction or broadcast failure' }) + async broadcastSwapTransaction( + @GetJwt() jwt: JwtPayload, + @Param('id', ParseIntPipe) id: number, + @Body() dto: RealUnitSellBroadcastDto, + ): Promise<{ txHash: string }> { + return this.realunitService.broadcastSwapTransaction(jwt.user, id, dto); + } + + @Put('pay/unsigned-transaction') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get unsigned ZCHF transfer transaction for an Open CryptoPay payment', + description: + 'Resolves recipient and exact amount from the OCP payment-link quote (same source the lnurlp callback uses) and builds the unsigned ZCHF ERC-20 transfer transaction to the DFX deposit address. Step 2a of the OCP pay flow; submit the signed transaction via `PUT /pay/submit`. Broadcast the swap transaction (`PUT /swap/:id/broadcast`) before requesting this pay transaction — the pay-tx nonce is derived from the pending block so a still-pending swap tx is counted and the two transactions do not collide on the same nonce.', + }) + @ApiOkResponse({ type: RealUnitOcpPayUnsignedTransactionDto }) + @ApiBadRequestResponse({ description: 'Invalid payment-link/quote reference or insufficient ETH for gas' }) + @ApiConflictResponse({ description: 'Insufficient ZCHF balance — swap not yet settled' }) + @ApiNotFoundResponse({ description: 'Unknown or expired payment-link/quote id' }) + async getOcpPayUnsignedTransaction( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitOcpPayDto, + ): Promise { + return this.realunitService.createOcpPayUnsignedTransaction(jwt.address, dto.paymentLinkId, dto.quoteId); + } + + @Put('pay/submit') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Submit a signed ZCHF transfer to settle an Open CryptoPay payment', + description: + 'Reconstructs the signed transaction and submits it into the existing lnurlp settlement path, where DFX validates recipient, amount, and min-fee, broadcasts it, and settles the OCP quote. Step 2b of the OCP pay flow.', + }) + @ApiOkResponse({ type: RealUnitOcpPayResultDto }) + @ApiBadRequestResponse({ description: 'Invalid signed transaction or settlement failure' }) + @ApiConflictResponse({ description: 'Insufficient ZCHF balance — swap not yet settled' }) + @ApiNotFoundResponse({ description: 'Unknown or expired payment-link/quote id' }) + async submitOcpPay(@Body() dto: RealUnitOcpPaySubmitDto): Promise { + return this.realunitService.submitOcpPay(dto); + } + + @Get('pay/:id/status') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get the status of an Open CryptoPay payment', + description: + 'Returns the current status (Pending/Completed/Cancelled/Expired) of the most recent payment for the given payment-link/payment id via a status-independent lookup — not only while a payment is pending. Step 3 of the OCP pay flow.', + }) + @ApiParam({ name: 'id', description: 'Payment-link or payment-link-payment unique id of the OCP payment' }) + @ApiOkResponse({ type: RealUnitOcpPayStatusDto }) + @ApiNotFoundResponse({ description: 'No payment found for the given payment-link/payment id' }) + async getOcpPayStatus(@Param('id') id: string): Promise { + return this.realunitService.getOcpPayStatus(id); + } + // --- Registration Info Endpoint --- @Get('registration') diff --git a/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts new file mode 100644 index 0000000000..541cd1de7b --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts @@ -0,0 +1,160 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsNotEmpty, IsNumber, IsPositive, IsString, Validate, ValidateIf } from 'class-validator'; +import { Util } from 'src/shared/utils/util'; +import { XOR } from 'src/shared/validators/xor.validator'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { FeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; +import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; +import { RealUnitSellBroadcastDto } from './realunit-sell.dto'; + +// --- Swap quote (REALU -> ZCHF, proceeds stay in the user wallet, IBAN-free) --- // + +// Input mirrors the sell DTO's amount XOR targetAmount pattern but drops `iban` and `currency`: +// the swap target is always ZCHF (the on-chain brokerbot base currency), so no fiat Sell route / payout +// is involved. `amount` is REALU shares, `targetAmount` is ZCHF. +export class RealUnitSwapDto { + @ApiPropertyOptional({ description: 'Amount of REALU shares to swap' }) + @IsNotEmpty() + @ValidateIf((b: RealUnitSwapDto) => Boolean(b.amount || !b.targetAmount)) + @Validate(XOR, ['targetAmount']) + @IsNumber() + @IsPositive() + @Type(() => Number) + amount?: number; + + @ApiPropertyOptional({ description: 'Target amount in ZCHF (alternative to amount)' }) + @IsNotEmpty() + @ValidateIf((b: RealUnitSwapDto) => Boolean(b.targetAmount || !b.amount)) + @Validate(XOR, ['amount']) + @IsNumber() + @IsPositive() + @Type(() => Number) + targetAmount?: number; +} + +export class RealUnitSwapPaymentInfoDto { + // --- Identification --- // + @ApiProperty({ description: 'Transaction request ID (feeds PUT /swap/:id/unsigned-transaction)' }) + id: number; + + @ApiProperty({ description: 'Transaction request UID' }) + uid: string; + + @ApiProperty({ description: 'Swap route ID' }) + routeId: number; + + @ApiProperty({ description: 'Price timestamp' }) + timestamp: Date; + + // --- Amounts --- // + @ApiProperty({ description: 'Amount of REALU shares to swap' }) + amount: number; + + @ApiProperty({ description: 'Estimated ZCHF amount the swap will pay out' }) + estimatedAmount: number; + + @ApiProperty({ description: 'Target asset name (always ZCHF)' }) + targetAsset: string; + + // --- Fee Info --- // + @ApiProperty({ type: FeeDto, description: 'Fee infos in source asset (REALU)' }) + fees: FeeDto; + + @ApiProperty({ description: 'Minimum volume in REALU shares' }) + minVolume: number; + + @ApiProperty({ description: 'Maximum volume in REALU shares' }) + maxVolume: number; + + @ApiProperty({ description: 'Minimum volume in target asset (ZCHF)' }) + minVolumeTarget: number; + + @ApiProperty({ description: 'Maximum volume in target asset (ZCHF)' }) + maxVolumeTarget: number; + + // --- Gas Info --- // + @ApiProperty({ description: 'User ETH balance on the token chain' }) + ethBalance: number; + + @ApiProperty({ description: 'Required ETH to cover gas for the brokerbot swap step' }) + requiredGasEth: number; + + // --- Validation --- // + @ApiProperty({ description: 'Whether the swap quote is valid' }) + isValid: boolean; + + @ApiPropertyOptional({ enum: QuoteError, description: 'Error code in case isValid is false' }) + error?: QuoteError; +} + +// --- Swap-only unsigned transaction (REALU -> ZCHF, proceeds stay in the user wallet) --- // + +export class RealUnitSwapUnsignedTransactionDto { + @ApiProperty({ description: 'Unsigned REALU transferAndCall swap transaction (serialized EIP-1559 hex)' }) + swap: string; +} + +// --- OCP pay (settle a ZCHF payment-link quote via the public lnurlp flow) --- // + +export class RealUnitOcpPayDto { + @ApiProperty({ + description: + 'Payment-link or payment-link-payment unique id decoded from the OCP LNURL (e.g. "pl_..." / "plp_...")', + }) + @IsNotEmpty() + @IsString() + @Transform(Util.trim) + paymentLinkId: string; + + @ApiProperty({ description: 'Quote unique id decoded from the OCP pay request' }) + @IsNotEmpty() + @IsString() + @Transform(Util.trim) + quoteId: string; +} + +// Extends the sell broadcast DTO (same unsigned-tx + signature shape) and adds the payment-link/quote +// references so the signed hex can be submitted into the existing lnurlp tx settlement path. +export class RealUnitOcpPaySubmitDto extends RealUnitSellBroadcastDto { + @ApiProperty({ description: 'Payment-link or payment-link-payment unique id of the OCP payment' }) + @IsNotEmpty() + @IsString() + @Transform(Util.trim) + paymentLinkId: string; + + @ApiProperty({ description: 'Quote unique id of the OCP payment' }) + @IsNotEmpty() + @IsString() + @Transform(Util.trim) + quoteId: string; +} + +export class RealUnitOcpPayUnsignedTransactionDto { + @ApiProperty({ + description: 'Unsigned ZCHF ERC-20 transfer transaction to the OCP recipient (serialized EIP-1559 hex)', + }) + unsignedTx: string; + + @ApiProperty({ description: 'ZCHF token contract address (recipient of the transfer call)' }) + tokenAddress: string; + + @ApiProperty({ description: 'Recipient address that receives the ZCHF transfer (DFX deposit address for the quote)' }) + recipient: string; + + @ApiProperty({ description: 'ZCHF amount to transfer (in token smallest unit / wei)' }) + amountWei: string; + + @ApiProperty({ description: 'EVM chain id of the ZCHF token' }) + chainId: number; +} + +export class RealUnitOcpPayResultDto { + @ApiProperty({ description: 'Blockchain transaction id of the submitted ZCHF payment' }) + txId: string; +} + +export class RealUnitOcpPayStatusDto { + @ApiProperty({ enum: PaymentLinkPaymentStatus, description: 'Status of the OCP payment' }) + status: PaymentLinkPaymentStatus; +} diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index a06f66f026..0dbb3db64a 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -7,7 +7,9 @@ import { Eip7702DelegationModule } from 'src/integration/blockchain/shared/evm/d import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; import { FaucetRequestModule } from 'src/subdomains/core/faucet-request/faucet-request.module'; +import { PaymentLinkPaymentModule } from 'src/subdomains/core/payment-link/payment-link-payment.module'; import { SellCryptoModule } from 'src/subdomains/core/sell-crypto/sell-crypto.module'; +import { ForwardingModule } from 'src/subdomains/generic/forwarding/forwarding.module'; import { KycModule } from 'src/subdomains/generic/kyc/kyc.module'; import { UserModule } from 'src/subdomains/generic/user/user.module'; import { LogModule } from 'src/subdomains/supporting/log/log.module'; @@ -51,7 +53,9 @@ import { RealUnitTransferRequestRepository } from './repositories/realunit-trans BankTxModule, PaymentModule, TransactionModule, + PaymentLinkPaymentModule, Eip7702DelegationModule, + ForwardingModule, forwardRef(() => BuyCryptoModule), forwardRef(() => SellCryptoModule), FaucetRequestModule, diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index a70699268b..eef1f974ed 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -42,8 +42,12 @@ import { toBitboxAscii } from 'src/shared/utils/bitbox-ascii.util'; import { PdfUtil } from 'src/shared/utils/pdf.util'; import { Util } from 'src/shared/utils/util'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; import { FaucetRequestService } from 'src/subdomains/core/faucet-request/services/faucet-request.service'; +import { PaymentLinkEvmHexBlockchains } from 'src/subdomains/core/payment-link/enums'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; +import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { KycContext } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; @@ -91,6 +95,14 @@ import { RealUnitConfirmAktionariatQueryDto, } from './dto/realunit-confirm-aktionariat.dto'; import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; +import { + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, +} from './dto/realunit-pay.dto'; import { AktionariatRegistrationDto, MAX_SERIALIZED_TIN_LENGTH, @@ -231,6 +243,8 @@ export class RealUnitService { private readonly buyService: BuyService, @Inject(forwardRef(() => SellService)) private readonly sellService: SellService, + @Inject(forwardRef(() => SwapService)) + private readonly swapService: SwapService, private readonly eip7702DelegationService: Eip7702DelegationService, private readonly ethereumService: EthereumService, private readonly sepoliaService: SepoliaService, @@ -245,6 +259,8 @@ export class RealUnitService { private readonly logService: LogService, private readonly supportIssueService: SupportIssueService, private readonly transferRequestRepo: RealUnitTransferRequestRepository, + private readonly lnUrlForwardService: LnUrlForwardService, + private readonly paymentLinkPaymentService: PaymentLinkPaymentService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -721,6 +737,23 @@ export class RealUnitService { return (await this.findRegistration(userData, walletAddress)).isForCurrentWallet; } + // Bundles the two RealUnit access gates (Aktionariat registration + KYC Level 30) so every RealUnit swap + // entry point (quote, unsigned-tx build, broadcast) enforces them consistently, not just the initial quote. + private async assertRealUnitAccess(userData: UserData, walletAddress: string): Promise { + if (!(await this.hasRegistrationForWallet(userData, walletAddress))) { + throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_SELL); + } + + if (userData.kycLevel < KycLevel.LEVEL_30) { + throw new KycLevelRequiredException( + KycLevel.LEVEL_30, + userData.kycLevel, + 'KYC Level 30 required for RealUnit swap', + KycContext.REALUNIT_SELL, + ); + } + } + async registerEmail(userDataId: number, dto: RealUnitEmailRegistrationDto): Promise { const userData = await this.userDataService.getActiveUserData(userDataId, { users: true }); @@ -1914,6 +1947,111 @@ export class RealUnitService { return response; } + // --- Swap Quote Methods (IBAN-free REALU -> ZCHF) --- // + + // Step 0 of the OCP pay flow: produces a SWAP-type TransactionRequest for REALU -> ZCHF WITHOUT a fiat + // IBAN, Sell route or payout — the ZCHF proceeds stay in the user wallet so they can be paid at an OCP/SPAR + // POS. A RealUnit holder paying at a POS need not have a DFX sell bank account, so requiring an IBAN (as + // the /sell quote does) would be a hard UX blocker. The request id returned here feeds + // `createSwapUnsignedTransaction` (PUT /swap/:id/unsigned-transaction). + // + // Gating mirrors the sell path's access gates (registration + KYC Level 30) — these decide WHO may use the + // RealUnit features at all and are enforced here. KYC TRADING LIMITS, however, do NOT apply to this swap: + // they are enforced at the fiat boundary (buy/sell), whereas 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 (see step 5 below). + // We deliberately reuse the existing crypto Swap-route machinery (TransactionRequestType.SWAP) so this is a + // genuine SWAP request — NOT a Sell — and no fiat route/IBAN is created. + // + // Design note: the standard SwapService payment-info models a DFX-custody swap (user deposits the source + // asset to a DFX deposit address). For RealUnit the on-chain execution stays the already-built user-signed + // brokerbot mechanism (buildSwapUnsignedTransaction), so the swap-route deposit address is unused here — we + // only consume the route binding, the quote, and the SWAP-type TransactionRequest. includeTx + // is false so no DFX-custody deposit tx is built. + async getSwapPaymentInfo(user: User, dto: RealUnitSwapDto): Promise { + const userData = user.userData; + + // 1+2. Registration + KYC Level 30 required (bundled gate, also enforced in the two swap-tx methods below) + await this.assertRealUnitAccess(userData, user.address); + + // 3. Get assets (source REALU -> target ZCHF) + const [realuAsset, zchfAsset] = await Promise.all([this.getRealuAsset(), this.getZchfAsset()]); + if (!realuAsset) throw new NotFoundException('REALU asset not found'); + if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); + + // 4. Create the SWAP quote + SWAP-type TransactionRequest via the crypto Swap-route machinery (IBAN-free). + // includeTx=false: the on-chain execution uses the user-signed brokerbot tx, not a DFX-custody deposit tx + // (see design note above). + const includeTx = false; + const swapPaymentInfo = await this.withPriceSourceGuard(() => + this.swapService.createSwapPaymentInfo( + user.id, + { + sourceAsset: realuAsset, + targetAsset: zchfAsset, + amount: dto.amount, + targetAmount: dto.targetAmount, + exactPrice: false, + }, + includeTx, + ), + ); + + // 5. No KYC-trading-limit throw here BY DESIGN. KYC 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 and is limit-exempt by the existing non-fiat RealUnit carve-out in TransactionHelper.getLimits + // (it returns Number.MAX_VALUE for any RealUnit tx that is not selling-REALU-for-fiat), so the quote can + // never carry QuoteError.LIMIT_EXCEEDED for this pair. We still surface any genuine quote error (e.g. + // min/max volume) via isValid/error on the returned DTO, but we do NOT map a limit error to a KYC level + // here — that would be misleading dead code. Do not re-add a LIMIT_EXCEEDED -> KYC-level throw. + + // 6. Fetch gas info and anchor the estimated ZCHF against the live on-chain brokerbot price (same as sell) + const evmClient = this.getEvmClient(); + const shares = Math.floor(swapPaymentInfo.amount); + const [ethBalance, gasPrice, brokerbotResult] = await Promise.all([ + evmClient.getNativeCoinBalanceForAddress(user.address), + evmClient.getRecommendedGasPrice(), + shares > 0 + ? this.blockchainService.getBrokerbotSellPrice(this.getBrokerbotAddress(), shares).catch(() => { + throw new PriceSourceUnavailableException(); + }) + : Promise.resolve(null), + ]); + + let estimatedAmount = swapPaymentInfo.estimatedAmount; + if (brokerbotResult && swapPaymentInfo.id) { + estimatedAmount = EvmUtil.fromWeiAmount( + ethers.BigNumber.from(brokerbotResult.zchfAmountWei.toString()), + zchfAsset.decimals, + ); + await this.transactionRequestService.updateEstimatedAmount(swapPaymentInfo.id, estimatedAmount); + } + + // Swap-only gas: 350k for the single brokerbotSell step (no deposit leg) + const swapGasLimit = ethers.BigNumber.from(350_000); + const requiredGasEth = EvmUtil.fromWeiAmount(gasPrice.mul(swapGasLimit)); + + return { + id: swapPaymentInfo.id, + uid: swapPaymentInfo.uid, + routeId: swapPaymentInfo.routeId, + timestamp: swapPaymentInfo.timestamp, + amount: shares, + estimatedAmount, + targetAsset: zchfAsset.name, + fees: swapPaymentInfo.fees, + minVolume: swapPaymentInfo.minVolume, + maxVolume: swapPaymentInfo.maxVolume, + minVolumeTarget: swapPaymentInfo.minVolumeTarget, + maxVolumeTarget: swapPaymentInfo.maxVolumeTarget, + ethBalance, + requiredGasEth, + isValid: shares < 1 ? false : swapPaymentInfo.isValid, + error: shares < 1 ? QuoteError.AMOUNT_TOO_LOW : swapPaymentInfo.error, + }; + } + // --- Sell Transaction Methods for BitBox --- async createSellUnsignedTransactions(userId: number, requestId: number): Promise<{ swap: string; deposit: string }> { @@ -1944,29 +2082,8 @@ export class RealUnitService { } // Swap tx: nonce N — REALU transferAndCall to brokerbot - const ERC677_INTERFACE = new ethers.utils.Interface([ - 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', - ]); const shares = Math.floor(request.amount); - const swapAmountWei = ethers.utils.parseUnits(shares.toString(), realuAsset.decimals ?? 18); - const swapData = ERC677_INTERFACE.encodeFunctionData('transferAndCall', [ - this.getBrokerbotAddress(), - swapAmountWei, - '0x', - ]); - - const swap = ethers.utils.serializeTransaction({ - type: 2, - chainId: client.chainId, - nonce, - maxPriorityFeePerGas: gasPrice, - maxFeePerGas: gasPrice, - gasLimit: swapGasLimit, - to: realuAsset.chainId, - value: ethers.BigNumber.from(0), - data: swapData, - accessList: [], - }); + const swap = this.buildSwapUnsignedTransaction(client.chainId, realuAsset, shares, nonce, gasPrice, swapGasLimit); // Deposit tx: nonce N+1 — ZCHF ERC20 transfer to deposit address // Query the brokerbot for the exact ZCHF amount at current price so deposit matches swap output @@ -1996,27 +2113,87 @@ export class RealUnitService { userId: number, requestId: number, dto: RealUnitSellBroadcastDto, + ): Promise<{ txHash: string }> { + return this.broadcastSignedTransaction(userId, requestId, dto); + } + + // Dedicated swap broadcast (PUT /swap/:id/broadcast) for clean OCP-flow semantics: the app broadcasts a + // swap via a /swap/* route, not a /sell/* one. Validates the signed payload against the server-known + // request (re-derived transferAndCall calldata), broadcasts, then marks the request complete so a + // second broadcast is rejected by the isComplete guard (replay protection). + async broadcastSwapTransaction( + userId: number, + requestId: number, + dto: RealUnitSellBroadcastDto, ): Promise<{ txHash: string }> { const request = await this.transactionRequestService.getOrThrow(requestId, userId); + + if (request.type !== TransactionRequestType.SWAP) throw new BadRequestException('Not a swap request'); + if (request.isComplete) throw new ConflictException('Transaction request is already confirmed'); + + const [realuAsset, zchfAsset] = await Promise.all([this.getRealuAsset(), this.getZchfAsset()]); + if (!realuAsset) throw new NotFoundException('REALU asset not found'); + if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); + if (request.sourceId !== realuAsset.id || request.targetId !== zchfAsset.id) { + throw new BadRequestException('Swap request source/target asset does not match REALU/ZCHF'); + } + await this.assertRealUnitAccess(request.user.userData, request.user.address); + if (!request.isValid) throw new BadRequestException('Transaction request is not valid'); - const { unsignedTx, r, s, v } = dto; - const parsed = ethers.utils.parseTransaction(unsignedTx); - const signedHex = ethers.utils.serializeTransaction( - { - type: 2, - chainId: parsed.chainId, - nonce: parsed.nonce, - maxPriorityFeePerGas: parsed.maxPriorityFeePerGas ?? ethers.BigNumber.from(0), - maxFeePerGas: parsed.maxFeePerGas ?? ethers.BigNumber.from(0), - gasLimit: parsed.gasLimit, - to: parsed.to, - value: parsed.value, - data: parsed.data, - accessList: parsed.accessList ?? [], - }, - { r, s, v }, - ); + const signedHex = this.reconstructSignedTransaction(dto); + + const client = this.getEvmClient(); + let parsedTx: ReturnType; + try { + parsedTx = ethers.utils.parseTransaction(signedHex); + } catch { + throw new BadRequestException('Invalid signed transaction'); + } + const expectedData = this.buildSwapCalldata(realuAsset, Math.floor(request.amount)); + + // Fail-closed: the signed tx the client broadcasts must match what the server built for THIS request — + // recipient contract, chain, value and calldata are all re-derived server-side rather than trusted from + // the client-supplied unsignedTx, so a mismatched/forged payload never reaches the network. The recovered + // signer must also match the request's user address, so an attacker cannot relay a transaction signed + // by a different wallet than the KYC'd requester. + const payloadMatches = + parsedTx.to != null && + Util.equalsIgnoreCase(parsedTx.to, realuAsset.chainId) && + parsedTx.chainId === client.chainId && + parsedTx.value.isZero() && + Util.equalsIgnoreCase(parsedTx.data, expectedData) && + parsedTx.from != null && + Util.equalsIgnoreCase(parsedTx.from, request.user.address); + + if (!payloadMatches) { + throw new BadRequestException('Signed swap transaction does not match the expected request payload'); + } + + const result = await client.sendSignedTransaction(signedHex); + + if (result.error) throw new BadRequestException(`Broadcast failed: ${result.error.message}`); + + const txHash = result.response?.hash; + if (!txHash) throw new BadRequestException('Broadcast returned no transaction hash'); + + await this.transactionRequestService.complete(request.id); + await this.faucetRequestService.resetFaucet(userId); + + return { txHash }; + } + + // Shared broadcast: validates the TransactionRequest, reconstructs the user-signed EIP-1559 hex and submits + // it to the network. Used by both the sell broadcast and the swap broadcast (REALU -> ZCHF brokerbot tx). + private async broadcastSignedTransaction( + userId: number, + requestId: number, + dto: RealUnitSellBroadcastDto, + ): Promise<{ txHash: string }> { + const request = await this.transactionRequestService.getOrThrow(requestId, userId); + if (!request.isValid) throw new BadRequestException('Transaction request is not valid'); + + const signedHex = this.reconstructSignedTransaction(dto); const client = this.getEvmClient(); const result = await client.sendSignedTransaction(signedHex); @@ -2031,12 +2208,331 @@ export class RealUnitService { return { txHash }; } + // --- Shared EVM Transaction Helpers --- // + + // Builds the ERC-677 `transferAndCall` calldata that sends REALU shares to the brokerbot. Pure function of + // (realuAsset, shares) — reused by buildSwapUnsignedTransaction (build time) and broadcastSwapTransaction + // (fail-closed payload validation before broadcast: re-derives the expected calldata from the server-known + // request instead of trusting whatever `to`/`data` the client attaches to the signed tx). + private buildSwapCalldata(realuAsset: Asset, shares: number): string { + const erc677Interface = new ethers.utils.Interface([ + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', + ]); + const swapAmountWei = ethers.utils.parseUnits(shares.toString(), realuAsset.decimals ?? 18); + return erc677Interface.encodeFunctionData('transferAndCall', [this.getBrokerbotAddress(), swapAmountWei, '0x']); + } + + // Builds the unsigned REALU `transferAndCall` (ERC-677) swap tx that sends REALU shares to the brokerbot. + // The brokerbot pays out ZCHF to the sender wallet — shared by the sell flow (deposit follows) and the + // OCP swap-only flow (ZCHF stays in the user wallet, no deposit leg). + private buildSwapUnsignedTransaction( + chainId: number, + realuAsset: Asset, + shares: number, + nonce: number, + gasPrice: BigNumber, + gasLimit: BigNumber, + ): string { + const swapData = this.buildSwapCalldata(realuAsset, shares); + + return ethers.utils.serializeTransaction({ + type: 2, + chainId, + nonce, + maxPriorityFeePerGas: gasPrice, + maxFeePerGas: gasPrice, + gasLimit, + to: realuAsset.chainId, + value: ethers.BigNumber.from(0), + data: swapData, + accessList: [], + }); + } + + // Reconstructs the signed EIP-1559 transaction hex from a previously built unsigned tx and the user signature. + // The unsigned hex is re-serialized with the signature so the network accepts it; shared by the sell broadcast + // and the OCP pay submission paths. Fail-closed on a syntactically broken unsignedTx (parse/serialize) + // with BadRequestException instead of a raw ethers 500. + private reconstructSignedTransaction(dto: RealUnitSellBroadcastDto): string { + const { unsignedTx, r, s, v } = dto; + + try { + const parsed = ethers.utils.parseTransaction(unsignedTx); + if (!parsed.maxPriorityFeePerGas || !parsed.maxFeePerGas) { + throw new Error('Unsigned transaction is missing EIP-1559 fee fields'); + } + + return ethers.utils.serializeTransaction( + { + type: 2, + chainId: parsed.chainId, + nonce: parsed.nonce, + maxPriorityFeePerGas: parsed.maxPriorityFeePerGas, + maxFeePerGas: parsed.maxFeePerGas, + gasLimit: parsed.gasLimit, + to: parsed.to, + value: parsed.value, + data: parsed.data, + accessList: parsed.accessList ?? [], + }, + { r, s, v }, + ); + } catch { + throw new BadRequestException('Invalid unsigned transaction'); + } + } + + // Parses a signed EIP-1559 hex and decodes its ERC-20 transfer() calldata. Wraps both throwing ethers + // calls so a client-supplied signed tx with an invalid/garbage calldata surfaces as a typed + // BadRequestException (400) instead of an uncaught ethers parse/decode error (raw 500). + private parseSignedErc20Transfer(signedHex: string): { + parsedTx: ReturnType; + amount: BigNumber; + } { + try { + const parsedTx = ethers.utils.parseTransaction(signedHex); + const { amount } = EvmUtil.decodeErc20Transfer(parsedTx.data); + return { parsedTx, amount }; + } catch { + throw new BadRequestException('Invalid unsigned transaction'); + } + } + private getEvmClient(): EvmClient { return [Environment.DEV, Environment.LOC].includes(Config.environment) ? this.sepoliaService.getDefaultClient() : this.ethereumService.getDefaultClient(); } + // --- OCP Pay-Flow Methods --- // + // Phase 2 pay flow: (1) swap REALU -> ZCHF keeping the ZCHF in the user wallet, then + // (2) pay that ZCHF to an Open CryptoPay recipient via the public lnurlp payment-link flow. + // The client cannot build EVM calldata locally, so the backend builds the unsigned txs and + // submits the reconstructed signed hex into the existing lnurlp settlement path. + + // Step 1 (swap-only): builds the REALU -> ZCHF swap tx WITHOUT the deposit sweep, so the ZCHF + // proceeds land in the user wallet. Reuses the sell unsigned-tx machinery via buildSwapUnsignedTransaction. + async createSwapUnsignedTransaction(userId: number, requestId: number): Promise<{ swap: string }> { + const request = await this.transactionRequestService.getOrThrow(requestId, userId); + + if (request.type !== TransactionRequestType.SWAP) throw new BadRequestException('Not a swap request'); + if (request.isComplete) throw new ConflictException('Transaction request is already confirmed'); + + const client = this.getEvmClient(); + const [realuAsset, zchfAsset] = await Promise.all([this.getRealuAsset(), this.getZchfAsset()]); + if (!realuAsset) throw new NotFoundException('REALU asset not found'); + if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); + if (request.sourceId !== realuAsset.id || request.targetId !== zchfAsset.id) { + throw new BadRequestException('Swap request source/target asset does not match REALU/ZCHF'); + } + await this.assertRealUnitAccess(request.user.userData, request.user.address); + + if (!request.isValid) throw new BadRequestException('Transaction request is not valid'); + if (!realuAsset.chainId) throw new BadRequestException('REALU asset has no contract address'); + + // Accepted residual risk: two near-simultaneous build calls for the same wallet can read the same + // pending nonce, causing a benign on-chain replace/reject for one of the two txs — no fund loss. + // Deliberately not reserving/locking the nonce here. + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount(request.user.address, 'pending'), + client.getRecommendedGasPrice(), + ]); + + const swapGasLimit = ethers.BigNumber.from(350_000); + const ethBalance = await client.getNativeCoinBalanceForAddress(request.user.address); + const requiredEth = EvmUtil.fromWeiAmount(gasPrice.mul(swapGasLimit)); + if (ethBalance < requiredEth) { + throw new BadRequestException( + `Insufficient ETH for gas: need ${requiredEth.toFixed(6)} ETH, have ${ethBalance.toFixed(6)} ETH`, + ); + } + + const shares = Math.floor(request.amount); + if (shares < 1) throw new BadRequestException('Swap amount rounds down to zero shares'); + + const swap = this.buildSwapUnsignedTransaction(client.chainId, realuAsset, shares, nonce, gasPrice, swapGasLimit); + + return { swap }; + } + + // Step 2a: builds the unsigned ZCHF ERC-20 transfer tx for an OCP payment. Recipient and exact amount are + // resolved from the payment-link/quote service (same source the lnurlp callback uses) by activating the + // quote and parsing the returned EVM payment URI. + async createOcpPayUnsignedTransaction( + senderAddress: string, + paymentLinkId: string, + quoteId: string, + ): Promise { + const zchfAsset = await this.getZchfAsset(); + if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); + if (!zchfAsset.chainId) throw new BadRequestException('ZCHF asset has no contract address'); + + // Guard against payment methods the payment-link engine cannot settle before touching it. The resolved + // method is SEPOLIA on DEV/LOC and ETHEREUM on PRD; both are supported EVM methods, so this passes for + // the RealUnit flow and OCP is testable end-to-end on Sepolia (non-PRD). The guard still fails fast with + // a clear, typed error for any genuinely-unsupported method. + this.assertPaymentLinkSupportsMethod(); + + // Activate the quote via the same path as the lnurlp callback to obtain the DFX deposit recipient and amount + const activation = await this.lnUrlForwardService.lnurlpCallbackForward(paymentLinkId, { + method: this.tokenBlockchain, + asset: zchfAsset.name, + quote: quoteId, + }); + + if (!('uri' in activation) || !activation.uri) { + throw new BadRequestException('OCP quote did not return an EVM payment request'); + } + + const { recipient, amountWei } = this.parseEvmPaymentRequest(activation.uri, zchfAsset); + + const client = this.getEvmClient(); + + // Fail-closed: the pay tx must not be built until the sender actually holds enough ZCHF on-chain. + // ZCHF only exists after the REALU→ZCHF swap has been mined; without this check a pay tx can be + // queued (pending nonce) and marked COMPLETED at TX_MEMPOOL while the swap failed or never settled. + const zchfBalanceWei = await client.getTokenBalanceWei(zchfAsset, senderAddress); + if (zchfBalanceWei.lt(amountWei)) { + throw new ConflictException('Insufficient ZCHF balance — swap not yet settled'); + } + + // Use the `pending` nonce: in the documented flow the swap tx (broadcast via PUT /v1/realunit/swap/:id/broadcast) + // may still be in the mempool when this pay tx is built. Counting pending txs avoids reusing the + // swap tx nonce, which would otherwise make both txs collide on the same nonce. + // Accepted residual risk: this shares the same pending-nonce race as the swap build — a benign + // on-chain replace/reject at worst, never fund loss. Deliberately not reserving/locking the nonce. + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount(senderAddress, 'pending'), + client.getRecommendedGasPrice(), + ]); + + const transferGasLimit = ethers.BigNumber.from(100_000); + // Extra 20% buffer on top of the already-buffered recommended gas price: the payment-link engine's minFee is a + // snapshot from OCP-quote time, and a network gas-price drop between quote and this tx build can otherwise push + // maxFeePerGas below that stale minFee, failing /pay/submit after the swap already executed. + const bufferedMaxFeePerGas = gasPrice.mul(120).div(100); + + const ethBalance = await client.getNativeCoinBalanceForAddress(senderAddress); + const requiredEth = EvmUtil.fromWeiAmount(bufferedMaxFeePerGas.mul(transferGasLimit)); + if (ethBalance < requiredEth) { + throw new BadRequestException( + `Insufficient ETH for gas: need ${requiredEth.toFixed(6)} ETH, have ${ethBalance.toFixed(6)} ETH`, + ); + } + + const transferData = EvmUtil.encodeErc20Transfer(recipient, amountWei); + const unsignedTx = ethers.utils.serializeTransaction({ + type: 2, + chainId: client.chainId, + nonce, + maxPriorityFeePerGas: gasPrice, + maxFeePerGas: bufferedMaxFeePerGas, + gasLimit: transferGasLimit, + to: zchfAsset.chainId, + value: ethers.BigNumber.from(0), + data: transferData, + accessList: [], + }); + + return { + unsignedTx, + tokenAddress: zchfAsset.chainId, + recipient, + amountWei: amountWei.toString(), + chainId: zchfAsset.evmChainId, + }; + } + + // Step 2b: reconstructs the user-signed hex and submits it into the existing lnurlp tx settlement path so + // DFX validates (recipient / amount / min-fee / ERC-20 selector), broadcasts, and settles the OCP quote. + // Auth note: the JWT (USER guard) is an access gate, NOT an ownership check — an OCP payment-link quote is + // a POS payment payable by whoever holds the quote, and the downstream lnurlp path re-validates + // recipient / amount / min-fee server-side. + async submitOcpPay(dto: RealUnitOcpPaySubmitDto): Promise { + const zchfAsset = await this.getZchfAsset(); + if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); + if (!zchfAsset.chainId) throw new BadRequestException('ZCHF asset has no contract address'); + + // Guard against payment methods the payment-link engine cannot settle — see createOcpPayUnsignedTransaction. + this.assertPaymentLinkSupportsMethod(); + + const signedHex = this.reconstructSignedTransaction(dto); + + // Defense-in-depth: re-check ZCHF sufficiency immediately before submitting into the payment-link + // engine (TOCTOU gap between createOcpPayUnsignedTransaction and this submit — sender may have + // moved ZCHF elsewhere). Sender and amount are derived from the signed hex itself. + const { parsedTx, amount: transferAmountWei } = this.parseSignedErc20Transfer(signedHex); + if (!parsedTx.from) throw new BadRequestException('Unable to recover sender address from signed transaction'); + + const client = this.getEvmClient(); + const zchfBalanceWei = await client.getTokenBalanceWei(zchfAsset, parsedTx.from); + if (zchfBalanceWei.lt(transferAmountWei)) { + throw new ConflictException('Insufficient ZCHF balance — swap not yet settled'); + } + + const result = await this.lnUrlForwardService.txHexForward(dto.paymentLinkId, { + method: this.tokenBlockchain, + asset: zchfAsset.name, + quote: dto.quoteId, + hex: signedHex, + }); + + return { txId: result.txId }; + } + + // Step 3: exposes the OCP payment status via a status-independent payment-link lookup + // (getMostRecentPayment), so COMPLETED/EXPIRED/etc. are returned instead of 404 after PENDING ends. + // Auth note: the JWT (USER guard) is an access gate, NOT an ownership check — an OCP payment-link quote is + // a POS payment payable by whoever holds the quote, and the downstream lnurlp path re-validates + // recipient / amount / min-fee server-side. + async getOcpPayStatus(paymentLinkId: string): Promise { + // see #4276 — status-independent lookup; COMPLETED/EXPIRED/etc. are returned instead of 404 + const payment = await this.paymentLinkPaymentService.getMostRecentPayment(paymentLinkId); + if (!payment) throw new NotFoundException('Payment not found'); + + return { status: payment.status }; + } + + // Guards the OCP pay endpoints against payment methods the payment-link engine cannot settle. The resolved + // method (SEPOLIA on DEV/LOC, ETHEREUM on PRD) is a supported EVM method, so this passes for the RealUnit + // flow. It still fails fast with a clear, typed error for any genuinely-unsupported method instead of + // letting a deep, opaque `Invalid method` bubble up from PaymentRequestMapper / executeHexPayment. + // tokenBlockchain is server config only (never client input) — a mismatch is a deployment/config problem + // (503), not a bad client request (400). + private assertPaymentLinkSupportsMethod(): void { + if (!PaymentLinkEvmHexBlockchains.includes(this.tokenBlockchain)) { + throw new ServiceUnavailableException( + `OCP pay is not available for ${this.tokenBlockchain}: the payment-link engine supports EVM methods only`, + ); + } + } + + // Parses an ERC-20 EVM payment request URI of the form + // `ethereum:@/transfer?address=&uint256=` into recipient + amount. + // Cross-checks the token contract in the URI path against the expected ZCHF asset and validates the + // recipient/amount so a malformed URI surfaces a typed BadRequestException instead of a raw parse throw. + private parseEvmPaymentRequest(uri: string, zchfAsset: Asset): { recipient: string; amountWei: BigNumber } { + // path token contract: `ethereum:@/transfer?...` + const uriTokenContract = uri.split('?')[0]?.split('@')[0]?.split(':')[1]; + if (!uriTokenContract || !Util.equalsIgnoreCase(uriTokenContract, zchfAsset.chainId)) { + throw new BadRequestException('EVM payment request token contract does not match expected ZCHF asset'); + } + + const query = uri.split('?')[1]; + const params = new URLSearchParams(query); + const recipient = params.get('address'); + const amount = params.get('uint256'); + if (!recipient || !amount) throw new BadRequestException('Invalid EVM payment request URI'); + + try { + if (!ethers.utils.isAddress(recipient)) throw new Error('invalid recipient address'); + const amountWei = BigNumber.from(amount); + return { recipient, amountWei }; + } catch { + throw new BadRequestException('Invalid EVM payment request recipient or amount'); + } + } + // --- Admin Methods --- private async getRealuQuote( From b3f9a5dcb8d8cae9da0862e1087dbb8fdf99b045 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:03:52 +0200 Subject: [PATCH 3/4] fix(gs): allow reading setting.value via the /gs/debug endpoint (#4318) 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. --- src/subdomains/generic/gs/dto/gs.dto.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index ba40f6f32b..269b24cdbe 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -1132,11 +1132,12 @@ export const DebugAllowedColumns: Record = { ], }, setting: { - // `key` is included so a debug investigation can locate a specific setting row by name. - // `value` is excluded — settings can hold credentials, exchange API config, etc. - // Listing key names alone discloses the config schema, but that schema is also visible - // in the codebase; values are the secret part and stay redacted. - columns: ['id', 'created', 'updated', 'key'], + // `value` is now readable via /gs/debug (data-owner-approved 2026-07-22); genuine + // secrets/credentials live in the Vault, not this table. The column can still hold + // internal IP/address/clerk lists, and exposing those to the Debug admin role is an + // accepted decision. + columns: ['id', 'created', 'updated', 'key', 'value'], + jsonbColumns: ['value'], }, sift_error_log: { // No `requestPayload` (full Sift request body — may contain card / KYC fields). From d65071739d5e50c657fd955636cf1fa376035a66 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:06:42 +0200 Subject: [PATCH 4/4] fix(log): retire arrived Scrypt EUR deposits from the toScrypt in-flight 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. --- .../log/__tests__/log-job.service.spec.ts | 434 +++++++++++++++++- .../supporting/log/log-job.service.ts | 113 ++++- 2 files changed, 544 insertions(+), 3 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts index 31d92de51c..d8c3e4a560 100644 --- a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { createCustomExchangeTx } from 'src/integration/exchange/dto/__mocks__/exchange-tx.entity.mock'; -import { ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; +import { ExchangeTx, ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; import { ExchangeTxService } from 'src/integration/exchange/services/exchange-tx.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; @@ -30,7 +30,7 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; -import { BankTxIndicator, BankTxType } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTx, BankTxIndicator, BankTxType } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { Bank } from '../../bank/bank/bank.entity'; import { BankService } from '../../bank/bank/bank.service'; import { frickCHF, frickEUR, olkyEUR, yapealCHF } from '../../bank/bank/__mocks__/bank.entity.mock'; @@ -1058,6 +1058,436 @@ describe('LogJobService', () => { expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual([]); }); + // --- getUnmatchedSenders pass-2: amount+date fallback for ref-less receivers --- + + it('should retire ref-less ok Scrypt EUR deposits by amount+date (bug #4311)', () => { + // Production bug: arrived Scrypt deposits with empty txId never entered receiverRefs, + // so their bank senders stayed in the toScrypt pending bucket until the 7-day cutoff. + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 30000, + instructedCurrency: 'EUR', + remittanceInfo: 'manual transfer A', + }), + createCustomBankTx({ + id: 2, + created: Util.hoursBefore(36), + valueDate: Util.hoursBefore(36), + instructedAmount: 70000, + instructedCurrency: 'EUR', + remittanceInfo: 'manual transfer B', + }), + createCustomBankTx({ + id: 3, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 50015.06, + instructedCurrency: 'EUR', + remittanceInfo: 'manual transfer C', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(40), + amount: 30000, + currency: 'EUR', + txId: undefined, + }), + createCustomExchangeTx({ + id: 2, + created: Util.hoursBefore(30), + amount: 70000, + currency: 'EUR', + txId: undefined, + }), + ]; + + // Pending bucket 150015.06 correctly becomes 50015.06 after pass-2 matching. + expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual([senderTx[2]]); + }); + + it('should still match by payout-id reference when a ref-less receiver is also present', () => { + // Pass 1 and pass 2 coexist: reference match retires one pair; amount match retires another. + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 10000, + instructedCurrency: 'EUR', + remittanceInfo: 'DFX Payout 90100', + }), + createCustomBankTx({ + id: 2, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 20000, + instructedCurrency: 'EUR', + remittanceInfo: 'manual no match', + }), + createCustomBankTx({ + id: 3, + created: Util.hoursBefore(12), + valueDate: Util.hoursBefore(12), + instructedAmount: 99999, + instructedCurrency: 'EUR', + remittanceInfo: 'still in transit', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(40), + amount: 10000, + currency: 'EUR', + txId: 'DEPOSIT-90100', + }), + createCustomExchangeTx({ + id: 2, + created: Util.hoursBefore(20), + amount: 20000, + currency: 'EUR', + txId: undefined, + }), + ]; + + // Sender 1 retired by pass 1 (payout id), sender 2 by pass 2 (amount), sender 3 stays. + expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual([senderTx[2]]); + }); + + it('should consume each ref-less receiver at most once for equal amounts', () => { + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 50000, + instructedCurrency: 'EUR', + remittanceInfo: 'sender one', + }), + createCustomBankTx({ + id: 2, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 50000, + instructedCurrency: 'EUR', + remittanceInfo: 'sender two', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(36), + amount: 50000, + currency: 'EUR', + txId: undefined, + }), + ]; + + const unmatched = service.getUnmatchedSenders(senderTx, receiverTx); + expect(unmatched).toHaveLength(1); + expect(senderTx).toContain(unmatched[0]); + }); + + it('should not match when amount difference exceeds tolerance', () => { + // Tolerance for 30000 is max(1, 0.01*30000) = 300; 500 is beyond that. + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 30000, + instructedCurrency: 'EUR', + remittanceInfo: 'no amount match', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(20), + amount: 30500, + currency: 'EUR', + txId: undefined, + }), + ]; + + expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual(senderTx); + }); + + it('should not match when date difference exceeds the 7-day window', () => { + // ~8 days apart — outside the pass-2 date window (and sender still recent via created). + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 30000, + instructedCurrency: 'EUR', + remittanceInfo: 'date too far', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(24 + 8 * 24), + amount: 30000, + currency: 'EUR', + txId: undefined, + }), + ]; + + expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual(senderTx); + }); + + it('should not match across different currencies even when amount and date align', () => { + const senderTx = [ + createCustomBankTx({ + id: 1, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 30000, + instructedCurrency: 'EUR', + remittanceInfo: 'eur sender', + }), + ]; + const receiverTx = [ + createCustomExchangeTx({ + id: 1, + created: Util.hoursBefore(20), + amount: 30000, + currency: 'CHF', + txId: undefined, + }), + ]; + + expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual(senderTx); + }); + + it('should return the same result regardless of input array order (determinism, bug #4311 follow-up)', () => { + // senderOld and senderNew both compete for receiverX (their best amount-diff match). + // senderOld can ONLY match receiverX (amount too far from receiverY, outside 1% tolerance). + // senderNew can match BOTH receiverX and receiverY, with receiverX being its best match too. + // A processing order that is not sorted by transfer-date could let senderNew grab receiverX + // first, stranding senderOld (which has no fallback) unmatched — an order-dependent result. + // The deterministic date-ascending processing order always processes senderOld first, so + // senderOld claims receiverX and senderNew falls back to receiverY: both retired, every time, + // regardless of input array order. + const senderOld = createCustomBankTx({ + id: 301, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 49700, + instructedCurrency: 'EUR', + remittanceInfo: 'det sender old', + }); + const senderNew = createCustomBankTx({ + id: 302, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 50300, + instructedCurrency: 'EUR', + remittanceInfo: 'det sender new', + }); + const receiverX = createCustomExchangeTx({ + id: 401, + created: Util.hoursBefore(36), + amount: 50000, + currency: 'EUR', + txId: undefined, + }); + const receiverY = createCustomExchangeTx({ + id: 402, + created: Util.hoursBefore(12), + amount: 50700, + currency: 'EUR', + txId: undefined, + }); + + const forward = service.getUnmatchedSenders([senderOld, senderNew], [receiverX, receiverY]); + const reversed = service.getUnmatchedSenders([senderNew, senderOld], [receiverY, receiverX]); + + expect(forward).toEqual([]); + expect(reversed).toEqual([]); + expect(reversed).toEqual(forward); + }); + + it('should prefer the exact amount match over an earlier near match (global assignment)', () => { + // Repro of the local-greedy date-order defect: senderA (older, 20100) is within + // tolerance of a ref-less 20000 receiver (diff 100 <= 201) and would grab it first + // under date-ascending processing, stranding senderB (exact 20000 match). Global + // candidate sort by amountDiff first retires senderB and correctly leaves senderA pending. + const senderA = createCustomBankTx({ + id: 501, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 20100, + instructedCurrency: 'EUR', + remittanceInfo: 'manual sender A', + }); + const senderB = createCustomBankTx({ + id: 502, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 20000, + instructedCurrency: 'EUR', + remittanceInfo: 'manual sender B', + }); + const receiverTx = [ + createCustomExchangeTx({ + id: 601, + created: Util.hoursBefore(36), + amount: 20000, + currency: 'EUR', + txId: undefined, + }), + ]; + + // Final filter preserves unmatchedByRef order (= input sender order). + expect(service.getUnmatchedSenders([senderA, senderB], receiverTx)).toEqual([senderA]); + expect(service.getUnmatchedSenders([senderB, senderA], receiverTx)).toEqual([senderA]); + }); + + it('should retire both senders via augmenting path when greedy would strand one', () => { + // Repro of the plain-greedy cardinality defect: edges in tolerance are + // S1–R1 (diff 2), S2–R1 (diff 0), S2–R2 (diff 9); S1–R2 (diff 11) is OUT. + // Greedy consumes S2–R1 first (lowest cost) and strands S1. Kuhn reassigns + // S2→R2 so S1 can take R1 — maximum cardinality, both senders retired. + const sender1 = createCustomBankTx({ + id: 701, + created: Util.hoursBefore(48), + valueDate: Util.hoursBefore(48), + instructedAmount: 1000, + instructedCurrency: 'EUR', + remittanceInfo: 'cardinality sender 1', + }); + const sender2 = createCustomBankTx({ + id: 702, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 1002, + instructedCurrency: 'EUR', + remittanceInfo: 'cardinality sender 2', + }); + const receiver1 = createCustomExchangeTx({ + id: 801, + created: Util.hoursBefore(36), + amount: 1002, + currency: 'EUR', + txId: undefined, + }); + const receiver2 = createCustomExchangeTx({ + id: 802, + created: Util.hoursBefore(12), + amount: 1011, + currency: 'EUR', + txId: undefined, + }); + + expect(service.getUnmatchedSenders([sender1, sender2], [receiver1, receiver2])).toEqual([]); + expect(service.getUnmatchedSenders([sender2, sender1], [receiver2, receiver1])).toEqual([]); + }); + + it('should not match and must not throw when a ref-less receiver or sender has a null amount', () => { + // TypeORM nullable columns can surface as `null` at runtime even though the TS field type says + // `number | undefined`. Both senders below must survive as unmatched, and the call must not throw. + const senderWithAmount = createCustomBankTx({ + id: 303, + created: Util.hoursBefore(24), + valueDate: Util.hoursBefore(24), + instructedAmount: 30000, + instructedCurrency: 'EUR', + remittanceInfo: 'sender with amount, null receiver', + }); + const senderNullAmount = createCustomBankTx({ + id: 304, + created: Util.hoursBefore(20), + valueDate: Util.hoursBefore(20), + instructedAmount: null as unknown as number, + instructedCurrency: 'EUR', + remittanceInfo: 'sender with null amount', + }); + const receiverNullAmount = createCustomExchangeTx({ + id: 701, + created: Util.hoursBefore(20), + amount: null as unknown as number, + currency: 'EUR', + txId: undefined, + }); + const receiverWithAmount = createCustomExchangeTx({ + id: 702, + created: Util.hoursBefore(20), + amount: 30000, + currency: 'EUR', + txId: undefined, + }); + + let result: (BankTx | ExchangeTx)[] = []; + expect(() => { + result = service.getUnmatchedSenders( + [senderWithAmount, senderNullAmount], + [receiverNullAmount, receiverWithAmount], + ); + }).not.toThrow(); + + // senderWithAmount would normally match receiverWithAmount (30000 == 30000), so the only way + // it survives is if receiverNullAmount is correctly excluded as a candidate and doesn't + // accidentally get treated as amount 0. senderNullAmount can never match anything. + // Given both are in the array, senderWithAmount's real match (receiverWithAmount) is available, + // so it WILL be retired -- only senderNullAmount remains unmatched. + expect(result).toEqual([senderNullAmount]); + }); + + it('should retire an ExchangeTx withdrawal sender against a ref-less BankTx receiver by amount+date (fromScrypt direction), and reject an out-of-tolerance amount', () => { + // Mirrors the existing toScrypt (bank -> exchange) pass-2 tests, but in the fromScrypt + // direction (exchange withdrawal -> bank receipt), proving the generic fallback also collapses + // the fromScrypt double-count. senderMatch's `created` is recent (survives the initial recency + // filter) but its `externalCreated` is 9 days old -- if getTransferDate wrongly used `created` + // instead of `externalCreated` for ExchangeTx, this match would incorrectly fall outside the + // 7-day window and the test would fail, proving externalCreated is actually being used. + const senderMatch = createCustomExchangeTx({ + id: 501, + created: Util.hoursBefore(20), + externalCreated: Util.hoursBefore(216), + type: ExchangeTxType.WITHDRAWAL, + amount: 20000, + currency: 'CHF', + txId: undefined, + }); + const receiverMatch = createCustomBankTx({ + id: 601, + created: Util.hoursBefore(216), + valueDate: Util.hoursBefore(216), + instructedAmount: 20000, + instructedCurrency: 'CHF', + remittanceInfo: undefined, + }); + const senderNoMatch = createCustomExchangeTx({ + id: 502, + created: Util.hoursBefore(20), + externalCreated: Util.hoursBefore(40), + type: ExchangeTxType.WITHDRAWAL, + amount: 20000, + currency: 'CHF', + txId: undefined, + }); + const receiverNoMatch = createCustomBankTx({ + id: 602, + created: Util.hoursBefore(40), + valueDate: Util.hoursBefore(40), + instructedAmount: 25000, + instructedCurrency: 'CHF', + remittanceInfo: undefined, + }); + + const result = service.getUnmatchedSenders([senderMatch, senderNoMatch], [receiverMatch, receiverNoMatch]); + + expect(result).toEqual([senderNoMatch]); + }); + // --- settlement-anchored buy_fiat liability (FinanceLog) --- // Yapeal CHF payout-bank asset: dexName = currency, bank = settling bank. diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 4a2f95c7eb..77867dddd8 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -1282,16 +1282,127 @@ export class LogJobService { if (!recentSenders.length || !receiverTx.length) return [...recentSenders]; + // Pass 1 — reference matching (unchanged): retire senders whose reference is present on a receiver. const receiverRefs = new Set(); for (const r of receiverTx) { const ref = this.getTxReference(r); if (ref) receiverRefs.add(ref); } - return recentSenders.filter((s) => { + const unmatchedByRef = recentSenders.filter((s) => { const ref = this.getTxReference(s); return !ref || !receiverRefs.has(ref); }); + + // Pass 2 — amount+date fallback for receivers without a usable reference only + // (e.g. already-arrived Scrypt EUR deposits with empty txId). + // Date window: 7 days in ms — mirrors the existing sender recency window. + const DATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + + const availableReceivers = receiverTx.filter((r) => !this.getTxReference(r)); + if (!unmatchedByRef.length || !availableReceivers.length) return unmatchedByRef; + + // Maximum-cardinality bipartite matching (Kuhn's augmenting-path algorithm). + // Plain greedy consumption of globally cost-sorted edges can strand a matchable + // sender: its only in-tolerance receiver may be claimed by another sender with a + // lower-cost edge to that same receiver, even though a different global assignment + // would retire both. Kuhn guarantees maximum cardinality; per-sender candidate + // sort keeps exact/cheap matches preferred when multiple assignments have equal size. + type SenderCandidate = { + receiver: BankTx | ExchangeTx; + amountDiff: number; + dateDiff: number; + }; + + const candidatesBySender = new Map(); + + for (const sender of unmatchedByRef) { + const senderAmount = this.getTransferAmount(sender); + const senderCurrency = this.getTransferCurrency(sender); + const senderDate = this.getTransferDate(sender); + + // null/undefined/NaN amounts must not participate (TypeORM nullable → null at runtime). + if (!Number.isFinite(senderAmount) || !senderCurrency) continue; + + const senderCandidates: SenderCandidate[] = []; + + for (const receiver of availableReceivers) { + const receiverAmount = this.getTransferAmount(receiver); + const receiverCurrency = this.getTransferCurrency(receiver); + const receiverDate = this.getTransferDate(receiver); + + if (!Number.isFinite(receiverAmount) || !receiverCurrency) continue; + if (senderCurrency !== receiverCurrency) continue; + + // Amount tolerance: 1% relative, minimum 1.0 absolute — covers FX/rounding noise + // between instructed and settled amounts without false matches on similar sizes. + const amountTolerance = Math.max(1.0, 0.01 * Math.max(Math.abs(senderAmount), Math.abs(receiverAmount))); + const amountDiff = Math.abs(senderAmount - receiverAmount); + if (amountDiff > amountTolerance) continue; + + const dateDiff = Math.abs(senderDate.getTime() - receiverDate.getTime()); + if (dateDiff > DATE_WINDOW_MS) continue; + + senderCandidates.push({ receiver, amountDiff, dateDiff }); + } + + if (!senderCandidates.length) continue; + + // Exact/closer matches first for this sender. + senderCandidates.sort((a, b) => { + if (a.amountDiff !== b.amountDiff) return a.amountDiff - b.amountDiff; + if (a.dateDiff !== b.dateDiff) return a.dateDiff - b.dateDiff; + return a.receiver.id - b.receiver.id; + }); + + candidatesBySender.set(sender, senderCandidates); + } + + // Senders holding an exact/near-exact match get first crack at claiming it. + const sendersToProcess = [...candidatesBySender.keys()].sort((a, b) => { + const bestA = candidatesBySender.get(a)![0]; + const bestB = candidatesBySender.get(b)![0]; + if (bestA.amountDiff !== bestB.amountDiff) return bestA.amountDiff - bestB.amountDiff; + if (bestA.dateDiff !== bestB.dateDiff) return bestA.dateDiff - bestB.dateDiff; + return a.id - b.id; + }); + + const matchOfReceiver = new Map(); + + const tryAssign = (sender: BankTx | ExchangeTx, visited: Set): boolean => { + for (const { receiver } of candidatesBySender.get(sender)!) { + if (visited.has(receiver)) continue; + visited.add(receiver); + + const currentMatch = matchOfReceiver.get(receiver); + if (!currentMatch || tryAssign(currentMatch, visited)) { + matchOfReceiver.set(receiver, sender); + return true; + } + } + return false; + }; + + for (const sender of sendersToProcess) { + tryAssign(sender, new Set()); + } + + const retiredSenders = new Set(matchOfReceiver.values()); + + return unmatchedByRef.filter((s) => !retiredSenders.has(s)); + } + + private getTransferAmount(tx: BankTx | ExchangeTx): number | null | undefined { + return tx instanceof BankTx ? tx.instructedAmount : tx.amount; + } + + private getTransferCurrency(tx: BankTx | ExchangeTx): string | undefined { + const currency = tx instanceof BankTx ? tx.instructedCurrency : tx.currency; + return currency || undefined; + } + + private getTransferDate(tx: BankTx | ExchangeTx): Date { + return tx instanceof BankTx ? (tx.valueDate ?? tx.created) : (tx.externalCreated ?? tx.created); } private getTxReference(tx: BankTx | ExchangeTx): string | undefined {