From 94b6460f4797011587bfbf520e2929705fcbaedc Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Mon, 2 Nov 2026 14:09:40 +0200 Subject: [PATCH] feat: replace get quote request --- .../backend/src/incomingPayment/service.ts | 12 +- .../backend/src/outgoingPayment/controller.ts | 10 +- .../backend/src/outgoingPayment/service.ts | 62 +++-- .../backend/src/outgoingPayment/validation.ts | 4 +- packages/wallet/backend/src/quote/service.ts | 90 +++++-- .../wallet/backend/src/rhyza/admin-client.ts | 36 ++- .../tests/incomingPayment/service.test.ts | 15 +- packages/wallet/backend/tests/mocks.ts | 3 +- .../tests/outgoingPayment/controller.test.ts | 8 +- .../tests/outgoingPayment/service.test.ts | 173 ++++++++++++++ .../backend/tests/quote/service.test.ts | 226 ++++++++++++++++-- .../backend/tests/rhyza/admin-client.test.ts | 51 ++++ .../wallet/frontend/src/lib/api/transfers.ts | 6 +- packages/wallet/frontend/src/pages/send.tsx | 18 +- packages/wallet/shared/src/types/qoute.ts | 1 + 15 files changed, 628 insertions(+), 87 deletions(-) create mode 100644 packages/wallet/backend/tests/outgoingPayment/service.test.ts diff --git a/packages/wallet/backend/src/incomingPayment/service.ts b/packages/wallet/backend/src/incomingPayment/service.ts index 55766c176..02ef1311d 100644 --- a/packages/wallet/backend/src/incomingPayment/service.ts +++ b/packages/wallet/backend/src/incomingPayment/service.ts @@ -6,7 +6,7 @@ import { Amount, Asset } from '@/rafiki/backend/generated/graphql' import { add, Duration } from 'date-fns' import axios from 'axios' import { Env } from '@/config/env' -import { NotFound } from '@shared/backend' +import { BadRequest, NotFound } from '@shared/backend' import { PaymentDetailsResponse } from '@wallet/shared' import { RhyzaAdminClient } from '@/rhyza/admin-client' @@ -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 ) @@ -81,9 +81,15 @@ export class IncomingPaymentService implements IIncomingPaymentService { ) } + const incomingAmount = Number((amount * 10 ** assetScale).toFixed()) + if (!Number.isSafeInteger(incomingAmount)) { + throw new BadRequest('Amount is too large') + } + const response = await this.rhyzaAdminClient.createPaymentIntent({ walletAddress: existingWalletAddress.url, - incomingAmount: amount, + // Rhyza expects base units. + incomingAmount, 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 cff53be64..7bc7bc4c3 100644 --- a/packages/wallet/backend/src/outgoingPayment/controller.ts +++ b/packages/wallet/backend/src/outgoingPayment/controller.ts @@ -13,11 +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 } + body: { quoteId, paymentIntentId, description } } = await validate(outgoingPaymentSchema, req) - await this.outgoingPaymentService.createByQuoteId(quoteId) + 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 432bf342b..361ea5270 100644 --- a/packages/wallet/backend/src/outgoingPayment/service.ts +++ b/packages/wallet/backend/src/outgoingPayment/service.ts @@ -1,28 +1,60 @@ -import { IncomingPaymentService } from '@/incomingPayment/service' -import { RafikiClient } from '@/rafiki/rafiki-client' +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) => Promise + createByQuoteId: (params: CreateByQuoteIdParams) => Promise } export class OutgoingPaymentService implements IOutgoingPaymentService { constructor( - private rafikiClient: RafikiClient, - private incomingPaymentService: IncomingPaymentService + private rhyzaAdminClient: RhyzaAdminClient, + private accountService: AccountService ) {} - async createByQuoteId(quoteId: string): Promise { - const quote = await this.rafikiClient.getQuote(quoteId) - const walletAddressId = quote.walletAddressId + async createByQuoteId(params: CreateByQuoteIdParams): Promise { + const quote = await this.rhyzaAdminClient.getQuote( + params.paymentIntentId, + params.quoteId + ) - const incomingPayment = - await this.incomingPaymentService.getPaymentDetailsByUrl(quote.receiver) - const description = incomingPayment?.description + 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') + } + + // Match by URL: older 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 + ) - await this.rafikiClient.createOutgoingPayment({ - walletAddressId, - quoteId, - metadata: { description } + await this.rhyzaAdminClient.createPayment({ + 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 4ba122265..91792351b 100644 --- a/packages/wallet/backend/src/outgoingPayment/validation.ts +++ b/packages/wallet/backend/src/outgoingPayment/validation.ts @@ -2,6 +2,8 @@ import { z } from 'zod' export const outgoingPaymentSchema = z.object({ body: z.object({ - quoteId: z.string().uuid() + quoteId: 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 e1f7a7de3..6aa3da064 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, + transformBalance +} 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,11 @@ export class QuoteService implements IQuoteService { ) const balance = account.balance - const originalValue = BigInt((params.amount * 10 ** assetScale).toFixed()) + const scaledAmount = params.amount * 10 ** assetScale + if (!Number.isSafeInteger(Math.round(scaledAmount))) { + throw new BadRequest('Amount is too large') + } + const originalValue = BigInt(scaledAmount.toFixed()) if (Number(balance) < originalValue) { throw new BadRequest('Not enough funds in account') @@ -68,6 +77,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 +88,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,10 +114,10 @@ export class QuoteService implements IQuoteService { amount: value }) if (isIncomingPayment) { - const payment = - await this.incomingPaymentService.getPaymentDetailsByUrl( - params.receiver - ) + // Optional adjustment; only intents held by this Rhyza instance can be looked up. + const payment = await this.incomingPaymentService + .getPaymentDetailsByUrl(params.receiver) + .catch(() => undefined) const amount = payment?.value ? transformBalance(payment?.value, assetDetails.assetScale) @@ -113,6 +132,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 +144,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 +168,17 @@ 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() + } + const quote: QuoteWithFees = { id: rhyzaQuote.id, + paymentIntentId: rhyzaQuote.paymentIntentId, debitAmount: { assetCode, assetScale, @@ -164,32 +192,46 @@ export class QuoteService implements IQuoteService { return this.addConversionInfo( quote, + rhyzaQuote, params.isReceive ? originalValue : undefined ) } private async addConversionInfo( quote: QuoteWithFees, + rhyzaQuote: RhyzaQuote, originalValue?: bigint ): 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 + const debitValue = BigInt(quote.debitAmount.value) + let fee: bigint + if (originalValue) { + fee = debitValue - originalValue + } else { + // Rhyza's exchangeRate is in base units: receiveAmount = debitAmount * exchangeRate. + const receiveInSenderCurrency = rhyzaQuote.exchangeRate + ? BigInt( + Math.floor( + Number(quote.receiveAmount.value) / rhyzaQuote.exchangeRate + ) + ) + : await this.convert({ + from: quote.receiveAmount.assetCode, + to: quote.debitAmount.assetCode, + amount: BigInt(quote.receiveAmount.value) + }) + fee = debitValue - receiveInSenderCurrency + } + // Rate rounding or drift can push this 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 a50f3c505..bd03d144e 100644 --- a/packages/wallet/backend/src/rhyza/admin-client.ts +++ b/packages/wallet/backend/src/rhyza/admin-client.ts @@ -102,6 +102,13 @@ export type QuoteCreateFromPaymentIntentUrl = QuoteCreate & { paymentIntentUrl: string } +export interface CreatePaymentArgs { + walletAddress: string + paymentIntentId: string + quoteId: string + metadata?: PaymentMetadata +} + export class RhyzaAdminClient { constructor(private http: HttpClient) {} @@ -183,18 +190,21 @@ 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 getQuote(paymentIntentId: string, quoteId: string): Promise { + return this.http.get( + `/payment-intents/${encodeURIComponent(paymentIntentId)}/quotes/${encodeURIComponent(quoteId)}` + ) + } + + async createPayment( + args: CreatePaymentArgs + ): Promise<{ id: string; paymentIntentId: string }> { + return this.http.post<{ id: string; paymentIntentId: string }>( + '/payments', + args + ) } } 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/mocks.ts b/packages/wallet/backend/tests/mocks.ts index f69a5dac3..4939f194a 100644 --- a/packages/wallet/backend/tests/mocks.ts +++ b/packages/wallet/backend/tests/mocks.ts @@ -206,7 +206,8 @@ export const mockOutgoingPaymentRequest = ( ): OutgoingPayment => { return { body: { - quoteId: 'ca1d9728-d38f-47e6-a88e-3bfe9e60438e' + quoteId: 'ca1d9728-d38f-47e6-a88e-3bfe9e60438e', + paymentIntentId: 'b2f6c1d4-8e3a-4c7b-9f1e-2d5a6b7c8e9f' }, ...overrides } diff --git a/packages/wallet/backend/tests/outgoingPayment/controller.test.ts b/packages/wallet/backend/tests/outgoingPayment/controller.test.ts index 80e7ba414..343daa8c7 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,7 +62,12 @@ describe('OutgoingPayment controller', () => { expect(createByQuoteIdSpy).toHaveBeenCalled() expect(createByQuoteIdSpy).toHaveBeenCalledTimes(1) - expect(createByQuoteIdSpy).toHaveBeenCalledWith(req.body.quoteId) + expect(createByQuoteIdSpy).toHaveBeenCalledWith({ + userId: 'user-1', + quoteId: req.body.quoteId, + paymentIntentId: req.body.paymentIntentId, + description: undefined + }) }) 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 new file mode 100644 index 000000000..cf8719a6d --- /dev/null +++ b/packages/wallet/backend/tests/outgoingPayment/service.test.ts @@ -0,0 +1,173 @@ +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('OutgoingPayment Service', () => { + let bindings: AwilixContainer + let appContainer: TestApp + let knex: Knex + let authService: AuthService + let outgoingPaymentService: OutgoingPaymentService + let userId: string + + const getQuote = 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' + }) + + 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' + }) + + 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', { + getQuote, + createPayment + }) + }) + + beforeEach(async (): Promise => { + const { user } = await loginUser({ + authService, + extraUserArgs: { isEmailVerified: true, gateHubUserId: 'mocked' } + }) + userId = user.id + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + jest.clearAllMocks() + }) + + afterAll(async (): Promise => { + await appContainer.stop() + await knex.destroy() + }) + + it('creates a Rhyza payment from the quote', async () => { + const walletAddress = await prepareWalletAddress(userId) + getQuote.mockResolvedValue(completedQuote(walletAddress)) + + await outgoingPaymentService.createByQuoteId({ + userId, + quoteId: 'quote-1', + paymentIntentId: 'pi-1', + description: 'rent' + }) + + expect(getQuote).toHaveBeenCalledWith('pi-1', 'quote-1') + expect(createPayment).toHaveBeenCalledWith({ + walletAddress: walletAddress.url, + paymentIntentId: 'pi-1', + quoteId: 'quote-1', + metadata: { description: 'rent' } + }) + }) + + it('rejects a quote that is not completed', async () => { + const walletAddress = await prepareWalletAddress(userId) + getQuote.mockResolvedValue({ + ...completedQuote(walletAddress), + state: 'FAILED' + }) + + 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) + getQuote.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('rejects a quote from a deactivated wallet address', async () => { + const walletAddress = await prepareWalletAddress(userId) + await WalletAddress.query().patchAndFetchById(walletAddress.id, { + active: false + }) + getQuote.mockResolvedValue(completedQuote(walletAddress)) + + await expect( + 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) + getQuote.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..27a5d34e3 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,176 @@ 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('never reports a negative fee from rate rounding', 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: '91', + exchangeRate: 0.9099999999 + }) + + const result = await quoteService.create({ + userId: userInfo.id, + amount: 0.1, + walletAddressId: walletAddress.id, + receiver, + isReceive: false + }) + + expect(result.fee?.value).toBe('0') + }) + + 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/rhyza/admin-client.test.ts b/packages/wallet/backend/tests/rhyza/admin-client.test.ts index 226d25d7d..c2607ce71 100644 --- a/packages/wallet/backend/tests/rhyza/admin-client.test.ts +++ b/packages/wallet/backend/tests/rhyza/admin-client.test.ts @@ -430,4 +430,55 @@ describe('RhyzaAdminClient', () => { expect(request.body).toEqual(args) }) }) + + describe('getQuote', () => { + it('GETs the quote from /payment-intents/:intentId/quotes/:id', async () => { + const quote = { + id: 'quote-1', + paymentIntentId: 'pi-1', + state: 'COMPLETED' + } + adapter.mockImplementationOnce(respond(200, quote)) + + await expect(client.getQuote('pi-1', 'quote-1')).resolves.toEqual(quote) + + const request = lastRequest(adapter) + expect(request.method).toBe('GET') + expect(request.url).toBe( + 'http://admin.test/payment-intents/pi-1/quotes/quote-1' + ) + }) + + it('raises NotFound on 404', async () => { + adapter.mockImplementationOnce(respond(404, { error: 'Quote not found' })) + + await expect(client.getQuote('pi-1', 'quote-1')).rejects.toBeInstanceOf( + NotFound + ) + }) + }) + + describe('createPayment', () => { + it('POSTs the payment details to /payments', async () => { + const args = { + walletAddress: 'https://ilp.test/alice', + paymentIntentId: 'pi-1', + quoteId: 'quote-1', + metadata: { description: 'rent' } + } + adapter.mockImplementationOnce( + respond(201, { id: 'payment-1', paymentIntentId: 'pi-1' }) + ) + + await expect(client.createPayment(args)).resolves.toEqual({ + id: 'payment-1', + paymentIntentId: 'pi-1' + }) + + const request = lastRequest(adapter) + expect(request.method).toBe('POST') + expect(request.url).toBe('http://admin.test/payments') + expect(request.body).toEqual(args) + }) + }) }) diff --git a/packages/wallet/frontend/src/lib/api/transfers.ts b/packages/wallet/frontend/src/lib/api/transfers.ts index 08206fcae..6d08ded02 100644 --- a/packages/wallet/frontend/src/lib/api/transfers.ts +++ b/packages/wallet/frontend/src/lib/api/transfers.ts @@ -25,7 +25,9 @@ export const sendSchema = z.object({ }) export const acceptQuoteSchema = z.object({ - quoteId: z.string().uuid() + quoteId: z.string().uuid(), + paymentIntentId: z.string().min(1), + description: z.string().optional() }) const TIME_UNITS = ['s', 'm', 'h', 'd'] as const @@ -91,7 +93,7 @@ type QuoteResult = SuccessResponse type SendError = ErrorResponse type SendResponse = QuoteResult | SendError -type AcceptQuoteArgs = z.infer +export type AcceptQuoteArgs = z.infer type AcceptQuoteError = ErrorResponse type AcceptQuoteResponse = SuccessResponse | AcceptQuoteError diff --git a/packages/wallet/frontend/src/pages/send.tsx b/packages/wallet/frontend/src/pages/send.tsx index 9c8b23dac..3b3eb7dba 100644 --- a/packages/wallet/frontend/src/pages/send.tsx +++ b/packages/wallet/frontend/src/pages/send.tsx @@ -12,7 +12,11 @@ import { PageHeader } from '@/components/PageHeader' import { TogglePayment } from '@/ui/TogglePayment' import { GetServerSideProps, InferGetServerSidePropsType } from 'next' import { accountService } from '@/lib/api/account' -import { sendSchema, transfersService } from '@/lib/api/transfers' +import { + AcceptQuoteArgs, + sendSchema, + transfersService +} from '@/lib/api/transfers' import { SuccessDialog } from '@/components/dialogs/SuccessDialog' import { formatAmount, @@ -294,8 +298,8 @@ const SendPage: NextPageWithLayout = ({ accounts, user }) => { } } - const handleAcceptQuote = async (id: string) => { - const response = await transfersService.acceptQuote({ quoteId: id }) + const handleAcceptQuote = async (args: AcceptQuoteArgs) => { + const response = await transfersService.acceptQuote(args) if (response.success) { openDialog( = ({ accounts, user }) => { const response = await transfersService.send(data, sepaDetails.nonce) if (response.success) { if (response.result) { - const quoteId = response.result.id + const { id: quoteId, paymentIntentId } = response.result openDialog( = ({ accounts, user }) => { sepaMatch={sepaDetails.description} type="quote" onAccept={() => { - handleAcceptQuote(quoteId) + handleAcceptQuote({ + quoteId, + paymentIntentId, + description: data.description || undefined + }) closeDialog() }} onClose={closeDialog} diff --git a/packages/wallet/shared/src/types/qoute.ts b/packages/wallet/shared/src/types/qoute.ts index bbd59381e..bd7c291e3 100644 --- a/packages/wallet/shared/src/types/qoute.ts +++ b/packages/wallet/shared/src/types/qoute.ts @@ -6,6 +6,7 @@ type AmountProps = AssetOP & { export interface QuoteResponse { id: string + paymentIntentId: string receiveAmount: AmountProps debitAmount: AmountProps fee?: AmountProps