diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbea27872a..4a4503b97e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -958,6 +958,9 @@ The RealUnit purchase and sale flows historically lived under `/v1/realunit/brok | `PUT /v1/realunit/sell/:id/unsigned-transactions` | Reads the on-chain sell price and builds the EIP-7702 batch the user has to sign | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | | `PUT /v1/realunit/sell/:id/confirm` | Verifies the user-signed batch against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | | `PUT /v1/realunit/sell/:id/broadcast` | Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no `readContract` | +| `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 new file mode 100644 index 0000000000..9f9f77dc8f --- /dev/null +++ b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts @@ -0,0 +1,47 @@ +import { ethers } from 'ethers'; +import { EvmClient, EvmClientParams } from '../evm-client'; + +// Minimal concrete subclass so the abstract EvmClient can be instantiated for unit-testing its own methods. +class TestEvmClient extends EvmClient { + constructor(params: EvmClientParams) { + super(params); + } +} + +describe('EvmClient', () => { + let client: TestEvmClient; + let providerGetTransactionCount: jest.Mock; + + beforeEach(() => { + client = new TestEvmClient({ + http: {} as any, + gatewayUrl: 'https://rpc.example.com', + apiKey: 'test-key', + // throw-away random key; never used to sign in these tests + walletPrivateKey: ethers.Wallet.createRandom().privateKey, + chainId: 1, + }); + + providerGetTransactionCount = jest.fn().mockResolvedValue(5); + // replace the real JSON-RPC provider with a stub + (client as any).provider = { getTransactionCount: providerGetTransactionCount }; + }); + + describe('getTransactionCount', () => { + const address = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + + it('defaults to the latest (mined) nonce when no block tag is given', async () => { + const result = await client.getTransactionCount(address); + + expect(result).toBe(5); + expect(providerGetTransactionCount).toHaveBeenCalledWith(address, 'latest'); + }); + + it('forwards the pending block tag to count still-pending mempool txs', async () => { + const result = await client.getTransactionCount(address, 'pending'); + + expect(result).toBe(5); + expect(providerGetTransactionCount).toHaveBeenCalledWith(address, 'pending'); + }); + }); +}); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 5530a984d6..9342271432 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -177,8 +177,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 0888c86a22..537b3f4955 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -95,6 +95,21 @@ export enum PaymentMerchantStatus { 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 user broadcasts tx and sends txId (not signed hex) export const UnverifiedTxIdBlockchains = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.TRON, Blockchain.CARDANO]; 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..f8e0b3b95c --- /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 { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +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 { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; +import { PayoutFiroService } from 'src/subdomains/supporting/payout/services/payout-firo.service'; +import { PaymentActivation } from '../../entities/payment-activation.entity'; +import { PaymentLinkPayment } from '../../entities/payment-link-payment.entity'; +import { PaymentQuote } from '../../entities/payment-quote.entity'; +import { PaymentQuoteStatus } from '../../enums'; +import { PaymentRequestMapper } from '../../dto/payment-request.mapper'; +import { TransferInfo } from '../../dto/payment-link.dto'; +import { PaymentQuoteRepository } from '../../repositories/payment-quote.repository'; +import { C2BPaymentLinkService } from '../c2b-payment-link.service'; +import { PaymentActivationService } from '../payment-activation.service'; +import { PaymentBalanceService } from '../payment-balance.service'; +import { PaymentLinkFeeService } from '../payment-link-fee.service'; +import { PaymentQuoteService } from '../payment-quote.service'; +import * as ConfigModule from 'src/config/config'; + +// 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 EVM_DEPOSIT_ADDRESS = '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'] = EVM_DEPOSIT_ADDRESS; + }); + + it('returns the EVM deposit address for Sepolia (same as the mainnet EVM chains)', () => { + expect(service.getDepositAddress(Blockchain.SEPOLIA)).toBe(EVM_DEPOSIT_ADDRESS); + expect(service.getDepositAddress(Blockchain.ETHEREUM)).toBe(EVM_DEPOSIT_ADDRESS); + }); + }); + + describe('PaymentLinkFeeService.calculateFee / getMinFee', () => { + let service: PaymentLinkFeeService; + let blockchainRegistryService: BlockchainRegistryService; + + const SEPOLIA_GAS_PRICE = 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(SEPOLIA_GAS_PRICE); + 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(+SEPOLIA_GAS_PRICE); + 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(+SEPOLIA_GAS_PRICE); + }); + }); + + describe('PaymentActivationService.createBlockchainRequest', () => { + let service: PaymentActivationService; + let paymentBalanceService: PaymentBalanceService; + let cryptoService: CryptoService; + + const EVM_DEPOSIT_ADDRESS = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'; + const PAYMENT_REQUEST = '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(EVM_DEPOSIT_ADDRESS); + jest.spyOn(cryptoService, 'getPaymentRequest').mockResolvedValue(PAYMENT_REQUEST); + 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(PAYMENT_REQUEST); + }); + }); + + 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 186bf3cf23..469624a6a5 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 63f3440708..79c2698848 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -382,6 +382,7 @@ export class PaymentQuoteService { try { switch (transferInfo.method) { case Blockchain.ETHEREUM: + case Blockchain.SEPOLIA: case Blockchain.ARBITRUM: case Blockchain.OPTIMISM: case Blockchain.BASE: diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts new file mode 100644 index 0000000000..fab1ea75e8 --- /dev/null +++ b/src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts @@ -0,0 +1,100 @@ +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { RealUnitController } from '../controllers/realunit.controller'; + +// 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: JwtPayload = { user: 42, address: '0xUser' } as any; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new RealUnitController(realunitService as any, {} as any, userService as any, {} as any, {} as any); + }); + + 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 any; + 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 any; + 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 any; + 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 any; + 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 ab690a0564..0f69e5bb82 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,5 +1,6 @@ -import { BadRequestException, ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ethers } from 'ethers'; import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; @@ -15,11 +16,15 @@ 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 { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; +import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; @@ -30,9 +35,13 @@ import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/re import { RealUnitDevService } from '../realunit-dev.service'; import { RealUnitService } from '../realunit.service'; +// Mutable so individual tests can switch between the testnet (loc → Sepolia) and mainnet (prd → Ethereum) +// token-blockchain branches the service derives at construction time. +let mockEnvironment = 'loc'; + jest.mock('src/config/config', () => ({ get Config() { - return { environment: 'loc' }; + return { environment: mockEnvironment }; }, Environment: { LOC: 'loc', @@ -42,7 +51,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' }, }, @@ -100,8 +109,19 @@ describe('RealUnitService', () => { let eip7702DelegationService: jest.Mocked; let transactionRequestService: jest.Mocked; let sellService: jest.Mocked; + let swapService: jest.Mocked; let userService: jest.Mocked; let kycService: jest.Mocked; + let lnUrlForwardService: jest.Mocked; + let faucetRequestService: jest.Mocked; + + const evmClient = { + chainId: 11155111, + getTransactionCount: jest.fn(), + getRecommendedGasPrice: jest.fn(), + getNativeCoinBalanceForAddress: jest.fn(), + sendSignedTransaction: jest.fn(), + }; const realuAsset = createCustomAsset({ id: 1, @@ -164,6 +184,12 @@ describe('RealUnitService', () => { getById: jest.fn(), }, }, + { + provide: SwapService, + useValue: { + createSwapPaymentInfo: jest.fn(), + }, + }, { provide: Eip7702DelegationService, useValue: { @@ -182,9 +208,17 @@ describe('RealUnitService', () => { { provide: RealUnitDevService, useValue: {} }, { provide: SwissQRService, useValue: {} }, { provide: FeeService, useValue: {} }, - { provide: FaucetRequestService, useValue: {} }, - { provide: EthereumService, useValue: {} }, - { provide: SepoliaService, useValue: {} }, + { provide: FaucetRequestService, useValue: { resetFaucet: jest.fn() } }, + { provide: EthereumService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, + { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, + { + provide: LnUrlForwardService, + useValue: { + lnurlpCallbackForward: jest.fn(), + txHexForward: jest.fn(), + waitForPayment: jest.fn(), + }, + }, ], }).compile(); @@ -194,8 +228,11 @@ describe('RealUnitService', () => { eip7702DelegationService = module.get(Eip7702DelegationService); transactionRequestService = module.get(TransactionRequestService); sellService = module.get(SellService); + swapService = module.get(SwapService); userService = module.get(UserService); kycService = module.get(KycService); + lnUrlForwardService = module.get(LnUrlForwardService); + faucetRequestService = module.get(FaucetRequestService); }); afterEach(() => { @@ -206,7 +243,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, @@ -238,7 +275,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', undefined, @@ -252,7 +289,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(BrokerbotCurrency.EUR); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', BrokerbotCurrency.EUR, @@ -262,7 +299,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, @@ -328,12 +365,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'), @@ -390,6 +430,564 @@ 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, user: { address: userAddress } }; + + beforeEach(() => { + evmClient.getTransactionCount.mockResolvedValue(7); + evmClient.getRecommendedGasPrice.mockResolvedValue(ethers.BigNumber.from(1_000_000_000)); + evmClient.getNativeCoinBalanceForAddress.mockResolvedValue(1); + }); + + it('should build the swap tx without a deposit leg', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + assetService.getAssetByQuery.mockResolvedValue(realuTxAsset); + + 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); + + 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.mockResolvedValue(realuTxAsset); + 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.mockResolvedValue(createCustomAsset({ name: 'REALU', chainId: undefined } as any)); + + 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({ name: 'REALU', chainId: realuContract, decimals: undefined } as any); + assetService.getAssetByQuery.mockResolvedValue(noDecimalsAsset); + + 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()); + }); + }); + + describe('getSwapPaymentInfo', () => { + const walletAddress = '0x4444444444444444444444444444444444444444'; + + function buildUser(opts: { kycLevel?: number; registered?: boolean } = {}): any { + const steps = + (opts.registered ?? true) ? [{ isFailed: false, isCanceled: false, getResult: () => ({ walletAddress }) }] : []; + return { + id: 42, + address: walletAddress, + userData: { + kycLevel: opts.kycLevel ?? 30, + getStepsWith: jest.fn().mockReturnValue(steps), + }, + }; + } + + 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(() => { + 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 () => { + await expect(service.getSwapPaymentInfo(buildUser({ registered: false }), { amount: 10 } as any)).rejects.toThrow( + ForbiddenException, + ); + expect(swapService.createSwapPaymentInfo).not.toHaveBeenCalled(); + }); + + it('should require KYC Level 30', async () => { + await expect(service.getSwapPaymentInfo(buildUser({ kycLevel: 20 }), { amount: 10 } as any)).rejects.toThrow( + ForbiddenException, + ); + 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.estimatedAmount).toBe(0.38); + expect(transactionRequestService.updateEstimatedAmount).not.toHaveBeenCalled(); + }); + + it('should fall back to the SwapService estimate when the brokerbot price query fails', async () => { + // the brokerbot lookup rejects -> .catch(() => null) -> estimate is not re-anchored + swapService.createSwapPaymentInfo.mockResolvedValue(swapInfo as any); + blockchainService.getBrokerbotSellPrice.mockRejectedValue(new Error('rpc down')); + + const result = await service.getSwapPaymentInfo(buildUser(), { amount: 10 } as any); + + expect(result.estimatedAmount).toBe(950); + 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 mockRequest = { id: 1, isValid: true, amount: 10, routeId: 7, user: { address: userAddress } }; + + const unsignedTx = 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: '0x', + accessList: [], + }); + + const broadcastDto = { + unsignedTx, + r: '0x' + '1'.repeat(64), + s: '0x' + '2'.repeat(64), + v: 27, + }; + + it('should reconstruct the signed hex, broadcast it and return the txHash', async () => { + transactionRequestService.getOrThrow.mockResolvedValue(mockRequest as any); + 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/); + }); + + it('should throw BadRequestException if the request is not valid', async () => { + transactionRequestService.getOrThrow.mockResolvedValue({ ...mockRequest, isValid: false } as any); + + 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); + 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); + evmClient.sendSignedTransaction.mockResolvedValue({ response: {} }); + + await expect(service.broadcastSwapTransaction(42, 1, broadcastDto)).rejects.toThrow(BadRequestException); + expect(faucetRequestService.resetFaucet).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, + ); + }); + }); + + 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 unsignedTx = ethers.utils.serializeTransaction({ + 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: '0x', + accessList: [], + }); + + const result = await service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx, + r: '0x' + '1'.repeat(64), + s: '0x' + '2'.repeat(64), + v: 27, + }); + + 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/); + }); + }); + + describe('getOcpPayStatus', () => { + it('should map the lnurlp wait status', async () => { + lnUrlForwardService.waitForPayment.mockResolvedValue({ status: PaymentLinkPaymentStatus.COMPLETED }); + + const result = await service.getOcpPayStatus('pl_abc'); + + expect(result).toEqual({ status: PaymentLinkPaymentStatus.COMPLETED }); + expect(lnUrlForwardService.waitForPayment).toHaveBeenCalledWith('pl_abc'); + }); + }); + + // 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 unsignedTx = ethers.utils.serializeTransaction({ + 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: '0x', + accessList: [], + }); + + const result = await service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx, + r: '0x' + '1'.repeat(64), + s: '0x' + '2'.repeat(64), + v: 27, + }); + + 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 ea66d6c19b..50d5b2620b 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiConflictResponse, ApiExcludeEndpoint, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, @@ -54,6 +55,16 @@ import { RealUnitRegistrationResponseDto, RealUnitRegistrationStatus, } from '../dto/realunit-registration.dto'; +import { + RealUnitOcpPayDto, + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, + RealUnitSwapUnsignedTransactionDto, +} from '../dto/realunit-pay.dto'; import { RealUnitSellBroadcastDto, RealUnitSellConfirmDto, @@ -604,6 +615,116 @@ export class RealUnitController { return this.realunitService.broadcastSellTransaction(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') id: string, + ): 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') id: string, + @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' }) + @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' }) + @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 OCP payment status by reusing the lnurlp wait path. 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 pending payment found for the given id' }) + @ApiBadRequestResponse({ description: 'Invalid payment-link/quote reference' }) + 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..d930533834 --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts @@ -0,0 +1,158 @@ +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' }) + @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)' }) + @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 (e.g. LIMIT_EXCEEDED)' }) + 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 a7d7a20cd6..452eddd7ef 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -7,6 +7,7 @@ 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 { 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 { BalanceModule } from '../balance/balance.module'; @@ -34,6 +35,7 @@ import { RealUnitService } from './realunit.service'; PaymentModule, TransactionModule, 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 1fa582aa37..e5b4444b9f 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -39,8 +39,11 @@ 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 { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; +import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; import { KycContext } from 'src/subdomains/generic/kyc/enums/kyc.enum'; @@ -85,6 +88,14 @@ import { RealUnitUserDataDto, RealUnitUserType, } from './dto/realunit-registration.dto'; +import { + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, +} from './dto/realunit-pay.dto'; import { RealUnitSellBroadcastDto, RealUnitSellConfirmDto, @@ -145,6 +156,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, @@ -155,6 +168,7 @@ export class RealUnitService { private readonly swissQrService: SwissQRService, private readonly feeService: FeeService, private readonly faucetRequestService: FaucetRequestService, + private readonly lnUrlForwardService: LnUrlForwardService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -1254,6 +1268,117 @@ 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. Registration required + if (!this.hasRegistrationForWallet(userData, user.address)) { + throw new RegistrationRequiredException(undefined, KycContext.REALUNIT_SELL); + } + + // 2. KYC Level check - Level 30 minimum (same as 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, + ); + } + + // 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. + const swapPaymentInfo = await this.swapService.createSwapPaymentInfo( + user.id, + { + sourceAsset: realuAsset, + targetAsset: zchfAsset, + amount: dto.amount, + targetAmount: dto.targetAmount, + exactPrice: false, + }, + false, + ); + + // 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(() => null) + : 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: swapPaymentInfo.amount, + estimatedAmount, + targetAsset: zchfAsset.name, + fees: swapPaymentInfo.fees, + minVolume: swapPaymentInfo.minVolume, + maxVolume: swapPaymentInfo.maxVolume, + minVolumeTarget: swapPaymentInfo.minVolumeTarget, + maxVolumeTarget: swapPaymentInfo.maxVolumeTarget, + ethBalance, + requiredGasEth, + isValid: swapPaymentInfo.isValid, + error: swapPaymentInfo.error, + }; + } + // --- Sell Transaction Methods for BitBox --- async createSellUnsignedTransactions(userId: number, requestId: number): Promise<{ swap: string; deposit: string }> { @@ -1284,29 +1409,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 @@ -1336,13 +1440,90 @@ 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. Reuses the shared reconstruction/broadcast helper. + async broadcastSwapTransaction( + userId: number, + requestId: number, + dto: RealUnitSellBroadcastDto, + ): Promise<{ txHash: string }> { + return this.broadcastSignedTransaction(userId, requestId, dto); + } + + // 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); + + 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.faucetRequestService.resetFaucet(userId); + + return { txHash }; + } + + // --- Shared EVM Transaction Helpers --- // + + // 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 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); + const swapData = erc677Interface.encodeFunctionData('transferAndCall', [ + this.getBrokerbotAddress(), + swapAmountWei, + '0x', + ]); + + 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. + private reconstructSignedTransaction(dto: RealUnitSellBroadcastDto): string { const { unsignedTx, r, s, v } = dto; const parsed = ethers.utils.parseTransaction(unsignedTx); - const signedHex = ethers.utils.serializeTransaction( + + return ethers.utils.serializeTransaction( { type: 2, chainId: parsed.chainId, @@ -1357,24 +1538,189 @@ export class RealUnitService { }, { r, s, v }, ); + } + + 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.isValid) throw new BadRequestException('Transaction request is not valid'); const client = this.getEvmClient(); - const result = await client.sendSignedTransaction(signedHex); + const realuAsset = await this.getRealuAsset(); + if (!realuAsset.chainId) throw new BadRequestException('REALU asset has no contract address'); - if (result.error) throw new BadRequestException(`Broadcast failed: ${result.error.message}`); + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount(request.user.address), + client.getRecommendedGasPrice(), + ]); - const txHash = result.response?.hash; - if (!txHash) throw new BadRequestException('Broadcast returned no transaction hash'); + 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`, + ); + } - await this.faucetRequestService.resetFaucet(userId); + const shares = Math.floor(request.amount); + const swap = this.buildSwapUnsignedTransaction(client.chainId, realuAsset, shares, nonce, gasPrice, swapGasLimit); - return { txHash }; + return { swap }; } - private getEvmClient(): EvmClient { - return [Environment.DEV, Environment.LOC].includes(Config.environment) - ? this.sepoliaService.getDefaultClient() - : this.ethereumService.getDefaultClient(); + // 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.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(); + // 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. + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount(senderAddress, 'pending'), + client.getRecommendedGasPrice(), + ]); + + const transferGasLimit = ethers.BigNumber.from(100_000); + const ethBalance = await client.getNativeCoinBalanceForAddress(senderAddress); + const requiredEth = EvmUtil.fromWeiAmount(gasPrice.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: gasPrice, + 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(); + + // Guard against payment methods the payment-link engine cannot settle — see createOcpPayUnsignedTransaction. + this.assertPaymentLinkSupportsMethod(); + + const signedHex = this.reconstructSignedTransaction(dto); + + 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 by reusing the lnurlp wait path. + // 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 { + const { status } = await this.lnUrlForwardService.waitForPayment(paymentLinkId); + return { 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. + private assertPaymentLinkSupportsMethod(): void { + if (!PaymentLinkEvmHexBlockchains.includes(this.tokenBlockchain)) { + throw new BadRequestException( + `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 ---