Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/wallet/backend/src/incomingPayment/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
)
Expand All @@ -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
})
Expand Down
10 changes: 8 additions & 2 deletions packages/wallet/backend/src/outgoingPayment/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
62 changes: 47 additions & 15 deletions packages/wallet/backend/src/outgoingPayment/service.ts
Original file line number Diff line number Diff line change
@@ -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<void>
createByQuoteId: (params: CreateByQuoteIdParams) => Promise<void>
}

export class OutgoingPaymentService implements IOutgoingPaymentService {
constructor(
private rafikiClient: RafikiClient,
private incomingPaymentService: IncomingPaymentService
private rhyzaAdminClient: RhyzaAdminClient,
private accountService: AccountService
) {}

async createByQuoteId(quoteId: string): Promise<void> {
const quote = await this.rafikiClient.getQuote(quoteId)
const walletAddressId = quote.walletAddressId
async createByQuoteId(params: CreateByQuoteIdParams): Promise<void> {
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
})
}
}
4 changes: 3 additions & 1 deletion packages/wallet/backend/src/outgoingPayment/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
90 changes: 66 additions & 24 deletions packages/wallet/backend/src/quote/service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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')
Expand All @@ -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(
Expand All @@ -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
}
}

Expand All @@ -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)
Expand All @@ -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 }
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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<QuoteWithFees> {
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
}
Expand Down
36 changes: 23 additions & 13 deletions packages/wallet/backend/src/rhyza/admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down Expand Up @@ -183,18 +190,21 @@ export class RhyzaAdminClient {
async createQuote(
args: QuoteCreateFromPaymentIntentId | QuoteCreateFromPaymentIntentUrl
): Promise<Quote> {
const response = await this.http.post<Quote>('/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<Quote>('/quotes', args)
}

async getQuote(paymentIntentId: string, quoteId: string): Promise<Quote> {
return this.http.get<Quote>(
`/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
)
}
}
Loading
Loading