From 3ed7ab41a94c52edc474c00594a7de375ea62c70 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 29 Jun 2026 16:32:19 -0700 Subject: [PATCH] fix(cash-wallet-cutover): preserve precise USD migration amounts --- .../cash-wallet-cutover/amount-conversion.ts | 31 +++- .../cash-wallet-cutover/runtime-services.ts | 155 ++++++++++++------ src/app/cash-wallet-cutover/worker.ts | 63 +++++-- src/services/ibex/client.ts | 19 +++ src/services/ibex/types.ts | 7 + .../amount-conversion.spec.ts | 8 + .../app/cash-wallet-cutover/worker.spec.ts | 93 +++++++++++ 7 files changed, 308 insertions(+), 68 deletions(-) create mode 100644 test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/worker.spec.ts diff --git a/src/app/cash-wallet-cutover/amount-conversion.ts b/src/app/cash-wallet-cutover/amount-conversion.ts index fd6bd4e09..9d503f70b 100644 --- a/src/app/cash-wallet-cutover/amount-conversion.ts +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -13,16 +13,37 @@ const parseNonNegativeInteger = ( return BigInt(value) } +const decimalToScaledInteger = ({ + value, + scale, +}: { + value: string + scale: number +}): string | InvalidCashWalletCutoverAmountError => { + if (!/^\d+(\.\d+)?$/.test(value)) { + return new InvalidCashWalletCutoverAmountError( + `Invalid non-negative decimal amount: ${value}`, + ) + } + + const [whole, fraction = ""] = value.split(".") + const scaleFactor = 10n ** BigInt(scale) + const paddedFraction = `${fraction}${"0".repeat(scale + 1)}`.slice(0, scale + 1) + const scaledFraction = paddedFraction.slice(0, scale) + const roundingDigit = Number(paddedFraction[scale] ?? "0") + + const scaled = + BigInt(whole) * scaleFactor + BigInt(scaledFraction === "" ? "0" : scaledFraction) + + return (scaled + (roundingDigit >= 5 ? 1n : 0n)).toString() +} + export const usdCentsToUsdtMicros = ( usdCents: string, ): string | InvalidCashWalletCutoverAmountError => { - const parsed = parseNonNegativeInteger(usdCents) - if (parsed instanceof Error) return parsed - return (parsed * USDT_MICROS_PER_USD_CENT).toString() + return decimalToScaledInteger({ value: usdCents, scale: 4 }) } -export const feeUsdCentsToUsdtMicros = usdCentsToUsdtMicros - export const usdtMicrosToUsdCentsCeil = ( usdtMicros: string, ): string | InvalidCashWalletCutoverAmountError => { diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index cad95c60e..1a5d13563 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -1,5 +1,4 @@ import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" -import { getBalanceForWallet } from "@app/wallets" import { decodeInvoice } from "@domain/bitcoin/lightning" import { InvalidWalletId } from "@domain/errors" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" @@ -26,7 +25,7 @@ type RuntimeServiceDependencies = { now?: () => Date addWalletIfNonexistent?: typeof addWalletIfNonexistent updateDefaultWalletId?: typeof updateDefaultWalletId - getBalanceForWallet?: typeof getBalanceForWallet + getRawAccountDetails?: typeof Ibex.getRawAccountDetails createInvoice?: typeof Ibex.addInvoice createNoAmountInvoice?: typeof Ibex.addInvoice payInvoice?: typeof Ibex.payInvoice @@ -37,9 +36,74 @@ type RuntimeServiceDependencies = { sleep?: SleepFn } -const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount -const isUsdtAmount = (amount: unknown): amount is USDTAmount => - amount instanceof USDTAmount +const normalizeDecimalString = (amount: number | string): string => + (typeof amount === "number" ? amount.toFixed(12) : amount).replace(/\.?0+$/, "") || "0" + +const decimalToScaledInteger = ({ + amount, + scale, + round = true, +}: { + amount: number | string + scale: number + round?: boolean +}): string | InvalidCashWalletCutoverAmountError => { + const normalized = normalizeDecimalString(amount) + if (!/^\d+(\.\d+)?$/.test(normalized)) { + return new InvalidCashWalletCutoverAmountError( + `Invalid non-negative decimal amount: ${normalized}`, + ) + } + + const [whole, fraction = ""] = normalized.split(".") + const scaleFactor = 10n ** BigInt(scale) + const paddedFraction = `${fraction}${"0".repeat(scale + 1)}`.slice(0, scale + 1) + const scaledFraction = paddedFraction.slice(0, scale) + const roundingDigit = Number(paddedFraction[scale] ?? "0") + const scaled = + BigInt(whole) * scaleFactor + BigInt(scaledFraction === "" ? "0" : scaledFraction) + + return (scaled + (round && roundingDigit >= 5 ? 1n : 0n)).toString() +} + +const scaledIntegerToDecimal = ({ + amount, + scale, +}: { + amount: string + scale: number +}): string => { + if (scale === 0) return amount + + const padded = amount.padStart(scale + 1, "0") + const whole = padded.slice(0, -scale) + const fraction = padded.slice(-scale).replace(/0+$/, "") + return fraction ? `${whole}.${fraction}` : whole +} + +const ibexUsdDollarsToPreciseCents = ( + amount: number | string, +): string | InvalidCashWalletCutoverAmountError => { + const dollarsScaledToEightDecimals = decimalToScaledInteger({ + amount, + scale: 8, + round: false, + }) + if (dollarsScaledToEightDecimals instanceof Error) return dollarsScaledToEightDecimals + + return scaledIntegerToDecimal({ + amount: dollarsScaledToEightDecimals, + scale: 6, + }) +} + +const ibexMajorUnitsToUsdtMicros = ( + amount: number | string, +): string | InvalidCashWalletCutoverAmountError => + decimalToScaledInteger({ amount, scale: 6 }) + +const hasOnlySubMicroMajorUnitDust = (amount: number | string | undefined): boolean => + amount === undefined || Number(normalizeDecimalString(amount)) < 0.000001 const ibexInvoiceToDomainInvoice = ( response: Awaited>, @@ -94,7 +158,7 @@ export const createCashWalletMigrationRuntimeServices = ( ) => { const addWallet = deps.addWalletIfNonexistent ?? addWalletIfNonexistent const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId - const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet + const rawAccountDetails = deps.getRawAccountDetails ?? Ibex.getRawAccountDetails const invoiceForRecipient = deps.createInvoice ?? Ibex.addInvoice const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice const payInvoice = deps.payInvoice ?? Ibex.payInvoice @@ -132,28 +196,20 @@ export const createCashWalletMigrationRuntimeServices = ( readSourceBalanceUsdCents: async ( migration: CashWalletMigration, ): Promise => { - const balance = await balanceForWallet({ - walletId: migration.legacyUsdWalletId, - currency: WalletCurrency.Usd, - }) - if (balance instanceof Error) return balance - if (!isUsdAmount(balance)) { - return new InvalidCashWalletCutoverAmountError("Expected USD balance") - } - return balance.asCents() + const account = await rawAccountDetails( + migration.legacyUsdWalletId as IbexAccountId, + ) + if (account instanceof Error) return account + return ibexUsdDollarsToPreciseCents(account.balance ?? 0) }, readDestinationBalanceUsdtMicros: async ( migration: CashWalletMigration, ): Promise => { - const balance = await balanceForWallet({ - walletId: migration.destinationUsdtWalletId, - currency: WalletCurrency.Usdt, - }) - if (balance instanceof Error) return balance - if (!isUsdtAmount(balance)) { - return new InvalidCashWalletCutoverAmountError("Expected USDT balance") - } - return balance.asSmallestUnits() + const account = await rawAccountDetails( + migration.destinationUsdtWalletId as IbexAccountId, + ) + if (account instanceof Error) return account + return ibexMajorUnitsToUsdtMicros(account.balance ?? 0) }, }, invoiceService: { @@ -203,15 +259,25 @@ export const createCashWalletMigrationRuntimeServices = ( senderWalletId, paymentRequest, senderAmountUsdCents, + senderAmountUsdtMicros, }: { senderWalletId: WalletId paymentRequest: string senderAmountUsdCents?: string + senderAmountUsdtMicros?: string }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> => { + if (senderAmountUsdCents !== undefined && senderAmountUsdtMicros !== undefined) { + return new InvalidCashWalletCutoverAmountError( + "Only one explicit sender amount can be provided", + ) + } + const send = - senderAmountUsdCents === undefined - ? undefined - : USDAmount.cents(senderAmountUsdCents) + senderAmountUsdCents !== undefined + ? USDAmount.cents(senderAmountUsdCents) + : senderAmountUsdtMicros !== undefined + ? USDTAmount.smallestUnits(senderAmountUsdtMicros) + : undefined if (send instanceof Error) return send const payment = await withIbexRateLimitRetry({ @@ -238,12 +304,9 @@ export const createCashWalletMigrationRuntimeServices = ( }: { legacyUsdWalletId: WalletId }): Promise => { - const balance = await balanceForWallet({ - walletId: legacyUsdWalletId, - currency: WalletCurrency.Usd, - }) - if (balance instanceof Error) return balance - if (!isUsdAmount(balance) || !balance.isZero()) { + const account = await rawAccountDetails(legacyUsdWalletId as IbexAccountId) + if (account instanceof Error) return account + if (!hasOnlySubMicroMajorUnitDust(account.balance)) { return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") } return true @@ -271,19 +334,18 @@ export const createCashWalletMigrationRuntimeServices = ( ) } - const currentBalance = await balanceForWallet({ - walletId: migration.destinationUsdtWalletId, - currency: WalletCurrency.Usdt, - }) - if (currentBalance instanceof Error) return currentBalance - if (!isUsdtAmount(currentBalance)) { - return new InvalidCashWalletCutoverAmountError("Expected USDT balance") - } + const currentAccount = await rawAccountDetails( + migration.destinationUsdtWalletId as IbexAccountId, + ) + if (currentAccount instanceof Error) return currentAccount + + const currentUsdtMicros = ibexMajorUnitsToUsdtMicros(currentAccount.balance ?? 0) + if (currentUsdtMicros instanceof Error) return currentUsdtMicros return destinationShortfallUsdtMicros({ targetUsdtMicros: migration.destinationAmountUsdtMicros, startingUsdtMicros: migration.destinationStartingBalanceUsdtMicros, - currentUsdtMicros: currentBalance.asSmallestUnits(), + currentUsdtMicros, }) }, }, @@ -317,12 +379,9 @@ export const createCashWalletMigrationRuntimeServices = ( }: { legacyUsdWalletId: WalletId }): Promise => { - const balance = await balanceForWallet({ - walletId: legacyUsdWalletId, - currency: WalletCurrency.Usd, - }) - if (balance instanceof Error) return balance - if (!isUsdAmount(balance) || !balance.isZero()) { + const account = await rawAccountDetails(legacyUsdWalletId as IbexAccountId) + if (account instanceof Error) return account + if (!hasOnlySubMicroMajorUnitDust(account.balance)) { return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") } return true diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index f0b860031..b71d0d4f3 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,11 +1,7 @@ import { decodeInvoice } from "@domain/bitcoin/lightning" import { assertCanTransition } from "./state-machine" -import { - feeUsdCentsToUsdtMicros, - usdCentsToUsdtMicros, - usdtMicrosToUsdCentsCeil, -} from "./amount-conversion" +import { usdCentsToUsdtMicros, usdtMicrosToUsdCentsCeil } from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -28,6 +24,10 @@ type CashWalletMigrationInvoiceService = { amount: string memo: string }): Promise + createNoAmountInvoice(args: { + recipientWalletId: WalletId + memo: string + }): Promise } type CashWalletMigrationNoAmountInvoiceService = { @@ -42,6 +42,7 @@ type CashWalletMigrationPaymentService = { senderWalletId: WalletId paymentRequest: string senderAmountUsdCents?: string + senderAmountUsdtMicros?: string }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> } @@ -76,6 +77,19 @@ type CashWalletMigrationProvisioningService = { } const CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS = 30 * 1000 +const CUTOVER_MIN_FIXED_USDT_INVOICE_MICROS = 10_000n + +const usesNoAmountFeeReimbursementInvoice = ( + feeAmountUsdtMicros: string, +): boolean | InvalidCashWalletCutoverAmountError => { + if (!/^\d+$/.test(feeAmountUsdtMicros)) { + return new InvalidCashWalletCutoverAmountError( + `Invalid non-negative integer amount: ${feeAmountUsdtMicros}`, + ) + } + + return BigInt(feeAmountUsdtMicros) < CUTOVER_MIN_FIXED_USDT_INVOICE_MICROS +} const isInvoicePaymentRequestStale = ({ paymentRequest, @@ -332,7 +346,7 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ const invoice = await invoiceService.createNoAmountInvoice({ recipientWalletId: migration.destinationUsdtWalletId, - memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:balance-move`, + memo: `cwco:${migration.runId}:${migration.id}:move`, }) if (invoice instanceof Error) return invoice @@ -363,21 +377,26 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ const feeAmountUsdCents = usdtMicrosToUsdCentsCeil(feeAmountUsdtMicros) if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents - const reimbursableFeeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) - if (reimbursableFeeAmountUsdtMicros instanceof Error) - return reimbursableFeeAmountUsdtMicros - const transition = assertCanTransition( migration.status, "fee_reimbursement_invoice_created", ) if (transition instanceof Error) return transition - const invoice = await invoiceService.createInvoice({ - recipientWalletId: migration.destinationUsdtWalletId, - amount: reimbursableFeeAmountUsdtMicros, - memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:fee-reimbursement`, - }) + const useNoAmountInvoice = usesNoAmountFeeReimbursementInvoice(feeAmountUsdtMicros) + if (useNoAmountInvoice instanceof Error) return useNoAmountInvoice + + const invoiceMemo = `cwco:${migration.runId}:${migration.id}:fee` + const invoice = useNoAmountInvoice + ? await invoiceService.createNoAmountInvoice({ + recipientWalletId: migration.destinationUsdtWalletId, + memo: invoiceMemo, + }) + : await invoiceService.createInvoice({ + recipientWalletId: migration.destinationUsdtWalletId, + amount: feeAmountUsdtMicros, + memo: invoiceMemo, + }) if (invoice instanceof Error) return invoice return migrationsRepo.transitionMigration({ @@ -475,9 +494,23 @@ export const sendCashWalletMigrationFeeReimbursementPayment = async ({ ) } + if (payableMigration.feeAmountUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeAmountUsdtMicros is required before fee reimbursement sending", + ) + } + + const useNoAmountInvoice = usesNoAmountFeeReimbursementInvoice( + payableMigration.feeAmountUsdtMicros, + ) + if (useNoAmountInvoice instanceof Error) return useNoAmountInvoice + const payment = await paymentService.payInvoice({ senderWalletId: treasuryWalletId, paymentRequest: payableMigration.feeReimbursementInvoicePaymentRequest, + senderAmountUsdtMicros: useNoAmountInvoice + ? payableMigration.feeAmountUsdtMicros + : undefined, }) if (payment instanceof Error) return payment diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index b9b06a584..e313146b1 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -37,6 +37,7 @@ import { GetFeeEstimateArgs, IbexAccountDetails, IbexFeeEstimation, + IbexRawAccountDetails, IbexInvoiceArgs, PayInvoiceArgs, CryptoReceiveOption, @@ -140,6 +141,23 @@ const getAccountDetails = async ( .then(errorHandler) } +const getRawAccountDetails = async ( + accountId: IbexAccountId, +): Promise => { + return Ibex.getAccountDetails({ accountId }) + .then((resp) => { + if (resp instanceof Error) return resp + + return { + id: resp.id, + userId: resp.userId, + name: resp.name, + balance: resp.balance, + } + }) + .then(errorHandler) +} + const getAccountTransactions = async ( params: GMetadataParam, ): Promise => { @@ -544,6 +562,7 @@ export default wrapAsyncFunctionsToRunInSpan({ getTransactionDetails, createAccount, getAccountDetails, + getRawAccountDetails, generateBitcoinAddress, addInvoice, invoiceFromHash, diff --git a/src/services/ibex/types.ts b/src/services/ibex/types.ts index a3183bd04..7b952e283 100644 --- a/src/services/ibex/types.ts +++ b/src/services/ibex/types.ts @@ -33,6 +33,13 @@ export type IbexAccountDetails = { balance: USDAmount | USDTAmount | undefined } +export type IbexRawAccountDetails = { + id: string | undefined + userId: string | undefined + name: string | undefined + balance: number | undefined +} + export type IbexInvoiceArgs = { accountId: IbexAccountId amount?: UsdWalletAmount diff --git a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts new file mode 100644 index 000000000..4a42c6691 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -0,0 +1,8 @@ +import { usdCentsToUsdtMicros } from "@app/cash-wallet-cutover/amount-conversion" + +describe("cash wallet cutover amount conversion", () => { + it("converts precise USD cents to USDT micros", () => { + expect(usdCentsToUsdtMicros("24.035292")).toBe("240353") + expect(usdCentsToUsdtMicros("24.744298")).toBe("247443") + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts new file mode 100644 index 000000000..32ffd382e --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -0,0 +1,93 @@ +import { + createCashWalletMigrationFeeReimbursementInvoice, + sendCashWalletMigrationFeeReimbursementPayment, +} from "@app/cash-wallet-cutover/worker" + +const migration = { + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-wallet-id" as WalletId, + destinationUsdtWalletId: "destination-wallet-id" as WalletId, + cutoverVersion: 3, + runId: "run-id", + status: "balance_move_verified", + idempotencyKey: "run-id:account-id", + attempts: 0, + updatedAt: new Date("2026-06-29T00:00:00.000Z"), +} as CashWalletMigration + +const invoice = { + paymentRequest: "lnbc1invoice" as Bolt11, + paymentHash: "payment-hash" as PaymentHash, +} + +const transitionRepo = () => ({ + transitionMigration: jest.fn(async ({ to, patch }) => ({ + ...migration, + ...patch, + status: to, + })), +}) + +describe("cash wallet migration worker fee reimbursement", () => { + it("uses a no-amount invoice for sub-cent USDT reimbursements", async () => { + const migrationsRepo = transitionRepo() + const invoiceService = { + createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn().mockResolvedValue(invoice), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration, + migrationsRepo, + invoiceService, + feeAmountUsdtMicros: "4735", + }) + + expect(result).toEqual({ + ...migration, + status: "fee_reimbursement_invoice_created", + feeAmountUsdCents: "1", + feeAmountUsdtMicros: "4735", + feeReimbursementInvoicePaymentRequest: invoice.paymentRequest, + feeReimbursementInvoicePaymentHash: invoice.paymentHash, + }) + expect(invoiceService.createNoAmountInvoice).toHaveBeenCalledWith({ + recipientWalletId: migration.destinationUsdtWalletId, + memo: `cwco:${migration.runId}:${migration.id}:fee`, + }) + expect(invoiceService.createInvoice).not.toHaveBeenCalled() + }) + + it("pays sub-cent reimbursement invoices with the exact USDT amount", async () => { + const migrationsRepo = transitionRepo() + const paymentService = { + payInvoice: jest.fn().mockResolvedValue({ + transactionId: "fee-transaction-id" as IbexTransactionId, + }), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: { + ...migration, + status: "fee_reimbursement_invoice_created", + feeAmountUsdtMicros: "4735", + feeReimbursementInvoicePaymentRequest: invoice.paymentRequest, + } as CashWalletMigration, + treasuryWalletId: "treasury-wallet-id" as WalletId, + migrationsRepo, + paymentService, + }) + + expect(result).toEqual({ + ...migration, + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-transaction-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "treasury-wallet-id", + paymentRequest: invoice.paymentRequest, + senderAmountUsdtMicros: "4735", + }) + }) +})