From 9219b152478b5e2c4edfafb5900a9e1076e69f5f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:03:18 +0200 Subject: [PATCH 1/8] feat(realunit): add swap-only and OCP pay-flow workflow endpoints Add RealUnit Phase 2 pay flow: swap REALU->ZCHF keeping the proceeds in the user wallet, then pay that ZCHF to an Open CryptoPay recipient via the public lnurlp payment-link flow. - swap-only: PUT /realunit/swap/:id/unsigned-transaction builds the REALU transferAndCall swap tx without the deposit sweep (extracted shared buildSwapUnsignedTransaction / reconstructSignedTransaction helpers from the sell flow) - OCP pay: PUT /realunit/pay/unsigned-transaction resolves recipient and amount from the payment-link quote and builds the unsigned ZCHF ERC-20 transfer; PUT /realunit/pay/submit reconstructs the signed hex and submits it into the existing lnurlp tx settlement path; GET /realunit/pay/:id/status exposes the payment status - reuse RealUnitBlockchainService, EvmUtil, payment-link/quote services and the sell flow guard set; no entity/column changes --- .../__tests__/realunit.service.spec.ts | 207 +++++++++++++++- .../controllers/realunit.controller.ts | 75 ++++++ .../realunit/dto/realunit-pay.dto.ts | 77 ++++++ .../supporting/realunit/realunit.module.ts | 2 + .../supporting/realunit/realunit.service.ts | 231 +++++++++++++++--- 5 files changed, 551 insertions(+), 41 deletions(-) create mode 100644 src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index ab690a0564..77031f16fd 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 { 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'; @@ -24,6 +25,8 @@ import { FeeService } from 'src/subdomains/supporting/payment/services/fee.servi import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; @@ -42,7 +45,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' }, }, @@ -102,6 +105,15 @@ describe('RealUnitService', () => { let sellService: jest.Mocked; let userService: jest.Mocked; let kycService: jest.Mocked; + let lnUrlForwardService: jest.Mocked; + + const evmClient = { + chainId: 11155111, + getTransactionCount: jest.fn(), + getRecommendedGasPrice: jest.fn(), + getNativeCoinBalanceForAddress: jest.fn(), + sendSignedTransaction: jest.fn(), + }; const realuAsset = createCustomAsset({ id: 1, @@ -184,7 +196,15 @@ describe('RealUnitService', () => { { provide: FeeService, useValue: {} }, { provide: FaucetRequestService, useValue: {} }, { provide: EthereumService, useValue: {} }, - { provide: SepoliaService, useValue: {} }, + { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, + { + provide: LnUrlForwardService, + useValue: { + lnurlpCallbackForward: jest.fn(), + txHexForward: jest.fn(), + waitForPayment: jest.fn(), + }, + }, ], }).compile(); @@ -196,6 +216,7 @@ describe('RealUnitService', () => { sellService = module.get(SellService); userService = module.get(UserService); kycService = module.get(KycService); + lnUrlForwardService = module.get(LnUrlForwardService); }); afterEach(() => { @@ -206,7 +227,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 +259,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', undefined, @@ -252,7 +273,7 @@ describe('RealUnitService', () => { await service.getBrokerbotInfo(BrokerbotCurrency.EUR); expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( - '0xBrokerbotAddress', + '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', '0xRealuChainId', '0xZchfChainId', BrokerbotCurrency.EUR, @@ -262,7 +283,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 +349,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 +414,173 @@ 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); + }); + }); + + describe('createOcpPayUnsignedTransaction', () => { + const amountWei = '5000000000000000000'; + + 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.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); + 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 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, + ); + }); + }); + + describe('submitOcpPay', () => { + 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: 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' }), + ); + 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'); + }); + }); + 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..33a29f1f9e 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -54,6 +54,14 @@ import { RealUnitRegistrationResponseDto, RealUnitRegistrationStatus, } from '../dto/realunit-registration.dto'; +import { + RealUnitOcpPayDto, + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapUnsignedTransactionDto, +} from '../dto/realunit-pay.dto'; import { RealUnitSellBroadcastDto, RealUnitSellConfirmDto, @@ -604,6 +612,73 @@ 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/: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; broadcast the signed transaction via `PUT /sell/: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('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`.', + }) + @ApiOkResponse({ type: RealUnitOcpPayUnsignedTransactionDto }) + @ApiBadRequestResponse({ description: 'Invalid payment-link/quote reference or insufficient ETH for gas' }) + 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' }) + 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 }) + 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..ea0cb5f6ca --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Util } from 'src/shared/utils/util'; +import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; +import { RealUnitSellBroadcastDto } from './realunit-sell.dto'; + +// --- Swap-only (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..4484350d88 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -45,6 +45,7 @@ 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'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; +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 { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; @@ -85,6 +86,12 @@ import { RealUnitUserDataDto, RealUnitUserType, } from './dto/realunit-registration.dto'; +import { + RealUnitOcpPayResultDto, + RealUnitOcpPayStatusDto, + RealUnitOcpPaySubmitDto, + RealUnitOcpPayUnsignedTransactionDto, +} from './dto/realunit-pay.dto'; import { RealUnitSellBroadcastDto, RealUnitSellConfirmDto, @@ -155,6 +162,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; } @@ -1284,29 +1292,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 @@ -1340,9 +1327,66 @@ export class RealUnitService { 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 +1401,145 @@ 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'); + + // 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); + + const client = this.getEvmClient(); + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount(senderAddress), + 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. + async submitOcpPay(dto: RealUnitOcpPaySubmitDto): Promise { + const zchfAsset = await this.getZchfAsset(); + + 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. + async getOcpPayStatus(paymentLinkId: string): Promise { + const { status } = await this.lnUrlForwardService.waitForPayment(paymentLinkId); + return { status }; + } + + // Parses an ERC-20 EVM payment request URI of the form + // `ethereum:@/transfer?address=&uint256=` into recipient + amount. + private parseEvmPaymentRequest(uri: string): { recipient: string; amountWei: BigNumber } { + 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'); + + return { recipient, amountWei: BigNumber.from(amount) }; } // --- Admin Methods --- From 4609dbd74143ed7972bd4d496d630462d4544455 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:20:18 +0200 Subject: [PATCH 2/8] fix(realunit): harden OCP pay flow (pending nonce, fail-fast on unsupported method, URI validation) - derive the OCP pay-tx nonce from the pending block tag so a still-pending swap tx is counted and the two txs do not collide on the same nonce; add an optional block-tag param to EvmClient.getTransactionCount - fail fast with a typed BadRequestException on both OCP endpoints when the resolved payment method is unsupported by the payment-link engine (Sepolia on DEV/LOC) instead of letting a deep `Invalid method` error bubble up; add the PaymentLinkEvmHexBlockchains constant mirroring the engine's supported set - cross-check the URI token contract against the ZCHF asset and validate the recipient/amount in parseEvmPaymentRequest, throwing a typed error on mismatch or malformed input - document the JWT access-gate (not ownership) semantics on the OCP endpoints - add Swagger error responses and a nonce-ordering note to the OCP endpoints - fix import ordering and extend specs for the new error paths --- .../blockchain/shared/evm/evm-client.ts | 7 +- .../core/payment-link/enums/index.ts | 12 ++ .../__tests__/realunit.service.spec.ts | 126 ++++++++++++++++-- .../controllers/realunit.controller.ts | 7 +- .../supporting/realunit/realunit.service.ts | 55 +++++++- 5 files changed, 190 insertions(+), 17 deletions(-) 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/enums/index.ts b/src/subdomains/core/payment-link/enums/index.ts index 0888c86a22..8b37734f01 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -95,6 +95,18 @@ export enum PaymentMerchantStatus { PROCESSED = 'Processed', } +// EVM blockchains the payment-link engine accepts for signed-hex payments (PaymentRequestMapper + +// PaymentQuoteService.executeHexPayment). Mainnet-only — testnets such as Sepolia are intentionally absent. +export const PaymentLinkEvmHexBlockchains = [ + Blockchain.ETHEREUM, + 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/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 77031f16fd..6c71140fd9 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -16,7 +16,9 @@ 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 { 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'; @@ -25,17 +27,19 @@ import { FeeService } from 'src/subdomains/supporting/payment/services/fee.servi import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; -import { PaymentLinkPaymentStatus } from 'src/subdomains/core/payment-link/enums'; -import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; 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', @@ -195,7 +199,7 @@ describe('RealUnitService', () => { { provide: SwissQRService, useValue: {} }, { provide: FeeService, useValue: {} }, { provide: FaucetRequestService, useValue: {} }, - { provide: EthereumService, useValue: {} }, + { provide: EthereumService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, { provide: LnUrlForwardService, @@ -476,9 +480,20 @@ describe('RealUnitService', () => { }); }); + // The OCP submit/pay endpoints fail fast on testnet (Sepolia) because the payment-link engine supports + // mainnet EVM methods only, so the engine-touching specs run under PRD (→ Ethereum). A dedicated block + // below asserts the fail-fast behaviour on the LOC/Sepolia branch. 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)); @@ -489,15 +504,15 @@ describe('RealUnitService', () => { assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); lnUrlForwardService.lnurlpCallbackForward.mockResolvedValue({ expiryDate: new Date(), - blockchain: Blockchain.SEPOLIA, - uri: `ethereum:${zchfTxAsset.chainId}@11155111/transfer?address=${dfxDepositAddress}&uint256=${amountWei}`, + 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.SEPOLIA, + method: Blockchain.ETHEREUM, asset: 'ZCHF', quote: 'quote_xyz', }); @@ -510,6 +525,62 @@ describe('RealUnitService', () => { 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); @@ -535,13 +606,21 @@ 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 unsignedTx = ethers.utils.serializeTransaction({ type: 2, - chainId: 11155111, + chainId: 1, nonce: 1, maxPriorityFeePerGas: ethers.BigNumber.from(1), maxFeePerGas: ethers.BigNumber.from(1), @@ -564,7 +643,7 @@ describe('RealUnitService', () => { expect(result.txId).toBe('0xTxId'); expect(lnUrlForwardService.txHexForward).toHaveBeenCalledWith( 'pl_abc', - expect.objectContaining({ method: Blockchain.SEPOLIA, asset: 'ZCHF', quote: 'quote_xyz' }), + expect.objectContaining({ method: Blockchain.ETHEREUM, asset: 'ZCHF', quote: 'quote_xyz' }), ); expect(lnUrlForwardService.txHexForward.mock.calls[0][1].hex).toMatch(/^0x/); }); @@ -581,6 +660,35 @@ describe('RealUnitService', () => { }); }); + // On LOC/DEV the token blockchain resolves to Sepolia, which the payment-link engine does not support. + // Both OCP pay endpoints must fail fast with a typed BadRequestException without touching the engine. + describe('OCP pay fail-fast on unsupported method (Sepolia)', () => { + it('createOcpPayUnsignedTransaction throws BadRequestException and never activates the quote', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + + await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( + BadRequestException, + ); + expect(lnUrlForwardService.lnurlpCallbackForward).not.toHaveBeenCalled(); + }); + + it('submitOcpPay throws BadRequestException and never forwards the hex', async () => { + assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + + await expect( + service.submitOcpPay({ + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + unsignedTx: '0x', + r: '0x' + '1'.repeat(64), + s: '0x' + '2'.repeat(64), + v: 27, + }), + ).rejects.toThrow(BadRequestException); + expect(lnUrlForwardService.txHexForward).not.toHaveBeenCalled(); + }); + }); + 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 33a29f1f9e..c2a6b4f698 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, @@ -641,10 +642,11 @@ export class RealUnitController { @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`.', + '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 /sell/: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, @@ -662,6 +664,7 @@ export class RealUnitController { }) @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); } @@ -675,6 +678,8 @@ export class RealUnitController { }) @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); } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 4484350d88..b8416b091f 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -40,12 +40,13 @@ 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 { 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'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; -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 { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; @@ -1456,6 +1457,13 @@ export class RealUnitService { const zchfAsset = await this.getZchfAsset(); if (!zchfAsset.chainId) throw new BadRequestException('ZCHF asset has no contract address'); + // Fail fast and clean before touching the payment-link engine: on DEV/LOC the resolved method is + // SEPOLIA, which the payment-link engine (PaymentRequestMapper / executeHexPayment) does not support — + // it would throw a deep, opaque `Invalid method Sepolia`. The OCP submit step is therefore exercisable + // end-to-end on mainnet only; the swap-only step is fully DEV-testable. Mirrors the platform's existing + // mainnet-only payment-link scope. + 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, @@ -1467,11 +1475,14 @@ export class RealUnitService { throw new BadRequestException('OCP quote did not return an EVM payment request'); } - const { recipient, amountWei } = this.parseEvmPaymentRequest(activation.uri); + 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 /sell/: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), + client.getTransactionCount(senderAddress, 'pending'), client.getRecommendedGasPrice(), ]); @@ -1509,9 +1520,15 @@ export class RealUnitService { // 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(); + // Fail fast on unsupported methods (e.g. SEPOLIA on DEV/LOC) — see createOcpPayUnsignedTransaction. + this.assertPaymentLinkSupportsMethod(); + const signedHex = this.reconstructSignedTransaction(dto); const result = await this.lnUrlForwardService.txHexForward(dto.paymentLinkId, { @@ -1525,21 +1542,49 @@ export class RealUnitService { } // 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. On DEV/LOC + // the resolved method is SEPOLIA, which PaymentRequestMapper / executeHexPayment reject — without this guard + // a deep, opaque `Invalid method Sepolia` would bubble up. Fails fast with a clear, typed error instead. + private assertPaymentLinkSupportsMethod(): void { + if (!PaymentLinkEvmHexBlockchains.includes(this.tokenBlockchain)) { + throw new BadRequestException( + `OCP pay is not available for ${this.tokenBlockchain}: the payment-link engine supports mainnet EVM methods only`, + ); + } + } + // Parses an ERC-20 EVM payment request URI of the form // `ethereum:@/transfer?address=&uint256=` into recipient + amount. - private parseEvmPaymentRequest(uri: string): { recipient: string; amountWei: BigNumber } { + // 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'); - return { recipient, amountWei: BigNumber.from(amount) }; + 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 --- From 74f93f09ffd6988c74e86bf4ddd2aed94f33ad7f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:40:42 +0200 Subject: [PATCH 3/8] feat(realunit): add IBAN-free REALU -> ZCHF swap quote and broadcast endpoints The OCP pay flow's PUT /swap/:id/unsigned-transaction consumes a SWAP-type TransactionRequest, but the only way to obtain one was PUT /sell, which requires a fiat IBAN and creates a Sell route + payout. Requiring a sell bank account to do a pure REALU -> ZCHF swap whose proceeds stay in the user wallet (to pay at an OCP/SPAR POS) is semantically wrong and blocked the swap-only path end to end. Add the missing entry points: - PUT /v1/realunit/swap: IBAN-free swap quote. Creates a TransactionRequestType.SWAP request via SwapService.createSwapPaymentInfo (REALU -> ZCHF), with no fiat IBAN, Sell route or payout. Same registration + KYC Level 30 gating as sell, and KYC trading limits stay enforced: a quote over the limit surfaces QuoteError.LIMIT_EXCEEDED, translated into the same KYC-Level-50 requirement the sell path throws. The ZCHF estimate is anchored to the live on-chain brokerbot sell price. Input mirrors the sell DTO's amount XOR targetAmount pattern but drops iban/currency (target is always ZCHF). - PUT /v1/realunit/swap/:id/broadcast: dedicated swap broadcast for clean OCP-flow semantics, reusing a shared private reconstruct/broadcast helper extracted from broadcastSellTransaction (no duplication). The on-chain execution stays the already-built user-signed brokerbot mechanism; this only adds the IBAN-free quote/request-creation and broadcast entry points. No entity or column changes, so no migration is needed (reuses TransactionRequest + Swap route). Update the swap unsigned-transaction and OCP pay ApiOperations to reference the new swap quote (step 0) and swap broadcast, and document the swap flow in CONTRIBUTING.md. Add jest specs: swap quote happy path, IBAN not required, limit-exceeded surfaces a typed error, swap broadcast returns txHash. --- CONTRIBUTING.md | 3 + .../__tests__/realunit.service.spec.ts | 147 +++++++++++++++++- .../controllers/realunit.controller.ts | 43 ++++- .../realunit/dto/realunit-pay.dto.ts | 89 ++++++++++- .../supporting/realunit/realunit.service.ts | 136 ++++++++++++++++ 5 files changed, 410 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbea27872a..0e09beb096 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). Reuses `SwapService.createSwapPaymentInfo` so KYC trading limits are enforced (`QuoteError.LIMIT_EXCEEDED`); 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/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 6c71140fd9..371815d9f6 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ethers } from 'ethers'; import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; @@ -16,6 +16,7 @@ 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'; @@ -23,6 +24,7 @@ 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'; @@ -107,6 +109,7 @@ 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; @@ -180,6 +183,12 @@ describe('RealUnitService', () => { getById: jest.fn(), }, }, + { + provide: SwapService, + useValue: { + createSwapPaymentInfo: jest.fn(), + }, + }, { provide: Eip7702DelegationService, useValue: { @@ -198,7 +207,7 @@ describe('RealUnitService', () => { { provide: RealUnitDevService, useValue: {} }, { provide: SwissQRService, useValue: {} }, { provide: FeeService, useValue: {} }, - { provide: FaucetRequestService, useValue: {} }, + { provide: FaucetRequestService, useValue: { resetFaucet: jest.fn() } }, { provide: EthereumService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, { provide: SepoliaService, useValue: { getDefaultClient: jest.fn().mockReturnValue(evmClient) } }, { @@ -218,6 +227,7 @@ 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); @@ -480,6 +490,139 @@ describe('RealUnitService', () => { }); }); + 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 surface a typed KYC-level error when the trading limit is exceeded (limits are NOT bypassed)', async () => { + swapService.createSwapPaymentInfo.mockResolvedValue({ + ...swapInfo, + isValid: false, + error: QuoteError.LIMIT_EXCEEDED, + } as any); + + await expect(service.getSwapPaymentInfo(buildUser(), { amount: 100000 } as any)).rejects.toThrow( + ForbiddenException, + ); + }); + + 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(); + }); + }); + + 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(); + }); + }); + // The OCP submit/pay endpoints fail fast on testnet (Sepolia) because the payment-link engine supports // mainnet EVM methods only, so the engine-touching specs run under PRD (→ Ethereum). A dedicated block // below asserts the fail-fast behaviour on the LOC/Sepolia branch. diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index c2a6b4f698..1e0bef26b6 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -61,6 +61,8 @@ import { RealUnitOcpPayStatusDto, RealUnitOcpPaySubmitDto, RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, RealUnitSwapUnsignedTransactionDto, } from '../dto/realunit-pay.dto'; import { @@ -618,13 +620,31 @@ export class RealUnitController { // 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, and KYC trading limits are still enforced (a quote over the limit returns a typed error / KYC-level requirement). 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 trading limit exceeded' }) + 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; broadcast the signed transaction via `PUT /sell/:id/broadcast`.', + '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 }) @@ -636,13 +656,32 @@ export class RealUnitController { 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 /sell/: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.', + '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' }) diff --git a/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts index ea0cb5f6ca..d930533834 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-pay.dto.ts @@ -1,11 +1,92 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; +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-only (REALU -> ZCHF, proceeds stay in the user wallet) --- // +// --- 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)' }) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index b8416b091f..d21fa994ba 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -39,6 +39,7 @@ 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'; @@ -92,6 +93,8 @@ import { RealUnitOcpPayStatusDto, RealUnitOcpPaySubmitDto, RealUnitOcpPayUnsignedTransactionDto, + RealUnitSwapDto, + RealUnitSwapPaymentInfoDto, } from './dto/realunit-pay.dto'; import { RealUnitSellBroadcastDto, @@ -153,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, @@ -1263,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 exactly (registration + KYC Level 30) and KYC trading LIMITS are still + // enforced: SwapService.createSwapPaymentInfo runs the same TransactionHelper.getTxDetails limit check and + // surfaces QuoteError.LIMIT_EXCEEDED, which we translate into the same KYC-Level-50 exception the sell path + // throws. 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 limit-enforced 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 limit-enforced 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. Check if limit exceeded (do NOT bypass — same translation as the sell path) + if (swapPaymentInfo.error === QuoteError.LIMIT_EXCEEDED) { + throw new KycLevelRequiredException( + KycLevel.LEVEL_50, + userData.kycLevel, + 'KYC Level 50 required for RealUnit swap exceeding trading limit', + KycContext.REALUNIT_SELL, + ); + } + + // 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 }> { @@ -1324,6 +1440,26 @@ 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'); From 1aff2a42a5c9aa130d3e687c8363ca273ca05f6b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:16:27 +0200 Subject: [PATCH 4/8] fix(realunit): make REALU -> ZCHF swap quote honest about limit-exemption The swap quote's QuoteError.LIMIT_EXCEEDED -> KYC-Level-50 mapping was dead code: TransactionHelper.getLimits returns Number.MAX_VALUE for any RealUnit transaction that is not selling-REALU-for-fiat, so the limit error can never fire for this crypto -> crypto pair. Remove the misleading limit-to-KYC-level throw and document why the swap is limit-exempt by design: KYC trading limits are enforced at the fiat boundary (buy/sell), whereas a REALU -> ZCHF swap is a self-custody, on-chain brokerbot action. Keep the registration and KYC Level 30 access gates (those are enforced) and keep surfacing genuine quote errors (e.g. min/max volume) via the DTO isValid/error fields. Update the Swagger error docs, CONTRIBUTING swap row and the swap spec to reflect the limit-exempt behavior instead of implying limit enforcement. --- CONTRIBUTING.md | 2 +- .../__tests__/realunit.service.spec.ts | 13 ++++--- .../controllers/realunit.controller.ts | 6 ++-- .../supporting/realunit/realunit.service.ts | 34 +++++++++---------- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e09beb096..4a4503b97e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -958,7 +958,7 @@ 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). Reuses `SwapService.createSwapPaymentInfo` so KYC trading limits are enforced (`QuoteError.LIMIT_EXCEEDED`); anchors the ZCHF estimate against the live on-chain sell price | **Yes** — `RealUnitBlockchainService.getBrokerbotSellPrice` | +| `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` | diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 371815d9f6..3dcaa2ead7 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -554,16 +554,21 @@ describe('RealUnitService', () => { expect(transactionRequestService.updateEstimatedAmount).toHaveBeenCalledWith(99, 960); }); - it('should surface a typed KYC-level error when the trading limit is exceeded (limits are NOT bypassed)', async () => { + 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); - await expect(service.getSwapPaymentInfo(buildUser(), { amount: 100000 } as any)).rejects.toThrow( - ForbiddenException, - ); + 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 () => { diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 1e0bef26b6..50d5b2620b 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -626,10 +626,12 @@ export class RealUnitController { @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, and KYC trading limits are still enforced (a quote over the limit returns a typed error / KYC-level requirement). Step 0 of the OCP pay flow: feed the returned `id` into `PUT /swap/:id/unsigned-transaction`. Requires KYC Level 30 and RealUnit registration.', + '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 trading limit exceeded' }) + @ApiBadRequestResponse({ + description: 'KYC Level 30 required, registration missing, or invalid swap amount (min/max volume)', + }) async getSwapPaymentInfo( @GetJwt() jwt: JwtPayload, @Body() dto: RealUnitSwapDto, diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index d21fa994ba..5168155306 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -1276,11 +1276,14 @@ export class RealUnitService { // 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 exactly (registration + KYC Level 30) and KYC trading LIMITS are still - // enforced: SwapService.createSwapPaymentInfo runs the same TransactionHelper.getTxDetails limit check and - // surfaces QuoteError.LIMIT_EXCEEDED, which we translate into the same KYC-Level-50 exception the sell path - // throws. 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. + // 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 @@ -1310,9 +1313,8 @@ export class RealUnitService { if (!realuAsset) throw new NotFoundException('REALU asset not found'); if (!zchfAsset) throw new NotFoundException('ZCHF asset not found'); - // 4. Create the limit-enforced 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. + // 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, { @@ -1325,15 +1327,13 @@ export class RealUnitService { false, ); - // 5. Check if limit exceeded (do NOT bypass — same translation as the sell path) - if (swapPaymentInfo.error === QuoteError.LIMIT_EXCEEDED) { - throw new KycLevelRequiredException( - KycLevel.LEVEL_50, - userData.kycLevel, - 'KYC Level 50 required for RealUnit swap exceeding trading limit', - KycContext.REALUNIT_SELL, - ); - } + // 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(); From aebfe50f4b71051960b727d646c3d007511849d0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:24:15 +0200 Subject: [PATCH 5/8] Fix stale swap comments: drop limit-enforced wording and use swap broadcast endpoint --- src/subdomains/supporting/realunit/realunit.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 5168155306..4cbce8cb75 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -1288,7 +1288,7 @@ export class RealUnitService { // 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 limit-enforced quote and the SWAP-type TransactionRequest. includeTx + // 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; @@ -1614,7 +1614,7 @@ export class RealUnitService { 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 /sell/:id/broadcast) + // 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([ From 5d78cdb6fc6742797d5a32b12c58a1311276742a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:58:25 +0200 Subject: [PATCH 6/8] test(realunit): cover OCP swap/pay branches and evm getTransactionCount to 100% Add diff-coverage for every changed executable line and branch in the OCP pay flow: - getSwapPaymentInfo: asset-not-found, shares<=0, brokerbot-query rejection, missing-id and brokerbot-null estimate fallbacks - broadcast: broadcast-error and no-tx-hash failure paths - createSwapUnsignedTransaction: missing REALU contract and decimals fallback to 18 - createOcpPayUnsignedTransaction: missing ZCHF contract and insufficient-gas paths - EvmClient.getTransactionCount: default latest tag and pending tag - thin controller delegations for the six OCP endpoints --- .../shared/evm/__tests__/evm-client.spec.ts | 47 +++++++ .../__tests__/realunit.controller.spec.ts | 100 +++++++++++++++ .../__tests__/realunit.service.spec.ts | 116 +++++++++++++++++- 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts create mode 100644 src/subdomains/supporting/realunit/__tests__/realunit.controller.spec.ts 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/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 3dcaa2ead7..1b191b0012 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, ForbiddenException } 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'; @@ -113,6 +113,7 @@ describe('RealUnitService', () => { let userService: jest.Mocked; let kycService: jest.Mocked; let lnUrlForwardService: jest.Mocked; + let faucetRequestService: jest.Mocked; const evmClient = { chainId: 11155111, @@ -231,6 +232,7 @@ describe('RealUnitService', () => { userService = module.get(UserService); kycService = module.get(KycService); lnUrlForwardService = module.get(LnUrlForwardService); + faucetRequestService = module.get(FaucetRequestService); }); afterEach(() => { @@ -488,6 +490,30 @@ describe('RealUnitService', () => { 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', () => { @@ -584,6 +610,54 @@ describe('RealUnitService', () => { ); 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', () => { @@ -626,6 +700,22 @@ describe('RealUnitService', () => { 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 OCP submit/pay endpoints fail fast on testnet (Sepolia) because the payment-link engine supports @@ -751,6 +841,30 @@ describe('RealUnitService', () => { 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', () => { From 64685ea486e231ad42b83e3feb5736c6766bf3ee Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:26:39 +0200 Subject: [PATCH 7/8] feat(payment-link): support Open CryptoPay on Sepolia for non-PRD testing Add Blockchain.SEPOLIA to the EVM groups of the three payment-link switch statements that previously fell through to the default throw (getDepositAddress, executeHexPayment, PaymentRequestMapper) and to the PaymentLinkEvmHexBlockchains set so the RealUnit OCP guard recognizes it. This makes the RealUnit OCP pay flow testable end-to-end on the Sepolia testnet on non-PRD (DEV/LOC). It stays PRD-safe: on PRD, TestBlockchains includes Sepolia, so PaymentLinkBlockchains filters it out and no PRD payment-link can offer Sepolia, leaving the new EVM cases unreachable there. The RealUnit assertPaymentLinkSupportsMethod guard is kept (still fails fast for genuinely-unsupported methods) and now passes for Sepolia. Update the now-stale mainnet-only comments and flip the RealUnit Sepolia fail-fast specs to assert the flow now proceeds on the testnet. Add a payment-link engine spec covering the new Sepolia EVM routes. --- .../dto/payment-request.mapper.ts | 1 + .../core/payment-link/enums/index.ts | 5 +- .../__tests__/payment-link-sepolia.spec.ts | 132 ++++++++++++++++++ .../services/payment-balance.service.ts | 1 + .../services/payment-quote.service.ts | 1 + .../__tests__/realunit.service.spec.ts | 83 ++++++++--- .../supporting/realunit/realunit.service.ts | 20 +-- 7 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts 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 8b37734f01..537b3f4955 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -96,9 +96,12 @@ export enum PaymentMerchantStatus { } // EVM blockchains the payment-link engine accepts for signed-hex payments (PaymentRequestMapper + -// PaymentQuoteService.executeHexPayment). Mainnet-only — testnets such as Sepolia are intentionally absent. +// 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, 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..9d51042d7b --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-sepolia.spec.ts @@ -0,0 +1,132 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; +import { TxValidationService } from 'src/integration/blockchain/shared/services/tx-validation.service'; +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 { PaymentActivation } from '../../entities/payment-activation.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 { 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('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-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-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.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 1b191b0012..0f69e5bb82 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -718,9 +718,9 @@ describe('RealUnitService', () => { }); }); - // The OCP submit/pay endpoints fail fast on testnet (Sepolia) because the payment-link engine supports - // mainnet EVM methods only, so the engine-touching specs run under PRD (→ Ethereum). A dedicated block - // below asserts the fail-fast behaviour on the LOC/Sepolia branch. + // 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'; @@ -922,32 +922,69 @@ describe('RealUnitService', () => { }); }); - // On LOC/DEV the token blockchain resolves to Sepolia, which the payment-link engine does not support. - // Both OCP pay endpoints must fail fast with a typed BadRequestException without touching the engine. - describe('OCP pay fail-fast on unsupported method (Sepolia)', () => { - it('createOcpPayUnsignedTransaction throws BadRequestException and never activates the quote', async () => { + // 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: '', + }); - await expect(service.createOcpPayUnsignedTransaction(userAddress, 'pl_abc', 'quote_xyz')).rejects.toThrow( - BadRequestException, - ); - expect(lnUrlForwardService.lnurlpCallbackForward).not.toHaveBeenCalled(); + 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 throws BadRequestException and never forwards the hex', async () => { + it('submitOcpPay passes the method guard and forwards the hex with the Sepolia method', async () => { assetService.getAssetByQuery.mockResolvedValue(zchfTxAsset); + lnUrlForwardService.txHexForward.mockResolvedValue({ txId: '0xTxId' }); - await expect( - service.submitOcpPay({ - paymentLinkId: 'pl_abc', - quoteId: 'quote_xyz', - unsignedTx: '0x', - r: '0x' + '1'.repeat(64), - s: '0x' + '2'.repeat(64), - v: 27, - }), - ).rejects.toThrow(BadRequestException); - expect(lnUrlForwardService.txHexForward).not.toHaveBeenCalled(); + 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' }), + ); }); }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 4cbce8cb75..e5b4444b9f 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -1593,11 +1593,10 @@ export class RealUnitService { const zchfAsset = await this.getZchfAsset(); if (!zchfAsset.chainId) throw new BadRequestException('ZCHF asset has no contract address'); - // Fail fast and clean before touching the payment-link engine: on DEV/LOC the resolved method is - // SEPOLIA, which the payment-link engine (PaymentRequestMapper / executeHexPayment) does not support — - // it would throw a deep, opaque `Invalid method Sepolia`. The OCP submit step is therefore exercisable - // end-to-end on mainnet only; the swap-only step is fully DEV-testable. Mirrors the platform's existing - // mainnet-only payment-link scope. + // 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 @@ -1662,7 +1661,7 @@ export class RealUnitService { async submitOcpPay(dto: RealUnitOcpPaySubmitDto): Promise { const zchfAsset = await this.getZchfAsset(); - // Fail fast on unsupported methods (e.g. SEPOLIA on DEV/LOC) — see createOcpPayUnsignedTransaction. + // Guard against payment methods the payment-link engine cannot settle — see createOcpPayUnsignedTransaction. this.assertPaymentLinkSupportsMethod(); const signedHex = this.reconstructSignedTransaction(dto); @@ -1686,13 +1685,14 @@ export class RealUnitService { return { status }; } - // Guards the OCP pay endpoints against payment methods the payment-link engine cannot settle. On DEV/LOC - // the resolved method is SEPOLIA, which PaymentRequestMapper / executeHexPayment reject — without this guard - // a deep, opaque `Invalid method Sepolia` would bubble up. Fails fast with a clear, typed error instead. + // 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 mainnet EVM methods only`, + `OCP pay is not available for ${this.tokenBlockchain}: the payment-link engine supports EVM methods only`, ); } } From 7d40e08da0a27b267af81b624729c0b3253f45cd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:48:47 +0200 Subject: [PATCH 8/8] fix(payment-link): add Sepolia to payment-link fee calculation for non-PRD OCP The Sepolia OCP enablement (64685ea4) added case Blockchain.SEPOLIA to the getDepositAddress, executeHexPayment and PaymentRequestMapper switches, but missed two further EVM-mainnet switches the OCP hex-payment flow traverses: - PaymentLinkFeeService.calculateFee: the EVM group fell through to undefined for Sepolia, so updateFees cached no fee, getMinFee(SEPOLIA) returned undefined and createTransferAmount dropped the Sepolia transfer-amount. getTransferAmountFor(SEPOLIA, ZCHF) then threw 'Invalid method or asset' at OCP quote activation. - PaymentActivationService.createBlockchainRequest: the EVM/deposit-address group omitted Sepolia, so activation would hit the default invalid-method throw even once the fee was available. Add case Blockchain.SEPOLIA to both EVM-mainnet groups (same minimal additive pattern; getEvmClient(SEPOLIA) and getDepositAddress already support Sepolia). PRD-safe: Sepolia is filtered out of PaymentLinkBlockchains on PRD, so these cases stay unreachable there. Extend payment-link-sepolia.spec to cover both new cases (calculateFee/getMinFee return a real gas price; createBlockchainRequest routes Sepolia to the EVM deposit-address branch). --- .../__tests__/payment-link-sepolia.spec.ts | 113 ++++++++++++++++++ .../services/payment-activation.service.ts | 1 + .../services/payment-link-fee.service.ts | 1 + 3 files changed, 115 insertions(+) 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 index 9d51042d7b..f8e0b3b95c 100644 --- 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 @@ -1,19 +1,28 @@ 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'; @@ -47,6 +56,110 @@ describe('Payment-link engine - Sepolia routing', () => { }); }); + 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; 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-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: