diff --git a/packages/wallet/backend/src/quote/controller.ts b/packages/wallet/backend/src/quote/controller.ts index 3ede0f0a2..e98314b73 100644 --- a/packages/wallet/backend/src/quote/controller.ts +++ b/packages/wallet/backend/src/quote/controller.ts @@ -1,4 +1,3 @@ -import { Amount, Quote } from '@/rafiki/backend/generated/graphql' import { validate } from '@/shared/validate' import type { NextFunction, Request } from 'express' import { QuoteService } from './service' @@ -7,12 +6,10 @@ import { Controller, toSuccessResponse } from '@shared/backend' import { QuoteResponse } from '@wallet/shared' interface IQuoteController { - create: Controller + create: Controller } -export type QuoteWithFees = Quote & { - fee?: Amount -} +export type QuoteWithFees = QuoteResponse export class QuoteController implements IQuoteController { constructor(private quoteService: QuoteService) {} diff --git a/packages/wallet/backend/src/quote/service.ts b/packages/wallet/backend/src/quote/service.ts index 5a8e1a0f0..e1f7a7de3 100644 --- a/packages/wallet/backend/src/quote/service.ts +++ b/packages/wallet/backend/src/quote/service.ts @@ -1,17 +1,16 @@ import { AccountService } from '@/account/service' import { IncomingPaymentService } from '@/incomingPayment/service' import { WalletAddress } from '@/walletAddress/model' -import { Quote } from '@/rafiki/backend/generated/graphql' -import { RafikiClient } from '@/rafiki/rafiki-client' import { incomingPaymentRegexp, transformBalance } from '@/utils/helpers' import { WalletAddressService } from '@/walletAddress/service' import { QuoteWithFees } from './controller' import { RatesService } from '@/rates/service' import { BadRequest } from '@shared/backend' -import { AssetService } from '@/asset/service' +import { RhyzaAdminClient } from '@/rhyza/admin-client' +import { randomUUID } from 'crypto' interface IQuoteService { - create: (params: CreateQuoteParams) => Promise + create: (params: CreateQuoteParams) => Promise } type CreateQuoteParams = { @@ -33,11 +32,10 @@ type ConvertParams = { export class QuoteService implements IQuoteService { constructor( private accountService: AccountService, - private rafikiClient: RafikiClient, private incomingPaymentService: IncomingPaymentService, private ratesService: RatesService, private walletAddressService: WalletAddressService, - private assetService: AssetService + private rhyzaAdminClient: RhyzaAdminClient ) {} async create(params: CreateQuoteParams): Promise { @@ -49,20 +47,18 @@ export class QuoteService implements IQuoteService { throw new BadRequest('Invalid wallet address') } - const { assetCode } = await this.accountService.findAccountById( + const { assetCode, assetScale } = await this.accountService.findAccountById( existingWalletAddress.accountId, params.userId ) - let asset = await this.assetService.getAssetByCode(assetCode) - const account = await this.accountService.getAccountById( params.userId, existingWalletAddress.accountId ) const balance = account.balance - const originalValue = BigInt((params.amount * 10 ** asset.scale).toFixed()) + const originalValue = BigInt((params.amount * 10 ** assetScale).toFixed()) if (Number(balance) < originalValue) { throw new BadRequest('Not enough funds in account') @@ -70,28 +66,29 @@ export class QuoteService implements IQuoteService { let value = originalValue - let paymentUrl = params.receiver - const isIncomingPayment = incomingPaymentRegexp.test(params.receiver) let assetDetails if (isIncomingPayment) { - const payment = - await this.incomingPaymentService.getExternalPayment(paymentUrl) + const payment = await this.incomingPaymentService.getExternalPayment( + params.receiver + ) assetDetails = { assetCode: payment.receivedAmount.assetCode, assetScale: payment.receivedAmount.assetScale } } else { const walletAddress = - await this.walletAddressService.getExternalWalletAddress(paymentUrl) + await this.walletAddressService.getExternalWalletAddress( + params.receiver + ) assetDetails = { assetCode: walletAddress.assetCode, assetScale: walletAddress.assetScale } } - if (params.isReceive && assetDetails.assetCode !== asset.code) { + if (params.isReceive && assetDetails.assetCode !== assetCode) { let convertedValue = await this.convert({ from: assetCode, to: assetDetails.assetCode, @@ -114,42 +111,57 @@ export class QuoteService implements IQuoteService { } value = convertedValue - - asset = { - code: assetDetails.assetCode, - scale: assetDetails.assetScale - } } - if (!isIncomingPayment) { - paymentUrl = await this.incomingPaymentService.createReceiver({ - amount: params.isReceive ? value : null, - asset: { - code: assetDetails.assetCode, - scale: assetDetails.assetScale - }, - walletAddressUrl: params.receiver, - description: params.description, + let paymentIntentType: + | { paymentIntentId: string } + | { paymentIntentUrl: string } + + if (isIncomingPayment) { + paymentIntentType = { paymentIntentUrl: params.receiver } + } else { + const { id } = await this.rhyzaAdminClient.createPaymentIntent({ + walletAddress: params.receiver, + incomingAmount: params.isReceive ? Number(value) : undefined, expiresAt: params.isReceive ? undefined - : new Date(Date.now() + 1000 * 15), - vopNonce: params.vopNonce + : new Date(Date.now() + 1000 * 15).toISOString(), + metadata: + params.description || params.vopNonce + ? { description: params.description, vopNonce: params.vopNonce } + : undefined }) + paymentIntentType = { paymentIntentId: id } } - const amountParams = { - assetCode: asset.code, - assetScale: asset.scale, - value - } + const amountParams = params.isReceive + ? { receiveAmount: value.toString() } + : { debitAmount: value.toString() } - const quote = await this.rafikiClient.createQuote({ - walletAddressId: params.walletAddressId, - receiveAmount: params.isReceive ? amountParams : undefined, - receiver: paymentUrl, - debitAmount: params.isReceive ? undefined : amountParams + const rhyzaQuote = await this.rhyzaAdminClient.createQuote({ + id: randomUUID(), + walletAddress: existingWalletAddress.url, + ...amountParams, + ...paymentIntentType }) + if (!rhyzaQuote.debitAmount || !rhyzaQuote.receiveAmount) { + throw new BadRequest('Could not calculate quote') + } + + const quote: QuoteWithFees = { + id: rhyzaQuote.id, + debitAmount: { + assetCode, + assetScale, + value: rhyzaQuote.debitAmount + }, + receiveAmount: { + ...assetDetails, + value: rhyzaQuote.receiveAmount + } + } + return this.addConversionInfo( quote, params.isReceive ? originalValue : undefined @@ -157,16 +169,16 @@ export class QuoteService implements IQuoteService { } private async addConversionInfo( - quote: Quote, + quote: QuoteWithFees, originalValue?: bigint - ): Promise { + ): Promise { if (quote.receiveAmount.assetCode === quote.debitAmount.assetCode) { return quote } const value = await this.convert({ from: quote.receiveAmount.assetCode, to: quote.debitAmount.assetCode, - amount: quote.receiveAmount.value + amount: BigInt(quote.receiveAmount.value) }) const feeInSenderCurrency = BigInt(quote.debitAmount.value) - value @@ -174,9 +186,10 @@ export class QuoteService implements IQuoteService { return { ...quote, fee: { - value: originalValue + value: (originalValue ? BigInt(quote.debitAmount.value) - originalValue - : feeInSenderCurrency, + : feeInSenderCurrency + ).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 06db4202a..bd2ae1e8e 100644 --- a/packages/wallet/backend/src/rhyza/admin-client.ts +++ b/packages/wallet/backend/src/rhyza/admin-client.ts @@ -4,7 +4,8 @@ import { PaymentMetadata, WalletAddress, Payment, - Pagination + Pagination, + Quote } from '@/rhyza/types' interface AssetCreateResponse { @@ -25,7 +26,7 @@ interface WalletAddressCreateResponse { export interface CreatePaymentIntentArgs { walletAddress: string - incomingAmount: number + incomingAmount?: number expiresAt?: string metadata?: PaymentMetadata } @@ -84,6 +85,23 @@ export interface GetPaymentIntentResponse { destinationIlpAddress?: string } +type QuoteAmount = + | { debitAmount: string; receiveAmount?: never } + | { receiveAmount: string; debitAmount?: never } + +type QuoteCreate = { + id: string + walletAddress: string +} & QuoteAmount + +export type QuoteCreateFromPaymentIntentId = QuoteCreate & { + paymentIntentId: string +} + +export type QuoteCreateFromPaymentIntentUrl = QuoteCreate & { + paymentIntentUrl: string +} + export class RhyzaAdminClient { constructor(private http: HttpClient) {} @@ -161,4 +179,22 @@ export class RhyzaAdminClient { `/wallet-addresses/${walletAddressId}/keys/${keyId}/revoke` ) } + + 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 + } + } } diff --git a/packages/wallet/backend/src/rhyza/types.ts b/packages/wallet/backend/src/rhyza/types.ts index a42e6618a..ae80d0fe2 100644 --- a/packages/wallet/backend/src/rhyza/types.ts +++ b/packages/wallet/backend/src/rhyza/types.ts @@ -11,6 +11,7 @@ export interface WalletAddress { export interface PaymentMetadata { description?: string + vopNonce?: string } export type GrantState = @@ -88,3 +89,18 @@ export interface Pagination { hasNextPage: boolean hasPreviousPage: boolean } + +export type QuoteState = 'PENDING' | 'FAILED' | 'COMPLETED' + +export interface Quote { + createdAt: string + id: string + paymentIntentId: string + senderWalletAddress: string + senderWalletAddressId: string + state: QuoteState + debitAmount?: string + exchangeRate?: number + expiresAt?: string + receiveAmount?: string +} diff --git a/packages/wallet/backend/tests/quote/service.test.ts b/packages/wallet/backend/tests/quote/service.test.ts index 903ff76d4..7e2a3e561 100644 --- a/packages/wallet/backend/tests/quote/service.test.ts +++ b/packages/wallet/backend/tests/quote/service.test.ts @@ -44,6 +44,17 @@ describe('Quote Service', () => { } } + const rhyzaCreateQuote = jest.fn().mockResolvedValue({ + id: 'quote-1', + paymentIntentId: 'pi-1', + senderWalletAddress: 'https://ilp.test/alice', + senderWalletAddressId: 'wa-1', + expiresAt: '2026-10-28T12:00:00.000Z', + debitAmount: '1000', + receiveAmount: '1000', + exchangeRate: 1 + }) + beforeAll(async (): Promise => { bindings = await createContainer(env) appContainer = await createApp(bindings) @@ -67,35 +78,17 @@ describe('Quote Service', () => { ) const quoteServiceDepsMocked = { - accountService, - assetService: { - getAssetByCode: (code: string) => - mockedListAssets.find((asset) => asset.code === code) - }, - rafikiClient: { - createQuote: () => ({ - id: uuid(), - receiver: `${faker.internet.url({ - appendSlash: true - })}incoming-payments/${uuid()}`, - receiveAmount: { - assetCode: 'BRG', - assetScale: 3, - value: 100 - }, - debitAmount: { - assetCode: 'BRG', - assetScale: 3, - value: 100 - }, - fee: { - value: 100, - assetCode: 'BRG', - assetScale: 2 - } + accountService: { + findAccountById: () => ({ + assetCode: 'BRG', + assetScale: 3 }), - getRafikiAsset: (assetCode: unknown) => - mockedListAssets.find((asset) => asset.code === assetCode) + getAccountById: () => ({ balance: 100000 }) + }, + incomingPaymentService: { + getExternalPayment: () => ({ + receivedAmount: { assetCode: 'BRG', assetScale: 3, value: '0' } + }) }, walletAddressService: { getExternalWalletAddress: () => ({ @@ -114,15 +107,11 @@ describe('Quote Service', () => { } }) }, - incomingPaymentService: { - createReceiver: () => faker.internet.url(), - getExternalPayment: () => ({ - receivedAmount: { - assetCode: 'BRG', - assetScale: 3, - value: 0 - } - }) + rhyzaAdminClient: { + createQuote: rhyzaCreateQuote, + createPaymentIntent: jest + .fn() + .mockResolvedValue({ id: 'pi-created', openPaymentsUrl: '' }) } } @@ -157,35 +146,61 @@ describe('Quote Service', () => { afterEach(async (): Promise => { await truncateTables(knex) + rhyzaCreateQuote.mockClear() }) describe('Create Quote', () => { - // TODO: fix after GateHub balance is implemented - it.skip('should create quote or quote with fee', async () => { + it('creates a quote from a payment intent URL', async () => { const { walletAddress } = await prepareQuoteDependencies() + const receiver = + 'https://rafiki-backend/work-acc/incoming-payments/d2697d29-ee3c-499f-bdbf-db49f57b1137' const result = await quoteService.create({ userId: userInfo.id, - amount: 100, + amount: 1, walletAddressId: walletAddress.id, - receiver: - 'https://rafiki-backend/work-acc/incoming-payments/d2697d29-ee3c-499f-bdbf-db49f57b1137', - vopNonce: '', + receiver, isReceive: true }) + expect(rhyzaCreateQuote).toHaveBeenCalledWith({ + id: expect.any(String), + walletAddress: walletAddress.url, + receiveAmount: '1000', + paymentIntentUrl: receiver + }) expect(result).toMatchObject({ debitAmount: { assetCode: 'BRG', assetScale: 3, - value: 100 + value: '1000' }, receiveAmount: { assetCode: 'BRG', assetScale: 3, - value: 100 + value: '1000' } }) }) + + it('creates a payment intent and quotes it by ID for wallet address receivers', async () => { + const { walletAddress } = await prepareQuoteDependencies() + const receiver = 'https://ilp.test/bob' + + await quoteService.create({ + userId: userInfo.id, + amount: 1, + walletAddressId: walletAddress.id, + receiver, + isReceive: false + }) + + expect(rhyzaCreateQuote).toHaveBeenCalledWith({ + id: expect.any(String), + walletAddress: walletAddress.url, + debitAmount: '1000', + paymentIntentId: 'pi-created' + }) + }) }) }) diff --git a/packages/wallet/backend/tests/rhyza/admin-client.test.ts b/packages/wallet/backend/tests/rhyza/admin-client.test.ts index 0a564cd7f..14542dec9 100644 --- a/packages/wallet/backend/tests/rhyza/admin-client.test.ts +++ b/packages/wallet/backend/tests/rhyza/admin-client.test.ts @@ -398,4 +398,36 @@ describe('RhyzaAdminClient', () => { ).rejects.toBeInstanceOf(Conflict) }) }) + + describe('createQuote', () => { + const quote = { + createdAt: '2026-10-28T11:00:00.000Z', + state: 'COMPLETED', + id: 'quote-1', + paymentIntentId: 'pi-1', + senderWalletAddress: 'https://ilp.test/alice', + senderWalletAddressId: 'wa-1', + expiresAt: '2026-10-28T12:00:00.000Z', + debitAmount: '1000', + receiveAmount: '1000', + exchangeRate: 1 + } + const args = { + id: 'quote-1', + walletAddress: 'https://ilp.test/alice', + debitAmount: '1000', + paymentIntentId: 'pi-1' + } + + it('POSTs the quote details to /quotes and returns the quote', async () => { + adapter.mockImplementationOnce(respond(201, quote)) + + await expect(client.createQuote(args)).resolves.toEqual(quote) + + const request = lastRequest(adapter) + expect(request.method).toBe('POST') + expect(request.url).toBe('http://admin.test/quotes') + expect(request.body).toEqual(args) + }) + }) })