diff --git a/packages/wallet/backend/src/incomingPayment/service.ts b/packages/wallet/backend/src/incomingPayment/service.ts index 55766c176..fc1fface7 100644 --- a/packages/wallet/backend/src/incomingPayment/service.ts +++ b/packages/wallet/backend/src/incomingPayment/service.ts @@ -1,7 +1,7 @@ import { AccountService } from '@/account/service' import { WalletAddress } from '@/walletAddress/model' import { RafikiClient } from '@/rafiki/rafiki-client' -import { transformAmount } from '@/utils/helpers' +import { toBaseUnits, transformAmount } from '@/utils/helpers' import { Amount, Asset } from '@/rafiki/backend/generated/graphql' import { add, Duration } from 'date-fns' import axios from 'axios' @@ -67,7 +67,7 @@ export class IncomingPaymentService implements IIncomingPaymentService { throw new NotFound() } - await this.accountService.findAccountById( + const { assetScale } = await this.accountService.findAccountById( existingWalletAddress.accountId, userId ) @@ -83,7 +83,7 @@ export class IncomingPaymentService implements IIncomingPaymentService { const response = await this.rhyzaAdminClient.createPaymentIntent({ walletAddress: existingWalletAddress.url, - incomingAmount: amount, + incomingAmount: Number(toBaseUnits(amount, assetScale)), expiresAt: expiryDate?.toISOString(), metadata: description ? { description } : undefined }) diff --git a/packages/wallet/backend/src/outgoingPayment/controller.ts b/packages/wallet/backend/src/outgoingPayment/controller.ts index 1455634bb..7bc7bc4c3 100644 --- a/packages/wallet/backend/src/outgoingPayment/controller.ts +++ b/packages/wallet/backend/src/outgoingPayment/controller.ts @@ -13,15 +13,17 @@ export class OutgoingPaymentController implements IOutgoingPaymentController { create = async (req: Request, res: CustomResponse, next: NextFunction) => { try { + const userId = req.session.user.id const { body: { quoteId, paymentIntentId, description } } = await validate(outgoingPaymentSchema, req) - await this.outgoingPaymentService.createByQuoteId( + await this.outgoingPaymentService.createByQuoteId({ + userId, quoteId, paymentIntentId, description - ) + }) res.status(200).json(toSuccessResponse()) } catch (e) { diff --git a/packages/wallet/backend/src/outgoingPayment/service.ts b/packages/wallet/backend/src/outgoingPayment/service.ts index 99bbe5fca..7f5aa20f8 100644 --- a/packages/wallet/backend/src/outgoingPayment/service.ts +++ b/packages/wallet/backend/src/outgoingPayment/service.ts @@ -1,32 +1,60 @@ import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { WalletAddress } from '@/walletAddress/model' +import { AccountService } from '@/account/service' +import { BadRequest, NotFound } from '@shared/backend' + +type CreateByQuoteIdParams = { + userId: string + quoteId: string + paymentIntentId: string + description?: string +} interface IOutgoingPaymentService { - createByQuoteId: ( - quoteId: string, - paymentIntentId: string, - description?: string - ) => Promise + createByQuoteId: (params: CreateByQuoteIdParams) => Promise } export class OutgoingPaymentService implements IOutgoingPaymentService { - constructor(private rhyzaAdminClient: RhyzaAdminClient) {} + constructor( + private rhyzaAdminClient: RhyzaAdminClient, + private accountService: AccountService + ) {} - async createByQuoteId( - quoteId: string, - paymentIntentId: string, - description?: string - ): Promise { + async createByQuoteId(params: CreateByQuoteIdParams): Promise { const quote = await this.rhyzaAdminClient.getQuoteById( - paymentIntentId, - quoteId + params.paymentIntentId, + params.quoteId + ) + + if (quote.state !== 'COMPLETED') { + throw new BadRequest('Quote is not valid') + } + if (quote.expiresAt && new Date(quote.expiresAt) <= new Date()) { + throw new BadRequest('Quote has expired') + } + + // By URL: pre-Rhyza wallet addresses keep their Rafiki id locally. + const walletAddress = await WalletAddress.query().findOne({ + url: quote.senderWalletAddress + }) + if (!walletAddress) { + throw new NotFound() + } + if (!walletAddress.active) { + throw new BadRequest('Invalid wallet address') + } + await this.accountService.findAccountById( + walletAddress.accountId, + params.userId ) - const walletAddress = quote.senderWalletAddress await this.rhyzaAdminClient.createPayment({ - walletAddress, - quoteId, - metadata: description ? { description } : undefined, - paymentIntentId + walletAddress: quote.senderWalletAddress, + paymentIntentId: quote.paymentIntentId, + quoteId: quote.id, + metadata: params.description + ? { description: params.description } + : undefined }) } } diff --git a/packages/wallet/backend/src/outgoingPayment/validation.ts b/packages/wallet/backend/src/outgoingPayment/validation.ts index 112d9b899..91792351b 100644 --- a/packages/wallet/backend/src/outgoingPayment/validation.ts +++ b/packages/wallet/backend/src/outgoingPayment/validation.ts @@ -3,7 +3,7 @@ import { z } from 'zod' export const outgoingPaymentSchema = z.object({ body: z.object({ quoteId: z.string().uuid(), - paymentIntentId: z.string().uuid(), + paymentIntentId: z.string().min(1), description: z.string().optional() }) }) diff --git a/packages/wallet/backend/src/quote/service.ts b/packages/wallet/backend/src/quote/service.ts index 1bfe19d65..50b04d28a 100644 --- a/packages/wallet/backend/src/quote/service.ts +++ b/packages/wallet/backend/src/quote/service.ts @@ -1,12 +1,17 @@ import { AccountService } from '@/account/service' import { IncomingPaymentService } from '@/incomingPayment/service' import { WalletAddress } from '@/walletAddress/model' -import { incomingPaymentRegexp, transformBalance } from '@/utils/helpers' +import { + incomingPaymentRegexp, + replaceIlpDev, + toBaseUnits +} from '@/utils/helpers' import { WalletAddressService } from '@/walletAddress/service' import { QuoteWithFees } from './controller' import { RatesService } from '@/rates/service' -import { BadRequest } from '@shared/backend' +import { BadRequest, InternalServerError } from '@shared/backend' import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { Quote as RhyzaQuote } from '@/rhyza/types' import { randomUUID } from 'crypto' interface IQuoteService { @@ -58,7 +63,7 @@ export class QuoteService implements IQuoteService { ) const balance = account.balance - const originalValue = BigInt((params.amount * 10 ** assetScale).toFixed()) + const originalValue = toBaseUnits(params.amount, assetScale) if (Number(balance) < originalValue) { throw new BadRequest('Not enough funds in account') @@ -68,6 +73,7 @@ export class QuoteService implements IQuoteService { const isIncomingPayment = incomingPaymentRegexp.test(params.receiver) + let receiverUrl = params.receiver let assetDetails if (isIncomingPayment) { const payment = await this.incomingPaymentService.getExternalPayment( @@ -78,13 +84,22 @@ export class QuoteService implements IQuoteService { assetScale: payment.receivedAmount.assetScale } } else { - const walletAddress = - await this.walletAddressService.getExternalWalletAddress( - params.receiver + const receiver = await this.walletAddressService.getByUrl( + replaceIlpDev(params.receiver) + ) + // Rhyza doesn't support payment intents at other providers yet. + if (!receiver) { + throw new BadRequest( + 'Sending to wallet addresses at other providers is not supported yet' ) + } + if (!receiver.active) { + throw new BadRequest('Invalid receiver wallet address') + } + receiverUrl = receiver.url assetDetails = { - assetCode: walletAddress.assetCode, - assetScale: walletAddress.assetScale + assetCode: receiver.account.assetCode, + assetScale: receiver.account.assetScale } } @@ -95,17 +110,20 @@ export class QuoteService implements IQuoteService { amount: value }) if (isIncomingPayment) { - const payment = - await this.incomingPaymentService.getPaymentDetailsByUrl( - params.receiver - ) - - const amount = payment?.value - ? transformBalance(payment?.value, assetDetails.assetScale) - : undefined + // Snap to the request's remaining amount if within 1%; skip if unknown or paid. + const payment = await this.incomingPaymentService + .getPaymentDetailsByUrl(params.receiver) + .catch(() => undefined) + + const amount = + payment && payment.value > 0 + ? BigInt(Math.round(payment.value * 10 ** assetDetails.assetScale)) + : undefined - // adjust the amount in case that after converting it to the receiver currency it is off by a small margin - if (amount && 1 - Number(amount) / Number(convertedValue) < 0.01) { + if ( + amount && + Math.abs(1 - Number(amount) / Number(convertedValue)) < 0.01 + ) { convertedValue = amount } } @@ -113,6 +131,10 @@ export class QuoteService implements IQuoteService { value = convertedValue } + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new BadRequest('Amount is too large') + } + let paymentIntentType: | { paymentIntentId: string } | { paymentIntentUrl: string } @@ -121,7 +143,7 @@ export class QuoteService implements IQuoteService { paymentIntentType = { paymentIntentUrl: params.receiver } } else { const { id } = await this.rhyzaAdminClient.createPaymentIntent({ - walletAddress: params.receiver, + walletAddress: receiverUrl, incomingAmount: params.isReceive ? Number(value) : undefined, expiresAt: params.isReceive ? undefined @@ -145,12 +167,22 @@ export class QuoteService implements IQuoteService { ...paymentIntentType }) - if (!rhyzaQuote.debitAmount || !rhyzaQuote.receiveAmount) { + if (rhyzaQuote.state !== 'COMPLETED') { throw new BadRequest('Could not calculate quote') } + if (!rhyzaQuote.debitAmount || !rhyzaQuote.receiveAmount) { + throw new InternalServerError() + } + + // Conversion and the 1% snap can raise the debit above the requested amount. + if (Number(balance) < Number(rhyzaQuote.debitAmount)) { + throw new BadRequest('Not enough funds in account') + } + const quote: QuoteWithFees = { id: rhyzaQuote.id, + paymentIntentId: rhyzaQuote.paymentIntentId, debitAmount: { assetCode, assetScale, @@ -159,38 +191,40 @@ export class QuoteService implements IQuoteService { receiveAmount: { ...assetDetails, value: rhyzaQuote.receiveAmount - }, - paymentIntentId: rhyzaQuote.paymentIntentId + } } - return this.addConversionInfo( - quote, - params.isReceive ? originalValue : undefined - ) + return this.addConversionInfo(quote, rhyzaQuote) } private async addConversionInfo( quote: QuoteWithFees, - originalValue?: bigint + rhyzaQuote: RhyzaQuote ): Promise { if (quote.receiveAmount.assetCode === quote.debitAmount.assetCode) { return quote } - const value = await this.convert({ - from: quote.receiveAmount.assetCode, - to: quote.debitAmount.assetCode, - amount: BigInt(quote.receiveAmount.value) - }) - const feeInSenderCurrency = BigInt(quote.debitAmount.value) - value + // exchangeRate is Rhyza's pre-fee rate in base units; round away float error. + const receiveInSenderCurrency = rhyzaQuote.exchangeRate + ? BigInt( + Math.round( + Number(quote.receiveAmount.value) / rhyzaQuote.exchangeRate + ) + ) + : await this.convert({ + from: quote.receiveAmount.assetCode, + to: quote.debitAmount.assetCode, + amount: BigInt(quote.receiveAmount.value) + }) + let fee = BigInt(quote.debitAmount.value) - receiveInSenderCurrency + // Rounding can land just below zero. + if (fee < 0n) fee = 0n return { ...quote, fee: { - value: (originalValue - ? BigInt(quote.debitAmount.value) - originalValue - : feeInSenderCurrency - ).toString(), + value: fee.toString(), assetScale: quote.debitAmount.assetScale, assetCode: quote.debitAmount.assetCode } diff --git a/packages/wallet/backend/src/rhyza/admin-client.ts b/packages/wallet/backend/src/rhyza/admin-client.ts index 0bd6d5799..b6b327191 100644 --- a/packages/wallet/backend/src/rhyza/admin-client.ts +++ b/packages/wallet/backend/src/rhyza/admin-client.ts @@ -192,19 +192,7 @@ export class RhyzaAdminClient { async createQuote( args: QuoteCreateFromPaymentIntentId | QuoteCreateFromPaymentIntentUrl ): Promise { - const response = await this.http.post('/quotes', args) - return { - createdAt: response.createdAt, - id: response.id, - paymentIntentId: response.paymentIntentId, - senderWalletAddress: response.senderWalletAddress, - senderWalletAddressId: response.senderWalletAddressId, - state: response.state, - debitAmount: response.debitAmount, - exchangeRate: response.exchangeRate, - expiresAt: response.expiresAt, - receiveAmount: response.receiveAmount - } + return this.http.post('/quotes', args) } async createPayment( @@ -218,9 +206,8 @@ export class RhyzaAdminClient { } async getQuoteById(paymentIntentId: string, quoteId: string): Promise { - const response = await this.http.get( - `/payment-intents/${paymentIntentId}/quotes/${quoteId}` + return this.http.get( + `/payment-intents/${encodeURIComponent(paymentIntentId)}/quotes/${encodeURIComponent(quoteId)}` ) - return response } } diff --git a/packages/wallet/backend/src/utils/helpers.ts b/packages/wallet/backend/src/utils/helpers.ts index 50668ae6c..dbf77efdd 100644 --- a/packages/wallet/backend/src/utils/helpers.ts +++ b/packages/wallet/backend/src/utils/helpers.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto' import NodeCache from 'node-cache' import { env } from '@/config/env' import { DEFAULT_ASSET_SCALE } from './consts' +import { BadRequest } from '@shared/backend' export const transformAmount = ( amount: string | bigint, @@ -25,6 +26,18 @@ export const transformBalance = (value: number, scale: number): bigint => { return BigInt(Math.floor(value * 10 ** scale)) } +// Rhyza takes amounts as JS numbers, so they must be safe integers. +export const toBaseUnits = (amount: number, scale: number): bigint => { + const value = Math.round(amount * 10 ** scale) + if (!Number.isSafeInteger(value)) { + throw new BadRequest('Amount is too large') + } + if (value <= 0) { + throw new BadRequest('Amount is too small') + } + return BigInt(value) +} + export const applyScale = ( value: number, scale: number = DEFAULT_ASSET_SCALE diff --git a/packages/wallet/backend/tests/incomingPayment/service.test.ts b/packages/wallet/backend/tests/incomingPayment/service.test.ts index 16177c225..93f97fd2b 100644 --- a/packages/wallet/backend/tests/incomingPayment/service.test.ts +++ b/packages/wallet/backend/tests/incomingPayment/service.test.ts @@ -12,7 +12,7 @@ import { mockedListAssets, mockExternalPayment } from '@/tests/mocks' import { WalletAddress } from '@/walletAddress/model' import { env } from '@/config/env' import axios from 'axios' -import { NotFound } from '@shared/backend' +import { BadRequest, NotFound } from '@shared/backend' import { Receiver } from '@/rafiki/backend/generated/graphql' describe('Incoming Payment Service', () => { @@ -137,7 +137,7 @@ describe('Incoming Payment Service', () => { expect(result).toEqual(openPaymentsUrl) expect(createPaymentIntent).toHaveBeenCalledWith({ walletAddress: walletAddress.url, - incomingAmount: 100, + incomingAmount: 100 * 10 ** mockedListAssets[0].scale, expiresAt: undefined, metadata: undefined }) @@ -163,7 +163,7 @@ describe('Incoming Payment Service', () => { expect(createPaymentIntent).toHaveBeenCalledWith({ walletAddress: walletAddress.url, - incomingAmount: 100, + incomingAmount: 100 * 10 ** mockedListAssets[0].scale, expiresAt: '2026-01-01T02:00:00.000Z', metadata: { description: 'Free Money!' } }) @@ -172,6 +172,15 @@ describe('Incoming Payment Service', () => { } }) + it('should reject amounts too large to send to Rhyza exactly', async () => { + const { walletAddress } = await prepareIncomePaymentDependencies() + + await expect( + incopmPaymentService.create(userId, walletAddress.id, 1e14) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPaymentIntent).not.toHaveBeenCalled() + }) + it('should return NotFound if the wallet address does not exist', async () => { await expect( incopmPaymentService.create(userId, faker.string.uuid(), 100) diff --git a/packages/wallet/backend/tests/outgoingPayment/controller.test.ts b/packages/wallet/backend/tests/outgoingPayment/controller.test.ts index 684f510c1..a5bf38cf2 100644 --- a/packages/wallet/backend/tests/outgoingPayment/controller.test.ts +++ b/packages/wallet/backend/tests/outgoingPayment/controller.test.ts @@ -48,6 +48,7 @@ describe('OutgoingPayment controller', () => { beforeEach(async (): Promise => { req = createRequest() res = createResponse() + Reflect.set(req, 'session', { user: { id: 'user-1' } }) }) it('should call createByQuoteId in outgoingPaymentService', async () => { @@ -61,11 +62,12 @@ describe('OutgoingPayment controller', () => { expect(createByQuoteIdSpy).toHaveBeenCalled() expect(createByQuoteIdSpy).toHaveBeenCalledTimes(1) - expect(createByQuoteIdSpy).toHaveBeenCalledWith( - req.body.quoteId, - req.body.paymentIntentId, - req.body.description - ) + expect(createByQuoteIdSpy).toHaveBeenCalledWith({ + userId: 'user-1', + quoteId: req.body.quoteId, + paymentIntentId: req.body.paymentIntentId, + description: req.body.description + }) }) it('should return status 400 if the request body is not valid', async () => { diff --git a/packages/wallet/backend/tests/outgoingPayment/service.test.ts b/packages/wallet/backend/tests/outgoingPayment/service.test.ts index 4305d45a4..013849ebf 100644 --- a/packages/wallet/backend/tests/outgoingPayment/service.test.ts +++ b/packages/wallet/backend/tests/outgoingPayment/service.test.ts @@ -1,72 +1,173 @@ -import { RhyzaAdminClient } from '@/rhyza/admin-client' -import { Quote } from '@/rhyza/types' +import { createApp, TestApp } from '@/tests/app' +import { Knex } from 'knex' +import { AuthService } from '@/auth/service' import { OutgoingPaymentService } from '@/outgoingPayment/service' +import { Cradle, createContainer } from '@/createContainer' +import { env } from '@/config/env' +import { mockedListAssets } from '@/tests/mocks' +import { faker } from '@faker-js/faker' +import { Account } from '@/account/model' +import { WalletAddress } from '@/walletAddress/model' +import { loginUser } from '@/tests/utils' +import { truncateTables } from '@shared/backend/tests' +import { AwilixContainer } from 'awilix' +import { BadRequest, NotFound } from '@shared/backend' -describe('OutgoingPaymentService', () => { - const quoteId = 'quote-1' - const paymentIntentId = 'payment-intent-1' - const description = 'Free Money!' - const quote: Quote = { - createdAt: '2026-11-02T10:00:00.000Z', - id: quoteId, - paymentIntentId, - senderWalletAddress: 'https://ilp.test/alice', - senderWalletAddressId: 'wallet-address-1', - state: 'COMPLETED', - debitAmount: '150', - receiveAmount: '150' - } +describe('OutgoingPayment Service', () => { + let bindings: AwilixContainer + let appContainer: TestApp + let knex: Knex + let authService: AuthService + let outgoingPaymentService: OutgoingPaymentService + let userId: string - let getQuoteById: jest.Mock - let createPayment: jest.Mock - let service: OutgoingPaymentService + const getQuoteById = jest.fn() + const createPayment = jest.fn() + + const prepareWalletAddress = async (ownerId: string) => { + const account = await Account.query().insert({ + name: faker.string.alpha(10), + userId: ownerId, + assetCode: mockedListAssets[0].code, + assetScale: mockedListAssets[0].scale, + gateHubWalletId: 'mocked' + }) - beforeEach(() => { - getQuoteById = jest.fn().mockResolvedValue(quote) - createPayment = jest.fn().mockResolvedValue({ - id: 'payment-1', - paymentIntentId + return WalletAddress.query().insert({ + url: `https://ilp.test/${faker.string.alpha(10)}`, + publicName: faker.string.alpha(10), + accountId: account.id, + id: faker.string.uuid() }) + } + + const completedQuote = (walletAddress: WalletAddress) => ({ + id: 'quote-1', + paymentIntentId: 'pi-1', + senderWalletAddress: walletAddress.url, + senderWalletAddressId: walletAddress.id, + state: 'COMPLETED', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + debitAmount: '1000', + receiveAmount: '1000' + }) - const rhyzaAdminClient = { + beforeAll(async (): Promise => { + bindings = await createContainer(env) + appContainer = await createApp(bindings) + knex = appContainer.knex + authService = await bindings.resolve('authService') + outgoingPaymentService = await bindings.resolve('outgoingPaymentService') + Reflect.set(outgoingPaymentService, 'rhyzaAdminClient', { getQuoteById, createPayment - } as unknown as RhyzaAdminClient + }) + }) + + beforeEach(async (): Promise => { + const { user } = await loginUser({ + authService, + extraUserArgs: { isEmailVerified: true, gateHubUserId: 'mocked' } + }) + userId = user.id + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + jest.clearAllMocks() + }) - service = new OutgoingPaymentService(rhyzaAdminClient) + afterAll(async (): Promise => { + await appContainer.stop() + await knex.destroy() }) - it('gets the quote and creates a payment using the quote sender address', async () => { - await service.createByQuoteId(quoteId, paymentIntentId, description) + it('creates a Rhyza payment from the quote', async () => { + const walletAddress = await prepareWalletAddress(userId) + getQuoteById.mockResolvedValue(completedQuote(walletAddress)) + + await outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1', + description: 'rent' + }) - expect(getQuoteById).toHaveBeenCalledWith(paymentIntentId, quoteId) + expect(getQuoteById).toHaveBeenCalledWith('pi-1', 'quote-1') expect(createPayment).toHaveBeenCalledWith({ - walletAddress: quote.senderWalletAddress, - quoteId, - metadata: { description }, - paymentIntentId + walletAddress: walletAddress.url, + paymentIntentId: 'pi-1', + quoteId: 'quote-1', + metadata: { description: 'rent' } }) }) - it('creates a payment without metadata when no description is provided', async () => { - await service.createByQuoteId(quoteId, paymentIntentId) + it('rejects a quote that is not completed', async () => { + const walletAddress = await prepareWalletAddress(userId) + getQuoteById.mockResolvedValue({ + ...completedQuote(walletAddress), + state: 'FAILED' + }) - expect(createPayment).toHaveBeenCalledWith({ - walletAddress: quote.senderWalletAddress, - quoteId, - metadata: undefined, - paymentIntentId + await expect( + outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1' + }) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPayment).not.toHaveBeenCalled() + }) + + it('rejects an expired quote', async () => { + const walletAddress = await prepareWalletAddress(userId) + getQuoteById.mockResolvedValue({ + ...completedQuote(walletAddress), + expiresAt: new Date(Date.now() - 1000).toISOString() }) + + await expect( + outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1' + }) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPayment).not.toHaveBeenCalled() }) - it('does not create a payment if the quote lookup fails', async () => { - const lookupError = new Error('Quote not found') - getQuoteById.mockRejectedValueOnce(lookupError) + it('rejects a quote from a deactivated wallet address', async () => { + const walletAddress = await prepareWalletAddress(userId) + await WalletAddress.query().patchAndFetchById(walletAddress.id, { + active: false + }) + getQuoteById.mockResolvedValue(completedQuote(walletAddress)) await expect( - service.createByQuoteId(quoteId, paymentIntentId, description) - ).rejects.toBe(lookupError) + outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1' + }) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPayment).not.toHaveBeenCalled() + }) + + it("rejects a quote from another user's wallet address", async () => { + const { user: otherUser } = await loginUser({ + authService, + extraUserArgs: { isEmailVerified: true, gateHubUserId: 'mocked' } + }) + const walletAddress = await prepareWalletAddress(otherUser.id) + getQuoteById.mockResolvedValue(completedQuote(walletAddress)) + await expect( + outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1' + }) + ).rejects.toBeInstanceOf(NotFound) expect(createPayment).not.toHaveBeenCalled() }) }) diff --git a/packages/wallet/backend/tests/quote/service.test.ts b/packages/wallet/backend/tests/quote/service.test.ts index 7e2a3e561..8f58049c8 100644 --- a/packages/wallet/backend/tests/quote/service.test.ts +++ b/packages/wallet/backend/tests/quote/service.test.ts @@ -9,8 +9,9 @@ import { AccountService } from '@/account/service' import { faker } from '@faker-js/faker' import { Account } from '@/account/model' import { WalletAddress } from '@/walletAddress/model' -import { loginUser, uuid } from '@/tests/utils' +import { loginUser } from '@/tests/utils' import { truncateTables } from '@shared/backend/tests' +import { BadRequest, NotFound } from '@shared/backend' import { AwilixContainer } from 'awilix' describe('Quote Service', () => { @@ -44,7 +45,34 @@ describe('Quote Service', () => { } } + const addLocalReceiver = async ( + url: string, + { assetCode = 'BRG', active = true } = {} + ) => { + const account = await Account.query().insert({ + name: faker.string.alpha(10), + userId: userInfo.id, + assetCode, + assetScale: 3, + gateHubWalletId: 'mocked' + }) + return WalletAddress.query().insert({ + url, + publicName: faker.string.alpha(10), + accountId: account.id, + id: faker.string.uuid(), + active + }) + } + + const remoteIncomingPaymentUrl = + 'https://remote.test/incoming-payments/5b1a3c7e-2f4d-4e6a-8b9c-0d1e2f3a4b5c' + const getPaymentDetailsByUrl = jest.fn() + const createPaymentIntent = jest + .fn() + .mockResolvedValue({ id: 'pi-created', openPaymentsUrl: '' }) const rhyzaCreateQuote = jest.fn().mockResolvedValue({ + state: 'COMPLETED', id: 'quote-1', paymentIntentId: 'pi-1', senderWalletAddress: 'https://ilp.test/alice', @@ -88,30 +116,20 @@ describe('Quote Service', () => { incomingPaymentService: { getExternalPayment: () => ({ receivedAmount: { assetCode: 'BRG', assetScale: 3, value: '0' } - }) - }, - walletAddressService: { - getExternalWalletAddress: () => ({ - assetCode: 'BRG', - assetScale: 3 }), - create: () => ({ - id: uuid(), - url: faker.internet.url() - }) + getPaymentDetailsByUrl }, ratesService: { getRates: () => ({ rates: { - BRG: 100 + BRG: 100, + EUR: 1 } }) }, rhyzaAdminClient: { createQuote: rhyzaCreateQuote, - createPaymentIntent: jest - .fn() - .mockResolvedValue({ id: 'pi-created', openPaymentsUrl: '' }) + createPaymentIntent } } @@ -146,7 +164,7 @@ describe('Quote Service', () => { afterEach(async (): Promise => { await truncateTables(knex) - rhyzaCreateQuote.mockClear() + jest.clearAllMocks() }) describe('Create Quote', () => { @@ -170,6 +188,8 @@ describe('Quote Service', () => { paymentIntentUrl: receiver }) expect(result).toMatchObject({ + id: 'quote-1', + paymentIntentId: 'pi-1', debitAmount: { assetCode: 'BRG', assetScale: 3, @@ -183,9 +203,10 @@ describe('Quote Service', () => { }) }) - it('creates a payment intent and quotes it by ID for wallet address receivers', async () => { + it('creates a payment intent and quotes it by ID for local wallet address receivers', async () => { const { walletAddress } = await prepareQuoteDependencies() const receiver = 'https://ilp.test/bob' + await addLocalReceiver(receiver) await quoteService.create({ userId: userInfo.id, @@ -202,5 +223,287 @@ describe('Quote Service', () => { paymentIntentId: 'pi-created' }) }) + + it('rejects wallet addresses at other providers', async () => { + const { walletAddress } = await prepareQuoteDependencies() + + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: 'https://remote.test/carol', + isReceive: true + }) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPaymentIntent).not.toHaveBeenCalled() + expect(rhyzaCreateQuote).not.toHaveBeenCalled() + }) + + it('finds local receivers entered as ilp.dev addresses', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const stored = await addLocalReceiver(`${env.OPEN_PAYMENTS_HOST}/gina`) + + await quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: 'https://ilp.dev/gina', + isReceive: false + }) + + expect(createPaymentIntent).toHaveBeenCalledWith( + expect.objectContaining({ walletAddress: stored.url }) + ) + }) + + it('rejects amounts too large to quote', async () => { + const { walletAddress } = await prepareQuoteDependencies() + + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1e19, + walletAddressId: walletAddress.id, + receiver: remoteIncomingPaymentUrl, + isReceive: false + }) + ).rejects.toBeInstanceOf(BadRequest) + }) + + it('rejects inactive local receivers', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/erin' + await addLocalReceiver(receiver, { active: false }) + + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver, + isReceive: true + }) + ).rejects.toBeInstanceOf(BadRequest) + expect(createPaymentIntent).not.toHaveBeenCalled() + }) + + it.each(['FAILED', 'PENDING'])( + 'throws BadRequest when the Rhyza quote is %s', + async (state) => { + const { walletAddress } = await prepareQuoteDependencies() + rhyzaCreateQuote.mockResolvedValueOnce({ + state, + id: 'quote-1', + paymentIntentId: 'pi-1' + }) + + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: remoteIncomingPaymentUrl, + isReceive: false + }) + ).rejects.toBeInstanceOf(BadRequest) + } + ) + + it('reports a zero fee when the converted receive amount exceeds the debit', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/frank' + await addLocalReceiver(receiver, { assetCode: 'EUR' }) + rhyzaCreateQuote.mockResolvedValueOnce({ + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + debitAmount: '100', + receiveAmount: '110', + exchangeRate: 1 + }) + + const result = await quoteService.create({ + userId: userInfo.id, + amount: 0.1, + walletAddressId: walletAddress.id, + receiver, + isReceive: false + }) + + expect(result.fee?.value).toBe('0') + }) + + it('rounds the converted receive amount instead of truncating float error', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/hank' + await addLocalReceiver(receiver, { assetCode: 'EUR' }) + rhyzaCreateQuote.mockResolvedValueOnce({ + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + debitAmount: '110', + receiveAmount: '110', + exchangeRate: 1.1 + }) + + const result = await quoteService.create({ + userId: userInfo.id, + amount: 0.11, + walletAddressId: walletAddress.id, + receiver, + isReceive: false + }) + + // 110 / 1.1 = 99.999…, which must count as 100 (fee 10), not 99 (fee 11). + expect(result.fee?.value).toBe('10') + }) + + it.each([ + { remaining: 0.995, expected: '995' }, + { remaining: 500, expected: '1000' }, + { remaining: -2, expected: '1000' }, + { remaining: 0.0001, expected: '1000' } + ])( + 'adjusts to the payment request amount only within 1% (remaining $remaining)', + async ({ remaining, expected }) => { + const { walletAddress } = await prepareQuoteDependencies() + Reflect.set(quoteService, 'incomingPaymentService', { + getExternalPayment: () => ({ + receivedAmount: { assetCode: 'EUR', assetScale: 3, value: '0' } + }), + getPaymentDetailsByUrl: jest + .fn() + .mockResolvedValue({ value: remaining, assetCode: 'EUR' }) + }) + + try { + await quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: remoteIncomingPaymentUrl, + isReceive: true + }) + + expect(rhyzaCreateQuote).toHaveBeenCalledWith( + expect.objectContaining({ receiveAmount: expected }) + ) + } finally { + Reflect.set(quoteService, 'incomingPaymentService', { + getExternalPayment: () => ({ + receivedAmount: { assetCode: 'BRG', assetScale: 3, value: '0' } + }), + getPaymentDetailsByUrl + }) + } + } + ) + + it('computes the fee from the Rhyza exchange rate for fixed-receive quotes too', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/ivan' + await addLocalReceiver(receiver, { assetCode: 'EUR' }) + rhyzaCreateQuote.mockResolvedValueOnce({ + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + debitAmount: '1010', + receiveAmount: '2000', + exchangeRate: 2 + }) + + const result = await quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver, + isReceive: true + }) + + expect(result.fee?.value).toBe('10') + }) + + it('rejects quotes whose debit exceeds the balance', async () => { + const { walletAddress } = await prepareQuoteDependencies() + rhyzaCreateQuote.mockResolvedValueOnce({ + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + debitAmount: '100001', + receiveAmount: '1000' + }) + + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: remoteIncomingPaymentUrl, + isReceive: true + }) + ).rejects.toThrow('Not enough funds in account') + }) + + it('computes the cross-currency fee from the Rhyza exchange rate', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/dave' + await addLocalReceiver(receiver, { assetCode: 'EUR' }) + rhyzaCreateQuote.mockResolvedValueOnce({ + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + debitAmount: '1000', + receiveAmount: '1980', + exchangeRate: 2 + }) + + const result = await quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver, + isReceive: false + }) + + expect(result.fee).toEqual({ + value: '10', + assetCode: 'BRG', + assetScale: 3 + }) + }) + + it.each([new NotFound(), new Error('Rhyza is down')])( + 'skips the optional amount adjustment when the lookup fails (%s)', + async (error) => { + const { walletAddress } = await prepareQuoteDependencies() + const receiveExternalPayment = jest.fn().mockReturnValue({ + receivedAmount: { assetCode: 'EUR', assetScale: 3, value: '0' } + }) + Reflect.set(quoteService, 'incomingPaymentService', { + getExternalPayment: receiveExternalPayment, + getPaymentDetailsByUrl: jest.fn().mockRejectedValue(error) + }) + + try { + await expect( + quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver: remoteIncomingPaymentUrl, + isReceive: true + }) + ).resolves.toBeDefined() + } finally { + Reflect.set(quoteService, 'incomingPaymentService', { + getExternalPayment: () => ({ + receivedAmount: { assetCode: 'BRG', assetScale: 3, value: '0' } + }), + getPaymentDetailsByUrl + }) + } + } + ) }) }) diff --git a/packages/wallet/backend/tests/utils/helpers.test.ts b/packages/wallet/backend/tests/utils/helpers.test.ts index b1e80d6c4..5adba52bc 100644 --- a/packages/wallet/backend/tests/utils/helpers.test.ts +++ b/packages/wallet/backend/tests/utils/helpers.test.ts @@ -1,4 +1,5 @@ -import { applyScale } from '@/utils/helpers' +import { applyScale, toBaseUnits } from '@/utils/helpers' +import { BadRequest } from '@shared/backend' describe('Utils Helper Functions', (): void => { describe('applyScale', (): void => { @@ -19,4 +20,21 @@ describe('Utils Helper Functions', (): void => { expect(applyScale(1000)).toBe(10) }) }) + + describe('toBaseUnits', (): void => { + it('converts to base units, absorbing float error', (): void => { + expect(toBaseUnits(10, 2)).toBe(1000n) + expect(toBaseUnits(0.29, 2)).toBe(29n) + expect(toBaseUnits(1.005, 3)).toBe(1005n) + }) + + it('rejects amounts that round to zero', (): void => { + expect(() => toBaseUnits(0.001, 2)).toThrow(BadRequest) + }) + + it('rejects amounts above the safe integer range', (): void => { + expect(() => toBaseUnits(1e14, 3)).toThrow(BadRequest) + expect(() => toBaseUnits(1e19, 2)).toThrow(BadRequest) + }) + }) }) diff --git a/packages/wallet/frontend/src/lib/api/transfers.ts b/packages/wallet/frontend/src/lib/api/transfers.ts index e26f8b4b7..6d08ded02 100644 --- a/packages/wallet/frontend/src/lib/api/transfers.ts +++ b/packages/wallet/frontend/src/lib/api/transfers.ts @@ -26,7 +26,7 @@ export const sendSchema = z.object({ export const acceptQuoteSchema = z.object({ quoteId: z.string().uuid(), - paymentIntentId: z.string().uuid(), + paymentIntentId: z.string().min(1), description: z.string().optional() }) diff --git a/packages/wallet/frontend/src/pages/send.tsx b/packages/wallet/frontend/src/pages/send.tsx index e73e0c12e..3b3eb7dba 100644 --- a/packages/wallet/frontend/src/pages/send.tsx +++ b/packages/wallet/frontend/src/pages/send.tsx @@ -349,8 +349,7 @@ const SendPage: NextPageWithLayout = ({ accounts, user }) => { const response = await transfersService.send(data, sepaDetails.nonce) if (response.success) { if (response.result) { - const quoteId = response.result.id - const paymentIntentId = response.result.paymentIntentId + const { id: quoteId, paymentIntentId } = response.result openDialog(