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/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index bd58ccefc2..269b24cdbe 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: [ @@ -1108,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). 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 { 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(