From 36bd58b2a3a6647248d03f091c31c6bbbeb13113 Mon Sep 17 00:00:00 2001 From: Vandana Date: Thu, 14 May 2026 12:11:47 -0400 Subject: [PATCH 1/2] ENG-297 fix(wallets): support USDT cash wallet parity --- dev/bin/eng-297-graphql-smoke.ts | 489 ++++++++++++++++++ src/app/payments/send-lightning.ts | 7 +- src/app/wallets/add-invoice-for-wallet.ts | 14 +- .../wallets/get-transactions-for-wallet.ts | 33 +- src/app/wallets/index.ts | 1 + src/app/wallets/send-on-chain.ts | 9 +- src/app/wallets/usd-wallet-amount.ts | 31 ++ src/app/wallets/validate.ts | 21 +- src/domain/shared/amount.ts | 13 +- src/domain/wallets/payment-input-validator.ts | 32 +- .../ln-noamount-usd-invoice-fee-probe.ts | 22 +- .../ln-noamount-usd-invoice-payment-send.ts | 17 +- .../root/mutation/ln-usd-invoice-fee-probe.ts | 22 +- .../root/mutation/onchain-usd-payment-send.ts | 14 +- .../root/query/on-chain-usd-tx-fee-query.ts | 13 +- src/services/ibex/client.ts | 47 +- src/services/ibex/types.ts | 19 +- .../get-transactions-for-wallet.spec.ts | 38 ++ .../app/wallets/usd-wallet-amount.spec.ts | 27 + test/flash/unit/domain/payments/index.spec.ts | 9 + .../onchain-usd-wallet-validator.spec.ts | 30 ++ .../services/ibex/client-usd-wallet.spec.ts | 135 +++++ 22 files changed, 958 insertions(+), 85 deletions(-) create mode 100644 dev/bin/eng-297-graphql-smoke.ts create mode 100644 src/app/wallets/usd-wallet-amount.ts create mode 100644 test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts create mode 100644 test/flash/unit/app/wallets/usd-wallet-amount.spec.ts create mode 100644 test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts create mode 100644 test/flash/unit/services/ibex/client-usd-wallet.spec.ts diff --git a/dev/bin/eng-297-graphql-smoke.ts b/dev/bin/eng-297-graphql-smoke.ts new file mode 100644 index 000000000..026893d15 --- /dev/null +++ b/dev/bin/eng-297-graphql-smoke.ts @@ -0,0 +1,489 @@ +import axios from "axios" + +type Args = { + url: string + token: string + first: number + amount: number + paymentRequest?: string + address?: string + strictFixtures: boolean +} + +type GraphQLResponse = { + data?: T + errors?: { message: string; path?: string[] }[] +} + +type Wallet = { + __typename: string + id: string + walletCurrency: string + balance?: number | null + transactions?: { + edges?: { + node?: { + id: string + settlementCurrency: string + settlementAmount: number + settlementDisplayAmount: string + settlementDisplayCurrency: string + settlementDisplayFee: string + settlementFee: number + settlementPrice: { + base: number + offset: number + } + initiationVia: { __typename: string } + settlementVia: { __typename: string } + } + }[] + } | null +} + +type CheckResult = { + name: string + status: "PASS" | "FAIL" | "SKIP" + details?: string +} + +const usage = ` +Usage: + yarn ts-node --transpile-only -r tsconfig-paths/register dev/bin/eng-297-graphql-smoke.ts \\ + --url http://localhost:4002/graphql \\ + --token "$AUTH_TOKEN" \\ + [--payment-request lnbc...] \\ + [--address bc1...] \\ + [--amount 100] + +Required: + --url GraphQL endpoint + --token Bearer token for a test account + +Optional fixtures: + --payment-request Bolt11 invoice for lnUsdInvoiceFeeProbe checks + --address On-chain address for onChainUsdTxFee checks + --amount Fractional cent amount for no-amount/on-chain checks. Default: 100 + --first Transaction page size per wallet. Default: 10 + --strict-fixtures Fail instead of skip checks that require optional fixtures +` + +const parseArgs = (argv: string[]): Args => { + const parsed: Record = {} + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === "--help" || arg === "-h") { + console.log(usage.trim()) + process.exit(0) + } + if (!arg.startsWith("--")) continue + + const key = arg.slice(2) + if (key === "strict-fixtures") { + parsed[key] = true + continue + } + + const value = argv[i + 1] + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`) + } + parsed[key] = value + i++ + } + + const url = (parsed.url || process.env.GRAPHQL_URL) as string | undefined + const token = (parsed.token || process.env.AUTH_TOKEN || process.env.TOKEN) as + | string + | undefined + + if (!url) throw new Error("Missing --url or GRAPHQL_URL") + if (!token) throw new Error("Missing --token, AUTH_TOKEN, or TOKEN") + + return { + url, + token, + first: Number(parsed.first || 10), + amount: Number(parsed.amount || 100), + paymentRequest: (parsed["payment-request"] || parsed.paymentRequest) as + | string + | undefined, + address: parsed.address as string | undefined, + strictFixtures: Boolean(parsed["strict-fixtures"]), + } +} + +const results: CheckResult[] = [] + +const pass = (name: string, details?: string) => { + results.push({ name, status: "PASS", details }) +} + +const fail = (name: string, details?: string) => { + results.push({ name, status: "FAIL", details }) +} + +const skip = (name: string, details?: string) => { + results.push({ name, status: "SKIP", details }) +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const graphql = async ( + args: Args, + operationName: string, + query: string, + variables?: Record, +): Promise => { + const resp = await axios.post>( + args.url, + { operationName, query, variables }, + { + headers: { + Authorization: `Bearer ${args.token}`, + "Content-Type": "application/json", + }, + validateStatus: () => true, + }, + ) + + if (resp.status < 200 || resp.status >= 300) { + throw new Error(`HTTP ${resp.status}: ${JSON.stringify(resp.data)}`) + } + + if (resp.data.errors?.length) { + throw new Error(JSON.stringify(resp.data.errors, null, 2)) + } + + if (!resp.data.data) throw new Error("Missing GraphQL data") + return resp.data.data +} + +const recordCheck = async (name: string, check: () => Promise) => { + try { + const details = await check() + pass(name, details || undefined) + } catch (err) { + fail(name, err instanceof Error ? err.message : `${err}`) + } +} + +const optionalCheck = async ( + args: Args, + name: string, + fixtureDescription: string, + check: () => Promise, +) => { + if (args.strictFixtures) { + await recordCheck(name, check) + return + } + + try { + const details = await check() + pass(name, details || undefined) + } catch (err) { + const message = err instanceof Error ? err.message : `${err}` + if (message.includes("Missing fixture:")) { + skip(name, fixtureDescription) + return + } + fail(name, message) + } +} + +const walletSummary = (wallet: Wallet) => + `${wallet.__typename}/${wallet.walletCurrency}/${wallet.id}` + +const getWallets = async (args: Args): Promise => { + const data = await graphql<{ + me: { + defaultAccount: { + wallets: Wallet[] + } + } | null + }>( + args, + "Eng297WalletsAndTransactions", + ` + query Eng297WalletsAndTransactions($first: Int!) { + me { + defaultAccount { + wallets { + __typename + ... on BTCWallet { + id + walletCurrency + balance + transactions(first: $first) { ...TransactionConnectionFields } + } + ... on UsdWallet { + id + walletCurrency + usdBalance: balance + isExternal + transactions(first: $first) { ...TransactionConnectionFields } + } + ... on UsdtWallet { + id + walletCurrency + usdtBalance: balance + isExternal + transactions(first: $first) { ...TransactionConnectionFields } + } + } + } + } + } + + fragment TransactionConnectionFields on TransactionConnection { + edges { + node { + id + settlementCurrency + settlementAmount + settlementDisplayAmount + settlementDisplayCurrency + settlementDisplayFee + settlementFee + settlementPrice { + base + offset + } + initiationVia { __typename } + settlementVia { __typename } + } + } + } + `, + { first: args.first }, + ) + + const defaultAccount = data.me?.defaultAccount + assert(defaultAccount, "Missing me.defaultAccount") + return defaultAccount.wallets +} + +const requireWallet = (wallets: Wallet[], currency: string): Wallet => { + const wallet = wallets.find((candidate) => candidate.walletCurrency === currency) + assert(wallet, `Missing ${currency} wallet`) + return wallet as Wallet +} + +const assertNoTransactionShapeGaps = (wallet: Wallet) => { + const transactions = wallet.transactions?.edges || [] + for (const edge of transactions) { + const tx = edge.node + if (!tx) { + throw new Error(`${walletSummary(wallet)} has transaction edge without node`) + } + assert(tx.id, `${walletSummary(wallet)} transaction missing id`) + assert( + tx.settlementCurrency, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementCurrency`, + ) + assert( + tx.settlementDisplayCurrency, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementDisplayCurrency`, + ) + assert( + tx.settlementPrice, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementPrice`, + ) + assert( + tx.initiationVia?.__typename, + `${walletSummary(wallet)} transaction ${tx.id} missing initiationVia.__typename`, + ) + assert( + tx.settlementVia?.__typename, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementVia.__typename`, + ) + } +} + +const runLnUsdInvoiceFeeProbe = async (args: Args, wallet: Wallet) => { + if (!args.paymentRequest) throw new Error("Missing fixture: paymentRequest") + + const data = await graphql<{ + lnUsdInvoiceFeeProbe: { + errors: { message: string }[] + amount?: number + invoiceAmount?: number + } + }>( + args, + "Eng297LnUsdInvoiceFeeProbe", + ` + mutation Eng297LnUsdInvoiceFeeProbe($input: LnUsdInvoiceFeeProbeInput!) { + lnUsdInvoiceFeeProbe(input: $input) { + errors { message } + amount + invoiceAmount + } + } + `, + { + input: { + walletId: wallet.id, + paymentRequest: args.paymentRequest, + }, + }, + ) + + const result = data.lnUsdInvoiceFeeProbe + assert( + result.errors.length === 0, + `${walletSummary(wallet)} lnUsdInvoiceFeeProbe errors: ${JSON.stringify(result.errors)}`, + ) + assert(result.amount !== undefined, `${walletSummary(wallet)} missing fee amount`) + assert( + result.invoiceAmount !== undefined, + `${walletSummary(wallet)} missing invoiceAmount`, + ) +} + +const runLnNoAmountUsdInvoiceFeeProbe = async (args: Args, wallet: Wallet) => { + if (!args.paymentRequest) throw new Error("Missing fixture: paymentRequest") + + const data = await graphql<{ + lnNoAmountUsdInvoiceFeeProbe: { + errors: { message: string }[] + amount?: number + invoiceAmount?: number + } + }>( + args, + "Eng297LnNoAmountUsdInvoiceFeeProbe", + ` + mutation Eng297LnNoAmountUsdInvoiceFeeProbe($input: LnNoAmountUsdInvoiceFeeProbeInput!) { + lnNoAmountUsdInvoiceFeeProbe(input: $input) { + errors { message } + amount + invoiceAmount + } + } + `, + { + input: { + walletId: wallet.id, + paymentRequest: args.paymentRequest, + amount: args.amount, + }, + }, + ) + + const result = data.lnNoAmountUsdInvoiceFeeProbe + assert( + result.errors.length === 0, + `${walletSummary(wallet)} lnNoAmountUsdInvoiceFeeProbe errors: ${JSON.stringify(result.errors)}`, + ) + assert(result.amount !== undefined, `${walletSummary(wallet)} missing fee amount`) + assert( + result.invoiceAmount !== undefined, + `${walletSummary(wallet)} missing invoiceAmount`, + ) +} + +const runOnChainUsdTxFee = async (args: Args, wallet: Wallet) => { + if (!args.address) throw new Error("Missing fixture: address") + + const data = await graphql<{ + onChainUsdTxFee: { + amount: number + } + }>( + args, + "Eng297OnChainUsdTxFee", + ` + query Eng297OnChainUsdTxFee( + $walletId: WalletId! + $address: OnChainAddress! + $amount: FractionalCentAmount! + ) { + onChainUsdTxFee(walletId: $walletId, address: $address, amount: $amount) { + amount + } + } + `, + { + walletId: wallet.id, + address: args.address, + amount: args.amount, + }, + ) + + assert( + data.onChainUsdTxFee.amount !== undefined, + `${walletSummary(wallet)} missing onChainUsdTxFee amount`, + ) +} + +const main = async () => { + const args = parseArgs(process.argv.slice(2)) + let wallets: Wallet[] = [] + + await recordCheck("wallet list includes USD and USDT wallets", async () => { + wallets = await getWallets(args) + const usdWallet = requireWallet(wallets, "USD") + const usdtWallet = requireWallet(wallets, "USDT") + return `${walletSummary(usdWallet)}, ${walletSummary(usdtWallet)}` + }) + + if (wallets.length > 0) { + await recordCheck("wallet transaction shapes are render-safe", async () => { + for (const wallet of wallets) assertNoTransactionShapeGaps(wallet) + const counts = wallets.map( + (wallet) => `${wallet.walletCurrency}:${wallet.transactions?.edges?.length || 0}`, + ) + return counts.join(", ") + }) + + for (const currency of ["USD", "USDT"]) { + const wallet = wallets.find((candidate) => candidate.walletCurrency === currency) + if (!wallet) continue + + await optionalCheck( + args, + `${currency} lnUsdInvoiceFeeProbe`, + "skipped; pass --payment-request to exercise Lightning fee probe", + () => runLnUsdInvoiceFeeProbe(args, wallet), + ) + + await optionalCheck( + args, + `${currency} lnNoAmountUsdInvoiceFeeProbe`, + "skipped; pass --payment-request to exercise no-amount Lightning fee probe", + () => runLnNoAmountUsdInvoiceFeeProbe(args, wallet), + ) + + await optionalCheck( + args, + `${currency} onChainUsdTxFee`, + "skipped; pass --address to exercise on-chain fee probe", + () => runOnChainUsdTxFee(args, wallet), + ) + } + } + + for (const result of results) { + const icon = result.status === "PASS" ? "✅" : result.status === "SKIP" ? "⏭️" : "❌" + console.log(`${icon} ${result.status} ${result.name}`) + if (result.details) console.log(` ${result.details}`) + } + + const failed = results.filter((result) => result.status === "FAIL") + if (failed.length > 0) { + console.error(`\n${failed.length} smoke check(s) failed`) + process.exit(1) + } + + console.log("\nENG-297 GraphQL smoke checks completed") +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/src/app/payments/send-lightning.ts b/src/app/payments/send-lightning.ts index 730de6755..880205c86 100644 --- a/src/app/payments/send-lightning.ts +++ b/src/app/payments/send-lightning.ts @@ -172,7 +172,7 @@ export const payNoAmountInvoiceByWalletIdForBtcWallet = async ( export const payNoAmountInvoiceByWalletIdForUsdWallet = async ( args: PayNoAmountInvoiceByWalletIdArgs, ): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId) + const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) return validated instanceof Error ? validated : payNoAmountInvoiceByWalletId(args) } @@ -278,7 +278,10 @@ const validateNoAmountInvoicePaymentInputs = async ({ const inputPaymentAmount = senderWallet.currency === WalletCurrency.Btc ? checkedToBtcPaymentAmount(amount) - : checkedToUsdPaymentAmount(amount) + : checkedToUsdPaymentAmount( + amount, + senderWallet.currency as typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt, + ) if (inputPaymentAmount instanceof Error) return inputPaymentAmount return { diff --git a/src/app/wallets/add-invoice-for-wallet.ts b/src/app/wallets/add-invoice-for-wallet.ts index 9a301d04b..49ef30994 100644 --- a/src/app/wallets/add-invoice-for-wallet.ts +++ b/src/app/wallets/add-invoice-for-wallet.ts @@ -20,9 +20,11 @@ import { import Ibex from "@services/ibex/client" import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" import { decodeInvoice } from "@domain/bitcoin/lightning/ln-invoice" -import { USDAmount } from "@domain/shared" + import { AddInvoiceResponse201 } from "ibex-client" +import { usdWalletAmountFromInput } from "./usd-wallet-amount" + import { validateIsBtcWallet, validateIsUsdWallet } from "./validate" const defaultBtcExpiration = DEFAULT_EXPIRATIONS["BTC"].delayMinutes @@ -46,7 +48,9 @@ const addInvoiceForSelf = async ({ const limitOk = await checkSelfWalletIdRateLimits(wallet.accountId) if (limitOk instanceof Error) return limitOk - const checkedAmount = amount ? USDAmount.cents(amount.toString()) : undefined + const checkedAmount = amount + ? usdWalletAmountFromInput(amount.toString(), wallet.currency) + : undefined if (checkedAmount instanceof Error) return checkedAmount const resp = await Ibex.addInvoice({ amount: checkedAmount, @@ -77,7 +81,7 @@ export const addInvoiceForSelfForUsdWallet = async ( const expiresIn = checkedToMinutes(args.expiresIn || defaultUsdExpiration) if (expiresIn instanceof Error) return expiresIn - const validated = await validateIsUsdWallet(walletId) + const validated = await validateIsUsdWallet(walletId, { includeUsdt: true }) if (validated instanceof Error) return validated return addInvoiceForSelf({ ...args, walletId, expiresIn }) } @@ -133,7 +137,7 @@ const addInvoiceForRecipient = async ({ const limitOk = await checkRecipientWalletIdRateLimits(wallet.accountId) if (limitOk instanceof Error) return limitOk - const checkedAmount = USDAmount.cents(amount.toString()) + const checkedAmount = usdWalletAmountFromInput(amount.toString(), wallet.currency) if (checkedAmount instanceof Error) return checkedAmount const resp = await Ibex.addInvoice({ amount: checkedAmount, @@ -163,7 +167,7 @@ export const addInvoiceForRecipientForUsdWallet = async ( const expiresIn = checkedToMinutes(args.expiresIn || defaultUsdExpiration) if (expiresIn instanceof Error) return expiresIn - const validated = await validateIsUsdWallet(recipientWalletId) + const validated = await validateIsUsdWallet(recipientWalletId, { includeUsdt: true }) if (validated instanceof Error) return validated return addInvoiceForRecipient({ ...args, recipientWalletId, expiresIn }) diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index e8925dd70..4d3bec459 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -1,4 +1,5 @@ import { PartialResult } from "@app/partial-result" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" import { baseLogger } from "@services/logger" @@ -32,9 +33,39 @@ export const getTransactionsForWallets = async ({ }) } +const currencyFromIbexCurrencyId = (currencyId: number | undefined): WalletCurrency | undefined => { + if (currencyId === USDAmount.currencyId) return WalletCurrency.Usd + if (currencyId === USDTAmount.currencyId) return WalletCurrency.Usdt + return undefined +} + export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] => { return ibexResp.map(trx => { - const currency = (trx.currencyId === 3 ? "USD" : "BTC") as WalletCurrency // WalletCurrency: "USD" | "BTC", + const currency = currencyFromIbexCurrencyId(trx.currencyId) + + if (!currency) { + baseLogger.error(`Failed to parse Ibex transaction currency. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`) + return { + walletId: (trx.accountId || "") as WalletId, + settlementAmount: 0 as Satoshis, + settlementFee: 0 as Satoshis, + settlementCurrency: WalletCurrency.Usd, + settlementDisplayAmount: `${trx.amount}`, + settlementDisplayFee: `${trx.networkFee}`, + settlementDisplayPrice: { + base: 0n, + offset: 0n, + displayCurrency: "USD" as DisplayCurrency, + walletCurrency: WalletCurrency.Usd, + }, + createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), + id: trx.id || "null", + status: "success" as TxStatus, + memo: null, + initiationVia: { type: "unknown" }, + settlementVia: { type: "unknown" }, + } as UnknownTypeTransaction + } const settlementDisplayPrice: WalletMinorUnitDisplayPrice = { base: trx.exchangeRateCurrencySats ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) : 0n, diff --git a/src/app/wallets/index.ts b/src/app/wallets/index.ts index 3839fed5a..94210271c 100644 --- a/src/app/wallets/index.ts +++ b/src/app/wallets/index.ts @@ -17,6 +17,7 @@ export * from "./settle-payout-txn" export * from "./update-legacy-on-chain-receipt" export * from "./update-pending-invoices" export * from "./validate" +export * from "./usd-wallet-amount" import { WalletsRepository } from "@services/mongoose" diff --git a/src/app/wallets/send-on-chain.ts b/src/app/wallets/send-on-chain.ts index a50ed3034..fddf17f32 100644 --- a/src/app/wallets/send-on-chain.ts +++ b/src/app/wallets/send-on-chain.ts @@ -3,7 +3,8 @@ import { NETWORK } from "@config" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { checkedToOnChainAddress } from "@domain/bitcoin/onchain" import { UnsupportedCurrencyError } from "@domain/errors" -import { ErrorLevel, USDAmount } from "@domain/shared" +import { ErrorLevel } from "@domain/shared" +import { UsdWalletAmount } from "@services/ibex/types" import { OnchainUsdPaymentValidator } from "@domain/wallets" import { AccountsRepository, WalletsRepository } from "@services/mongoose" @@ -19,15 +20,15 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null - amount?: number | FractionalCentAmount | USDAmount + amount?: number | FractionalCentAmount | UsdWalletAmount } type PayOnChainByUsdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { - amount: USDAmount + amount: UsdWalletAmount } type PayOnChainByWalletIdForUsdWalletArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { - amount: number | FractionalCentAmount | USDAmount + amount: number | FractionalCentAmount | UsdWalletAmount } /* diff --git a/src/app/wallets/usd-wallet-amount.ts b/src/app/wallets/usd-wallet-amount.ts new file mode 100644 index 000000000..e2c166e4f --- /dev/null +++ b/src/app/wallets/usd-wallet-amount.ts @@ -0,0 +1,31 @@ +import { UnsupportedCurrencyError } from "@domain/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { WalletsRepository } from "@services/mongoose" + +export type UsdWalletAmount = USDAmount | USDTAmount + +export const usdWalletAmountFromInput = ( + amount: string | number, + currency: WalletCurrency, +): UsdWalletAmount | ApplicationError => { + const raw = amount.toString() + + if (currency === WalletCurrency.Usd) return USDAmount.cents(raw) + if (currency === WalletCurrency.Usdt) return USDTAmount.smallestUnits(raw) + + return new UnsupportedCurrencyError(`USD wallet amount unsupported for ${currency}`) +} + +export const usdWalletAmountFromWalletId = async ({ + walletId, + amount, +}: { + walletId: WalletId + amount: string | number +}): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + return usdWalletAmountFromInput(amount, wallet.currency) +} diff --git a/src/app/wallets/validate.ts b/src/app/wallets/validate.ts index a214ada91..3b22093f5 100644 --- a/src/app/wallets/validate.ts +++ b/src/app/wallets/validate.ts @@ -9,7 +9,7 @@ export const validateIsBtcWallet = async ( const wallet = await WalletsRepository().findById(walletId) if (wallet instanceof Error) return wallet - if (wallet.currency === WalletCurrency.Usd) { + if (wallet.currency !== WalletCurrency.Btc) { return new MismatchedCurrencyForWalletError() } return true @@ -17,11 +17,28 @@ export const validateIsBtcWallet = async ( export const validateIsUsdWallet = async ( walletId: WalletId, + args?: { includeUsdt?: boolean }, ): Promise => { const wallet = await WalletsRepository().findById(walletId) if (wallet instanceof Error) return wallet - if (wallet.currency === WalletCurrency.Btc) { + const isAllowed = + wallet.currency === WalletCurrency.Usd || + (args?.includeUsdt === true && wallet.currency === WalletCurrency.Usdt) + + if (!isAllowed) { + return new MismatchedCurrencyForWalletError() + } + return true +} + +export const validateIsUsdtWallet = async ( + walletId: WalletId, +): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + if (wallet.currency !== WalletCurrency.Usdt) { return new MismatchedCurrencyForWalletError() } return true diff --git a/src/domain/shared/amount.ts b/src/domain/shared/amount.ts index 0e867aad4..2f5438784 100644 --- a/src/domain/shared/amount.ts +++ b/src/domain/shared/amount.ts @@ -90,9 +90,16 @@ export const UsdPaymentAmount = (cents: bigint): UsdPaymentAmount => { } } -export const checkedToUsdPaymentAmount = ( +export function checkedToUsdPaymentAmount( amount: number | null, -): UsdPaymentAmount | ValidationError => { +): UsdPaymentAmount | ValidationError +export function checkedToUsdPaymentAmount< + T extends typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt, +>(amount: number | null, currency: T): PaymentAmount | ValidationError +export function checkedToUsdPaymentAmount( + amount: number | null, + currency: typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt = WalletCurrency.Usd, +): UsdPaymentAmount | PaymentAmount | ValidationError { if (amount === null) { return new InvalidUsdPaymentAmountError() } @@ -105,7 +112,7 @@ export const checkedToUsdPaymentAmount = ( return new InvalidUsdPaymentAmountError() } if (!(amount && amount > 0)) return new InvalidUsdPaymentAmountError() - return paymentAmountFromNumber({ amount, currency: WalletCurrency.Usd }) + return paymentAmountFromNumber({ amount, currency }) } export const paymentAmountFromNumber = ({ diff --git a/src/domain/wallets/payment-input-validator.ts b/src/domain/wallets/payment-input-validator.ts index eefe1b093..0491b3786 100644 --- a/src/domain/wallets/payment-input-validator.ts +++ b/src/domain/wallets/payment-input-validator.ts @@ -1,4 +1,10 @@ -import { USDAmount, ValidationError, isUsdWallet, validator } from "@domain/shared" +import { + USDAmount, + USDTAmount, + ValidationError, + WalletCurrency, + validator, +} from "@domain/shared" import { isActiveAccount, walletBelongsToAccount } from "@domain/accounts" import { SendOnchainArgs } from "@services/ibex/types" @@ -8,23 +14,35 @@ import { SendOnchainArgs } from "@services/ibex/types" // else return true // } -const checkOnchainMin = async (o: { amount: USDAmount }) => { +const isUsdWalletForOnChainPayment = async (o: { wallet: Wallet }) => { + if ( + o.wallet.currency === WalletCurrency.Usd || + o.wallet.currency === WalletCurrency.Usdt + ) { + return true + } + return new ValidationError(`Expected USD, got ${o.wallet.currency}`) +} + +const checkOnchainMin = async (o: { amount: USDAmount | USDTAmount }) => { // TODO: Currently relying on Ibex to enforce dust limits // const { dustThreshold } = getOnChainWalletConfig() // const minBtc = BtcAmount.sats(dustThreshold.toString()) // const btcPrice = await PriceService().getUsdCentRealTimePrice(_) // if (btcPrice instanceof PriceServiceError) return new ValidationError(btcPrice) // const minUsd = minBtc.convertAtRate(MoneyAmount.from("50000", WalletCurrency.Usd)) - const minUsd = USDAmount.ZERO - return o.amount.isGreaterThan(minUsd) - ? true - : new ValidationError(`Amount must be greater than ${minUsd.asDollars()}`) + const isGreaterThanZero = + o.amount instanceof USDTAmount + ? o.amount.isGreaterThan(USDTAmount.ZERO) + : o.amount.isGreaterThan(USDAmount.ZERO) + + return isGreaterThanZero ? true : new ValidationError("Amount must be greater than 0") } type SendOnchainArgsWithContext = SendOnchainArgs & { wallet: Wallet; account: Account } export const OnchainUsdPaymentValidator = validator([ - isUsdWallet, + isUsdWalletForOnChainPayment, isActiveAccount, walletBelongsToAccount, checkOnchainMin, diff --git a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts index 1950be8a7..89fe1592f 100644 --- a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts +++ b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts @@ -1,22 +1,15 @@ -import { InvalidFeeProbeStateError } from "@domain/bitcoin/lightning" - -// import { Payments } from "@app" +import { usdWalletAmountFromWalletId, UsdWalletAmount } from "@app/wallets" import { GT } from "@graphql/index" import WalletId from "@graphql/shared/types/scalar/wallet-id" -import CentAmount from "@graphql/public/types/scalar/cent-amount" import CentAmountPayload from "@graphql/public/types/payload/cent-amount" import LnPaymentRequest from "@graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" -import { normalizePaymentAmount } from "../../../shared/root/mutation" - // FLASH FORK: import ibex dependencies import Ibex from "@services/ibex/client" -import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" -import { GetFeeEstimateResponse200 } from "ibex-client" -import { BigIntConversionError, checkedToUsdPaymentAmount, USDAmount, ValidationError } from "@domain/shared" +import { IbexError } from "@services/ibex/errors" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import { IbexFeeEstimation } from "@services/ibex/types" @@ -55,9 +48,14 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ // }) // TODO: Move Ibex call to Payments interface - const checkedAmount = USDAmount.cents(amount.toString()) - if (checkedAmount instanceof BigIntConversionError) return checkedAmount - const resp: IbexFeeEstimation | IbexError = await Ibex.getLnFeeEstimation({ + const checkedAmount = await usdWalletAmountFromWalletId({ + walletId, + amount: amount.toString(), + }) + if (checkedAmount instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(checkedAmount)] } + } + const resp: IbexFeeEstimation | IbexError = await Ibex.getLnFeeEstimation({ invoice: paymentRequest as Bolt11, send: checkedAmount, }) diff --git a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts index 0ffbb8aca..e8d8d6227 100644 --- a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts @@ -13,10 +13,10 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract // FLASH FORK: import ibex dependencies import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { usdWalletAmountFromWalletId } from "@app/wallets" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" -import { BigIntConversionError, checkedToUsdPaymentAmount, paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" const LnNoAmountUsdInvoicePaymentInput = GT.Input({ name: "LnNoAmountUsdInvoicePaymentInput", @@ -90,9 +90,16 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< if (!domainAccount) throw new Error("Authentication required") // eslint-disable-next-line @typescript-eslint/no-explicit-any - - const usCents = USDAmount.cents(amount.toString()) - if (usCents instanceof BigIntConversionError) return usCents + const usCents = await usdWalletAmountFromWalletId({ + walletId, + amount: amount.toString(), + }) + if (usCents instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(usCents)], + } + } const PayLightningInvoice = await Ibex.payInvoice({ invoice: paymentRequest as Bolt11, accountId: walletId, @@ -102,7 +109,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< if (PayLightningInvoice instanceof IbexError) { return { status: "failed", - errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)] + errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)], } } diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts index 8c2223ff9..ccfb2c7bb 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts @@ -1,7 +1,3 @@ -import { InvalidFeeProbeStateError } from "@domain/bitcoin/lightning" - -// import { Payments } from "@app" - import { GT } from "@graphql/index" import WalletId from "@graphql/shared/types/scalar/wallet-id" import CentAmountPayload from "@graphql/public/types/payload/cent-amount" @@ -10,17 +6,10 @@ import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { checkedToWalletId } from "@domain/wallets" -import { normalizePaymentAmount } from "../../../shared/root/mutation" - -// FLASH FORK: import ibex dependencies import Ibex from "@services/ibex/client" -import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" -import { ValidationError, WalletCurrency } from "@domain/shared" -import { baseLogger } from "@services/logger" -import IError from "@graphql/shared/types/abstract/error" -import USDCentsScalar from "@graphql/shared/types/scalar/usd-cents" -import CentAmount from "@graphql/public/types/scalar/cent-amount" +import { IbexError } from "@services/ibex/errors" +import { WalletsRepository } from "@services/mongoose" // import { IbexRoutes } from "../../../../services/ibex/Routes" // import { requestIBexPlugin } from "../../../../services/ibex/IbexHelper" @@ -86,9 +75,14 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< // uncheckedPaymentRequest: paymentRequest, // }) + const wallet = await WalletsRepository().findById(walletIdChecked) + if (wallet instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(wallet)] } + } + const resp = await Ibex.getLnFeeEstimation({ invoice: paymentRequest as Bolt11, - // send: { currencyId: USDollars.currencyId }, + currency: wallet.currency, }) if (resp instanceof IbexError) return { errors: [mapAndParseErrorForGqlResponse(resp)] } diff --git a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index bdd8f4b83..b45224a6c 100644 --- a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -11,7 +11,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { Wallets } from "@app/index" -import { BigIntConversionError, USDAmount } from "@domain/shared" +import { usdWalletAmountFromWalletId } from "@app/wallets" const OnChainUsdPaymentSendInput = GT.Input({ name: "OnChainUsdPaymentSendInput", @@ -67,8 +67,16 @@ const OnChainUsdPaymentSendMutation = GT.Field< } if (!domainAccount) throw new Error("Authentication required") - const usdAmount = USDAmount.cents(amount.toString()) - if (usdAmount instanceof BigIntConversionError) return usdAmount + const usdAmount = await usdWalletAmountFromWalletId({ + walletId, + amount: amount.toString(), + }) + if (usdAmount instanceof Error) { + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(usdAmount)], + } + } const result = await Wallets.payOnChainByWalletId({ senderAccount: domainAccount, diff --git a/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts b/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts index 90b96e51d..63977876f 100644 --- a/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts +++ b/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts @@ -1,11 +1,7 @@ import { PayoutSpeed as DomainPayoutSpeed } from "@domain/bitcoin/onchain" -import { paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" - -// import { Wallets } from "@app" +import { usdWalletAmountFromWalletId } from "@app/wallets" import { GT } from "@graphql/index" -import { mapError } from "@graphql/error-map" - import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import OnChainAddress from "@graphql/shared/types/scalar/on-chain-address" import PayoutSpeed from "@graphql/public/types/scalar/payout-speed" @@ -13,8 +9,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import OnChainUsdTxFee from "@graphql/public/types/object/onchain-usd-tx-fee" -import { normalizePaymentAmount } from "../../../shared/root/mutation" - // FLASH FORK: import ibex dependencies import Ibex from "@services/ibex/client" @@ -46,7 +40,10 @@ const OnChainUsdTxFeeQuery = GT.Field({ // speed, // }) - const send = USDAmount.cents(amount.toString()) + const send = await usdWalletAmountFromWalletId({ + walletId, + amount: amount.toString(), + }) if (send instanceof Error) return send const resp = await Ibex.estimateOnchainFee(send, address) diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index da0b4b1f4..56c23c72e 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -40,6 +40,7 @@ import { CryptoReceiveInfo, CreateCryptoReceiveInfoRequest, IbexCurrency, + UsdWalletAmount, } from "./types" import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors" @@ -58,6 +59,30 @@ const createAccount = async ( return Ibex.createAccount({ name, currencyId }).then(errorHandler) } +const ibexCurrencyIdForUsdAmount = (amount: UsdWalletAmount): IbexCurrencyId => { + if (amount instanceof USDAmount) return USDAmount.currencyId + return USDTAmount.currencyId +} + +const ibexCurrencyIdForUsdWalletCurrency = ( + currency?: WalletCurrency, +): IbexCurrencyId => { + if (currency === WalletCurrency.Usdt) return USDTAmount.currencyId + return USDAmount.currencyId +} + +const parseIbexUsdAmount = ( + amount: number | string, + currencyId: IbexCurrencyId, +): UsdWalletAmount | ParseError => { + const parsed = + currencyId === USDTAmount.currencyId + ? USDTAmount.fromNumber(amount.toString()) + : USDAmount.dollars(amount.toString()) + + return parsed instanceof Error ? new ParseError(parsed) : parsed +} + const parseAccountBalance = ( balance: number | undefined, currency: WalletCurrency, @@ -132,16 +157,16 @@ const invoiceFromHash = async ( return Ibex.invoiceFromHash({ invoice_hash }).then(errorHandler) } -// Only supports USD for now const getLnFeeEstimation = async ( args: GetFeeEstimateArgs, -): Promise => { - const currencyId = USDAmount.currencyId - // const amount = (args.send instanceof IbexCurrency) ? args.send.amount.toString() : undefined +): Promise | IbexError> => { + const currencyId = args.send + ? ibexCurrencyIdForUsdAmount(args.send) + : ibexCurrencyIdForUsdWalletCurrency(args.currency) const resp = await Ibex.getFeeEstimation({ bolt11: args.invoice as string, - amount: args.send?.asDollars(8), + amount: args.send?.toIbex().toString(), currencyId: currencyId.toString(), }) if (resp instanceof Error) return new IbexError(resp) @@ -150,10 +175,10 @@ const getLnFeeEstimation = async ( else if (resp.invoiceAmount === null || resp.invoiceAmount === undefined) return new UnexpectedIbexResponse("invoiceAmount not found.") else { - const fee = USDAmount.dollars(resp.amount) - if (fee instanceof Error) return new ParseError(fee) - const invoiceAmount = USDAmount.dollars(resp.invoiceAmount) - if (invoiceAmount instanceof Error) return new ParseError(invoiceAmount) + const fee = parseIbexUsdAmount(resp.amount, currencyId) + if (fee instanceof Error) return fee + const invoiceAmount = parseIbexUsdAmount(resp.invoiceAmount, currencyId) + if (invoiceAmount instanceof Error) return invoiceAmount return { fee, invoice: invoiceAmount, @@ -190,12 +215,12 @@ const sendOnchain = async ( } const estimateOnchainFee = async ( - send: USDAmount, + send: UsdWalletAmount, address: OnChainAddress, ): Promise => { return Ibex.estimateFeeV2({ "amount": send.toIbex(), - "currency-id": USDAmount.currencyId.toString(), + "currency-id": ibexCurrencyIdForUsdAmount(send).toString(), address, }).then(errorHandler) } diff --git a/src/services/ibex/types.ts b/src/services/ibex/types.ts index 16764d137..c7fde4b71 100644 --- a/src/services/ibex/types.ts +++ b/src/services/ibex/types.ts @@ -1,26 +1,29 @@ -import { USDAmount, USDTAmount } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +export type UsdWalletAmount = USDAmount | USDTAmount export type PayInvoiceArgs = { accountId: IbexAccountId invoice: Bolt11 - send?: USDAmount // must match currency of account + send?: UsdWalletAmount // must match currency of account } export type SendOnchainArgs = { accountId: IbexAccountId // source of funds address: OnChainAddress // destination - amount: USDAmount + amount: UsdWalletAmount } // Ibex supports fee estimation in different currencies export type GetFeeEstimateArgs = { invoice: Bolt11 - send?: USDAmount + send?: UsdWalletAmount + currency?: WalletCurrency } -export type IbexFeeEstimation = { - fee: USDAmount - invoice: USDAmount +export type IbexFeeEstimation = { + fee: T + invoice: T } export type IbexAccountDetails = { @@ -32,7 +35,7 @@ export type IbexAccountDetails = { export type IbexInvoiceArgs = { accountId: IbexAccountId - amount?: USDAmount + amount?: UsdWalletAmount memo: string expiration?: Seconds } diff --git a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts new file mode 100644 index 000000000..d47e1775f --- /dev/null +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -0,0 +1,38 @@ +import { toWalletTransactions } from "@app/wallets/get-transactions-for-wallet" +import { WalletCurrency } from "@domain/shared" +import { GResponse200 } from "ibex-client" + +describe("toWalletTransactions", () => { + it("maps IBEX USDT currency id to USDT wallet currency", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 19446, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(19446) + }) + + it("does not silently classify unknown IBEX currency ids as BTC", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 100, + currencyId: 999, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).not.toBe(WalletCurrency.Btc) + expect(transaction.initiationVia.type).toBe("unknown") + expect(transaction.settlementVia.type).toBe("unknown") + }) +}) diff --git a/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts new file mode 100644 index 000000000..4c2573745 --- /dev/null +++ b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts @@ -0,0 +1,27 @@ +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +describe("usdWalletAmountFromInput", () => { + it("treats USD wallet input as cents", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Usd) + + expect(amount).toBeInstanceOf(USDAmount) + expect((amount as USDAmount).asCents()).toBe("19446") + expect((amount as USDAmount).toIbex()).toBe(194.46) + }) + + it("treats USDT wallet input as micro-USDT", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Usdt) + + expect(amount).toBeInstanceOf(USDTAmount) + expect((amount as USDTAmount).asSmallestUnits()).toBe("19446") + expect((amount as USDTAmount).asNumber()).toBe("0.019446") + expect((amount as USDTAmount).toIbex()).toBe(0.019446) + }) + + it("rejects BTC", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Btc) + + expect(amount).toBeInstanceOf(Error) + }) +}) diff --git a/test/flash/unit/domain/payments/index.spec.ts b/test/flash/unit/domain/payments/index.spec.ts index 2aecb5119..95f28c06a 100644 --- a/test/flash/unit/domain/payments/index.spec.ts +++ b/test/flash/unit/domain/payments/index.spec.ts @@ -37,6 +37,15 @@ describe("checkedToBtcPaymentAmount", () => { }) describe("checkedToUsdPaymentAmount", () => { + it("returns USDT payment amounts when called with a USDT wallet currency", () => { + expect(checkedToUsdPaymentAmount(19446, WalletCurrency.Usdt)).toEqual( + expect.objectContaining({ + currency: WalletCurrency.Usdt, + amount: 19446n, + }), + ) + }) + it("errors on null", () => { expect(checkedToUsdPaymentAmount(null)).toBeInstanceOf(InvalidUsdPaymentAmountError) }) diff --git a/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts b/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts new file mode 100644 index 000000000..241dae0f7 --- /dev/null +++ b/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts @@ -0,0 +1,30 @@ +import { AccountStatus } from "@domain/accounts" +import { OnchainUsdPaymentValidator } from "@domain/wallets" +import { USDTAmount, WalletCurrency, isValidated } from "@domain/shared" + +const account = { + id: "account-id" as AccountId, + status: AccountStatus.Active, +} as Account + +const usdtWallet = { + id: "wallet-id" as WalletId, + accountId: account.id, + currency: WalletCurrency.Usdt, +} as Wallet + +describe("Onchain USD wallet payment validation", () => { + it("accepts USDT USD wallets", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + + const result = await OnchainUsdPaymentValidator({ + account, + wallet: usdtWallet, + accountId: usdtWallet.id as IbexAccountId, + address: "0xabc" as OnChainAddress, + amount, + }) + + expect(isValidated(result)).toBe(true) + }) +}) diff --git a/test/flash/unit/services/ibex/client-usd-wallet.spec.ts b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts new file mode 100644 index 000000000..0c6dc89e4 --- /dev/null +++ b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts @@ -0,0 +1,135 @@ +const mockAddInvoice = jest.fn() +const mockGetFeeEstimation = jest.fn() +const mockEstimateFeeV2 = jest.fn() + +jest.mock("@services/ibex/cache", () => ({ + Redis: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock("ibex-client", () => { + class AuthenticationError extends Error {} + class ApiError extends Error {} + class UnexpectedResponseError extends Error {} + class IbexClientError extends Error {} + + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + authentication: { + storage: { + getAccessToken: jest.fn(), + setAccessToken: jest.fn(), + setRefreshToken: jest.fn(), + }, + }, + addInvoice: (...args: unknown[]) => mockAddInvoice(...args), + getFeeEstimation: (...args: unknown[]) => mockGetFeeEstimation(...args), + estimateFeeV2: (...args: unknown[]) => mockEstimateFeeV2(...args), + })), + AuthenticationError, + ApiError, + UnexpectedResponseError, + IbexClientError, + } +}) + +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" + +describe("IBEX USD wallet amount handling", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("creates USDT invoices using decimal USDT amount", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1" } }) + + await Ibex.addInvoice({ + accountId: "ibex-usdt-account" as IbexAccountId, + amount, + memo: "usdt invoice", + }) + + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ibex-usdt-account", + amount: 0.019446, + }), + ) + }) + + it("estimates LN fees with USDT currency id and parses USDT amounts", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockGetFeeEstimation.mockResolvedValue({ amount: 0.000123, invoiceAmount: 0.019446 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + send: amount, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: "0.019446", + currencyId: "29", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDTAmount }).fee).toBeInstanceOf(USDTAmount) + expect((result as { fee: USDTAmount }).fee.asSmallestUnits()).toBe("123") + expect((result as { invoice: USDTAmount }).invoice.asSmallestUnits()).toBe("19446") + }) + + it("estimates fixed-amount LN fees with USDT currency id when no send amount is provided", async () => { + mockGetFeeEstimation.mockResolvedValue({ amount: 0.000123, invoiceAmount: 0.019446 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + currency: WalletCurrency.Usdt, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: undefined, + currencyId: "29", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDTAmount }).fee).toBeInstanceOf(USDTAmount) + expect((result as { fee: USDTAmount }).fee.asSmallestUnits()).toBe("123") + }) + + it("keeps USD LN fee estimation behavior unchanged", async () => { + const amount = USDAmount.cents("19446") as USDAmount + mockGetFeeEstimation.mockResolvedValue({ amount: 0.12, invoiceAmount: 194.46 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + send: amount, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: "194.46", + currencyId: "3", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDAmount }).fee).toBeInstanceOf(USDAmount) + expect((result as { fee: USDAmount }).fee.asCents()).toBe("12") + }) + + it("estimates on-chain fees with USDT currency id", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockEstimateFeeV2.mockResolvedValue({ fee: 0.000123 }) + + await Ibex.estimateOnchainFee(amount, "0xabc" as OnChainAddress) + + expect(mockEstimateFeeV2).toHaveBeenCalledWith({ + "amount": 0.019446, + "currency-id": "29", + "address": "0xabc", + }) + }) +}) From a09b529a55f901b1f4ccb6e842ecac41cca66383 Mon Sep 17 00:00:00 2001 From: forge0x Date: Mon, 18 May 2026 12:10:46 -0400 Subject: [PATCH 2/2] fix(wallets): support USDT intraledger sends --- src/app/payments/send-intraledger.ts | 14 +- .../app/payments/send-intraledger.spec.ts | 262 ++++++++++++++++++ 2 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/app/payments/send-intraledger.spec.ts diff --git a/src/app/payments/send-intraledger.ts b/src/app/payments/send-intraledger.ts index fb7017d09..b38633361 100644 --- a/src/app/payments/send-intraledger.ts +++ b/src/app/payments/send-intraledger.ts @@ -7,6 +7,7 @@ import { } from "@app/prices" import { removeDeviceTokens } from "@app/users/remove-device-tokens" import { validateIsBtcWallet, validateIsUsdWallet } from "@app/wallets" +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" import { InvalidLightningPaymentFlowBuilderStateError, @@ -15,11 +16,12 @@ import { } from "@domain/payments" import { AccountLevel, AccountValidator } from "@domain/accounts" import { DisplayAmountsConverter } from "@domain/fiat" -import { BigIntConversionError, checkedToUsdPaymentAmount, ErrorLevel, paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" +import { checkedToUsdPaymentAmount, ErrorLevel, paymentAmountFromNumber, ValidationError, WalletCurrency } from "@domain/shared" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { ResourceExpiredLockServiceError } from "@domain/lock" import { checkedToWalletId, SettlementMethod } from "@domain/wallets" import { DeviceTokensNotRegisteredNotificationsServiceError } from "@domain/notifications" +import { MismatchedCurrencyForWalletError } from "@domain/errors" import { LockService } from "@services/lock" import { LedgerService } from "@services/ledger" @@ -74,8 +76,12 @@ const intraledgerPaymentSendWalletId = async ({ kratosUserId: recipientUserId, } = recipientAccount - const amount = USDAmount.cents(uncheckedAmount.toString()) - if (amount instanceof BigIntConversionError) return amount + if (senderWallet.currency !== recipientWallet.currency) { + return new MismatchedCurrencyForWalletError() + } + + const amount = usdWalletAmountFromInput(uncheckedAmount.toString(), senderWallet.currency) + if (amount instanceof Error) return amount const invoiceResp = await Ibex.addInvoice({ accountId: recipientWalletId, amount, @@ -132,7 +138,7 @@ export const intraledgerPaymentSendWalletIdForBtcWallet = async ( export const intraledgerPaymentSendWalletIdForUsdWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, ): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId) + const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) return validated instanceof Error ? validated : intraledgerPaymentSendWalletId(args) } diff --git a/test/flash/unit/app/payments/send-intraledger.spec.ts b/test/flash/unit/app/payments/send-intraledger.spec.ts new file mode 100644 index 000000000..179ff1259 --- /dev/null +++ b/test/flash/unit/app/payments/send-intraledger.spec.ts @@ -0,0 +1,262 @@ +const mockAddInvoice = jest.fn() +const mockPayInvoice = jest.fn() +const mockFindWalletById = jest.fn() +const mockFindAccountById = jest.fn() + +jest.mock("@config", () => ({ + getCallbackServiceConfig: jest.fn(() => ({})), + getValuesToSkipProbe: jest.fn(() => []), +})) + +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@app/prices", () => ({ + btcFromUsdMidPriceFn: jest.fn(), + getCurrentPriceAsDisplayPriceRatio: jest.fn(), + usdFromBtcMidPriceFn: jest.fn(), +})) + +jest.mock("@app/wallets", () => { + const { MismatchedCurrencyForWalletError } = jest.requireActual("@domain/errors") + const { WalletCurrency } = jest.requireActual("@domain/shared") + + const validateIsBtcWallet = jest.fn(async () => true) + const validateIsUsdWallet = jest.fn(async (walletId, args) => { + const wallet = await mockFindWalletById(walletId) + if (wallet instanceof Error) return wallet + + if ( + wallet.currency === WalletCurrency.Usd || + (args?.includeUsdt === true && wallet.currency === WalletCurrency.Usdt) + ) { + return true + } + + return new MismatchedCurrencyForWalletError() + }) + + return { validateIsBtcWallet, validateIsUsdWallet } +}) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + addInvoice: (...args: unknown[]) => mockAddInvoice(...args), + payInvoice: (...args: unknown[]) => mockPayInvoice(...args), + }, +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindAccountById(...args), + })), + WalletsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + })), + UsersRepository: jest.fn(), +})) + +jest.mock("@services/dealer-price", () => ({ + DealerPriceService: jest.fn(() => ({})), +})) + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(() => ({})), +})) + +jest.mock("@services/ledger", () => ({ + LedgerService: jest.fn(() => ({})), +})) + +jest.mock("@services/ledger/facade", () => ({})) + +jest.mock("@services/notifications", () => ({ + NotificationsService: jest.fn(() => ({})), +})) + +jest.mock("@services/svix", () => ({ + CallbackService: jest.fn(() => ({})), +})) + +jest.mock("@app/payments/helpers", () => ({ + addContactsAfterSend: jest.fn(), + checkIntraledgerLimits: jest.fn(async () => true), + checkTradeIntraAccountLimits: jest.fn(async () => true), + getPriceRatioForLimits: jest.fn(async () => ({})), +})) + +import { intraledgerPaymentSendWalletIdForUsdWallet } from "@app/payments/send-intraledger" +import { MismatchedCurrencyForWalletError } from "@domain/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +const senderUsdWalletId = "11111111-1111-4111-8111-111111111111" as WalletId +const senderUsdtWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const recipientUsdWalletId = "33333333-3333-4333-8333-333333333333" as WalletId +const recipientUsdtWalletId = "44444444-4444-4444-8444-444444444444" as WalletId + +const activeAccount = (id: string) => + ({ + id, + status: "active", + level: 1, + }) as unknown as Account + +const wallet = ({ + id, + accountId, + currency, +}: { + id: string + accountId: string + currency: string +}) => + ({ + id, + accountId, + currency, + }) as unknown as Wallet + +describe("intraledgerPaymentSendWalletIdForUsdWallet", () => { + beforeEach(() => { + jest.clearAllMocks() + + mockFindAccountById.mockImplementation(async (accountId: AccountId) => + activeAccount(accountId as string), + ) + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1recipient" } }) + mockPayInvoice.mockResolvedValue({ status: 2 }) + }) + + it("keeps USD to USD using cent amount semantics", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdWalletId) { + return wallet({ + id: senderUsdWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usd, + }) + } + return wallet({ + id: recipientUsdWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usd, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 19446, + memo: "USD intraledger", + }) + + expect(result).toEqual({ value: "success" }) + expect(mockAddInvoice).toHaveBeenCalledWith({ + accountId: recipientUsdWalletId, + amount: expect.any(USDAmount), + memo: "USD intraledger", + }) + expect(mockAddInvoice.mock.calls[0][0].amount.asCents()).toBe("19446") + expect(mockAddInvoice.mock.calls[0][0].amount.toIbex()).toBe(194.46) + expect(mockPayInvoice).toHaveBeenCalledWith({ + accountId: senderUsdWalletId, + invoice: "lnbc1recipient", + }) + }) + + it("sends USDT to USDT using USDT micro-unit amount semantics", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdtWalletId) { + return wallet({ + id: senderUsdtWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usdt, + }) + } + return wallet({ + id: recipientUsdtWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usdt, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdtWalletId, + recipientWalletId: recipientUsdtWalletId, + amount: 19446, + memo: "USDT intraledger", + }) + + expect(result).toEqual({ value: "success" }) + expect(mockAddInvoice).toHaveBeenCalledWith({ + accountId: recipientUsdtWalletId, + amount: expect.any(USDTAmount), + memo: "USDT intraledger", + }) + expect(mockAddInvoice.mock.calls[0][0].amount.asSmallestUnits()).toBe("19446") + expect(mockAddInvoice.mock.calls[0][0].amount.toIbex()).toBe(0.019446) + expect(mockPayInvoice).toHaveBeenCalledWith({ + accountId: senderUsdtWalletId, + invoice: "lnbc1recipient", + }) + }) + + it("rejects USD to USDT as a mixed-currency intraledger payment", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdWalletId) { + return wallet({ + id: senderUsdWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usd, + }) + } + return wallet({ + id: recipientUsdtWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usdt, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdtWalletId, + amount: 100, + memo: "mixed currency", + }) + + expect(result).toBeInstanceOf(MismatchedCurrencyForWalletError) + expect(mockAddInvoice).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("rejects USDT to USD as a mixed-currency intraledger payment", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdtWalletId) { + return wallet({ + id: senderUsdtWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usdt, + }) + } + return wallet({ + id: recipientUsdWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usd, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdtWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 100, + memo: "mixed currency", + }) + + expect(result).toBeInstanceOf(MismatchedCurrencyForWalletError) + expect(mockAddInvoice).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) +})