From 853713c3c311dc4bfdc706c47f5f7ab0bf20a325 Mon Sep 17 00:00:00 2001 From: Vandana Date: Fri, 5 Jun 2026 17:03:01 -0700 Subject: [PATCH 1/2] fix(ibex): convert payToLnurl amount to explicit millisatoshis The IBEX LNURL-pay API (POST /v2/lnurl/pay/send) expects the amount in millisatoshis, but PayLnurlArgs.send.amount passed the wallet currency's base unit directly (USDT micros, USD cents, or BTC sats). Changed PayLnurlArgs to accept amountMsat: number instead of send: IbexCurrency. This makes the expected unit explicit and forces callers to perform the msat conversion at the app layer where the DealerPriceService is available. ENG-406 --- src/services/ibex/client.ts | 2 +- src/services/ibex/index.types.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index cf448778e..5a792425c 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -254,7 +254,7 @@ const payToLnurl = async ( ): Promise => { return Ibex.payToLnurl({ accountId: args.accountId, - amount: args.send.amount, + amount: args.amountMsat, params: args.params, webhookUrl: WebhookServer.endpoints.onPay.lnurl, webhookSecret: WebhookServer.secret, diff --git a/src/services/ibex/index.types.d.ts b/src/services/ibex/index.types.d.ts index 01e8c559a..63b912b69 100644 --- a/src/services/ibex/index.types.d.ts +++ b/src/services/ibex/index.types.d.ts @@ -25,7 +25,7 @@ type IbexTransactionId = string & { readonly brand: unique symbol } type PayLnurlArgs = { accountId: IbexAccountId, - send: IbexCurrency, + amountMsat: number, params: string, // what is this? } From 44fc03ef15f68918a807638cac3e998969776068 Mon Sep 17 00:00:00 2001 From: Vandana Date: Fri, 5 Jun 2026 17:44:53 -0700 Subject: [PATCH 2/2] feat(lnurl): add USD wallet LNURL payment mutation --- dev/apollo-federation/supergraph.graphql | 31 +- .../2026-06-05-eng-406-usdt-lnurl-units.md | 368 ++++++++++++++++++ src/app/payments/lnurl-pay.ts | 59 +++ src/domain/errors.ts | 1 + src/graphql/error-map.ts | 3 + src/graphql/public/mutations.ts | 2 + .../root/mutation/lnurl-payment-send.ts | 220 +++++++++++ src/graphql/public/schema.graphql | 27 +- src/services/ibex/client.ts | 9 +- src/services/ibex/index.types.d.ts | 14 +- .../flash/unit/app/payments/lnurl-pay.spec.ts | 105 +++++ .../root/mutation/lnurl-payment-send.spec.ts | 171 ++++++++ 12 files changed, 995 insertions(+), 15 deletions(-) create mode 100644 docs/plans/2026-06-05-eng-406-usdt-lnurl-units.md create mode 100644 src/app/payments/lnurl-pay.ts create mode 100644 src/graphql/public/root/mutation/lnurl-payment-send.ts create mode 100644 test/flash/unit/app/payments/lnurl-pay.spec.ts create mode 100644 test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 09c7b3662..27ae70684 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -268,7 +268,7 @@ type BridgeAddExternalAccountPayload @join__type(graph: PUBLIC) { errors: [Error!]! - externalAccount: BridgeExternalAccount + externalAccount: BridgeExternalAccountLink } type BridgeCreateVirtualAccountPayload @@ -287,6 +287,13 @@ type BridgeExternalAccount status: String! } +type BridgeExternalAccountLink + @join__type(graph: PUBLIC) +{ + expiresAt: String! + linkUrl: String! +} + input BridgeInitiateKycInput @join__type(graph: PUBLIC) { @@ -1092,6 +1099,22 @@ A bech32-encoded HTTPS/Onion URL that can be interacted with automatically by a scalar Lnurl @join__type(graph: PUBLIC) +input LnurlPaymentSendInput + @join__type(graph: PUBLIC) +{ + """Amount to spend from the USD/USDT wallet, in USD cents.""" + amount: FractionalCentAmount! + + """LNURL-pay value to decode and pay.""" + lnurl: Lnurl! + + """Optional memo for the Lightning payment.""" + memo: Memo + + """Wallet ID with sufficient balance. Must belong to the current user.""" + walletId: WalletId! +} + input LnUsdInvoiceCreateInput @join__type(graph: PUBLIC) { @@ -1311,6 +1334,12 @@ type Mutation """ lnUsdInvoiceCreateOnBehalfOfRecipient(input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!): LnInvoicePayload! lnUsdInvoiceFeeProbe(input: LnUsdInvoiceFeeProbeInput!): CentAmountPayload! + + """ + Pay a LNURL-pay endpoint using a USD/USDT wallet balance. + The wallet amount is converted to whole-satoshi millisatoshis before calling IBEX. + """ + lnurlPaymentSend(input: LnurlPaymentSendInput!): PaymentSendPayload! merchantMapSuggest(input: MerchantMapSuggestInput!): MerchantPayload! onChainAddressCreate(input: OnChainAddressCreateInput!): OnChainAddressPayload! onChainAddressCurrent(input: OnChainAddressCurrentInput!): OnChainAddressPayload! diff --git a/docs/plans/2026-06-05-eng-406-usdt-lnurl-units.md b/docs/plans/2026-06-05-eng-406-usdt-lnurl-units.md new file mode 100644 index 000000000..661a6ff55 --- /dev/null +++ b/docs/plans/2026-06-05-eng-406-usdt-lnurl-units.md @@ -0,0 +1,368 @@ +# Fix USDT LNURL-pay Amount Units + +> **ENG-406** — Fix USDT LNURL-pay amount units for Flash Lightning Address edge case + +## Overview + +The IBEX LNURL-pay API (`POST /v2/lnurl/pay/send`) expects the `amount` field in **millisatoshis**, but the `payToLnurl` wrapper was passing `args.send.amount` directly — the wallet currency's base unit (USDT micros, USD cents, or BTC sats). + +## Root Cause + +`src/services/ibex/client.ts:255` — `payToLnurl` passed `args.send.amount` directly to `Ibex.payToLnurl()`. The `args.send` was of type `IbexCurrency` (`{ amount: number, currencyId: IbexCurrencyId }`), and for USDT wallets `amount` is USDT micros (~10,000 per USD cent), not millisatoshis. + +## Fix + +**Interface change** — `src/services/ibex/index.types.d.ts`: +- Replaced `send: IbexCurrency` with `amountMsat: number` on `PayLnurlArgs` + +**Call-site change** — `src/services/ibex/client.ts`: +- Changed `amount: args.send.amount` → `amount: args.amountMsat` + +## Rationale + +- The field name `amountMsat` makes the expected unit unambiguous +- Conversion from wallet currency → msats requires the DealerPriceService (app layer), not available in the services layer +- Callers are forced to perform explicit conversion, preventing silent unit bugs +- `PayLnurlArgs` is only used by `payToLnurl` — no other callers to break + +## Remaining Work + +- Wire up a GraphQL mutation that calls `payToLnurl` with proper MSAT conversion (ENG-406 follow-up) — completed in the follow-up implementation described below +- Authored by: Vandana + +## Implementation Status + +Implemented locally on `eng-274/sandbox-e2e-plan`: + +- Strengthened `PayLnurlArgs.amountMsat` from raw `number` to branded `MilliSatoshis`. +- Added `src/app/payments/lnurl-pay.ts` with USD/USDT wallet amount conversion, whole-satoshi msat generation, IBEX int32 validation, and LNURL `minSendable` / `maxSendable` validation. +- Added `InvalidLnurlAmountError` and mapped it to the existing GraphQL LNURL validation error surface. +- Added wallet-level public GraphQL mutation `lnurlPaymentSend`. +- Registered the mutation in `src/graphql/public/mutations.ts`. +- Regenerated `src/graphql/public/schema.graphql` and `dev/apollo-federation/supergraph.graphql`. +- Added focused unit tests for the conversion helper and mutation resolver. + +Verification: + +- `yarn test:unit --testPathPattern=app/payments/lnurl-pay.spec.ts` — passed. +- `yarn test:unit --testPathPattern=graphql/public/root/mutation/lnurl-payment-send.spec.ts` — passed. +- `npx prettier --check ...` on touched files — passed. +- `npx tsc --noEmit --skipLibCheck` — no ENG-406 source/test errors after fixes; still fails on pre-existing unrelated test type errors in offers, cash-wallet history resolver specs, and Bridge webhook/reconciliation specs. +- `yarn check:sdl` — wrote schemas and composed the supergraph, then failed because `src/graphql/public/schema.graphql` had expected generated changes to commit. + +--- + +## Dual-Model Review Follow-Up + +The wrapper fix is correct, but the follow-up wiring needs more detail before implementation: + +- `amountMsat: number` is clearer than `send: IbexCurrency`, but a branded `MilliSatoshis` type is safer than a raw number. +- IBEX's LNURL-pay request field is named `amount`, but the unit is millisatoshis. +- IBEX documents the field as `int32`, so the plan must reject values above `2_147_483_647` msats before calling IBEX. +- Wallet compatibility requires whole-satoshi payments, so `amountMsat` should be a multiple of `1000`. +- Rounding must happen before LNURL `minSendable` / `maxSendable` validation, because rounding can move a value across a bound. +- Caller-side conversion belongs in the app/graphql layer. The IBEX service wrapper should receive msats and should not import dealer-price or wallet conversion logic. + +--- + +## Follow-Up Implementation Plan: LNURL-Pay From USD/USDT Wallets + +> **For implementer:** execute this task-by-task. Do not push without Dread's explicit approval. + +**Goal:** Add a wallet-level GraphQL mutation that pays a LNURL-pay endpoint from a USD/USDT cash wallet by converting the user-entered wallet amount into integer millisatoshis before calling `Ibex.payToLnurl`. + +**Architecture:** Keep IBEX service code as a thin API wrapper. Decode/fetch/validate the LNURL-pay metadata in the GraphQL/app layer, convert the wallet amount using existing wallet/dealer-price helpers, round to whole satoshis, validate against LNURL bounds, then call `Ibex.payToLnurl({ accountId, amountMsat, params })`. + +**Tech Stack:** TypeScript, GraphQL schema builders, existing Flash wallet helpers, `DealerPriceService`, IBEX LNURL-pay API. + +### Task 1: Strengthen the IBEX wrapper type + +**Files:** +- Modify: `src/services/ibex/index.types.d.ts` +- Modify: `src/services/ibex/client.ts` + +**Step 1: Change `PayLnurlArgs.amountMsat` to the existing branded type** + +```ts +type PayLnurlArgs = { + accountId: IbexAccountId, + amountMsat: MilliSatoshis, + params: string, +} +``` + +`MilliSatoshis` already exists globally in `src/domain/bitcoin/index.types.d.ts`. This keeps the service boundary explicit without inventing another unit type. + +**Step 2: Keep `client.ts` as a pass-through** + +```ts +const payToLnurl = async ( + args: PayLnurlArgs, +): Promise => { + return Ibex.payToLnurl({ + accountId: args.accountId, + amount: args.amountMsat, + params: args.params, + webhookUrl: WebhookServer.endpoints.onPay.lnurl, + webhookSecret: WebhookServer.secret, + }).then(errorHandler) +} +``` + +Do not add conversion logic here. + +### Task 2: Add LNURL-pay conversion helper + +**Files:** +- Create: `src/app/payments/lnurl-pay.ts` +- Test: `test/flash/unit/app/payments/lnurl-pay.spec.ts` + +**Step 1: Define constants** + +```ts +const MSATS_PER_SAT = 1000 +const IBEX_LNURL_PAY_AMOUNT_MAX_MSAT = 2_147_483_647 +``` + +**Step 2: Add helper to convert USD/USDT wallet cents to whole-satoshi msats** + +Use the same wallet-amount semantics as `ln-noamount-usd-invoice-payment-send.ts`: + +```ts +import { toMilliSatsFromNumber } from "@domain/bitcoin" +import { checkedToUsdPaymentAmount } from "@domain/shared" + +export const amountMsatFromUsdWalletAmount = async ({ + amount, + btcFromUsd, +}: { + amount: UsdWalletAmount + btcFromUsd: IDealerPriceService["getSatsFromCentsForImmediateSell"] +}): Promise => { + const usdPaymentAmount = checkedToUsdPaymentAmount( + Number(amount.asUsdCents()), + WalletCurrency.Usd, + ) + if (usdPaymentAmount instanceof Error) return usdPaymentAmount + + const sats = await btcFromUsd(usdPaymentAmount) + if (sats instanceof Error) return sats + + const wholeSats = Math.floor(Number(sats.amount)) + const msats = wholeSats * MSATS_PER_SAT + + return toMilliSatsFromNumber(msats) +} +``` + +Implementation note: verify the buy/sell dealer method against existing send-lightning semantics before finalizing. The intended behavior is "user spends USD/USDT wallet balance to send BTC over Lightning." Existing outgoing payment flows pass both `hedgeBuyUsd` and `hedgeSellUsd` into the payment-flow builder; tests should pin the chosen dealer method. + +**Step 3: Add validation helper** + +```ts +export const validateLnurlPayAmountMsat = ({ + amountMsat, + minSendable, + maxSendable, +}: { + amountMsat: MilliSatoshis + minSendable: number + maxSendable: number +}): ValidationError | true => { + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + return new InvalidLnurlAmountError("LNURL amount must be positive integer msats") + } + + if (amountMsat % MSATS_PER_SAT !== 0) { + return new InvalidLnurlAmountError("LNURL amount must be a whole-satoshi amount") + } + + if (amountMsat > IBEX_LNURL_PAY_AMOUNT_MAX_MSAT) { + return new InvalidLnurlAmountError("LNURL amount exceeds IBEX int32 limit") + } + + if (amountMsat < minSendable || amountMsat > maxSendable) { + return new InvalidLnurlAmountError("LNURL amount outside minSendable/maxSendable bounds") + } + + return true +} +``` + +If no suitable domain error exists, add a small `InvalidLnurlAmountError` near the existing LNURL errors rather than reusing a misleading generic error. + +**Step 4: Unit tests** + +Cover: + +- Converts USD cents to msats using the injected dealer conversion. +- Floors/rounds to whole satoshis, then multiplies by `1000`. +- Rejects `amountMsat % 1000 !== 0`. +- Rejects values below `minSendable` after rounding. +- Rejects values above `maxSendable` after rounding. +- Rejects values above `2_147_483_647` msats. +- Propagates dealer-price errors. + +### Task 3: Add wallet-level GraphQL mutation + +**Files:** +- Create: `src/graphql/public/root/mutation/lnurl-payment-send.ts` +- Modify: `src/graphql/public/mutations.ts` +- Test: `test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts` or the nearest existing GraphQL mutation test path. + +**Mutation name:** `lnurlPaymentSend` + +**Input shape:** + +```ts +const LnurlPaymentSendInput = GT.Input({ + name: "LnurlPaymentSendInput", + fields: () => ({ + walletId: { + type: GT.NonNull(WalletId), + description: "Wallet ID with sufficient balance. Must belong to the current user.", + }, + lnurl: { + type: GT.NonNull(Lnurl), + description: "LNURL-pay value to decode and pay.", + }, + amount: { + type: GT.NonNull(FractionalCentAmount), + description: "Amount to spend from the USD/USDT wallet, in USD cents.", + }, + memo: { + type: Memo, + description: "Optional memo for the Lightning payment.", + }, + }), +}) +``` + +**Payload:** reuse `PaymentSendPayload`. + +**Registration:** import the mutation in `src/graphql/public/mutations.ts` and add it under `mutationFields.authed.atWalletLevel`, next to the other Lightning payment send mutations. + +### Task 4: Implement the mutation resolver + +**Files:** +- Modify: `src/graphql/public/root/mutation/lnurl-payment-send.ts` + +Resolver flow: + +1. Validate GraphQL scalar outputs (`walletId`, `lnurl`, `amount`, `memo`) exactly like the existing invoice send mutations. +2. Require `domainAccount`. +3. Resolve the routed wallet ID through `resolveCashWalletMutationWalletIdForAccount({ account: domainAccount, walletId, client: cashWalletClientCapabilities })`. +4. Use `usdWalletAmountFromWalletId({ walletId: routedWalletId, amount: amount.toString() })`. +5. Validate the routed wallet is a USD wallet, allowing USDT by passing `includeUsdt: true` through the existing wallet validation path or by relying on `usdWalletAmountFromWalletId` to reject non-USD/non-USDT currencies. +6. Decode the LNURL through `Ibex.decodeLnurl({ lnurl })`. +7. Fetch the LNURL-pay metadata from the decoded URL if IBEX only returns the callback URL. The metadata must include `callback`, `minSendable`, `maxSendable`, and `metadata`. +8. Serialize the LNURL-pay metadata into the `params` string expected by `Ibex.payToLnurl`. Match IBEX docs for `params`: JSON string containing `callback`, `maxSendable`, `minSendable`, `metadata`, and `tag: "payRequest"`. +9. Convert wallet amount to msats using the helper from Task 2 and `DealerPriceService`. +10. Validate integer, whole-satoshi, int32, and LNURL bounds. +11. Call: + +```ts +const payment = await Ibex.payToLnurl({ + accountId: routedWalletId, + amountMsat, + params, +}) +``` + +12. Map `IbexError` through `mapAndParseErrorForGqlResponse`. +13. Map IBEX transaction payment status IDs to `PaymentSendStatus` using the same switch as `ln-noamount-usd-invoice-payment-send.ts`. +14. Return `{ errors: [], status: status.value }`. + +### Task 5: Add tests for resolver behavior + +**Files:** +- Test: `test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts` +- Possibly update shared test mocks for `@services/ibex/client` and `@services/dealer-price`. + +Test cases: + +- Rejects invalid `walletId`, invalid `lnurl`, invalid `amount`, and invalid `memo` scalar outputs. +- Rejects unauthenticated context. +- Routes wallet ID through `resolveCashWalletMutationWalletIdForAccount`. +- Allows USDT cash wallet IDs after cutover routing. +- Calls `Ibex.decodeLnurl` before `Ibex.payToLnurl`. +- Builds `params` with `callback`, `minSendable`, `maxSendable`, `metadata`, and `tag`. +- Converts wallet amount to whole-satoshi msats before calling IBEX. +- Rejects below `minSendable` after rounding. +- Rejects above `maxSendable` after rounding. +- Rejects above the IBEX int32 limit. +- Propagates dealer-price errors. +- Propagates IBEX decode and pay errors as GraphQL payload errors. +- Maps pending/success/failure status IDs to `PaymentSendStatus`. + +### Task 6: Update schema artifacts and docs + +**Files:** +- Modify if generated: `src/graphql/public/schema.graphql` +- Modify if needed: mobile/backend API docs that list public payment mutations. + +Commands: + +```bash +yarn write-sdl +``` + +If `check:sdl` is the project-standard gate for schema drift, run: + +```bash +yarn check:sdl +``` + +### Task 7: Verification + +Run focused checks first: + +```bash +yarn test:unit --testPathPattern=lnurl-payment-send +``` + +Run related existing tests: + +```bash +yarn test:unit --testPathPattern=ln-noamount-usd-invoice-payment-send +yarn test:unit --testPathPattern=services/ibex/client-usd-wallet +``` + +Run type checks that cover the changed files: + +```bash +npx tsc --noEmit --skipLibCheck +``` + +If the full repo type check still has unrelated pre-existing failures, capture the exact failure set and verify that none are in: + +- `src/services/ibex/client.ts` +- `src/services/ibex/index.types.d.ts` +- `src/app/payments/lnurl-pay.ts` +- `src/graphql/public/root/mutation/lnurl-payment-send.ts` +- new tests + +### Task 8: Commit locally only + +Stage explicit paths: + +```bash +git add \ + src/services/ibex/client.ts \ + src/services/ibex/index.types.d.ts \ + src/app/payments/lnurl-pay.ts \ + src/graphql/public/root/mutation/lnurl-payment-send.ts \ + src/graphql/public/mutations.ts \ + src/graphql/public/schema.graphql \ + test/flash/unit/app/payments/lnurl-pay.spec.ts \ + test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts \ + docs/plans/2026-06-05-eng-406-usdt-lnurl-units.md +``` + +Commit message: + +```bash +git commit -m "fix(ibex): wire LNURL-pay msat conversion" +``` + +Do not push until Dread explicitly asks. diff --git a/src/app/payments/lnurl-pay.ts b/src/app/payments/lnurl-pay.ts new file mode 100644 index 000000000..066fcef0c --- /dev/null +++ b/src/app/payments/lnurl-pay.ts @@ -0,0 +1,59 @@ +import { toMilliSatsFromNumber } from "@domain/bitcoin" +import { InvalidLnurlAmountError } from "@domain/errors" +import { checkedToUsdPaymentAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { UsdWalletAmount } from "@app/wallets/usd-wallet-amount" + +export const MSATS_PER_SAT = 1000 +export const IBEX_LNURL_PAY_AMOUNT_MAX_MSAT = 2_147_483_647 + +export const amountMsatFromUsdWalletAmount = async ({ + amount, + btcFromUsd, +}: { + amount: UsdWalletAmount + btcFromUsd: IDealerPriceService["getSatsFromCentsForImmediateSell"] +}): Promise => { + const usdCents = amount instanceof USDTAmount ? amount.asUsdCents() : amount.asCents() + + const usdPaymentAmount = checkedToUsdPaymentAmount(Number(usdCents), WalletCurrency.Usd) + if (usdPaymentAmount instanceof Error) return usdPaymentAmount + + const sats = await btcFromUsd(usdPaymentAmount) + if (sats instanceof Error) return sats + + const wholeSats = Math.floor(Number(sats.amount)) + const msats = wholeSats * MSATS_PER_SAT + + return toMilliSatsFromNumber(msats) +} + +export const validateLnurlPayAmountMsat = ({ + amountMsat, + minSendable, + maxSendable, +}: { + amountMsat: MilliSatoshis + minSendable: number + maxSendable: number +}): true | ValidationError => { + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + return new InvalidLnurlAmountError("LNURL amount must be positive integer msats") + } + + if (amountMsat % MSATS_PER_SAT !== 0) { + return new InvalidLnurlAmountError("LNURL amount must be a whole-satoshi amount") + } + + if (amountMsat > IBEX_LNURL_PAY_AMOUNT_MAX_MSAT) { + return new InvalidLnurlAmountError("LNURL amount exceeds IBEX int32 limit") + } + + if (amountMsat < minSendable || amountMsat > maxSendable) { + return new InvalidLnurlAmountError( + "LNURL amount outside minSendable/maxSendable bounds", + ) + } + + return true +} diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 9908293d3..d4ea375bb 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -158,3 +158,4 @@ export class MultipleCurrenciesForSingleCurrencyOperationError extends Validatio export class InvalidIdempotencyKeyError extends ValidationError {} export class InvalidLnurlError extends ValidationError {} +export class InvalidLnurlAmountError extends ValidationError {} diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 480711d87..6e0d12d70 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -792,6 +792,9 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidLnurlError": return new InvalidLnurlError({ message: error.message, logger: baseLogger }) + case "InvalidLnurlAmountError": + return new InvalidLnurlError({ message: error.message, logger: baseLogger }) + case "CashWalletCutoverInProgressError": message = "Cash Wallet cutover is in progress. Please try again shortly." return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 8469c5266..75207efc4 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -16,6 +16,7 @@ import LnNoAmountInvoiceFeeProbeMutation from "@graphql/public/root/mutation/ln- import LnNoAmountUsdInvoiceFeeProbeMutation from "@graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe" import LnNoAmountInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-noamount-invoice-payment-send" import LnNoAmountUsdInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send" +import LnurlPaymentSendMutation from "@graphql/public/root/mutation/lnurl-payment-send" import OnChainAddressCreateMutation from "@graphql/public/root/mutation/on-chain-address-create" import OnChainAddressCurrentMutation from "@graphql/public/root/mutation/on-chain-address-current" import UserLoginMutation from "@graphql/shared/root/mutation/user-login" @@ -141,6 +142,7 @@ export const mutationFields = { lnInvoicePaymentSend: LnInvoicePaymentSendMutation, lnNoAmountInvoicePaymentSend: LnNoAmountInvoicePaymentSendMutation, lnNoAmountUsdInvoicePaymentSend: LnNoAmountUsdInvoicePaymentSendMutation, + lnurlPaymentSend: LnurlPaymentSendMutation, onChainAddressCreate: OnChainAddressCreateMutation, onChainAddressCurrent: OnChainAddressCurrentMutation, diff --git a/src/graphql/public/root/mutation/lnurl-payment-send.ts b/src/graphql/public/root/mutation/lnurl-payment-send.ts new file mode 100644 index 000000000..f689b8d81 --- /dev/null +++ b/src/graphql/public/root/mutation/lnurl-payment-send.ts @@ -0,0 +1,220 @@ +import axios from "axios" +import dedent from "dedent" + +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" +import { + amountMsatFromUsdWalletAmount, + validateLnurlPayAmountMsat, +} from "@app/payments/lnurl-pay" +import { usdWalletAmountFromWalletId } from "@app/wallets" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { InvalidLnurlError } from "@domain/errors" +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import PaymentSendPayload from "@graphql/public/types/payload/payment-send" +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" +import { InputValidationError } from "@graphql/error" +import Lnurl from "@graphql/shared/types/scalar/lnurl" +import Memo from "@graphql/shared/types/scalar/memo" +import WalletId from "@graphql/shared/types/scalar/wallet-id" +import { DealerPriceService } from "@services/dealer-price" +import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" + +type LnurlPayMetadata = { + callback: string + minSendable: number + maxSendable: number + metadata: string + tag?: string +} + +const LnurlPaymentSendInput = GT.Input({ + name: "LnurlPaymentSendInput", + fields: () => ({ + walletId: { + type: GT.NonNull(WalletId), + description: "Wallet ID with sufficient balance. Must belong to the current user.", + }, + lnurl: { + type: GT.NonNull(Lnurl), + description: "LNURL-pay value to decode and pay.", + }, + amount: { + type: GT.NonNull(FractionalCentAmount), + description: "Amount to spend from the USD/USDT wallet, in USD cents.", + }, + memo: { + type: Memo, + description: "Optional memo for the Lightning payment.", + }, + }), +}) + +const isLnurlPayMetadata = (value: unknown): value is LnurlPayMetadata => { + if (!value || typeof value !== "object") return false + const candidate = value as Partial + return ( + typeof candidate.callback === "string" && + Number.isFinite(candidate.minSendable) && + Number.isFinite(candidate.maxSendable) && + typeof candidate.metadata === "string" + ) +} + +const paramsFromMetadata = ({ + callback, + minSendable, + maxSendable, + metadata, +}: LnurlPayMetadata): string => + JSON.stringify({ + callback, + maxSendable, + minSendable, + metadata, + tag: "payRequest", + }) + +const paymentStatusFromIbex = (payment: Record): PaymentSendStatus => { + switch (payment.transaction?.payment?.status?.id) { + case 1: + return PaymentSendStatus.Pending + case 2: + return PaymentSendStatus.Success + case 3: + return PaymentSendStatus.Failure + default: + return PaymentSendStatus.Pending + } +} + +const LnurlPaymentSendMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + walletId: WalletId | InputValidationError + lnurl: Lnurl | InputValidationError + amount: FractionalCentAmount | InputValidationError + memo?: Memo | InputValidationError + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(PaymentSendPayload), + description: dedent`Pay a LNURL-pay endpoint using a USD/USDT wallet balance. + The wallet amount is converted to whole-satoshi millisatoshis before calling IBEX.`, + args: { + input: { type: GT.NonNull(LnurlPaymentSendInput) }, + }, + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { + const { walletId, lnurl, amount, memo } = args.input + + if (walletId instanceof InputValidationError) { + return { status: "failed", errors: [{ message: walletId.message }] } + } + if (lnurl instanceof InputValidationError) { + return { status: "failed", errors: [{ message: lnurl.message }] } + } + if (amount instanceof InputValidationError) { + return { status: "failed", errors: [{ message: amount.message }] } + } + if (memo instanceof InputValidationError) { + return { status: "failed", errors: [{ message: memo.message }] } + } + + if (!domainAccount) throw new Error("Authentication required") + + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } + + const walletAmount = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, + amount: amount.toString(), + }) + if (walletAmount instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(walletAmount)], + } + } + + const decoded = await Ibex.decodeLnurl({ lnurl }) + if (decoded instanceof IbexError) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(decoded)], + } + } + if (!decoded.decodedLnurl) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(new InvalidLnurlError())], + } + } + + const metadataResponse = await axios.get(decoded.decodedLnurl) + const metadata = metadataResponse.data + if (!isLnurlPayMetadata(metadata)) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(new InvalidLnurlError())], + } + } + + const dealer = DealerPriceService() + const amountMsat = await amountMsatFromUsdWalletAmount({ + amount: walletAmount, + btcFromUsd: dealer.getSatsFromCentsForImmediateSell, + }) + if (amountMsat instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(amountMsat)], + } + } + + const validAmount = validateLnurlPayAmountMsat({ + amountMsat, + minSendable: metadata.minSendable, + maxSendable: metadata.maxSendable, + }) + if (validAmount instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(validAmount)], + } + } + + const payment = await Ibex.payToLnurl({ + accountId: routedWalletId, + amountMsat, + params: paramsFromMetadata(metadata), + }) + if (payment instanceof IbexError) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(payment)], + } + } + + return { + errors: [], + status: paymentStatusFromIbex(payment).value, + } + }, +}) + +export default LnurlPaymentSendMutation diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 30c15eada..b2339be97 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -243,7 +243,7 @@ input BankAccountInput { type BridgeAddExternalAccountPayload { errors: [Error!]! - externalAccount: BridgeExternalAccount + externalAccount: BridgeExternalAccountLink } type BridgeCreateVirtualAccountPayload { @@ -258,6 +258,11 @@ type BridgeExternalAccount { status: String! } +type BridgeExternalAccountLink { + expiresAt: String! + linkUrl: String! +} + input BridgeInitiateKycInput { email: String full_name: String @@ -878,6 +883,20 @@ A bech32-encoded HTTPS/Onion URL that can be interacted with automatically by a """ scalar Lnurl +input LnurlPaymentSendInput { + """Amount to spend from the USD/USDT wallet, in USD cents.""" + amount: FractionalCentAmount! + + """LNURL-pay value to decode and pay.""" + lnurl: Lnurl! + + """Optional memo for the Lightning payment.""" + memo: Memo + + """Wallet ID with sufficient balance. Must belong to the current user.""" + walletId: WalletId! +} + type MapInfo { coordinates: Coordinates! title: String! @@ -1039,6 +1058,12 @@ type Mutation { """ lnUsdInvoiceCreateOnBehalfOfRecipient(input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!): LnInvoicePayload! lnUsdInvoiceFeeProbe(input: LnUsdInvoiceFeeProbeInput!): CentAmountPayload! + + """ + Pay a LNURL-pay endpoint using a USD/USDT wallet balance. + The wallet amount is converted to whole-satoshi millisatoshis before calling IBEX. + """ + lnurlPaymentSend(input: LnurlPaymentSendInput!): PaymentSendPayload! merchantMapSuggest(input: MerchantMapSuggestInput!): MerchantPayload! onChainAddressCreate(input: OnChainAddressCreateInput!): OnChainAddressPayload! onChainAddressCurrent(input: OnChainAddressCurrentInput!): OnChainAddressPayload! diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 5a792425c..b72e571fc 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -328,7 +328,6 @@ const ibexGet = (token: string, path: string) => const ibexPost = (token: string, path: string, body: unknown) => ibexFetch(token, path, { method: "POST", body: JSON.stringify(body) }) - const createIbexAccount = async ( name: string, currencyId: IbexCurrencyId, @@ -336,10 +335,10 @@ const createIbexAccount = async ( try { const token = await getIbexToken() if (token instanceof IbexError) return token - const data = await ibexPost( - token, - "/account/create" - , { name, currencyId }) + const data = await ibexPost(token, "/account/create", { + name, + currencyId, + }) if (data instanceof IbexError) return data return data } catch (err) { diff --git a/src/services/ibex/index.types.d.ts b/src/services/ibex/index.types.d.ts index 63b912b69..f10631984 100644 --- a/src/services/ibex/index.types.d.ts +++ b/src/services/ibex/index.types.d.ts @@ -13,20 +13,19 @@ interface IbexAccount { type IbexAccountId = WalletId -// type IbexInvoiceArgs = { +// type IbexInvoiceArgs = { // accountId: IbexAccountId, // amount?: IbexAmount // memo: string -// expiration?: Seconds +// expiration?: Seconds // }; -type AccountArgs = { name: string, currency: WalletCurrency } +type AccountArgs = { name: string; currency: WalletCurrency } type IbexTransactionId = string & { readonly brand: unique symbol } - type PayLnurlArgs = { - accountId: IbexAccountId, - amountMsat: number, - params: string, // what is this? + accountId: IbexAccountId + amountMsat: MilliSatoshis + params: string // what is this? } // Flash types @@ -34,4 +33,3 @@ type PayLnurlArgs = { // fee: T, // } type Bolt11 = string & { readonly brand: unique symbol } - diff --git a/test/flash/unit/app/payments/lnurl-pay.spec.ts b/test/flash/unit/app/payments/lnurl-pay.spec.ts new file mode 100644 index 000000000..4caad3339 --- /dev/null +++ b/test/flash/unit/app/payments/lnurl-pay.spec.ts @@ -0,0 +1,105 @@ +import { + amountMsatFromUsdWalletAmount, + IBEX_LNURL_PAY_AMOUNT_MAX_MSAT, + MSATS_PER_SAT, + validateLnurlPayAmountMsat, +} from "@app/payments/lnurl-pay" +import { UnknownDealerPriceServiceError } from "@domain/dealer-price" +import { InvalidLnurlAmountError } from "@domain/errors" +import { paymentAmountFromNumber, USDTAmount, WalletCurrency } from "@domain/shared" + +describe("amountMsatFromUsdWalletAmount", () => { + it("converts USDT wallet cents to whole-satoshi millisatoshis using dealer sell pricing", async () => { + const amount = USDTAmount.usdCents("19446") as USDTAmount + const btcFromUsd = jest.fn(async (usdAmount) => { + expect(usdAmount.amount).toBe(19446n) + expect(usdAmount.currency).toBe(WalletCurrency.Usd) + return paymentAmountFromNumber({ + amount: 1234, + currency: WalletCurrency.Btc, + }) as BtcPaymentAmount + }) + + const result = await amountMsatFromUsdWalletAmount({ amount, btcFromUsd }) + + expect(result).toBe(1_234_000) + expect(btcFromUsd).toHaveBeenCalledTimes(1) + }) + + it("converts dealer satoshis to millisatoshis", async () => { + const amount = USDTAmount.usdCents("500") as USDTAmount + const btcFromUsd = jest.fn( + async () => + paymentAmountFromNumber({ + amount: 42, + currency: WalletCurrency.Btc, + }) as BtcPaymentAmount, + ) + + const result = await amountMsatFromUsdWalletAmount({ amount, btcFromUsd }) + + expect(result).toBe(42 * MSATS_PER_SAT) + }) + + it("propagates dealer-price errors", async () => { + const amount = USDTAmount.usdCents("500") as USDTAmount + const error = new UnknownDealerPriceServiceError("dealer unavailable") + const btcFromUsd = jest.fn(async () => error) + + const result = await amountMsatFromUsdWalletAmount({ amount, btcFromUsd }) + + expect(result).toBe(error) + }) +}) + +describe("validateLnurlPayAmountMsat", () => { + it("accepts positive whole-satoshi millisatoshis inside LNURL bounds", () => { + const result = validateLnurlPayAmountMsat({ + amountMsat: 10_000 as MilliSatoshis, + minSendable: 1_000, + maxSendable: 20_000, + }) + + expect(result).toBe(true) + }) + + it("rejects sub-satoshi millisatoshis", () => { + const result = validateLnurlPayAmountMsat({ + amountMsat: 1_500 as MilliSatoshis, + minSendable: 1_000, + maxSendable: 20_000, + }) + + expect(result).toBeInstanceOf(InvalidLnurlAmountError) + }) + + it("rejects values below minSendable after rounding", () => { + const result = validateLnurlPayAmountMsat({ + amountMsat: 1_000 as MilliSatoshis, + minSendable: 2_000, + maxSendable: 20_000, + }) + + expect(result).toBeInstanceOf(InvalidLnurlAmountError) + }) + + it("rejects values above maxSendable after rounding", () => { + const result = validateLnurlPayAmountMsat({ + amountMsat: 21_000 as MilliSatoshis, + minSendable: 1_000, + maxSendable: 20_000, + }) + + expect(result).toBeInstanceOf(InvalidLnurlAmountError) + }) + + it("rejects values above the IBEX int32 request limit", () => { + const result = validateLnurlPayAmountMsat({ + amountMsat: (IBEX_LNURL_PAY_AMOUNT_MAX_MSAT + 1) as MilliSatoshis, + minSendable: 1_000, + maxSendable: IBEX_LNURL_PAY_AMOUNT_MAX_MSAT + 2, + }) + + expect(result).toBeInstanceOf(InvalidLnurlAmountError) + }) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts b/test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts new file mode 100644 index 000000000..5b00e4a7e --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/lnurl-payment-send.spec.ts @@ -0,0 +1,171 @@ +const mockResolveCashWalletMutationWalletIdForAccount = jest.fn() +const mockUsdWalletAmountFromWalletId = jest.fn() +const mockDecodeLnurl = jest.fn() +const mockPayToLnurl = jest.fn() +const mockGetSatsFromCentsForImmediateSell = jest.fn() +const mockAxiosGet = jest.fn() + +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletMutationWalletIdForAccount: ( + ...args: Parameters + ) => mockResolveCashWalletMutationWalletIdForAccount(...args), +})) + +jest.mock("@app/wallets", () => ({ + usdWalletAmountFromWalletId: ( + ...args: Parameters + ) => mockUsdWalletAmountFromWalletId(...args), +})) + +jest.mock("@services/dealer-price", () => ({ + DealerPriceService: jest.fn(() => ({ + getSatsFromCentsForImmediateSell: ( + ...args: Parameters + ) => mockGetSatsFromCentsForImmediateSell(...args), + })), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + decodeLnurl: (...args: Parameters) => + mockDecodeLnurl(...args), + payToLnurl: (...args: Parameters) => mockPayToLnurl(...args), + }, +})) + +jest.mock("axios", () => ({ + get: (...args: Parameters) => mockAxiosGet(...args), +})) + +import LnurlPaymentSendMutation from "@graphql/public/root/mutation/lnurl-payment-send" +import { paymentAmountFromNumber, USDTAmount, WalletCurrency } from "@domain/shared" +import { IbexError } from "@services/ibex/errors" + +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 + +type MutationResult = { + status: string + errors: { message: string }[] +} + +const resolveMutation = (overrides = {}) => + LnurlPaymentSendMutation.resolve?.( + null, + { + input: { + walletId, + lnurl: "LNURL1DP68GURN8GHJ7MRWW4EXCTN" as Lnurl, + amount: 19446 as FractionalCentAmount, + memo: "memo" as Memo, + ...overrides, + }, + }, + { + domainAccount, + cashWalletClientCapabilities: client, + } as GraphQLPublicContextAuth, + {} as never, + ) as Promise + +describe("LnurlPaymentSendMutation", () => { + beforeEach(() => { + jest.clearAllMocks() + mockResolveCashWalletMutationWalletIdForAccount.mockResolvedValue(routedWalletId) + mockUsdWalletAmountFromWalletId.mockResolvedValue( + USDTAmount.usdCents("19446") as USDTAmount, + ) + mockDecodeLnurl.mockResolvedValue({ + decodedLnurl: "https://lnurl.example/.well-known/lnurlp/alice", + }) + mockAxiosGet.mockResolvedValue({ + data: { + callback: "https://lnurl.example/callback", + minSendable: 1_000, + maxSendable: 2_000_000, + metadata: '[["text/plain","alice"]]', + tag: "payRequest", + }, + }) + mockGetSatsFromCentsForImmediateSell.mockResolvedValue( + paymentAmountFromNumber({ + amount: 1234, + currency: WalletCurrency.Btc, + }), + ) + mockPayToLnurl.mockResolvedValue({ + transaction: { payment: { status: { id: 2 } } }, + }) + }) + + it("decodes LNURL metadata, converts USDT wallet amount to msats, and pays IBEX", async () => { + const result = await resolveMutation() + + expect(mockResolveCashWalletMutationWalletIdForAccount).toHaveBeenCalledWith({ + account: domainAccount, + walletId, + client, + }) + expect(mockUsdWalletAmountFromWalletId).toHaveBeenCalledWith({ + walletId: routedWalletId, + amount: "19446", + }) + expect(mockDecodeLnurl).toHaveBeenCalledWith({ + lnurl: "LNURL1DP68GURN8GHJ7MRWW4EXCTN", + }) + expect(mockAxiosGet).toHaveBeenCalledWith( + "https://lnurl.example/.well-known/lnurlp/alice", + ) + expect(mockPayToLnurl).toHaveBeenCalledWith({ + accountId: routedWalletId, + amountMsat: 1_234_000, + params: JSON.stringify({ + callback: "https://lnurl.example/callback", + maxSendable: 2_000_000, + minSendable: 1_000, + metadata: '[["text/plain","alice"]]', + tag: "payRequest", + }), + }) + expect(result).toEqual({ errors: [], status: "success" }) + }) + + it("rejects converted msats below LNURL minSendable before calling IBEX pay", async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + callback: "https://lnurl.example/callback", + minSendable: 2_000, + maxSendable: 2_000_000, + metadata: '[["text/plain","alice"]]', + tag: "payRequest", + }, + }) + mockGetSatsFromCentsForImmediateSell.mockResolvedValueOnce( + paymentAmountFromNumber({ + amount: 1, + currency: WalletCurrency.Btc, + }), + ) + + const result = await resolveMutation() + + expect(mockPayToLnurl).not.toHaveBeenCalled() + expect(result?.status).toBe("failed") + expect(result?.errors[0].message).toMatch(/minSendable|maxSendable/i) + }) + + it("maps IBEX pay failures into payload errors", async () => { + mockPayToLnurl.mockResolvedValueOnce(new IbexError(new Error("ibex failed"))) + + const result = await resolveMutation() + + expect(result?.status).toBe("failed") + expect(result?.errors[0].message).toBeTruthy() + }) +})