diff --git a/src/app/wallets/get-send-all-onchain-amount.ts b/src/app/wallets/get-send-all-onchain-amount.ts new file mode 100644 index 000000000..76955c8a7 --- /dev/null +++ b/src/app/wallets/get-send-all-onchain-amount.ts @@ -0,0 +1,61 @@ +import { USDAmount } from "@domain/shared" + +import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" +import { baseLogger } from "@services/logger" + +// #270: `onChainPaymentSendAll` handed IBEX the entire wallet balance, and IBEX +// charges the on-chain fee on top of the amount it forwards. A full-balance +// request therefore either fails at the rail or leaves dust behind instead of +// sweeping the wallet. Estimate the fee for this payout and subtract it so the +// sweep lands. +// +// A failed estimate is not a reason to refuse the send: every branch below +// returns a usable amount, and the caller keeps the pre-existing behaviour +// (whole balance) when the rail has nothing to say about the fee. +export const getSendAllOnChainAmount = async ({ + amount, + address, +}: { + amount: USDAmount + address: OnChainAddress +}): Promise => { + const resp = await Ibex.estimateOnchainFee(amount, address) + if (resp instanceof IbexError) { + baseLogger.warn( + { address, message: resp.message }, + "Onchain fee estimate unavailable for sendAll; sweeping the full balance", + ) + return amount + } + + const fee = resp.fee === undefined ? Number.NaN : Number(resp.fee) + if (!Number.isFinite(fee) || fee <= 0) { + baseLogger.warn( + { address, fee: resp.fee }, + "Onchain fee estimate returned no fee for sendAll; sweeping the full balance", + ) + return amount + } + + const feeAmount = USDAmount.dollars(fee) + if (feeAmount instanceof Error) { + baseLogger.warn( + { address, fee, message: feeAmount.message }, + "Could not parse the onchain fee estimate for sendAll; sweeping the full balance", + ) + return amount + } + + // A fee that eats the whole balance is not a sendable sweep: sending + // `amount - fee` would be zero or negative, which the rail refuses anyway. + if (!amount.isGreaterThan(feeAmount)) { + baseLogger.warn( + { address, fee }, + "Onchain fee covers the whole balance for sendAll; nothing left to sweep", + ) + return amount + } + + return amount.subtract(feeAmount) +} diff --git a/src/app/wallets/index.ts b/src/app/wallets/index.ts index 94210271c..d14a0c640 100644 --- a/src/app/wallets/index.ts +++ b/src/app/wallets/index.ts @@ -1,41 +1,42 @@ -export * from "./add-invoice-for-wallet" -export * from "./add-pending-on-chain-transaction" -export * from "./remove-pending-on-chain-transaction" -export * from "./add-settled-on-chain-transaction" -export * from "./create-on-chain-address" -export * from "./get-balance-for-wallet" -export * from "./get-last-on-chain-address" -export * from "./get-on-chain-fee" -export * from "./get-pending-onchain-balance-for-wallet" -export * from "./get-transaction-by-id" -export * from "./get-transactions-by-addresses" -export * from "./get-transactions-by-hash" -export * from "./get-transactions-for-wallet" -export * from "./send-on-chain" -export * from "./register-broadcasted-payout-txn" -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" - -export const getWallet = async (walletId: WalletId) => { - const wallets = WalletsRepository() - return wallets.findById(walletId) -} - -export const listWalletsByAccountId = async ( - accountId: AccountId, -): Promise => { - return WalletsRepository().listByAccountId(accountId) -} - -export const listWalletIds = async ( - walletCurrency: WalletCurrency, -): Promise => { - const wallets = await WalletsRepository().listByWalletCurrency(walletCurrency) - if (wallets instanceof Error) return wallets - return wallets.map((wallet) => wallet.id) -} +export * from "./add-invoice-for-wallet" +export * from "./add-pending-on-chain-transaction" +export * from "./remove-pending-on-chain-transaction" +export * from "./add-settled-on-chain-transaction" +export * from "./create-on-chain-address" +export * from "./get-balance-for-wallet" +export * from "./get-last-on-chain-address" +export * from "./get-on-chain-fee" +export * from "./get-pending-onchain-balance-for-wallet" +export * from "./get-transaction-by-id" +export * from "./get-transactions-by-addresses" +export * from "./get-transactions-by-hash" +export * from "./get-transactions-for-wallet" +export * from "./get-send-all-onchain-amount" +export * from "./send-on-chain" +export * from "./register-broadcasted-payout-txn" +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" + +export const getWallet = async (walletId: WalletId) => { + const wallets = WalletsRepository() + return wallets.findById(walletId) +} + +export const listWalletsByAccountId = async ( + accountId: AccountId, +): Promise => { + return WalletsRepository().listByAccountId(accountId) +} + +export const listWalletIds = async ( + walletCurrency: WalletCurrency, +): Promise => { + const wallets = await WalletsRepository().listByWalletCurrency(walletCurrency) + if (wallets instanceof Error) return wallets + return wallets.map((wallet) => wallet.id) +} diff --git a/src/graphql/public/root/mutation/onchain-payment-send-all.ts b/src/graphql/public/root/mutation/onchain-payment-send-all.ts index 7e2472508..87f850791 100644 --- a/src/graphql/public/root/mutation/onchain-payment-send-all.ts +++ b/src/graphql/public/root/mutation/onchain-payment-send-all.ts @@ -1,132 +1,140 @@ -import { PayoutSpeed as DomainPayoutSpeed } from "@domain/bitcoin/onchain" - -import { GT } from "@graphql/index" -import Memo from "@graphql/shared/types/scalar/memo" -import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" -import OnChainAddress from "@graphql/shared/types/scalar/on-chain-address" -import PaymentSendPayload from "@graphql/public/types/payload/payment-send" -import PayoutSpeed from "@graphql/public/types/scalar/payout-speed" -import WalletId from "@graphql/shared/types/scalar/wallet-id" - -import { Wallets } from "@app" -import { authorizeSend } from "@app/payments/authorize-send" -import { getBalanceForWallet } from "@app/wallets" -import { USDAmount, WalletCurrency } from "@domain/shared" - -const OnChainPaymentSendAllInput = GT.Input({ - name: "OnChainPaymentSendAllInput", - fields: () => ({ - walletId: { type: GT.NonNull(WalletId) }, - address: { type: GT.NonNull(OnChainAddress) }, - speed: { - type: PayoutSpeed, - defaultValue: DomainPayoutSpeed.Fast, - }, - memo: { type: Memo }, - }), -}) - -const OnChainPaymentSendAllMutation = GT.Field< - null, - GraphQLPublicContextAuth, - { - input: { - walletId: WalletId | InputValidationError - address: OnChainAddress | InputValidationError - memo: Memo | InputValidationError | null - speed: PayoutSpeed | InputValidationError - } - } ->({ - extensions: { - complexity: 120, - }, - type: GT.NonNull(PaymentSendPayload), - args: { - input: { type: GT.NonNull(OnChainPaymentSendAllInput) }, - }, - resolve: async (_, args, { domainAccount }) => { - const { walletId, address, memo, speed } = args.input - - if (walletId instanceof Error) { - return { errors: [{ message: walletId.message }] } - } - - if (address instanceof Error) { - return { errors: [{ message: address.message }] } - } - - if (memo instanceof Error) { - return { errors: [{ message: memo.message }] } - } - - if (speed instanceof Error) { - return { errors: [{ message: speed.message }] } - } - - const amount = await getBalanceForWallet({ - walletId, - currency: WalletCurrency.Usd, - }) - if (amount instanceof Error) return amount - if (!(amount instanceof USDAmount)) { - return { errors: [{ message: "Onchain payments require a USD wallet" }] } - } - - // ENG-573 send guard: attempt budget + amount sanity + daily-limit cap, - // before anything reaches IBEX. - // - // Skipped when the balance rounds to zero cents. `getBalanceForWallet` - // returns `USDAmount.ZERO` for a drained or never-funded wallet as a normal - // value, not an error — post-cutover that is the default read of every - // migrated account's legacy USD wallet - // (@app/wallets/get-balance-for-wallet) — and `asPaymentAmount()` truncates - // sub-cent dust to the same `0n`. Handed to the guard, that is - // `invalid-amount`, and in log-only every ordinary empty-wallet tap would - // land in the one census bucket the runbook calls malformed client input - // and says should be near zero — during the very sample the enforce - // decision is made from. That census bucket is the reason for the skip. - // - // It is NOT that the rail has a nicer answer. Skipping the guard does not - // authorise anything: `payOnChainByWalletId` refuses one layer down, at - // `OnchainUsdPaymentValidator`'s `checkOnchainMin`, which returns a bare - // `ValidationError("Amount must be greater than 0")` — and `mapError` has - // no case for that beyond the catch-all, so the client sees "Unexpected - // error occurred, please try again or contact support if it persists (code: - // ValidationError: Amount must be greater than 0)". Poor wording, but - // exactly what this rail returned before ENG-573; fixing it is not the - // guard's job. Pinned by test/flash/unit/graphql/error-map.spec.ts. - const cents = amount.asPaymentAmount().amount - if (cents !== 0n) { - const authorized = await authorizeSend({ - senderAccount: domainAccount, - senderWalletId: walletId, - amount: { currency: "USD", cents }, - kind: "onchain", - }) - if (authorized instanceof Error) { - return { status: "failed", errors: [mapAndParseErrorForGqlResponse(authorized)] } - } - } - - const result = await Wallets.payOnChainByWalletId({ - senderAccount: domainAccount, - senderWalletId: walletId, - amount, - address, - speed, - memo, - }) - - if (result instanceof Error) { - return { status: "failed", errors: [mapAndParseErrorForGqlResponse(result)] } - } - - return { - errors: [], - status: result.status.value, - } - }, -}) - -export default OnChainPaymentSendAllMutation +import { PayoutSpeed as DomainPayoutSpeed } from "@domain/bitcoin/onchain" + +import { GT } from "@graphql/index" +import Memo from "@graphql/shared/types/scalar/memo" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import OnChainAddress from "@graphql/shared/types/scalar/on-chain-address" +import PaymentSendPayload from "@graphql/public/types/payload/payment-send" +import PayoutSpeed from "@graphql/public/types/scalar/payout-speed" +import WalletId from "@graphql/shared/types/scalar/wallet-id" + +import { Wallets } from "@app" +import { authorizeSend } from "@app/payments/authorize-send" +import { getBalanceForWallet } from "@app/wallets" +import { USDAmount, WalletCurrency } from "@domain/shared" + +const OnChainPaymentSendAllInput = GT.Input({ + name: "OnChainPaymentSendAllInput", + fields: () => ({ + walletId: { type: GT.NonNull(WalletId) }, + address: { type: GT.NonNull(OnChainAddress) }, + speed: { + type: PayoutSpeed, + defaultValue: DomainPayoutSpeed.Fast, + }, + memo: { type: Memo }, + }), +}) + +const OnChainPaymentSendAllMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + walletId: WalletId | InputValidationError + address: OnChainAddress | InputValidationError + memo: Memo | InputValidationError | null + speed: PayoutSpeed | InputValidationError + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(PaymentSendPayload), + args: { + input: { type: GT.NonNull(OnChainPaymentSendAllInput) }, + }, + resolve: async (_, args, { domainAccount }) => { + const { walletId, address, memo, speed } = args.input + + if (walletId instanceof Error) { + return { errors: [{ message: walletId.message }] } + } + + if (address instanceof Error) { + return { errors: [{ message: address.message }] } + } + + if (memo instanceof Error) { + return { errors: [{ message: memo.message }] } + } + + if (speed instanceof Error) { + return { errors: [{ message: speed.message }] } + } + + const amount = await getBalanceForWallet({ + walletId, + currency: WalletCurrency.Usd, + }) + if (amount instanceof Error) return amount + if (!(amount instanceof USDAmount)) { + return { errors: [{ message: "Onchain payments require a USD wallet" }] } + } + + // ENG-573 send guard: attempt budget + amount sanity + daily-limit cap, + // before anything reaches IBEX. + // + // Skipped when the balance rounds to zero cents. `getBalanceForWallet` + // returns `USDAmount.ZERO` for a drained or never-funded wallet as a normal + // value, not an error — post-cutover that is the default read of every + // migrated account's legacy USD wallet + // (@app/wallets/get-balance-for-wallet) — and `asPaymentAmount()` truncates + // sub-cent dust to the same `0n`. Handed to the guard, that is + // `invalid-amount`, and in log-only every ordinary empty-wallet tap would + // land in the one census bucket the runbook calls malformed client input + // and says should be near zero — during the very sample the enforce + // decision is made from. That census bucket is the reason for the skip. + // + // It is NOT that the rail has a nicer answer. Skipping the guard does not + // authorise anything: `payOnChainByWalletId` refuses one layer down, at + // `OnchainUsdPaymentValidator`'s `checkOnchainMin`, which returns a bare + // `ValidationError("Amount must be greater than 0")` — and `mapError` has + // no case for that beyond the catch-all, so the client sees "Unexpected + // error occurred, please try again or contact support if it persists (code: + // ValidationError: Amount must be greater than 0)". Poor wording, but + // exactly what this rail returned before ENG-573; fixing it is not the + // guard's job. Pinned by test/flash/unit/graphql/error-map.spec.ts. + const cents = amount.asPaymentAmount().amount + if (cents !== 0n) { + const authorized = await authorizeSend({ + senderAccount: domainAccount, + senderWalletId: walletId, + amount: { currency: "USD", cents }, + kind: "onchain", + }) + if (authorized instanceof Error) { + return { status: "failed", errors: [mapAndParseErrorForGqlResponse(authorized)] } + } + } + + // #270: IBEX charges its on-chain fee on top of the amount it forwards, so + // handing it the whole balance either fails at the rail or leaves dust + // behind. Estimate the fee for this payout and sweep `balance - fee`. + const sendAmount = await Wallets.getSendAllOnChainAmount({ + amount, + address, + }) + + const result = await Wallets.payOnChainByWalletId({ + senderAccount: domainAccount, + senderWalletId: walletId, + amount: sendAmount, + address, + speed, + memo, + }) + + if (result instanceof Error) { + return { status: "failed", errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + status: result.status.value, + } + }, +}) + +export default OnChainPaymentSendAllMutation diff --git a/test/flash/unit/app/wallets/get-send-all-onchain-amount.spec.ts b/test/flash/unit/app/wallets/get-send-all-onchain-amount.spec.ts new file mode 100644 index 000000000..4ac9075c5 --- /dev/null +++ b/test/flash/unit/app/wallets/get-send-all-onchain-amount.spec.ts @@ -0,0 +1,91 @@ +const mockEstimateOnchainFee = jest.fn() + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + estimateOnchainFee: (...args: unknown[]) => mockEstimateOnchainFee(...args), + }, +})) + +jest.mock("@services/ibex/errors", () => ({ + IbexError: class IbexError extends Error {}, +})) + +import { USDAmount } from "@domain/shared" +import { IbexError } from "@services/ibex/errors" + +import { getSendAllOnChainAmount } from "@app/wallets/get-send-all-onchain-amount" + +const address = "bc1qexampleaddress" as OnChainAddress + +// #270: IBEX charges its on-chain fee on top of the amount it forwards, so a +// sendAll sweep has to hand over `balance - fee` rather than the whole balance. +describe("getSendAllOnChainAmount", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("subtracts the estimated fee from the balance", async () => { + mockEstimateOnchainFee.mockResolvedValue({ fee: 0.01 }) + + const amount = USDAmount.cents("1000") as USDAmount + const result = await getSendAllOnChainAmount({ + amount, + address, + }) + + expect(mockEstimateOnchainFee).toHaveBeenCalledWith(amount, address) + expect(result).toBeInstanceOf(USDAmount) + expect((result as USDAmount).asCents()).toBe("999") + }) + + it("keeps fractional cents of the estimate instead of flooring them", async () => { + mockEstimateOnchainFee.mockResolvedValue({ fee: 0.004 }) + + const result = await getSendAllOnChainAmount({ + amount: USDAmount.cents("1000") as USDAmount, + address, + }) + + expect((result as USDAmount).asCents(2)).toBe("999.60") + }) + + it("keeps the full balance when the estimate call fails", async () => { + mockEstimateOnchainFee.mockResolvedValue(new IbexError(new Error("ibex down"))) + + const result = await getSendAllOnChainAmount({ + amount: USDAmount.cents("1000") as USDAmount, + address, + }) + + expect((result as USDAmount).asCents()).toBe("1000") + }) + + it.each([ + ["missing", undefined], + ["zero", 0], + ["not a number", "abc"], + ])("keeps the full balance when the fee is %s", async (_label, fee) => { + mockEstimateOnchainFee.mockResolvedValue({ fee }) + + const result = await getSendAllOnChainAmount({ + amount: USDAmount.cents("1000") as USDAmount, + address, + }) + + expect((result as USDAmount).asCents()).toBe("1000") + }) + + // A fee that covers the whole balance leaves nothing sendable: `balance - fee` + // would be zero or negative, which the rail refuses anyway. + it("keeps the full balance when the fee covers it", async () => { + mockEstimateOnchainFee.mockResolvedValue({ fee: 10 }) + + const result = await getSendAllOnChainAmount({ + amount: USDAmount.cents("100") as USDAmount, + address, + }) + + expect((result as USDAmount).asCents()).toBe("100") + }) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/send-guard-wiring.spec.ts b/test/flash/unit/graphql/public/root/mutation/send-guard-wiring.spec.ts index 20b7f86a7..838e22aca 100644 --- a/test/flash/unit/graphql/public/root/mutation/send-guard-wiring.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/send-guard-wiring.spec.ts @@ -1,351 +1,353 @@ -// ENG-573 send guard wiring for the send mutations that have no resolver spec -// of their own: the no-amount BTC invoice send and both on-chain USD sends. -// Each case proves the guard runs with the right amount/kind and that a -// rejection returns a failed payload before anything downstream is touched. - -const mockAuthorizeSend = jest.fn() -jest.mock("@app/payments/authorize-send", () => ({ - authorizeSend: async (args: unknown) => { - const result = await mockAuthorizeSend(args) - return result === undefined ? true : result - }, -})) - -const mockPayNoAmountInvoiceByWalletIdForBtcWallet = jest.fn() -const mockPayOnChainByWalletId = jest.fn() -jest.mock("@app", () => ({ - Payments: { - payNoAmountInvoiceByWalletIdForBtcWallet: (args: unknown) => - mockPayNoAmountInvoiceByWalletIdForBtcWallet(args), - }, - Wallets: { - payOnChainByWalletId: (args: unknown) => mockPayOnChainByWalletId(args), - }, -})) - -const mockUsdWalletAmountFromWalletId = jest.fn() -const mockGetBalanceForWallet = jest.fn() -jest.mock("@app/wallets", () => ({ - usdWalletAmountFromWalletId: (args: unknown) => mockUsdWalletAmountFromWalletId(args), - getBalanceForWallet: (args: unknown) => mockGetBalanceForWallet(args), -})) - -const mockResolveCashWalletMutationWalletIdForAccount = jest.fn() -jest.mock("@app/cash-wallet-cutover", () => ({ - resolveCashWalletMutationWalletIdForAccount: (args: unknown) => - mockResolveCashWalletMutationWalletIdForAccount(args), -})) - -import fs from "fs" -import path from "path" - -import { SEND_GUARD_NOT_APPLICABLE } from "@app/payments/send-guard-optout" -import { WithdrawalLimitsExceededError } from "@domain/errors" -import { USDAmount } from "@domain/shared" -import LnNoAmountInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-noamount-invoice-payment-send" -import OnChainPaymentSendAllMutation from "@graphql/public/root/mutation/onchain-payment-send-all" -import OnChainUsdPaymentSendMutation from "@graphql/public/root/mutation/onchain-usd-payment-send" - -type MutationResult = { status?: string; errors: { message: string }[] } - -const walletId = "11111111-1111-4111-8111-111111111111" as WalletId -const routedWalletId = "22222222-2222-4222-8222-222222222222" as WalletId -const domainAccount = { id: "account-id" } as Account -const client = { - cashWalletPresentation: "usdt", - hasUsdtCashWalletSupport: true, -} as const -const context = { - domainAccount, - cashWalletClientCapabilities: client, -} as GraphQLPublicContextAuth - -const rejection = new WithdrawalLimitsExceededError( - "Cannot transfer more than $125.00 in 24 hours", -) - -type Resolver = ( - source: null, - args: { input: Record }, - ctx: GraphQLPublicContextAuth, - info: never, -) => Promise - -const run = (mutation: unknown, input: Record) => - (mutation as { resolve: Resolver }).resolve(null, { input }, context, {} as never) - -describe("ENG-573 send guard wiring", () => { - beforeEach(() => { - jest.clearAllMocks() - mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue(routedWalletId) - // Mirror the real send function's contract: it hands `authorize` to - // withPaymentIdempotency, which runs it only on the path that will pay and - // short-circuits on a rejection. - mockPayNoAmountInvoiceByWalletIdForBtcWallet.mockImplementation( - async ({ authorize }: { authorize?: () => Promise }) => { - const authorized = await authorize?.() - return authorized instanceof Error ? authorized : { value: "success" } - }, - ) - mockPayOnChainByWalletId.mockResolvedValue({ status: { value: "success" } }) - mockUsdWalletAmountFromWalletId.mockResolvedValue(USDAmount.cents("700")) - mockGetBalanceForWallet.mockResolvedValue(USDAmount.cents("12345")) - }) - - describe("lnNoAmountInvoicePaymentSend (BTC sats)", () => { - const input = { - walletId, - paymentRequest: "lnbc1noamount", - amount: 700, - memo: null, - idempotencyKey: null, - } - - // ENG-573 round 2: the guard is handed DOWN as the idempotency wrapper's - // `authorize` hook (withPaymentIdempotency lives inside - // payNoAmountInvoiceByWalletIdForBtcWallet), not awaited ahead of it. Ahead - // of the wrapper, every retry of a timed-out send burned a burst point - // before reaching the replay and was re-priced against a moved mid price. - it("hands the guard down to the app layer instead of running it first", async () => { - const result = await run(LnNoAmountInvoicePaymentSendMutation, input) - - expect(mockPayNoAmountInvoiceByWalletIdForBtcWallet).toHaveBeenCalledTimes(1) - const { authorize } = mockPayNoAmountInvoiceByWalletIdForBtcWallet.mock.calls[0][0] - expect(typeof authorize).toBe("function") - - expect(mockAuthorizeSend).toHaveBeenCalledWith({ - senderAccount: domainAccount, - senderWalletId: walletId, - amount: { currency: "BTC", sats: 700 }, - kind: "lightning", - }) - expect(result).toEqual({ errors: [], status: "success" }) - }) - - it("does not consult the guard when the wrapper never runs the hook (a replay)", async () => { - mockPayNoAmountInvoiceByWalletIdForBtcWallet.mockResolvedValueOnce({ - value: "success", - }) - - const result = await run(LnNoAmountInvoicePaymentSendMutation, input) - - expect(result).toEqual({ errors: [], status: "success" }) - expect(mockAuthorizeSend).not.toHaveBeenCalled() - }) - - it("returns a failed payload carrying the guard's error when the hook rejects", async () => { - mockAuthorizeSend.mockResolvedValueOnce(rejection) - - const result = await run(LnNoAmountInvoicePaymentSendMutation, input) - - expect(result.status).toBe("failed") - expect(result.errors[0]).toMatchObject({ message: rejection.message }) - }) - }) - - describe("onChainUsdPaymentSend (USD cents)", () => { - const input = { - walletId, - address: "bc1qexampleaddress", - amount: 700, - memo: null, - speed: "FAST", - } - - it("authorises the cent amount against the routed wallet as an onchain send, then pays", async () => { - const result = await run(OnChainUsdPaymentSendMutation, input) - - expect(mockAuthorizeSend).toHaveBeenCalledWith({ - senderAccount: domainAccount, - senderWalletId: routedWalletId, - amount: { currency: "USD", cents: 700 }, - kind: "onchain", - }) - expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) - expect(result).toEqual({ errors: [], status: "success" }) - }) - - it("fails before amount resolution or payout when the guard rejects", async () => { - mockAuthorizeSend.mockResolvedValueOnce(rejection) - - const result = await run(OnChainUsdPaymentSendMutation, input) - - expect(result.status).toBe("failed") - expect(result.errors[0]).toMatchObject({ message: rejection.message }) - expect(mockUsdWalletAmountFromWalletId).not.toHaveBeenCalled() - expect(mockPayOnChainByWalletId).not.toHaveBeenCalled() - }) - }) - - describe("onChainPaymentSendAll (whole balance)", () => { - const input = { - walletId, - address: "bc1qexampleaddress", - memo: null, - speed: "FAST", - } - - it("authorises the full balance in cents as an onchain send, then pays", async () => { - const result = await run(OnChainPaymentSendAllMutation, input) - - expect(mockAuthorizeSend).toHaveBeenCalledWith({ - senderAccount: domainAccount, - senderWalletId: walletId, - amount: { currency: "USD", cents: 12345n }, - kind: "onchain", - }) - expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) - expect(result).toEqual({ errors: [], status: "success" }) - }) - - it("fails before the payout when the guard rejects", async () => { - mockAuthorizeSend.mockResolvedValueOnce(rejection) - - const result = await run(OnChainPaymentSendAllMutation, input) - - expect(result.status).toBe("failed") - expect(result.errors[0]).toMatchObject({ message: rejection.message }) - expect(mockPayOnChainByWalletId).not.toHaveBeenCalled() - }) - - // `getBalanceForWallet` returns USDAmount.ZERO for a drained or - // never-funded wallet as a NORMAL value, not an error — post-cutover that is - // the default read of every migrated account's legacy USD wallet. Handing - // the guard `cents: 0n` made it reject with InvalidSendAmountError, so in - // log-only every ordinary empty-wallet tap landed in the `invalid-amount` - // census bucket the runbook says should be near zero — malformed client - // input, during the very sample the enforce decision is made from. That - // census bucket is the reason for the skip. It is NOT that the rail has a - // nicer answer: `checkOnchainMin` returns a bare ValidationError that - // mapError sends to the unexpected-error catch-all (pinned in - // test/flash/unit/graphql/error-map.spec.ts). - it.each([ - ["a zero balance", USDAmount.ZERO], - ["sub-cent dust, which truncates to the same zero cents", USDAmount.cents("0.4")], - ])("skips the guard on %s and lets the rail answer", async (_label, balance) => { - mockGetBalanceForWallet.mockResolvedValueOnce(balance) - - const result = await run(OnChainPaymentSendAllMutation, input) - - expect(mockAuthorizeSend).not.toHaveBeenCalled() - // Not authorised — refused one layer down, by OnchainUsdPaymentValidator's - // checkOnchainMin inside payOnChainByWalletId (mocked here), exactly as - // before ENG-573. - expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) - expect(result).toEqual({ errors: [], status: "success" }) - }) - - it("still guards a balance of one cent", async () => { - mockGetBalanceForWallet.mockResolvedValueOnce(USDAmount.cents("1")) - - await run(OnChainPaymentSendAllMutation, input) - - expect(mockAuthorizeSend).toHaveBeenCalledWith({ - senderAccount: domainAccount, - senderWalletId: walletId, - amount: { currency: "USD", cents: 1n }, - kind: "onchain", - }) - }) - }) -}) - -// `authorize` used to be optional on the arg types that carry it, which meant a -// send path that simply forgot the hook compiled, passed its tests and shipped -// unguarded. Not hypothetical: `onchain-payment-send.ts` and -// `onchain-usd-payment-send-as-sats.ts` are stubbed resolvers with their full -// send bodies sitting commented out one line below, so whoever re-enables them -// gets no compile error and no failing test from an omission. -// -// `yarn tsc-check` covers test/**, so the @ts-expect-error annotations below -// ARE the test: make `authorize` optional again and each one becomes an -// "Unused '@ts-expect-error' directive" error. -describe("ENG-573: the guard hook is required, not optional", () => { - it("refuses to type-check an intraledger send with no guard hook", () => { - const unguarded = { - senderWalletId: walletId, - recipientWalletId: routedWalletId, - amount: 100, - memo: null, - } - // @ts-expect-error `authorize` is required — a send path cannot ship unguarded - const args: IntraLedgerPaymentSendWalletIdArgs = unguarded - - expect(args).toBe(unguarded) - }) - - it("refuses to type-check a no-amount lightning send with no guard hook", () => { - const unguarded = { - senderWalletId: walletId, - uncheckedPaymentRequest: "lnbc1noamount", - amount: 700, - memo: null, - senderAccount: domainAccount, - } - // @ts-expect-error `authorize` is required — a send path cannot ship unguarded - const args: PayNoAmountInvoiceByWalletIdArgs = unguarded - - expect(args).toBe(unguarded) - }) - - // The escape hatch, so "required" does not push somebody back to `as any`: - // system credits opt out by name, which is also the grep that answers "what - // still sends without the guard". - it("accepts the named opt-out the system-credit callers use", async () => { - const args: IntraLedgerPaymentSendWalletIdArgs = { - senderWalletId: walletId, - recipientWalletId: routedWalletId, - amount: 100, - memo: null, - authorize: SEND_GUARD_NOT_APPLICABLE, - } - - expect(await args.authorize()).toBe(true) - }) -}) - -// docs/send-guard.md, "Not covered by the guard at all", is the inventory the -// page sends a reader to before they conclude a rail is guarded — so an -// omission there is a defect, not a doc nit. It has already been wrong once: -// the Bridge USDT withdrawal rail, which carries the largest per-transaction -// amounts on the platform, was missing from it while cashout was listed. -// -// These keep page and tree in step from both directions. Wire one of these -// rails into the guard and the first assertion fails; add a new unguarded -// money-moving rail and nothing here changes until someone adds it to the list, -// at which point the second assertion makes them name it in the doc too. -describe("ENG-573: the unguarded-rail inventory in docs/send-guard.md", () => { - const repoRoot = path.resolve(__dirname, "../".repeat(7)) - const read = (relative: string) => - fs.readFileSync(path.join(repoRoot, relative), "utf8") - const doc = read("docs/send-guard.md") - - // [rail, the RESOLVER that would do the wiring, the identifier the doc must name] - // - // The second element has to be the resolver, not the service that moves the - // money: every one of the eight guarded rails calls `authorizeSend` from its - // GraphQL resolver (intraledger-payment-send.ts, lnurl-payment-send.ts, - // onchain-payment-send-all.ts, …), never from `@app` or `@services`. Pointed - // at a service file this assertion is true forever and the stated invariant - // above — wire a rail in and this fails — does not hold. - const unguarded: [string, string, string][] = [ - [ - "cashout", - "src/graphql/public/root/mutation/offers/initiate-cash-out.ts", - "src/app/offers/ValidOffer.ts", - ], - [ - "Bridge USDT withdrawal", - "src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts", - "bridge-initiate-withdrawal.ts", - ], - ] - - it.each(unguarded)("%s still does not call the guard", (_rail, source) => { - expect(read(source)).not.toContain("authorizeSend") - }) - - it.each(unguarded)("%s is named in the inventory", (_rail, _source, identifier) => { - expect(doc).toContain(identifier) - }) -}) +// ENG-573 send guard wiring for the send mutations that have no resolver spec +// of their own: the no-amount BTC invoice send and both on-chain USD sends. +// Each case proves the guard runs with the right amount/kind and that a +// rejection returns a failed payload before anything downstream is touched. + +const mockAuthorizeSend = jest.fn() +jest.mock("@app/payments/authorize-send", () => ({ + authorizeSend: async (args: unknown) => { + const result = await mockAuthorizeSend(args) + return result === undefined ? true : result + }, +})) + +const mockPayNoAmountInvoiceByWalletIdForBtcWallet = jest.fn() +const mockPayOnChainByWalletId = jest.fn() +jest.mock("@app", () => ({ + Payments: { + payNoAmountInvoiceByWalletIdForBtcWallet: (args: unknown) => + mockPayNoAmountInvoiceByWalletIdForBtcWallet(args), + }, + Wallets: { + payOnChainByWalletId: (args: unknown) => mockPayOnChainByWalletId(args), + getSendAllOnChainAmount: (args: { amount: unknown }) => + Promise.resolve(args.amount), + }, +})) + +const mockUsdWalletAmountFromWalletId = jest.fn() +const mockGetBalanceForWallet = jest.fn() +jest.mock("@app/wallets", () => ({ + usdWalletAmountFromWalletId: (args: unknown) => mockUsdWalletAmountFromWalletId(args), + getBalanceForWallet: (args: unknown) => mockGetBalanceForWallet(args), +})) + +const mockResolveCashWalletMutationWalletIdForAccount = jest.fn() +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletMutationWalletIdForAccount: (args: unknown) => + mockResolveCashWalletMutationWalletIdForAccount(args), +})) + +import fs from "fs" +import path from "path" + +import { SEND_GUARD_NOT_APPLICABLE } from "@app/payments/send-guard-optout" +import { WithdrawalLimitsExceededError } from "@domain/errors" +import { USDAmount } from "@domain/shared" +import LnNoAmountInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-noamount-invoice-payment-send" +import OnChainPaymentSendAllMutation from "@graphql/public/root/mutation/onchain-payment-send-all" +import OnChainUsdPaymentSendMutation from "@graphql/public/root/mutation/onchain-usd-payment-send" + +type MutationResult = { status?: string; errors: { message: string }[] } + +const walletId = "11111111-1111-4111-8111-111111111111" as WalletId +const routedWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const domainAccount = { id: "account-id" } as Account +const client = { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, +} as const +const context = { + domainAccount, + cashWalletClientCapabilities: client, +} as GraphQLPublicContextAuth + +const rejection = new WithdrawalLimitsExceededError( + "Cannot transfer more than $125.00 in 24 hours", +) + +type Resolver = ( + source: null, + args: { input: Record }, + ctx: GraphQLPublicContextAuth, + info: never, +) => Promise + +const run = (mutation: unknown, input: Record) => + (mutation as { resolve: Resolver }).resolve(null, { input }, context, {} as never) + +describe("ENG-573 send guard wiring", () => { + beforeEach(() => { + jest.clearAllMocks() + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue(routedWalletId) + // Mirror the real send function's contract: it hands `authorize` to + // withPaymentIdempotency, which runs it only on the path that will pay and + // short-circuits on a rejection. + mockPayNoAmountInvoiceByWalletIdForBtcWallet.mockImplementation( + async ({ authorize }: { authorize?: () => Promise }) => { + const authorized = await authorize?.() + return authorized instanceof Error ? authorized : { value: "success" } + }, + ) + mockPayOnChainByWalletId.mockResolvedValue({ status: { value: "success" } }) + mockUsdWalletAmountFromWalletId.mockResolvedValue(USDAmount.cents("700")) + mockGetBalanceForWallet.mockResolvedValue(USDAmount.cents("12345")) + }) + + describe("lnNoAmountInvoicePaymentSend (BTC sats)", () => { + const input = { + walletId, + paymentRequest: "lnbc1noamount", + amount: 700, + memo: null, + idempotencyKey: null, + } + + // ENG-573 round 2: the guard is handed DOWN as the idempotency wrapper's + // `authorize` hook (withPaymentIdempotency lives inside + // payNoAmountInvoiceByWalletIdForBtcWallet), not awaited ahead of it. Ahead + // of the wrapper, every retry of a timed-out send burned a burst point + // before reaching the replay and was re-priced against a moved mid price. + it("hands the guard down to the app layer instead of running it first", async () => { + const result = await run(LnNoAmountInvoicePaymentSendMutation, input) + + expect(mockPayNoAmountInvoiceByWalletIdForBtcWallet).toHaveBeenCalledTimes(1) + const { authorize } = mockPayNoAmountInvoiceByWalletIdForBtcWallet.mock.calls[0][0] + expect(typeof authorize).toBe("function") + + expect(mockAuthorizeSend).toHaveBeenCalledWith({ + senderAccount: domainAccount, + senderWalletId: walletId, + amount: { currency: "BTC", sats: 700 }, + kind: "lightning", + }) + expect(result).toEqual({ errors: [], status: "success" }) + }) + + it("does not consult the guard when the wrapper never runs the hook (a replay)", async () => { + mockPayNoAmountInvoiceByWalletIdForBtcWallet.mockResolvedValueOnce({ + value: "success", + }) + + const result = await run(LnNoAmountInvoicePaymentSendMutation, input) + + expect(result).toEqual({ errors: [], status: "success" }) + expect(mockAuthorizeSend).not.toHaveBeenCalled() + }) + + it("returns a failed payload carrying the guard's error when the hook rejects", async () => { + mockAuthorizeSend.mockResolvedValueOnce(rejection) + + const result = await run(LnNoAmountInvoicePaymentSendMutation, input) + + expect(result.status).toBe("failed") + expect(result.errors[0]).toMatchObject({ message: rejection.message }) + }) + }) + + describe("onChainUsdPaymentSend (USD cents)", () => { + const input = { + walletId, + address: "bc1qexampleaddress", + amount: 700, + memo: null, + speed: "FAST", + } + + it("authorises the cent amount against the routed wallet as an onchain send, then pays", async () => { + const result = await run(OnChainUsdPaymentSendMutation, input) + + expect(mockAuthorizeSend).toHaveBeenCalledWith({ + senderAccount: domainAccount, + senderWalletId: routedWalletId, + amount: { currency: "USD", cents: 700 }, + kind: "onchain", + }) + expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) + expect(result).toEqual({ errors: [], status: "success" }) + }) + + it("fails before amount resolution or payout when the guard rejects", async () => { + mockAuthorizeSend.mockResolvedValueOnce(rejection) + + const result = await run(OnChainUsdPaymentSendMutation, input) + + expect(result.status).toBe("failed") + expect(result.errors[0]).toMatchObject({ message: rejection.message }) + expect(mockUsdWalletAmountFromWalletId).not.toHaveBeenCalled() + expect(mockPayOnChainByWalletId).not.toHaveBeenCalled() + }) + }) + + describe("onChainPaymentSendAll (whole balance)", () => { + const input = { + walletId, + address: "bc1qexampleaddress", + memo: null, + speed: "FAST", + } + + it("authorises the full balance in cents as an onchain send, then pays", async () => { + const result = await run(OnChainPaymentSendAllMutation, input) + + expect(mockAuthorizeSend).toHaveBeenCalledWith({ + senderAccount: domainAccount, + senderWalletId: walletId, + amount: { currency: "USD", cents: 12345n }, + kind: "onchain", + }) + expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) + expect(result).toEqual({ errors: [], status: "success" }) + }) + + it("fails before the payout when the guard rejects", async () => { + mockAuthorizeSend.mockResolvedValueOnce(rejection) + + const result = await run(OnChainPaymentSendAllMutation, input) + + expect(result.status).toBe("failed") + expect(result.errors[0]).toMatchObject({ message: rejection.message }) + expect(mockPayOnChainByWalletId).not.toHaveBeenCalled() + }) + + // `getBalanceForWallet` returns USDAmount.ZERO for a drained or + // never-funded wallet as a NORMAL value, not an error — post-cutover that is + // the default read of every migrated account's legacy USD wallet. Handing + // the guard `cents: 0n` made it reject with InvalidSendAmountError, so in + // log-only every ordinary empty-wallet tap landed in the `invalid-amount` + // census bucket the runbook says should be near zero — malformed client + // input, during the very sample the enforce decision is made from. That + // census bucket is the reason for the skip. It is NOT that the rail has a + // nicer answer: `checkOnchainMin` returns a bare ValidationError that + // mapError sends to the unexpected-error catch-all (pinned in + // test/flash/unit/graphql/error-map.spec.ts). + it.each([ + ["a zero balance", USDAmount.ZERO], + ["sub-cent dust, which truncates to the same zero cents", USDAmount.cents("0.4")], + ])("skips the guard on %s and lets the rail answer", async (_label, balance) => { + mockGetBalanceForWallet.mockResolvedValueOnce(balance) + + const result = await run(OnChainPaymentSendAllMutation, input) + + expect(mockAuthorizeSend).not.toHaveBeenCalled() + // Not authorised — refused one layer down, by OnchainUsdPaymentValidator's + // checkOnchainMin inside payOnChainByWalletId (mocked here), exactly as + // before ENG-573. + expect(mockPayOnChainByWalletId).toHaveBeenCalledTimes(1) + expect(result).toEqual({ errors: [], status: "success" }) + }) + + it("still guards a balance of one cent", async () => { + mockGetBalanceForWallet.mockResolvedValueOnce(USDAmount.cents("1")) + + await run(OnChainPaymentSendAllMutation, input) + + expect(mockAuthorizeSend).toHaveBeenCalledWith({ + senderAccount: domainAccount, + senderWalletId: walletId, + amount: { currency: "USD", cents: 1n }, + kind: "onchain", + }) + }) + }) +}) + +// `authorize` used to be optional on the arg types that carry it, which meant a +// send path that simply forgot the hook compiled, passed its tests and shipped +// unguarded. Not hypothetical: `onchain-payment-send.ts` and +// `onchain-usd-payment-send-as-sats.ts` are stubbed resolvers with their full +// send bodies sitting commented out one line below, so whoever re-enables them +// gets no compile error and no failing test from an omission. +// +// `yarn tsc-check` covers test/**, so the @ts-expect-error annotations below +// ARE the test: make `authorize` optional again and each one becomes an +// "Unused '@ts-expect-error' directive" error. +describe("ENG-573: the guard hook is required, not optional", () => { + it("refuses to type-check an intraledger send with no guard hook", () => { + const unguarded = { + senderWalletId: walletId, + recipientWalletId: routedWalletId, + amount: 100, + memo: null, + } + // @ts-expect-error `authorize` is required — a send path cannot ship unguarded + const args: IntraLedgerPaymentSendWalletIdArgs = unguarded + + expect(args).toBe(unguarded) + }) + + it("refuses to type-check a no-amount lightning send with no guard hook", () => { + const unguarded = { + senderWalletId: walletId, + uncheckedPaymentRequest: "lnbc1noamount", + amount: 700, + memo: null, + senderAccount: domainAccount, + } + // @ts-expect-error `authorize` is required — a send path cannot ship unguarded + const args: PayNoAmountInvoiceByWalletIdArgs = unguarded + + expect(args).toBe(unguarded) + }) + + // The escape hatch, so "required" does not push somebody back to `as any`: + // system credits opt out by name, which is also the grep that answers "what + // still sends without the guard". + it("accepts the named opt-out the system-credit callers use", async () => { + const args: IntraLedgerPaymentSendWalletIdArgs = { + senderWalletId: walletId, + recipientWalletId: routedWalletId, + amount: 100, + memo: null, + authorize: SEND_GUARD_NOT_APPLICABLE, + } + + expect(await args.authorize()).toBe(true) + }) +}) + +// docs/send-guard.md, "Not covered by the guard at all", is the inventory the +// page sends a reader to before they conclude a rail is guarded — so an +// omission there is a defect, not a doc nit. It has already been wrong once: +// the Bridge USDT withdrawal rail, which carries the largest per-transaction +// amounts on the platform, was missing from it while cashout was listed. +// +// These keep page and tree in step from both directions. Wire one of these +// rails into the guard and the first assertion fails; add a new unguarded +// money-moving rail and nothing here changes until someone adds it to the list, +// at which point the second assertion makes them name it in the doc too. +describe("ENG-573: the unguarded-rail inventory in docs/send-guard.md", () => { + const repoRoot = path.resolve(__dirname, "../".repeat(7)) + const read = (relative: string) => + fs.readFileSync(path.join(repoRoot, relative), "utf8") + const doc = read("docs/send-guard.md") + + // [rail, the RESOLVER that would do the wiring, the identifier the doc must name] + // + // The second element has to be the resolver, not the service that moves the + // money: every one of the eight guarded rails calls `authorizeSend` from its + // GraphQL resolver (intraledger-payment-send.ts, lnurl-payment-send.ts, + // onchain-payment-send-all.ts, …), never from `@app` or `@services`. Pointed + // at a service file this assertion is true forever and the stated invariant + // above — wire a rail in and this fails — does not hold. + const unguarded: [string, string, string][] = [ + [ + "cashout", + "src/graphql/public/root/mutation/offers/initiate-cash-out.ts", + "src/app/offers/ValidOffer.ts", + ], + [ + "Bridge USDT withdrawal", + "src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts", + "bridge-initiate-withdrawal.ts", + ], + ] + + it.each(unguarded)("%s still does not call the guard", (_rail, source) => { + expect(read(source)).not.toContain("authorizeSend") + }) + + it.each(unguarded)("%s is named in the inventory", (_rail, _source, identifier) => { + expect(doc).toContain(identifier) + }) +})