diff --git a/.gitignore b/.gitignore index cdcb8c41c..4407023fa 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ junit.xml .yarnrc.yml docker-compose.local.yml + +# Planning scratch files +findings.md +progress.md +task_plan.md diff --git a/DEV.md b/DEV.md index 05f11208c..dd4e87a6d 100644 --- a/DEV.md +++ b/DEV.md @@ -153,6 +153,27 @@ make stop-frappe # stop local Frappe Update your `dev-overrides.yaml` with local Frappe credentials if running locally. +### Admin Panel API URL + +The Frappe Admin Panel uses the Frappe site config key `flash_admin_api_url` to +call the Flash admin GraphQL API for pages such as `alert-users`. + +- **Local Docker Frappe:** use `http://host.docker.internal:4001/graphql` so the + Frappe container can reach the backend admin GraphQL server running on the + host machine. Compose maps that hostname with Docker's symbolic + `host-gateway`, and the value can be overridden locally with + `FRAPPE_FLASH_API` if a developer's Docker runtime needs a different host + route. +- **Test / production Kubernetes:** do **not** use `host.docker.internal`. Set + `flash_admin_api_url` from the deployment config to the environment-specific + Kubernetes-reachable admin GraphQL endpoint, such as an internal service DNS + name or routed internal gateway URL. The endpoint should route to the admin + GraphQL server's `/graphql` path unless that environment explicitly rewrites a + different path. + +If a local full Frappe backup includes `site_config_backup.json`, treat this key +as environment-specific and override it during deployment restore. + --- ## Testing diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 0b7a93596..28ad62e5d 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -264,6 +264,159 @@ input BankAccountInput currency: String! } +type BridgeAddExternalAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + externalAccount: BridgeExternalAccountLink +} + +input BridgeCancelWithdrawalRequestInput + @join__type(graph: PUBLIC) +{ + withdrawalId: ID! +} + +type BridgeCancelWithdrawalRequestPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +input BridgeCreateExternalAccountInput + @join__type(graph: PUBLIC) +{ + accountNumber: String! + accountOwnerName: String! + bankName: String! + checkingOrSavings: String = "checking" + city: String! + country: String! + postalCode: String! + routingNumber: String! + state: String! + streetLine1: String! +} + +type BridgeCreateExternalAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + +type BridgeCreateVirtualAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + virtualAccount: BridgeVirtualAccount +} + +type BridgeExternalAccount + @join__type(graph: PUBLIC) +{ + accountNumberLast4: String! + bankName: String! + id: ID! + status: String! +} + +type BridgeExternalAccountLink + @join__type(graph: PUBLIC) +{ + expiresAt: String! + linkUrl: String! +} + +input BridgeInitiateKycInput + @join__type(graph: PUBLIC) +{ + email: String + full_name: String + type: String +} + +type BridgeInitiateKycPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + kycLink: BridgeKycLink +} + +input BridgeInitiateWithdrawalInput + @join__type(graph: PUBLIC) +{ + withdrawalId: ID! +} + +type BridgeInitiateWithdrawalPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +type BridgeKycLink + @join__type(graph: PUBLIC) +{ + kycLink: String! + tosLink: String! +} + +input BridgeRequestWithdrawalInput + @join__type(graph: PUBLIC) +{ + amount: String! + externalAccountId: ID! +} + +type BridgeRequestWithdrawalPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +type BridgeVirtualAccount + @join__type(graph: PUBLIC) +{ + accountNumber: String + accountNumberLast4: String + bankName: String + id: ID + kycLink: String + message: String + pending: Boolean + routingNumber: String + tosLink: String +} + +type BridgeWithdrawal + @join__type(graph: PUBLIC) +{ + amount: String! + bridgeDeveloperFee: String + bridgeExchangeFee: String + bridgeTransferId: String + createdAt: String! + currency: String! + estimatedBridgeFee: String + estimatedBridgeFeePercent: String + estimatedCustomerFee: String + estimatedGasBuffer: String + externalAccountId: String + failureReason: String + finalAmount: String + flashFee: String + flashFeeIsEstimate: Boolean! + flashFeeNotice: String + flashFeePercent: String + id: ID! + status: String! + subtotalAmount: String +} + """ A wallet belonging to an account which contains a BTC balance and a list of transactions. """ @@ -418,6 +571,29 @@ type CashoutOffer walletId: WalletId! } +type CashWalletCutover + @join__type(graph: PUBLIC) +{ + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +enum CashWalletCutoverState + @join__type(graph: PUBLIC) +{ + COMPLETE @join__enumValue(graph: PUBLIC) + IN_PROGRESS @join__enumValue(graph: PUBLIC) + PRE @join__enumValue(graph: PUBLIC) +} + """(Positive) Cent amount (1/100 of a dollar)""" scalar CentAmount @join__type(graph: PUBLIC) @@ -985,6 +1161,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) { @@ -1101,6 +1293,13 @@ type Mutation accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! + bridgeAddExternalAccount: BridgeAddExternalAccountPayload! + bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! + bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! + bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! + bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! + bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! + bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! @@ -1200,6 +1399,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! @@ -1557,9 +1762,15 @@ type Query @join__type(graph: PUBLIC) { accountDefaultWallet(username: Username!, walletCurrency: WalletCurrency): PublicWallet! + bridgeExternalAccounts: [BridgeExternalAccount] + bridgeKycStatus: String + bridgeVirtualAccount: BridgeVirtualAccount + bridgeWithdrawalRequest(id: ID!): BridgeWithdrawal + bridgeWithdrawals: [BridgeWithdrawal] btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] businessMapMarkers: [MapMarker!]! + cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload @@ -1940,6 +2151,53 @@ type UpgradePayload scalar USDCents @join__type(graph: PUBLIC) +""" +A wallet belonging to an account which contains a USDT balance and a list of transactions. +""" +type UsdtWallet implements Wallet + @join__implements(graph: PUBLIC, interface: "Wallet") + @join__type(graph: PUBLIC) +{ + accountId: ID! + balance: FractionalCentAmount + id: ID! + isExternal: Boolean! + lnurlp: Lnurl + + """An unconfirmed incoming onchain balance.""" + pendingIncomingBalance: SignedAmount! + transactions( + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + transactionsByAddress( + """Returns the items that include this address.""" + address: OnChainAddress! + + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + walletCurrency: WalletCurrency! +} + """ A wallet belonging to an account which contains a USD balance and a list of transactions. """ @@ -2333,6 +2591,7 @@ enum WalletCurrency { BTC @join__enumValue(graph: PUBLIC) USD @join__enumValue(graph: PUBLIC) + USDT @join__enumValue(graph: PUBLIC) } """Unique identifier of a wallet""" diff --git a/dev/bin/eng-297-graphql-smoke.ts b/dev/bin/eng-297-graphql-smoke.ts new file mode 100644 index 000000000..026893d15 --- /dev/null +++ b/dev/bin/eng-297-graphql-smoke.ts @@ -0,0 +1,489 @@ +import axios from "axios" + +type Args = { + url: string + token: string + first: number + amount: number + paymentRequest?: string + address?: string + strictFixtures: boolean +} + +type GraphQLResponse = { + data?: T + errors?: { message: string; path?: string[] }[] +} + +type Wallet = { + __typename: string + id: string + walletCurrency: string + balance?: number | null + transactions?: { + edges?: { + node?: { + id: string + settlementCurrency: string + settlementAmount: number + settlementDisplayAmount: string + settlementDisplayCurrency: string + settlementDisplayFee: string + settlementFee: number + settlementPrice: { + base: number + offset: number + } + initiationVia: { __typename: string } + settlementVia: { __typename: string } + } + }[] + } | null +} + +type CheckResult = { + name: string + status: "PASS" | "FAIL" | "SKIP" + details?: string +} + +const usage = ` +Usage: + yarn ts-node --transpile-only -r tsconfig-paths/register dev/bin/eng-297-graphql-smoke.ts \\ + --url http://localhost:4002/graphql \\ + --token "$AUTH_TOKEN" \\ + [--payment-request lnbc...] \\ + [--address bc1...] \\ + [--amount 100] + +Required: + --url GraphQL endpoint + --token Bearer token for a test account + +Optional fixtures: + --payment-request Bolt11 invoice for lnUsdInvoiceFeeProbe checks + --address On-chain address for onChainUsdTxFee checks + --amount Fractional cent amount for no-amount/on-chain checks. Default: 100 + --first Transaction page size per wallet. Default: 10 + --strict-fixtures Fail instead of skip checks that require optional fixtures +` + +const parseArgs = (argv: string[]): Args => { + const parsed: Record = {} + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === "--help" || arg === "-h") { + console.log(usage.trim()) + process.exit(0) + } + if (!arg.startsWith("--")) continue + + const key = arg.slice(2) + if (key === "strict-fixtures") { + parsed[key] = true + continue + } + + const value = argv[i + 1] + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`) + } + parsed[key] = value + i++ + } + + const url = (parsed.url || process.env.GRAPHQL_URL) as string | undefined + const token = (parsed.token || process.env.AUTH_TOKEN || process.env.TOKEN) as + | string + | undefined + + if (!url) throw new Error("Missing --url or GRAPHQL_URL") + if (!token) throw new Error("Missing --token, AUTH_TOKEN, or TOKEN") + + return { + url, + token, + first: Number(parsed.first || 10), + amount: Number(parsed.amount || 100), + paymentRequest: (parsed["payment-request"] || parsed.paymentRequest) as + | string + | undefined, + address: parsed.address as string | undefined, + strictFixtures: Boolean(parsed["strict-fixtures"]), + } +} + +const results: CheckResult[] = [] + +const pass = (name: string, details?: string) => { + results.push({ name, status: "PASS", details }) +} + +const fail = (name: string, details?: string) => { + results.push({ name, status: "FAIL", details }) +} + +const skip = (name: string, details?: string) => { + results.push({ name, status: "SKIP", details }) +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const graphql = async ( + args: Args, + operationName: string, + query: string, + variables?: Record, +): Promise => { + const resp = await axios.post>( + args.url, + { operationName, query, variables }, + { + headers: { + Authorization: `Bearer ${args.token}`, + "Content-Type": "application/json", + }, + validateStatus: () => true, + }, + ) + + if (resp.status < 200 || resp.status >= 300) { + throw new Error(`HTTP ${resp.status}: ${JSON.stringify(resp.data)}`) + } + + if (resp.data.errors?.length) { + throw new Error(JSON.stringify(resp.data.errors, null, 2)) + } + + if (!resp.data.data) throw new Error("Missing GraphQL data") + return resp.data.data +} + +const recordCheck = async (name: string, check: () => Promise) => { + try { + const details = await check() + pass(name, details || undefined) + } catch (err) { + fail(name, err instanceof Error ? err.message : `${err}`) + } +} + +const optionalCheck = async ( + args: Args, + name: string, + fixtureDescription: string, + check: () => Promise, +) => { + if (args.strictFixtures) { + await recordCheck(name, check) + return + } + + try { + const details = await check() + pass(name, details || undefined) + } catch (err) { + const message = err instanceof Error ? err.message : `${err}` + if (message.includes("Missing fixture:")) { + skip(name, fixtureDescription) + return + } + fail(name, message) + } +} + +const walletSummary = (wallet: Wallet) => + `${wallet.__typename}/${wallet.walletCurrency}/${wallet.id}` + +const getWallets = async (args: Args): Promise => { + const data = await graphql<{ + me: { + defaultAccount: { + wallets: Wallet[] + } + } | null + }>( + args, + "Eng297WalletsAndTransactions", + ` + query Eng297WalletsAndTransactions($first: Int!) { + me { + defaultAccount { + wallets { + __typename + ... on BTCWallet { + id + walletCurrency + balance + transactions(first: $first) { ...TransactionConnectionFields } + } + ... on UsdWallet { + id + walletCurrency + usdBalance: balance + isExternal + transactions(first: $first) { ...TransactionConnectionFields } + } + ... on UsdtWallet { + id + walletCurrency + usdtBalance: balance + isExternal + transactions(first: $first) { ...TransactionConnectionFields } + } + } + } + } + } + + fragment TransactionConnectionFields on TransactionConnection { + edges { + node { + id + settlementCurrency + settlementAmount + settlementDisplayAmount + settlementDisplayCurrency + settlementDisplayFee + settlementFee + settlementPrice { + base + offset + } + initiationVia { __typename } + settlementVia { __typename } + } + } + } + `, + { first: args.first }, + ) + + const defaultAccount = data.me?.defaultAccount + assert(defaultAccount, "Missing me.defaultAccount") + return defaultAccount.wallets +} + +const requireWallet = (wallets: Wallet[], currency: string): Wallet => { + const wallet = wallets.find((candidate) => candidate.walletCurrency === currency) + assert(wallet, `Missing ${currency} wallet`) + return wallet as Wallet +} + +const assertNoTransactionShapeGaps = (wallet: Wallet) => { + const transactions = wallet.transactions?.edges || [] + for (const edge of transactions) { + const tx = edge.node + if (!tx) { + throw new Error(`${walletSummary(wallet)} has transaction edge without node`) + } + assert(tx.id, `${walletSummary(wallet)} transaction missing id`) + assert( + tx.settlementCurrency, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementCurrency`, + ) + assert( + tx.settlementDisplayCurrency, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementDisplayCurrency`, + ) + assert( + tx.settlementPrice, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementPrice`, + ) + assert( + tx.initiationVia?.__typename, + `${walletSummary(wallet)} transaction ${tx.id} missing initiationVia.__typename`, + ) + assert( + tx.settlementVia?.__typename, + `${walletSummary(wallet)} transaction ${tx.id} missing settlementVia.__typename`, + ) + } +} + +const runLnUsdInvoiceFeeProbe = async (args: Args, wallet: Wallet) => { + if (!args.paymentRequest) throw new Error("Missing fixture: paymentRequest") + + const data = await graphql<{ + lnUsdInvoiceFeeProbe: { + errors: { message: string }[] + amount?: number + invoiceAmount?: number + } + }>( + args, + "Eng297LnUsdInvoiceFeeProbe", + ` + mutation Eng297LnUsdInvoiceFeeProbe($input: LnUsdInvoiceFeeProbeInput!) { + lnUsdInvoiceFeeProbe(input: $input) { + errors { message } + amount + invoiceAmount + } + } + `, + { + input: { + walletId: wallet.id, + paymentRequest: args.paymentRequest, + }, + }, + ) + + const result = data.lnUsdInvoiceFeeProbe + assert( + result.errors.length === 0, + `${walletSummary(wallet)} lnUsdInvoiceFeeProbe errors: ${JSON.stringify(result.errors)}`, + ) + assert(result.amount !== undefined, `${walletSummary(wallet)} missing fee amount`) + assert( + result.invoiceAmount !== undefined, + `${walletSummary(wallet)} missing invoiceAmount`, + ) +} + +const runLnNoAmountUsdInvoiceFeeProbe = async (args: Args, wallet: Wallet) => { + if (!args.paymentRequest) throw new Error("Missing fixture: paymentRequest") + + const data = await graphql<{ + lnNoAmountUsdInvoiceFeeProbe: { + errors: { message: string }[] + amount?: number + invoiceAmount?: number + } + }>( + args, + "Eng297LnNoAmountUsdInvoiceFeeProbe", + ` + mutation Eng297LnNoAmountUsdInvoiceFeeProbe($input: LnNoAmountUsdInvoiceFeeProbeInput!) { + lnNoAmountUsdInvoiceFeeProbe(input: $input) { + errors { message } + amount + invoiceAmount + } + } + `, + { + input: { + walletId: wallet.id, + paymentRequest: args.paymentRequest, + amount: args.amount, + }, + }, + ) + + const result = data.lnNoAmountUsdInvoiceFeeProbe + assert( + result.errors.length === 0, + `${walletSummary(wallet)} lnNoAmountUsdInvoiceFeeProbe errors: ${JSON.stringify(result.errors)}`, + ) + assert(result.amount !== undefined, `${walletSummary(wallet)} missing fee amount`) + assert( + result.invoiceAmount !== undefined, + `${walletSummary(wallet)} missing invoiceAmount`, + ) +} + +const runOnChainUsdTxFee = async (args: Args, wallet: Wallet) => { + if (!args.address) throw new Error("Missing fixture: address") + + const data = await graphql<{ + onChainUsdTxFee: { + amount: number + } + }>( + args, + "Eng297OnChainUsdTxFee", + ` + query Eng297OnChainUsdTxFee( + $walletId: WalletId! + $address: OnChainAddress! + $amount: FractionalCentAmount! + ) { + onChainUsdTxFee(walletId: $walletId, address: $address, amount: $amount) { + amount + } + } + `, + { + walletId: wallet.id, + address: args.address, + amount: args.amount, + }, + ) + + assert( + data.onChainUsdTxFee.amount !== undefined, + `${walletSummary(wallet)} missing onChainUsdTxFee amount`, + ) +} + +const main = async () => { + const args = parseArgs(process.argv.slice(2)) + let wallets: Wallet[] = [] + + await recordCheck("wallet list includes USD and USDT wallets", async () => { + wallets = await getWallets(args) + const usdWallet = requireWallet(wallets, "USD") + const usdtWallet = requireWallet(wallets, "USDT") + return `${walletSummary(usdWallet)}, ${walletSummary(usdtWallet)}` + }) + + if (wallets.length > 0) { + await recordCheck("wallet transaction shapes are render-safe", async () => { + for (const wallet of wallets) assertNoTransactionShapeGaps(wallet) + const counts = wallets.map( + (wallet) => `${wallet.walletCurrency}:${wallet.transactions?.edges?.length || 0}`, + ) + return counts.join(", ") + }) + + for (const currency of ["USD", "USDT"]) { + const wallet = wallets.find((candidate) => candidate.walletCurrency === currency) + if (!wallet) continue + + await optionalCheck( + args, + `${currency} lnUsdInvoiceFeeProbe`, + "skipped; pass --payment-request to exercise Lightning fee probe", + () => runLnUsdInvoiceFeeProbe(args, wallet), + ) + + await optionalCheck( + args, + `${currency} lnNoAmountUsdInvoiceFeeProbe`, + "skipped; pass --payment-request to exercise no-amount Lightning fee probe", + () => runLnNoAmountUsdInvoiceFeeProbe(args, wallet), + ) + + await optionalCheck( + args, + `${currency} onChainUsdTxFee`, + "skipped; pass --address to exercise on-chain fee probe", + () => runOnChainUsdTxFee(args, wallet), + ) + } + } + + for (const result of results) { + const icon = result.status === "PASS" ? "✅" : result.status === "SKIP" ? "⏭️" : "❌" + console.log(`${icon} ${result.status} ${result.name}`) + if (result.details) console.log(` ${result.details}`) + } + + const failed = results.filter((result) => result.status === "FAIL") + if (failed.length > 0) { + console.error(`\n${failed.length} smoke check(s) failed`) + process.exit(1) + } + + console.log("\nENG-297 GraphQL smoke checks completed") +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/dev/bruno/Flash GraphQL API/admin/account-update-level.bru b/dev/bruno/Flash GraphQL API/admin/account-update-level.bru index 679cd0e8e..244ae7d0f 100644 --- a/dev/bruno/Flash GraphQL API/admin/account-update-level.bru +++ b/dev/bruno/Flash GraphQL API/admin/account-update-level.bru @@ -32,7 +32,7 @@ body:graphql { body:graphql:vars { { "input": { - "uid": "69a6f3aeaa8667d2c0d65922", + "uid": "69fb995b012e5336d69a0f1f", "level": "THREE", "erpParty": "John Doe" } diff --git a/dev/bruno/Flash GraphQL API/environments/local.bru b/dev/bruno/Flash GraphQL API/environments/local.bru index 9ea0c4934..55107b224 100644 --- a/dev/bruno/Flash GraphQL API/environments/local.bru +++ b/dev/bruno/Flash GraphQL API/environments/local.bru @@ -8,4 +8,8 @@ vars { token: walletId: walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 + userEmail: mauriente@gmail.com + userFullName: maurientes + bridgeExternalAccountId: + bridgeWithdrawalId: } diff --git a/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/01 discover legacy cashout wallet.bru b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/01 discover legacy cashout wallet.bru new file mode 100644 index 000000000..64a600c2d --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/01 discover legacy cashout wallet.bru @@ -0,0 +1,68 @@ +meta { + name: 01 discover legacy cashout wallet + type: graphql + seq: 1 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query Eng357CashoutInputs { + me { + id + defaultAccount { + id + defaultWalletId + wallets { + id + walletCurrency + balance + } + } + bankAccounts { + id + accountName + bankName + currency + } + } + } +} + +script:post-response { + test("discovers the legacy USD cashout wallet without the cash-wallet capability header", function () { + const body = res.getBody(); + const errors = body.errors ?? []; + expect(errors, JSON.stringify(errors)).to.be.empty; + + const me = body.data?.me; + expect(me, "me").to.exist; + + const wallets = me.defaultAccount?.wallets ?? []; + const cashoutAccountId = me.defaultAccount?.id; + const legacyUsdWallet = wallets.find((wallet) => wallet.walletCurrency === "USD"); + const legacyUsdWalletId = legacyUsdWallet?.id ?? me.defaultAccount?.defaultWalletId; + expect(cashoutAccountId, "cashout account id").to.exist; + expect(legacyUsdWallet?.id, "legacy USD wallet id").to.exist; + expect(legacyUsdWalletId, "legacy USD wallet id or defaultWalletId").to.exist; + + bru.setEnvVar("cashoutAccountId", cashoutAccountId); + bru.setEnvVar("legacyUsdWalletId", legacyUsdWalletId); + + console.log("cashoutAccountId:", cashoutAccountId); + console.log("legacyUsdWalletId:", legacyUsdWalletId); + }); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/02 discover USDT cashout inputs.bru b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/02 discover USDT cashout inputs.bru new file mode 100644 index 000000000..8a414d3d6 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/02 discover USDT cashout inputs.bru @@ -0,0 +1,80 @@ +meta { + name: 02 discover USDT cashout inputs + type: graphql + seq: 2 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json + X-Flash-Client-Capabilities: cash-wallet-usdt-v1 +} + +body:graphql { + query Eng357CashoutInputs { + me { + id + defaultAccount { + id + defaultWalletId + wallets { + id + walletCurrency + balance + } + } + bankAccounts { + id + accountName + bankName + currency + } + } + } +} + +script:post-response { + test("discovers the post-cutover USDT wallet and cashout bank account with the cash-wallet capability header", function () { + const body = res.getBody(); + const errors = body.errors ?? []; + expect(errors, JSON.stringify(errors)).to.be.empty; + + const me = body.data?.me; + expect(me, "me").to.exist; + + const wallets = me.defaultAccount?.wallets ?? []; + const cashoutAccountId = me.defaultAccount?.id; + const usdtWallet = wallets.find((wallet) => wallet.walletCurrency === "USDT"); + expect(cashoutAccountId, "cashout account id").to.exist; + expect(bru.getEnvVar("legacyUsdWalletId"), "legacy USD wallet id from step 01").to.exist; + expect(usdtWallet?.id, "USDT wallet id").to.exist; + expect(usdtWallet.id, "USDT wallet should be the post-cutover default wallet").to.eql( + me.defaultAccount?.defaultWalletId, + ); + + const bankAccounts = me.bankAccounts ?? []; + const bankAccount = + bankAccounts.find((account) => account.currency === "JMD") ?? bankAccounts[0]; + const cashoutBankAccountId = + bankAccount?.id ?? bru.getEnvVar("cashoutBankAccountId") ?? "12345 - First Global"; + expect(cashoutBankAccountId, "cashout bank account id").to.exist; + + bru.setEnvVar("cashoutAccountId", cashoutAccountId); + bru.setEnvVar("usdtWalletId", usdtWallet.id); + bru.setEnvVar("cashoutBankAccountId", cashoutBankAccountId); + + console.log("cashoutAccountId:", cashoutAccountId); + console.log("usdtWalletId:", usdtWallet.id); + console.log("cashoutBankAccountId:", cashoutBankAccountId); + }); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/03 request cashout offer.bru b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/03 request cashout offer.bru new file mode 100644 index 000000000..8b1bb4cd9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/03 request cashout offer.bru @@ -0,0 +1,78 @@ +meta { + name: 03 request cashout offer + type: graphql + seq: 3 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json + ~X-Flash-Client-Capabilities: cash-wallet-usdt-v1 +} + +body:graphql { + mutation Eng357RequestCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { + offer { + offerId + walletId + send + receiveUsd + receiveJmd + flashFee + expiresAt + } + errors { + ... on GraphQLApplicationError { + code + message + path + } + } + } + } +} + +body:graphql:vars { + { + "input": { + "walletId": "{{legacyUsdWalletId}}", + "amount": 100, + "bankAccountId": "{{cashoutBankAccountId}}" + } + } +} + +script:post-response { + test("creates a post-cutover USDT-settled cashout offer from the legacy USD wallet input", function () { + const body = res.getBody(); + const graphqlErrors = body.errors ?? []; + expect(graphqlErrors, JSON.stringify(graphqlErrors)).to.be.empty; + + const payload = body.data?.requestCashout; + const appErrors = payload?.errors ?? []; + expect(appErrors, JSON.stringify(appErrors)).to.be.empty; + + const offer = payload?.offer; + expect(offer?.offerId, "offerId").to.exist; + expect(offer.walletId, "offer walletId should be the resolved USDT wallet").to.eql( + bru.getEnvVar("usdtWalletId"), + ); + expect(offer.walletId, "offer walletId should not stay on legacy USD").to.not.eql( + bru.getEnvVar("legacyUsdWalletId"), + ); + + bru.setEnvVar("cashoutOfferId", offer.offerId); + console.log("cashoutOfferId:", offer.offerId); + }); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/04 initiate cashout offer.bru b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/04 initiate cashout offer.bru new file mode 100644 index 000000000..d251d4227 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/04 initiate cashout offer.bru @@ -0,0 +1,61 @@ +meta { + name: 04 initiate cashout offer + type: graphql + seq: 4 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json + X-Flash-Client-Capabilities: cash-wallet-usdt-v1 +} + +body:graphql { + mutation Eng357InitiateCashout($input: InitiateCashoutInput!) { + initiateCashout(input: $input) { + id + errors { + ... on GraphQLApplicationError { + code + message + path + } + } + } + } +} + +body:graphql:vars { + { + "input": { + "offerId": "{{cashoutOfferId}}", + "walletId": "{{legacyUsdWalletId}}" + } + } +} + +script:post-response { + test("initiates the Redis-loaded USDT cashout offer using the legacy USD wallet for auth", function () { + const body = res.getBody(); + const graphqlErrors = body.errors ?? []; + expect(graphqlErrors, JSON.stringify(graphqlErrors)).to.be.empty; + + const payload = body.data?.initiateCashout; + const appErrors = payload?.errors ?? []; + expect(appErrors, JSON.stringify(appErrors)).to.be.empty; + expect(payload?.id, "cashout id").to.exist; + + bru.setEnvVar("cashoutId", payload.id); + console.log("cashoutId:", payload.id); + }); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/folder.bru b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/folder.bru new file mode 100644 index 000000000..5e8d71621 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/ENG-357 cashout cutover/folder.bru @@ -0,0 +1,33 @@ +meta { + name: ENG-357 cashout cutover + seq: 4 +} + +auth { + mode: inherit +} + +docs { + # ENG-357 Cashout V1 cutover smoke + + Focused local Bruno flow for PR #395. + + Run order: + 1. `login flow/userLogin` + 2. `token/ENG-357 cashout cutover/01 discover legacy cashout wallet` + 3. `token/ENG-357 cashout cutover/02 discover USDT cashout inputs` + 4. `token/ENG-357 cashout cutover/03 request cashout offer` + 5. `token/ENG-357 cashout cutover/04 initiate cashout offer` + + This intentionally requests cashout with the legacy USD wallet from the old-client + view, then verifies the offer settles against the post-cutover USDT cash wallet. + Step 01 omits `X-Flash-Client-Capabilities` to discover the legacy USD wallet. + Step 02 sends `X-Flash-Client-Capabilities: cash-wallet-usdt-v1` to discover the + USDT wallet and bank account. PR #395 should return an offer whose `walletId` is + the account's USDT wallet, then `initiateCashout` should successfully reload that + offer from Redis while authenticating with the legacy wallet id. + + If step 03 returns the USD wallet instead of USDT, the local account/config is not + exercising the post-cutover route. If step 04 returns a server error after step 03 + succeeds, that strongly points at the USDT offer Redis serialization round-trip. +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru new file mode 100644 index 000000000..cbcfbc6fa --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru @@ -0,0 +1,45 @@ +meta { + name: bridgeCancelWithdrawalRequest + type: graphql + seq: 39 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeCancelWithdrawalRequest($input: BridgeCancelWithdrawalRequestInput!) { + bridgeCancelWithdrawalRequest(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "withdrawalId": "{{bridgeWithdrawalId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeCreateVirtualAccount.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCreateVirtualAccount.bru new file mode 100644 index 000000000..740fb6ec7 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCreateVirtualAccount.bru @@ -0,0 +1,37 @@ +meta { + name: bridgeCreateVirtualAccount + type: graphql + seq: 37 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeCreateVirtualAccount { + bridgeCreateVirtualAccount { + errors { + message + code + } + virtualAccount { + id + bankName + routingNumber + accountNumberLast4 + } + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} \ No newline at end of file diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateKyc.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateKyc.bru new file mode 100644 index 000000000..4d9db31b0 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateKyc.bru @@ -0,0 +1,44 @@ +meta { + name: bridgeInitiateKyc + type: graphql + seq: 36 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeInitiateKyc($input: BridgeInitiateKycInput!) { + bridgeInitiateKyc(input: $input) { + errors { + message + } + kycLink { + kycLink + tosLink + } + } + } +} + +body:graphql:vars { + { + "input": { + "email": "{{userEmail}}", + "type": "individual", + "full_name": "{{userFullName}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru new file mode 100644 index 000000000..e938f7ddd --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru @@ -0,0 +1,45 @@ +meta { + name: bridgeInitiateWithdrawal + type: graphql + seq: 38 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) { + bridgeInitiateWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "withdrawalId": "{{bridgeWithdrawalId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru new file mode 100644 index 000000000..49fe78ba9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru @@ -0,0 +1,47 @@ +meta { + name: bridgeRequestWithdrawal + type: graphql + seq: 37 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + externalAccountId + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "amount": "50.00", + "externalAccountId": "{{bridgeExternalAccountId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeKycStatus.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeKycStatus.bru new file mode 100644 index 000000000..c85ab9cb9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeKycStatus.bru @@ -0,0 +1,26 @@ +meta { + name: bridgeKycStatus + type: graphql + seq: 19 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeKycStatus { + bridgeKycStatus + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeVirtualAccount.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeVirtualAccount.bru new file mode 100644 index 000000000..e3e9c0f3e --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeVirtualAccount.bru @@ -0,0 +1,32 @@ +meta { + name: bridgeVirtualAccount + type: graphql + seq: 18 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeVirtualAccount { + bridgeVirtualAccount { + id + bankName + routingNumber + accountNumberLast4 + accountNumber + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru new file mode 100644 index 000000000..68a428f40 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru @@ -0,0 +1,40 @@ +meta { + name: bridgeWithdrawalRequest + type: graphql + seq: 33 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeWithdrawalRequest($id: ID!) { + bridgeWithdrawalRequest(id: $id) { + id + amount + currency + externalAccountId + status + failureReason + createdAt + } + } +} + +body:graphql:vars { + { + "id": "{{bridgeWithdrawalId}}" + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 3aa7f3da7..6e864eca2 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -15,6 +15,54 @@ ibex: # production overrides to enforce the allowlist; empty disables it (ISL-112). allowedIps: [] +bridge: + enabled: true + apiKey: "" # real key injected via config overrides; never commit a real key + baseUrl: "https://api.sandbox.bridge.xyz/v0" + minWithdrawalAmount: 2 + developerFeePercent: 2.0 + withdrawalFeeEstimate: + bridgeFixedFeePercent: 0.6 + usdtTransferGasLimit: 65000 + gasPriceBufferMultiplier: 1.5 + ethereumGasRpcUrls: + - "https://ethereum-rpc.publicnode.com" + - "https://eth.llamarpc.com" + - "https://cloudflare-eth.com" + ethUsdPriceUrl: "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" + timeoutMs: 3000 + cacheTtlMs: 60000 + fallbackGasPriceGwei: 30 + ethUsdFallback: 3000 + timeoutMs: 15000 + webhook: + port: 4009 + replaySecret: "also-not-so-secret" + publicKeys: + kyc: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxrV+s8CvC0+s1W6vZG52 + 5eozo6W6HzkTcLQMWDoEzQX+ulEoYH2fPuXeupi11MdVLpEqNqYas8LD3BIf/c9H + kK54V8vnXNwoHa5ROp/Gjp3B17q3wGfjLa8bQJoJZFWd9W+e3TjUohCDNpeD/qv+ + bkY2y3b1QixmXKK3REw35sfiEe5NkGMU4aEfXhZieIZ1mKXLsIgsgrIpv9BFwQr5 + +h3R7Vv3hGKVgSZHnRMa9F1/go8v5Au8gj+9w0LxxRJikoJCubI6igaTCivibxuo + QXWfFylw6m7eQTvZDQz70pnUEakofRlvKasetbyKmvLzMhuRHeqsxgi8C4ZCx7MP + dwIDAQAB + -----END PUBLIC KEY----- + deposit: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxrV+s8CvC0+s1W6vZG52 + 5eozo6W6HzkTcLQMWDoEzQX+ulEoYH2fPuXeupi11MdVLpEqNqYas8LD3BIf/c9H + kK54V8vnXNwoHa5ROp/Gjp3B17q3wGfjLa8bQJoJZFWd9W+e3TjUohCDNpeD/qv+ + bkY2y3b1QixmXKK3REw35sfiEe5NkGMU4aEfXhZieIZ1mKXLsIgsgrIpv9BFwQr5 + +h3R7Vv3hGKVgSZHnRMa9F1/go8v5Au8gj+9w0LxxRJikoJCubI6igaTCivibxuo + QXWfFylw6m7eQTvZDQz70pnUEakofRlvKasetbyKmvLzMhuRHeqsxgi8C4ZCx7MP + dwIDAQAB + -----END PUBLIC KEY----- + transfer: "" + external_account: "" + timestampSkewMs: 300000 + # See Foreign Exchange Rates: https://www.firstglobal-bank.com/ exchangeRates: USD: diff --git a/dev/erpnext/backup.sh b/dev/erpnext/backup.sh index 230d68296..7a652db18 100755 --- a/dev/erpnext/backup.sh +++ b/dev/erpnext/backup.sh @@ -1,17 +1,111 @@ #!/bin/bash +set -euo pipefail -# Create backups directory on host if it doesn't exist -BACKUP_DIR="$(dirname "$0")/backups" +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) + +BACKUP_DIR="$SCRIPT_DIR/backups" mkdir -p "$BACKUP_DIR" -docker exec -it flash-frappe-frontend-1 mkdir -p /tmp/backups +cd "$REPO_ROOT" + +FRAPPE_FRONTEND_SERVICE="frappe-frontend" +FRAPPE_BACKEND_SERVICE="frappe-backend" +SITE_NAME="${SITE_NAME:-frontend}" +APP_NAMES="${APP_NAMES:-}" +BACKUP_DIR_IN_CONTAINER="/tmp/backups" + +docker compose exec -T "$FRAPPE_FRONTEND_SERVICE" mkdir -p "$BACKUP_DIR_IN_CONTAINER" +docker compose exec -T "$FRAPPE_BACKEND_SERVICE" mkdir -p "$BACKUP_DIR_IN_CONTAINER" + +if [ -z "$APP_NAMES" ]; then + APP_NAMES=$(docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" list-apps | awk 'NF && $1 !~ /^(frappe|erpnext)$/ {print $1}' | xargs) +fi # Run the backup inside the container and capture output -BACKUP_OUTPUT=$(docker exec flash-frappe-frontend-1 bench --site frontend backup --backup-path /tmp/backups) +BACKUP_OUTPUT=$(docker compose exec -T "$FRAPPE_FRONTEND_SERVICE" bench --site "$SITE_NAME" backup --backup-path "$BACKUP_DIR_IN_CONTAINER") # Extract the database path from the output line containing "Database:" -BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | grep "Database:" | awk '{print $2}') -echo $BACKUP_FILE -docker cp flash-frappe-frontend-1:$BACKUP_FILE "$BACKUP_DIR/" +BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | awk -F': +' '/Database:/{split($2, parts, /[[:space:]]+/); print parts[1]; exit}') +SITE_CONFIG_FILE=$(echo "$BACKUP_OUTPUT" | awk -F': +' '/Config:|Site Config:/{split($2, parts, /[[:space:]]+/); print parts[1]; exit}' || true) + +if [ -z "$BACKUP_FILE" ]; then + echo "Error: could not find database backup path in bench output" + echo "$BACKUP_OUTPUT" + exit 1 +fi + +BACKUP_FILENAME=$(basename "$BACKUP_FILE") +BACKUP_PREFIX=${BACKUP_FILENAME%-database.sql.gz} +if [ "$BACKUP_PREFIX" = "$BACKUP_FILENAME" ]; then + BACKUP_PREFIX=${BACKUP_FILENAME%.sql.gz} +fi + +BUNDLE_STAGING_DIR=$(mktemp -d "$BACKUP_DIR/full-backup.XXXXXX") +trap 'rm -rf "$BUNDLE_STAGING_DIR"' EXIT + +mkdir -p "$BUNDLE_STAGING_DIR/apps" + +docker compose cp "$FRAPPE_FRONTEND_SERVICE:$BACKUP_FILE" "$BUNDLE_STAGING_DIR/database.sql.gz" + +if [ -n "$SITE_CONFIG_FILE" ]; then + docker compose cp "$FRAPPE_FRONTEND_SERVICE:$SITE_CONFIG_FILE" "$BUNDLE_STAGING_DIR/site_config_backup.json" || { + echo "Warning: could not copy site config backup from $SITE_CONFIG_FILE" + } +fi + +BACKED_UP_APPS="" +for APP_NAME in $APP_NAMES; do + if ! [[ "$APP_NAME" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Error: invalid app name '$APP_NAME'" + exit 1 + fi + + APP_ARCHIVE_PATH="$BACKUP_DIR_IN_CONTAINER/$APP_NAME-app.tar.gz" + + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" sh -c ' + app="$1" + archive_path="$2" + app_dir="/home/frappe/frappe-bench/apps/$app" + + if [ ! -d "$app_dir" ]; then + echo "Error: app directory not found: $app_dir" >&2 + exit 1 + fi + + rm -f "$archive_path" + tar \ + -C /home/frappe/frappe-bench/apps \ + --exclude="$app/.git" \ + --exclude="$app/.worktrees" \ + --exclude="$app/.pytest_cache" \ + --exclude="$app/dev/backups" \ + --exclude="*/__pycache__" \ + --exclude="*.pyc" \ + -czf "$archive_path" "$app" + ' sh "$APP_NAME" "$APP_ARCHIVE_PATH" + + docker compose cp "$FRAPPE_BACKEND_SERVICE:$APP_ARCHIVE_PATH" "$BUNDLE_STAGING_DIR/apps/$APP_NAME.tar.gz" + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" rm -f "$APP_ARCHIVE_PATH" + + BACKED_UP_APPS="$BACKED_UP_APPS $APP_NAME" +done + +if [ -z "$BACKED_UP_APPS" ]; then + echo "Warning: no non-core app source archives were included" +fi + +cat > "$BUNDLE_STAGING_DIR/manifest.env" <" + echo "Usage: $0 " echo "Example: $0 backups/20260122_062420-frontend-database.sql.gz" + echo "Example: $0 backups/20260122_062420-frontend-full-backup.tar.gz" exit 1 fi BACKUP_FILE="$1" DB_PASSWORD="admin" # defined in docker compose FRAPPE_BACKEND_SERVICE="frappe-backend" -SITE_NAME="frontend" +FRAPPE_FRONTEND_SERVICE="frappe-frontend" +SITE_NAME="${SITE_NAME:-frontend}" RESTORE_DIR="/tmp/restore" +RESTORE_STAGING_DIR="" -# Check if backup file exists if [ ! -f "$BACKUP_FILE" ]; then echo "Error: Backup file '$BACKUP_FILE' not found" exit 1 @@ -29,20 +30,136 @@ BACKUP_FILENAME=$(basename "$BACKUP_FILE") cd "$REPO_ROOT" -# Copy the backup file from host to container restore directory -docker compose exec -T "$FRAPPE_BACKEND_SERVICE" mkdir -p "$RESTORE_DIR" -docker compose cp "$BACKUP_FILE" "$FRAPPE_BACKEND_SERVICE:$RESTORE_DIR/$BACKUP_FILENAME" +cleanup() { + if [ -n "$RESTORE_STAGING_DIR" ]; then + rm -rf "$RESTORE_STAGING_DIR" + fi +} +trap cleanup EXIT + +remove_stale_locks() { + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" rm -f "/home/frappe/frappe-bench/sites/$SITE_NAME/locks/"*.lock 2>/dev/null || true +} + +migrate_site() { + echo "Migrating $SITE_NAME" + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate || { + echo "Migration failed, retrying..." + sleep 5 + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate + } +} + +restore_database() { + local database_backup="$1" + local database_filename + + database_filename=$(basename "$database_backup") + + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" mkdir -p "$RESTORE_DIR" + docker compose cp "$database_backup" "$FRAPPE_BACKEND_SERVICE:$RESTORE_DIR/$database_filename" + + remove_stale_locks + + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" restore --db-root-password "$DB_PASSWORD" "$RESTORE_DIR/$database_filename" +} + +restore_app_archive_to_service() { + local service_name="$1" + local app_archive="$2" + local app_archive_filename + local app_name + local container_archive + + app_archive_filename=$(basename "$app_archive") + app_name=${app_archive_filename%.tar.gz} + container_archive="$RESTORE_DIR/apps/$app_archive_filename" + + docker compose exec -T "$service_name" mkdir -p "$RESTORE_DIR/apps" + docker compose cp "$app_archive" "$service_name:$container_archive" + docker compose exec -T --user root "$service_name" chmod 644 "$container_archive" + + docker compose exec -T "$service_name" sh -c ' + app="$1" + archive_path="$2" + apps_dir="/home/frappe/frappe-bench/apps" + previous_apps_dir="/tmp/restore-previous-apps" + timestamp=$(date -u +"%Y%m%d%H%M%S") + + if tar -tzf "$archive_path" | grep -E "^\.\./|/\.\./|^/|(^|/)\.\.$" >/dev/null; then + echo "Error: unsafe paths found in $archive_path" >&2 + exit 1 + fi + + if ! tar -tzf "$archive_path" | grep -q "^$app/"; then + echo "Error: archive $archive_path does not contain top-level app directory $app/" >&2 + exit 1 + fi -# Remove stale locks if present (e.g. from frappe-create-site) -docker compose exec -T "$FRAPPE_BACKEND_SERVICE" rm -f "/home/frappe/frappe-bench/sites/$SITE_NAME/locks/"*.lock 2>/dev/null || true + mkdir -p "$previous_apps_dir" + if [ -d "$apps_dir/$app" ]; then + mv "$apps_dir/$app" "$previous_apps_dir/$app.$timestamp" + fi -# Restore the database inside the container with the password -docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" restore --db-root-password "$DB_PASSWORD" "$RESTORE_DIR/$BACKUP_FILENAME" + tar -C "$apps_dir" -xzf "$archive_path" + ' sh "$app_name" "$container_archive" +} + +restore_app_archive() { + local app_archive="$1" + local app_archive_filename + local app_name + + app_archive_filename=$(basename "$app_archive") + app_name=${app_archive_filename%.tar.gz} + + if ! [[ "$app_name" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Error: invalid app archive name '$app_archive_filename'" + exit 1 + fi + + restore_app_archive_to_service "$FRAPPE_BACKEND_SERVICE" "$app_archive" + restore_app_archive_to_service "$FRAPPE_FRONTEND_SERVICE" "$app_archive" + + echo "Restored app source: $app_name" +} + +is_full_backup_bundle() { + local tar_list + + tar_list=$(mktemp) + if tar -tzf "$BACKUP_FILE" > "$tar_list" 2>/dev/null && grep -Eq '(^|^\./)database\.sql\.gz$' "$tar_list"; then + rm -f "$tar_list" + return 0 + fi -# Run migrate to sync database schema with current code -echo "Migrating $SITE_NAME" -docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate || { - echo "Migration failed, retrying..." - sleep 5 - docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate + rm -f "$tar_list" + return 1 } + +restore_full_backup_bundle() { + RESTORE_STAGING_DIR=$(mktemp -d) + tar -xzf "$BACKUP_FILE" -C "$RESTORE_STAGING_DIR" + + if [ ! -f "$RESTORE_STAGING_DIR/database.sql.gz" ]; then + echo "Error: full backup bundle is missing database.sql.gz" + exit 1 + fi + + if [ -d "$RESTORE_STAGING_DIR/apps" ]; then + for app_archive in "$RESTORE_STAGING_DIR"/apps/*.tar.gz; do + [ -e "$app_archive" ] || continue + restore_app_archive "$app_archive" + done + fi + + restore_database "$RESTORE_STAGING_DIR/database.sql.gz" +} + +if is_full_backup_bundle; then + restore_full_backup_bundle +else + restore_database "$BACKUP_FILE" +fi + +migrate_site diff --git a/dev/erpnext/start.sh b/dev/erpnext/start.sh index e65ee19f2..cc4f6f2dd 100755 --- a/dev/erpnext/start.sh +++ b/dev/erpnext/start.sh @@ -1,7 +1,9 @@ #!/bin/bash +set -euo pipefail docker compose up frappe -d +docker compose up frappe-frontend -d --force-recreate echo "Login to http://frontend.local:8080/#login" echo "Username: Administrator" -echo "Password: admin" \ No newline at end of file +echo "Password: admin" diff --git a/dev/setup-bridge-webhooks.js b/dev/setup-bridge-webhooks.js new file mode 100644 index 000000000..91d59fe05 --- /dev/null +++ b/dev/setup-bridge-webhooks.js @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/no-var-requires, import/order */ + +const fs = require("fs") +const path = require("path") +const { spawn } = require("child_process") +const yaml = require("js-yaml") + +const ROUTES = { + kyc: { + path: "/kyc", + eventCategories: ["customer", "kyc_link"], + }, + deposit: { + path: "/deposit", + eventCategories: ["virtual_account.activity", "bridge_wallet.activity"], + }, + transfer: { + path: "/transfer", + eventCategories: ["transfer"], + }, + external_account: { + path: "/external-account", + eventCategories: ["external_account"], + }, +} + +const DEFAULT_BRIDGE_BASE_URL = "https://api.sandbox.bridge.xyz/v0" +const DEFAULT_PORT = 4009 + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const trimTrailingSlash = (value) => value.replace(/\/+$/, "") + +const buildWebhookDefinitions = (baseUrl) => { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + return Object.fromEntries( + Object.entries(ROUTES).map(([key, route]) => [ + key, + { + url: `${normalizedBaseUrl}${route.path}`, + eventCategories: route.eventCategories, + }, + ]), + ) +} + +const extractNgrokHttpsUrl = (response) => { + const tunnel = response?.tunnels?.find( + (candidate) => + candidate?.proto === "https" && typeof candidate.public_url === "string", + ) + if (!tunnel) { + throw new Error("No HTTPS ngrok tunnel found on local ngrok API") + } + return tunnel.public_url +} + +const isObject = (value) => + value !== null && typeof value === "object" && !Array.isArray(value) + +const mergeDeep = (base, override) => { + const merged = { ...(isObject(base) ? base : {}) } + for (const [key, value] of Object.entries(override)) { + if (isObject(value) && isObject(merged[key])) { + merged[key] = mergeDeep(merged[key], value) + } else { + merged[key] = value + } + } + return merged +} + +const mergeDevOverrides = (existing, generated) => + mergeDeep(existing, { + bridge: { + apiKey: generated.apiKey, + baseUrl: generated.baseUrl, + webhook: { + uri: generated.webhookBaseUrl, + publicKeys: generated.publicKeys, + }, + }, + }) + +const reconcileBridgeWebhooks = async (api, definitions) => { + const existingWebhooks = await api.listWebhooks() + const webhooksToDelete = existingWebhooks.filter( + (webhook) => webhook.status !== "deleted", + ) + + for (const webhook of webhooksToDelete) { + await api.deleteWebhook(webhook.id) + } + + const publicKeys = {} + const created = {} + + for (const [key, definition] of Object.entries(definitions)) { + const webhook = await api.createWebhook({ key, ...definition }) + created[key] = webhook + publicKeys[key] = webhook.public_key + await api.enableWebhook(webhook.id, definition) + } + + return { + publicKeys, + created, + existingWebhookCount: existingWebhooks.length, + deletedWebhookCount: webhooksToDelete.length, + } +} + +const readYamlFile = (filePath) => { + if (!fs.existsSync(filePath)) return {} + return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {} +} + +const writeYamlFile = (filePath, data) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), "utf8") +} + +const defaultOverridesPath = () => { + const configDir = + process.env.CONFIG_PATH || path.join(process.env.HOME ?? ".", ".config/flash") + return path.join(configDir, "dev-overrides.yaml") +} + +const loadMergedConfig = ({ baseConfigPath, overridesPath }) => + mergeDeep(readYamlFile(baseConfigPath), readYamlFile(overridesPath)) + +const fetchJson = async ({ method, url, apiKey, body, idempotencyKey }) => { + const headers = { + "Api-Key": apiKey, + "Content-Type": "application/json", + } + if (idempotencyKey) { + headers["Idempotency-Key"] = idempotencyKey + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + const text = await response.text() + const parsed = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new Error( + `Bridge ${method} ${url} failed (${response.status}): ${JSON.stringify(parsed)}`, + ) + } + return parsed +} + +const createBridgeApi = ({ apiKey, baseUrl }) => { + const normalizedBaseUrl = trimTrailingSlash(baseUrl) + return { + listWebhooks: async () => { + const response = await fetchJson({ + method: "GET", + url: `${normalizedBaseUrl}/webhooks`, + apiKey, + }) + return response.data ?? [] + }, + deleteWebhook: async (id) => + fetchJson({ + method: "DELETE", + url: `${normalizedBaseUrl}/webhooks/${id}`, + apiKey, + }), + createWebhook: async ({ key, url, eventCategories }) => + fetchJson({ + method: "POST", + url: `${normalizedBaseUrl}/webhooks`, + apiKey, + idempotencyKey: `flash-dev-${key}-${Date.now()}`, + body: { + url, + event_epoch: "webhook_creation", + event_categories: eventCategories, + }, + }), + enableWebhook: async (id, definition) => + fetchJson({ + method: "PUT", + url: `${normalizedBaseUrl}/webhooks/${id}`, + apiKey, + body: { + url: definition.url, + status: "active", + event_categories: definition.eventCategories, + }, + }), + } +} + +const getNgrokTunnels = async () => { + const response = await fetch("http://127.0.0.1:4040/api/tunnels") + if (!response.ok) { + throw new Error(`ngrok API returned ${response.status}`) + } + return response.json() +} + +const hasNgrok = () => { + const paths = (process.env.PATH ?? "").split(path.delimiter) + return paths.some((candidate) => fs.existsSync(path.join(candidate, "ngrok"))) +} + +const startNgrok = ({ port }) => { + if (!hasNgrok()) { + throw new Error("ngrok is not installed or not on PATH") + } + + const logPath = path.join( + process.env.TMPDIR ?? "/tmp", + `flash-bridge-ngrok-${port}.log`, + ) + const logFd = fs.openSync(logPath, "a") + const child = spawn("ngrok", ["http", String(port), "--log", "stdout"], { + detached: true, + stdio: ["ignore", logFd, logFd], + }) + child.unref() + return { pid: child.pid, logPath } +} + +const ensureNgrokTunnel = async ({ port, retries = 20, intervalMs = 500 }) => { + try { + return extractNgrokHttpsUrl(await getNgrokTunnels()) + } catch { + startNgrok({ port }) + } + + for (let attempt = 0; attempt < retries; attempt += 1) { + await sleep(intervalMs) + try { + return extractNgrokHttpsUrl(await getNgrokTunnels()) + } catch { + // ngrok is still starting; keep polling until retries are exhausted. + } + } + + throw new Error("ngrok did not expose an HTTPS tunnel before timeout") +} + +const parseArgs = (argv) => { + const args = { + baseConfigPath: "dev/config/base-config.yaml", + overridesPath: defaultOverridesPath(), + port: DEFAULT_PORT, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === "--help" || arg === "-h") args.help = true + else if (arg === "--base-config") args.baseConfigPath = argv[++index] + else if (arg === "--overrides") args.overridesPath = argv[++index] + else if (arg === "--port") args.port = Number(argv[++index]) + else if (arg === "--api-key") args.apiKey = argv[++index] + else if (arg === "--base-url") args.baseUrl = argv[++index] + else throw new Error(`Unknown argument: ${arg}`) + } + + return args +} + +const usage = () => `Usage: node dev/setup-bridge-webhooks.js [options] + +Options: + --base-config Base YAML config path (default: dev/config/base-config.yaml) + --overrides Local override YAML path (default: $CONFIG_PATH/dev-overrides.yaml or ~/.config/flash/dev-overrides.yaml) + --port Local Bridge webhook port (default: 4009) + --api-key Bridge sandbox API key (default: existing config/env) + --base-url Bridge API base URL (default: existing config/env or sandbox) + --help Show this help message +` + +const run = async (argv = process.argv.slice(2)) => { + const args = parseArgs(argv) + if (args.help) { + console.log(usage()) + return + } + + const config = loadMergedConfig({ + baseConfigPath: args.baseConfigPath, + overridesPath: args.overridesPath, + }) + const apiKey = args.apiKey || process.env.BRIDGE_API_KEY || config.bridge?.apiKey + if (!apiKey) { + throw new Error( + "Bridge API key is required. Set bridge.apiKey in dev-overrides.yaml or pass --api-key.", + ) + } + + const baseUrl = + args.baseUrl || + process.env.BRIDGE_BASE_URL || + config.bridge?.baseUrl || + DEFAULT_BRIDGE_BASE_URL + + console.log(`Starting/using ngrok tunnel for localhost:${args.port}...`) + const webhookBaseUrl = await ensureNgrokTunnel({ port: args.port }) + console.log(`ngrok HTTPS URL: ${webhookBaseUrl}`) + + const definitions = buildWebhookDefinitions(webhookBaseUrl) + const bridgeApi = createBridgeApi({ apiKey, baseUrl }) + + console.log("Fetching and removing old Bridge sandbox webhooks...") + const { publicKeys, created, existingWebhookCount, deletedWebhookCount } = + await reconcileBridgeWebhooks(bridgeApi, definitions) + console.log(`Bridge reported ${existingWebhookCount} existing webhooks.`) + console.log(`Deleted ${deletedWebhookCount} old active/disabled webhooks.`) + + const existingOverrides = readYamlFile(args.overridesPath) + const merged = mergeDevOverrides(existingOverrides, { + apiKey, + baseUrl, + webhookBaseUrl, + publicKeys, + }) + writeYamlFile(args.overridesPath, merged) + + const activeCount = Object.keys(created).length + console.log(`Created and enabled ${activeCount} Bridge sandbox webhooks.`) + console.log("Smoke check passed: webhook public keys were returned and saved locally.") + console.log(`Updated local overrides: ${args.overridesPath}`) + console.log("") + console.log("Next steps:") + console.log(" 1. Start the Bridge webhook server:") + console.log( + ` yarn bridge-webhook --configPath ${args.baseConfigPath} --configPath ${args.overridesPath}`, + ) + console.log(" 2. Run the sandbox E2E suite:") + console.log(" IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e:ci") +} + +if (require.main === module) { + run().catch((error) => { + console.error(`Bridge webhook setup failed: ${error.message}`) + process.exit(1) + }) +} + +module.exports = { + buildWebhookDefinitions, + createBridgeApi, + extractNgrokHttpsUrl, + mergeDevOverrides, + reconcileBridgeWebhooks, + run, +} diff --git a/dev/setup.sh b/dev/setup.sh index 1cc94302f..2f5c7cdd8 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -1,6 +1,6 @@ #!/bin/bash # Flash Dev Environment Setup -# Usage: ./dev/setup.sh +# Usage: ./dev/setup.sh [--dev|--webhook] # # This script validates your environment, installs dependencies, # configures credentials, and starts the development server. @@ -16,6 +16,52 @@ info() { echo -e "${GREEN}✓${NC} $1"; } warn() { echo -e "${YELLOW}⚠${NC} $1"; } fail() { echo -e "${RED}✗${NC} $1"; exit 1; } +RUN_WEBHOOK_SETUP=false +WEBHOOK_ONLY=false + +usage() { + cat << EOF +Usage: ./dev/setup.sh [--dev|--webhook] + +Options: + --dev Run normal dev setup, then configure Bridge sandbox webhooks. + --webhook Only configure Bridge sandbox webhooks and local dev overrides. + --help Show this help message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dev) + RUN_WEBHOOK_SETUP=true + shift + ;; + --webhook) + RUN_WEBHOOK_SETUP=true + WEBHOOK_ONLY=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +run_bridge_webhook_setup() { + echo "" + echo "Configuring Bridge sandbox webhooks..." + node dev/setup-bridge-webhooks.js +} + +if [ "$WEBHOOK_ONLY" = true ]; then + run_bridge_webhook_setup + exit 0 +fi + echo "" echo "═══════════════════════════════════════════════════" echo " Flash Backend — Development Environment Setup" @@ -136,6 +182,10 @@ echo "Starting Docker dependencies..." docker compose up bats-deps -d 2>&1 | grep -E '(Created|Started|Running)' || true info "Docker dependencies running" +if [ "$RUN_WEBHOOK_SETUP" = true ]; then + run_bridge_webhook_setup +fi + echo "" echo "═══════════════════════════════════════════════════" echo " ✅ Setup complete!" diff --git a/docker-compose.yml b/docker-compose.yml index 690a13c30..9e8a77b1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -202,6 +202,8 @@ services: deploy: restart_policy: condition: on-failure + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - frappe-sites:/home/frappe/frappe-bench/sites - frappe-logs:/home/frappe/frappe-bench/logs @@ -228,7 +230,10 @@ services: REDIS_CACHE: redis:6379/1 REDIS_QUEUE: redis:6379/2 SOCKETIO_PORT: "9000" - FLASH_API: "http://localhost:4002/admin/graphql" ### THIS IS THE HOST MACHINE. NOT DOCKER. + # Local Frappe containers use Docker's host alias to reach a backend + # running on the host. Test/prod k8s must override this with an internal + # service or gateway URL for the admin GraphQL /graphql endpoint. + FLASH_API: "${FRAPPE_FLASH_API:-http://host.docker.internal:4001/graphql}" FLASH_SECRET: "not-so-secret" volumes: - frappe-sites:/home/frappe/frappe-bench/sites @@ -287,6 +292,7 @@ services: image: brh28/frappe-flash:${FRAPPE_TAG:-latest} ports: [ "8080:8080" ] depends_on: + - frappe-backend - frappe-websocket deploy: restart_policy: diff --git a/docs/bridge-integration/ALERTING.md b/docs/bridge-integration/ALERTING.md new file mode 100644 index 000000000..270621a8b --- /dev/null +++ b/docs/bridge-integration/ALERTING.md @@ -0,0 +1,91 @@ +# Bridge Alerting (ENG-361) + +Operational alerting for the Bridge integration. When a Bridge signal fails +(webhook processing, ERPNext audit write, or a Bridge API outage), the +`AlertService` (`src/services/alerts`) fans the alert out to the configured +destinations. + +## Routing + +| Severity | PagerDuty (page) | Slack / Mattermost (inform) | Discord (inform) | +| ------------ | :--------------: | :-------------------------: | :--------------: | +| **critical** | ✅ | ✅ | ✅ | +| **warning** | — | ✅ | ✅ | + +Delivery is best-effort and fire-and-forget — a failing or unconfigured +destination never blocks or fails the webhook/request path. **A destination +with no configured credential is silently skipped**, so channels can be enabled +incrementally. + +### Deduplication + +Alerts carry a stable `dedupKey` so repeated failures do not spam on-call or chat: + +| Destination | Behavior | +| ----------- | -------- | +| **PagerDuty** | Events API v2 `dedup_key` groups triggers into one incident | +| **Slack / Discord** | First message per `dedupKey` within TTL; duplicates are skipped | + +Key classes (see `src/services/alerts/dedup-key.ts`): + +- `bridge-api:5xx` / `bridge-api:timeout` / `bridge-api:network` — coarse outage keys (30 min inform TTL) +- `erpnext-audit:deposit:{transfer_id}` — per deposit audit failure (1 h inform TTL) +- `erpnext-audit:transfer-complete:{transfer_id}` / `transfer-failed:{transfer_id}` — per transfer audit failure +- `bridge-webhook:deposit:{event_id}` / `bridge-webhook:transfer:{transfer_id}:{event}` — per webhook processing error +- `ibex:crypto-receive:{tx_hash}` — per IBEX crypto receive webhook failure (1 h inform TTL) +- `ibex:reconcile:bridge-without-ibex:{tx_hash}` / `ibex:reconcile:ibex-without-bridge:{tx_hash}` — per reconciliation orphan +- `ibex:reconcile:failed:{tx_hash}` — reconciliation handler threw + +Inform dedup is in-process per pod; PagerDuty dedup is global to the service integration. + +## Alert sources + +| Source | Severity | Where | +| ------------------------------------------------- | -------- | ----------------------------------------------------------- | +| ERPNext audit-write failure (deposit + transfer) | critical | `services/bridge/webhook-server/routes/{deposit,transfer}.ts` | +| Bridge webhook processing exception | critical | same routes (catch block) | +| Bridge API outage — 5xx / timeout / network | critical | `services/bridge/client.ts` | +| IBEX crypto receive webhook failure | warning | `services/ibex/webhook-server/routes/crypto-receive.ts` | +| Bridge↔IBEX reconciliation orphan / failure | warning | `services/bridge/reconciliation.ts`, deposit/crypto catch | + +`4xx` responses from Bridge are normal API rejections and are **not** alerted. + +## Configuration + +Three optional env vars, each gating one destination: + +| Env var | Destination | Value | +| ----------------------------- | ------------------- | ------------------------------------------- | +| `ALERT_PAGERDUTY_ROUTING_KEY` | PagerDuty | Events API v2 **integration / routing key** | +| `ALERT_SLACK_WEBHOOK_URL` | Slack or Mattermost | Incoming-webhook URL | +| `ALERT_DISCORD_WEBHOOK_URL` | Discord | Channel webhook URL | + +### How to get each value + +**PagerDuty** — `ALERT_PAGERDUTY_ROUTING_KEY` +1. PagerDuty → **Services** → pick (or create) the service that should page for Bridge. +2. **Integrations** → **Add integration** → **Events API v2**. +3. Copy the **Integration Key** — that is the routing key. + +**Slack** — `ALERT_SLACK_WEBHOOK_URL` +1. Create/choose a Slack app → **Incoming Webhooks** → **Activate**. +2. **Add New Webhook to Workspace** → choose the target channel. +3. Copy the URL (`https://hooks.slack.com/services/...`). + _Mattermost works too_ — it accepts the same `{ text }` payload; use its incoming-webhook URL. + +**Discord** — `ALERT_DISCORD_WEBHOOK_URL` +1. Discord → target channel → **Edit Channel** → **Integrations** → **Webhooks**. +2. **New Webhook** → name it → **Copy Webhook URL**. + +### Where to set them + +- **Local dev:** add to `.env` (and `.env.ci` for CI). +- **Staging / production:** set as environment variables / secrets in the deployment — the same place `MATTERMOST_WEBHOOK_URL` is configured. Treat all three as **secrets**. + +> If none are set, alerting is a no-op (no errors, no delivery) — useful until the channels are provisioned. + +## Verifying in staging (ENG-361 acceptance) + +1. Set at least `ALERT_PAGERDUTY_ROUTING_KEY` and `ALERT_SLACK_WEBHOOK_URL` in staging. +2. Simulate a Bridge webhook failure (e.g. force an ERPNext audit-write error, or replay a malformed transfer webhook). +3. Confirm on-call is paged via PagerDuty **and** a message posts to Slack within ~1 minute. diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md new file mode 100644 index 000000000..65211ab37 --- /dev/null +++ b/docs/bridge-integration/API.md @@ -0,0 +1,308 @@ +# Bridge.xyz GraphQL API Reference + +All Bridge-related operations require the user to be authenticated and have an **Account Level 1** or higher. + +## Mutations + +### `bridgeInitiateKyc` + +Starts the KYC process for the authenticated user. + +**Request:** +```graphql +mutation BridgeInitiateKyc { + bridgeInitiateKyc { + errors { + message + } + kycLink { + kycLink + tosLink + } + } +} +``` + +**Response:** +- `kycLink`: URL to the Bridge/Persona KYC flow. +- `tosLink`: URL to the Bridge Terms of Service. + +--- + +### `bridgeCreateVirtualAccount` + +Creates a virtual bank account for the user to receive USD deposits. Requires approved KYC. + +**Request:** +```graphql +mutation BridgeCreateVirtualAccount { + bridgeCreateVirtualAccount { + errors { + message + } + virtualAccount { + bridgeVirtualAccountId + bankName + routingNumber + accountNumberLast4 + } + } +} +``` + +**Response:** +- `bridgeVirtualAccountId`: Unique identifier for the virtual account. +- `bankName`: Name of the bank (e.g., "Bridge Bank"). +- `routingNumber`: ABA routing number. +- `accountNumberLast4`: Last 4 digits of the account number. + +--- + +### `bridgeAddExternalAccount` + +Returns a hosted URL for the user to link their external bank account (via Plaid/Bridge). + +**Request:** +```graphql +mutation BridgeAddExternalAccount { + bridgeAddExternalAccount { + errors { + message + } + externalAccount { + linkUrl + expiresAt + } + } +} +``` + +**Response:** +- `linkUrl`: URL to the bank linking flow. +- `expiresAt`: Expiration timestamp for the link. + +--- + +### `bridgeRequestWithdrawal` + +Validates a withdrawal and creates a pending record for the confirmation screen. Does **not** call the Bridge API. If an identical pending request already exists (same account, amount, and external account), the existing record is returned. + +**Request:** +```graphql +mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + externalAccountId + status + createdAt + } + } +} +``` + +**Input:** +- `amount`: String representation of the amount (e.g., "100.00"). Must be positive with at most 6 decimal places and above the configured minimum. +- `externalAccountId`: The ID of the linked bank account. + +**Response:** +- `id`: MongoDB withdrawal record ID — pass this to `bridgeInitiateWithdrawal` or `bridgeCancelWithdrawalRequest`. +- `status`: Always `"pending"` on success. +- `externalAccountId`: Linked bank account used for the withdrawal. + +--- + +### `bridgeInitiateWithdrawal` + +Submits a previously requested withdrawal to Bridge. Re-checks USDT balance at execution time. + +**Request:** +```graphql +mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) { + bridgeInitiateWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } +} +``` + +**Input:** +- `withdrawalId`: The `id` returned by `bridgeRequestWithdrawal`. + +**Response:** +- `id`: Withdrawal record ID. +- `status`: Withdrawal status after Bridge transfer creation (typically `"pending"` until the webhook settles). + +**Errors:** +- `BridgeWithdrawalNotFoundError`: Withdrawal ID does not exist or belongs to another account. +- `BridgeWithdrawalAlreadyInitiatedError`: Withdrawal was already submitted to Bridge. +- `BridgeInsufficientFundsError`: Balance dropped between request and confirm. + +--- + +### `bridgeCancelWithdrawalRequest` + +Cancels a pending withdrawal before it has been submitted to Bridge. + +**Request:** +```graphql +mutation BridgeCancelWithdrawalRequest($input: BridgeCancelWithdrawalRequestInput!) { + bridgeCancelWithdrawalRequest(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } +} +``` + +**Input:** +- `withdrawalId`: The `id` returned by `bridgeRequestWithdrawal`. + +**Response:** +- `status`: `"cancelled"` on success. + +**Errors:** +- `BridgeWithdrawalNotFoundError`: Withdrawal ID does not exist or belongs to another account. +- `BridgeWithdrawalAlreadyInitiatedError`: Transfer was already submitted to Bridge and cannot be cancelled. + +--- + +## Queries + +### `bridgeKycStatus` + +Returns the current KYC status for the user. + +**Request:** +```graphql +query BridgeKycStatus { + bridgeKycStatus +} +``` + +**Possible Values:** +- `"pending"`: KYC is in progress. +- `"approved"`: KYC is complete and approved. +- `"rejected"`: KYC was rejected. +- `null`: KYC has not been initiated. + +--- + +### `bridgeVirtualAccount` + +Returns the user's virtual account details if one exists. + +**Request:** +```graphql +query BridgeVirtualAccount { + bridgeVirtualAccount { + bridgeVirtualAccountId + bankName + routingNumber + accountNumberLast4 + } +} +``` + +--- + +### `bridgeExternalAccounts` + +Lists all linked external bank accounts. + +**Request:** +```graphql +query BridgeExternalAccounts { + bridgeExternalAccounts { + bridgeExternalAccountId + bankName + accountNumberLast4 + status + } +} +``` + +--- + +### `bridgeWithdrawalRequest` + +Fetches a single withdrawal record by ID for the confirmation screen. Returns `null` if the ID does not exist or belongs to another account (no cross-account leakage). + +**Request:** +```graphql +query BridgeWithdrawalRequest($id: ID!) { + bridgeWithdrawalRequest(id: $id) { + id + amount + currency + externalAccountId + status + failureReason + createdAt + } +} +``` + +--- + +### `bridgeWithdrawals` + +Lists the user's withdrawal history (submitted transfers only). + +**Request:** +```graphql +query BridgeWithdrawals { + bridgeWithdrawals { + id + amount + currency + status + bridgeTransferId + failureReason + createdAt + } +} +``` + +## Error Codes + +| Code | Description | +| --- | --- | +| `BRIDGE_DISABLED` | Bridge integration is disabled in configuration. | +| `BRIDGE_ACCOUNT_LEVEL_ERROR` | User account level is below 1. | +| `BRIDGE_INVALID_AMOUNT` | Withdrawal amount is malformed or not positive. | +| `BRIDGE_BELOW_MINIMUM_WITHDRAWAL` | Withdrawal amount is below the configured minimum. | +| `BRIDGE_KYC_PENDING` | Operation requires approved KYC, but it is still pending. | +| `BRIDGE_KYC_REJECTED` | KYC was rejected. | +| `BRIDGE_KYC_OFFBOARDED` | Bridge offboarded the customer. | +| `BRIDGE_KYC_TIER_CEILING_EXCEEDED` | Withdrawal amount exceeds the KYC tier ceiling. | +| `BRIDGE_CUSTOMER_NOT_FOUND` | Bridge customer record not found for the user. | +| `BRIDGE_WITHDRAWAL_NOT_FOUND` | Withdrawal request not found or does not belong to the caller. | +| `BRIDGE_WITHDRAWAL_ALREADY_INITIATED` | Withdrawal was already submitted to Bridge. | +| `BRIDGE_INSUFFICIENT_FUNDS` | USDT balance is insufficient for the withdrawal. | +| `BRIDGE_RATE_LIMIT` | Bridge rate-limited the request. | +| `BRIDGE_TIMEOUT` | Bridge request timed out. | +| `BRIDGE_TRANSFER_FAILED` | Bridge transfer failed. | +| `BRIDGE_WEBHOOK_VALIDATION` | Bridge webhook signature validation failed. | +| `BRIDGE_API_ERROR` | Bridge API returned an unclassified provider error. | +| `BRIDGE_ERROR` | Unclassified Bridge domain error. | diff --git a/docs/bridge-integration/ARCHITECTURE.md b/docs/bridge-integration/ARCHITECTURE.md new file mode 100644 index 000000000..5a927736c --- /dev/null +++ b/docs/bridge-integration/ARCHITECTURE.md @@ -0,0 +1,67 @@ +# Bridge.xyz Integration Architecture + +## System Overview + +The Bridge.xyz integration enables USD on-ramp and off-ramp functionality for Flash users. It allows users to convert between USD (via bank transfers) and USDT (Ethereum), which is then integrated into the Flash ecosystem via IBEX. + +## Component Architecture + +The integration consists of three main components: + +1. **Flash Backend**: The core service that orchestrates the flow between users, Bridge.xyz, and IBEX. It exposes a GraphQL API for the mobile app and handles webhooks from Bridge.xyz. +2. **Bridge.xyz API**: An external service that provides virtual bank accounts, KYC processing, and USD/USDT conversion. +3. **IBEX**: An external service used by Flash to manage Bitcoin and Lightning wallets, and in this context, to provide USDT receive addresses and handle USDT deposits. + +### Component Diagram + +```ascii ++-------------+ GraphQL +----------------+ +| Mobile App | <-----------------> | Flash Backend | ++-------------+ +----------------+ + ^ ^ + | | + | API | API + v v + +------------+ +------------+ + | Bridge.xyz | | IBEX | + +------------+ +------------+ + | | + | USD/USDT | USDT + v v + +----------------------------+ + | Ethereum Network | + +----------------------------+ +``` + +## Data Flow + +### On-Ramp (USD -> USDT) + +1. **KYC**: User initiates KYC via Flash, which creates a Bridge customer and returns a KYC link (Persona). +2. **Virtual Account**: Once KYC is approved, Flash creates a Bridge virtual account for receiving USD deposits. +3. **Deposit**: User sends USD to the virtual account. +4. **Conversion**: Bridge converts USD to USDT and sends it to the user's on-chain address. +5. **Credit**: IBEX detects the USDT deposit and notifies Flash via webhook, which credits the user's wallet. + +### Off-Ramp (USDT -> USD) + +1. **Link Bank**: User links an external bank account via Bridge's hosted UI. +2. **Withdrawal**: User initiates a withdrawal in Flash. +3. **Transfer**: Flash initiates a Bridge transfer from the user's Bridge balance to the linked external bank account. +4. **Conversion**: Bridge converts USDT to USD and sends it to the bank via ACH. + +## Technology Stack + +- **Language**: TypeScript +- **Runtime**: Node.js +- **API**: GraphQL (Apollo Server) +- **Database**: MongoDB (Mongoose) for storing Bridge account mappings and transfer states. +- **Communication**: REST API (Bridge.xyz), Webhooks. + +## Security Model + +- **Account Level**: Bridge functionality is restricted to users with Account Level 1 or higher. +- **KYC**: All users must pass Bridge's KYC process (powered by Persona). +- **Webhook Verification**: All incoming webhooks from Bridge.xyz are verified using asymmetric RSA-SHA256 signatures. +- **Idempotency**: All critical API calls to Bridge include an `Idempotency-Key` to prevent duplicate transactions. +- **Data Isolation**: Bridge customer IDs and account details are mapped to Flash internal account IDs. diff --git a/docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md b/docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md new file mode 100644 index 000000000..52c0ef316 --- /dev/null +++ b/docs/bridge-integration/ENG-276-IMPLEMENTATION-RESUME.md @@ -0,0 +1,308 @@ +# ENG-276 Implementation Resume + +## Context + +`ENG-276` targets operational reliability for Bridge on-ramp deposits by closing the gap between: + +- Bridge transfer/deposit lifecycle events +- IBEX crypto receive settlement events +- Operator tooling needed to replay and triage failures + +The issue also folds in two related reliability needs: + +- Persist Bridge fee on every deposit log row +- Provide replay capability for stuck webhook handlers + +In practice, Flash receives information from two systems that represent different parts of the same real-world flow: + +1. Bridge emits webhook events for transfer state transitions. +2. IBEX emits crypto receive webhooks when USDT settlement is observed. + +Without reconciliation and replay tooling, operations cannot quickly identify: + +- Bridge events that never materialized in IBEX +- IBEX settlements with no corresponding Bridge event +- Stuck handlers needing replay + +--- + +## What We Are Solving + +### Primary objective (ENG-276 acceptance scope) + +1. Orphan events must be surfaced with triage context (ops visibility). +2. Replay CLI must rerun stuck handlers for a chosen transfer id. +3. Bridge fee value must be persisted on every deposit row. + +### Supporting objective + +Ensure replay handling matches Bridge webhook envelope format documented by Bridge: + +- `event_type` can be generic (`updated.status_transitioned`) +- `event_object_status` carries status transitions like `funds_received` +- `event_object` carries the resource payload +- `event_created_at` carries event timestamp + +Reference: +- [Bridge webhook event structure](https://apidocs.bridge.xyz/platform/additional-information/webhooks/structure) +- [Bridge list webhook events](https://apidocs.bridge.xyz/api-reference/webhooks/list-webhook-events) + +--- + +## How It Was Solved + +## 1) AC3: Fee persisted on every deposit row + +### Changes + +- Deposit handler now always computes `developerFee` with fallback chain: + 1. `receipt.developer_fee` + 2. `event_object.developer_fee` + 3. `"0"` +- `developerFee` made required/defaulted in persistence path. + +### Result + +Every `BridgeDepositLog` row has a fee value, including null/missing upstream fee cases. + +--- + +## 2) AC1: Orphan detection and ops surfacing + +### Changes + +- Added IBEX receive log persistence. +- Added reconciliation service comparing 24h Bridge deposits vs IBEX receives. +- Added orphan persistence model with rich triage context. +- Added reconciliation execution in cron and manual script entrypoint. + +### Result + +Ops can now see explicit orphan records with: + +- orphan type (`bridge_without_ibex` / `ibex_without_bridge`) +- correlation keys (transfer id / tx hash) +- detection window +- reason and context required for triage + +--- + +## 3) AC2: Replay by chosen transfer id + +### Changes + +- Replay CLI supports `--transfer-id`. +- Replay filtering extracts transfer id from supported object shapes. +- Replay route accepts canonical Bridge envelope and maps it to existing handlers. + +### Result + +Ops can target replay for a specific transfer id rather than replaying all events in a window. + +--- + +## 4) Bridge documentation alignment fixes + +### Event list API fix + +Bridge webhook events listing uses: + +- `GET /webhook_events` +- query params like `starting_after`, `limit`, `category` + +Client was updated to use this endpoint and parameter mapping while preserving internal caller interface. + +### GET idempotency fix + +Bridge rejects `Idempotency-Key` on certain GET endpoints (including webhook events list). +Client request logic now sends `Idempotency-Key` only on non-GET methods. + +--- + +## Reconciliation Sequence Diagram (Step-by-Step) + +```mermaid +sequenceDiagram + autonumber + participant Cron as Cron Job / Manual Script + participant Rec as reconcileBridgeAndIbexDeposits + participant BDL as BridgeDepositLog (Mongo) + participant IBL as IbexCryptoReceiveLog (Mongo) + participant ORP as BridgeReconciliationOrphan (Mongo) + participant Ops as Ops Tooling / Operator + + Cron->>Rec: Start reconciliation(window=24h) + Rec->>BDL: Query Bridge deposits in window (state=funds_received) + BDL-->>Rec: Bridge deposit set + Rec->>IBL: Query IBEX receives in same window + IBL-->>Rec: IBEX receive set + + Note over Rec: Build hash maps by tx hash for fast matching + + loop For each Bridge deposit + alt Missing destinationTxHash + Rec->>ORP: Upsert orphan (bridge_without_ibex)
reason=no destinationTxHash + else destinationTxHash not found in IBEX set + Rec->>ORP: Upsert orphan (bridge_without_ibex)
reason=no IBEX match in window + else Matched + Rec-->>Rec: No orphan + end + end + + loop For each IBEX receive + alt tx hash not found in Bridge set + Rec->>ORP: Upsert orphan (ibex_without_bridge)
reason=no Bridge match in window + else Matched + Rec-->>Rec: No orphan + end + end + + Rec-->>Cron: Return summary counts + Cron-->>Ops: Log reconciliation summary + Ops->>ORP: Inspect orphans with triage context +``` + +--- + +## Manual Validation Summary + +## Curl Samples (Manual Console Tests) + +Set base variables: + +```bash +BASE_URL="http://localhost:4009" +REPLAY_SECRET="also-not-so-secret" +``` + +AC3 Case A (receipt fee present; expected persisted `developerFee = "0.5"`): + +```bash +curl -s -X POST "$BASE_URL/internal/replay" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REPLAY_SECRET" \ + -d '{ + "event_id":"wh_ac3_a_101", + "event_type":"updated.status_transitioned", + "event_object_status":"funds_received", + "event_object":{ + "id":"tr_ac3_a_101", + "state":"funds_received", + "amount":"10.00", + "currency":"usd", + "developer_fee":"0.2", + "on_behalf_of":"cust_a", + "receipt":{"developer_fee":"0.5","initial_amount":"10.00","subtotal_amount":"9.50","final_amount":"9.50","destination_tx_hash":"tx_ac3_a_101"} + }, + "event_created_at":"2026-05-06T10:00:00.000Z", + "operator":"manual-test", + "time_window_start":"2026-05-06T00:00:00.000Z", + "time_window_end":"2026-05-06T23:59:59.000Z", + "dry_run":false + }' +``` + +AC3 Case B (receipt fee null; fallback to `event_object.developer_fee`, expected `"0.7"`): + +```bash +curl -s -X POST "$BASE_URL/internal/replay" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REPLAY_SECRET" \ + -d '{ + "event_id":"wh_ac3_b_101", + "event_type":"updated.status_transitioned", + "event_object_status":"funds_received", + "event_object":{ + "id":"tr_ac3_b_101", + "state":"funds_received", + "amount":"20.00", + "currency":"usd", + "developer_fee":"0.7", + "on_behalf_of":"cust_b", + "receipt":{"developer_fee":null,"initial_amount":"20.00","subtotal_amount":"19.30","final_amount":"19.30","destination_tx_hash":"tx_ac3_b_101"} + }, + "event_created_at":"2026-05-06T10:05:00.000Z", + "operator":"manual-test", + "time_window_start":"2026-05-06T00:00:00.000Z", + "time_window_end":"2026-05-06T23:59:59.000Z", + "dry_run":false + }' +``` + +AC3 Case C (both missing/null; expected default `"0"`): + +```bash +curl -s -X POST "$BASE_URL/internal/replay" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REPLAY_SECRET" \ + -d '{ + "event_id":"wh_ac3_c_101", + "event_type":"updated.status_transitioned", + "event_object_status":"funds_received", + "event_object":{ + "id":"tr_ac3_c_101", + "state":"funds_received", + "amount":"30.00", + "currency":"usd", + "on_behalf_of":"cust_c", + "receipt":{"developer_fee":null,"initial_amount":"30.00","subtotal_amount":"30.00","final_amount":"30.00","destination_tx_hash":"tx_ac3_c_101"} + }, + "event_created_at":"2026-05-06T10:10:00.000Z", + "operator":"manual-test", + "time_window_start":"2026-05-06T00:00:00.000Z", + "time_window_end":"2026-05-06T23:59:59.000Z", + "dry_run":false + }' +``` + +AC2 replay command (transfer-id targeted replay): + +```bash +BRIDGE_WEBHOOK_REPLAY_SECRET="$REPLAY_SECRET" BRIDGE_WEBHOOK_URL="$BASE_URL" \ +yarn replay-bridge-events --configPath dev/config/base-config.yaml \ + --start 2026-05-01T00:00:00Z --end 2026-05-07T00:00:00Z \ + --event-type transfer --transfer-id tr_ac3_b_101 +``` + +AC1 reconciliation command: + +```bash +. ./.env && yarn reconcile-bridge-ibex-deposits --configPath dev/config/base-config.yaml --window-hours 24 +``` + +DB verification query (without `mongosh`, using node + mongoose): + +```bash +. ./.env && node -e "const mongoose=require('mongoose'); (async()=>{await mongoose.connect(process.env.MONGODB_CON); const db=mongoose.connection.db; const deposit=await db.collection('bridgedepositlogs').find({transferId:{\$in:['tr_ac3_a_101','tr_ac3_b_101','tr_ac3_c_101']}},{projection:{_id:0,transferId:1,developerFee:1,destinationTxHash:1,createdAt:1}}).sort({createdAt:-1}).toArray(); const orphans=await db.collection('bridgereconciliationorphans').find({txHash:{\$in:['tx_ac3_a_101','tx_ac3_b_101','tx_ac3_c_101']}},{projection:{_id:0,orphanType:1,orphanKey:1,transferId:1,txHash:1,detectedAt:1}}).sort({detectedAt:-1}).toArray(); console.log(JSON.stringify({deposit, orphans}, null, 2)); await mongoose.disconnect(); })().catch(async(e)=>{console.error(e); try{await mongoose.disconnect();}catch{} process.exit(1);});" +``` + +### AC3 checks + +Three manual replay cases confirmed persisted values: + +- Case A: receipt fee present -> persisted that receipt fee +- Case B: receipt fee null -> persisted fallback `event_object.developer_fee` +- Case C: both missing/null -> persisted `"0"` + +### AC1 checks + +Reconciliation run produced orphan rows and summary counts, confirming surfacing path. + +### AC2 checks + +Replay command path works with transfer-id filter; endpoint-level issues were resolved by: + +- switching to documented webhook events listing endpoint +- removing idempotency header on GET + +--- + +## Final State + +`ENG-276` goals are implemented with: + +- deterministic fee persistence +- reconciliation + orphan triage surface +- replay targeting for specific transfers +- Bridge API/documentation-aligned event retrieval and envelope handling + diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md new file mode 100644 index 000000000..b1b8e7a40 --- /dev/null +++ b/docs/bridge-integration/FLOWS.md @@ -0,0 +1,143 @@ +# Bridge.xyz Integration Flows + +This document describes the step-by-step flows for USD on-ramp and off-ramp using Bridge.xyz and IBEX. + +## On-Ramp Flow (USD -> USDT) + +This flow allows users to deposit USD from their bank account and receive USDT in their Flash wallet. + +### Sequence Diagram + +```ascii +User Flash App Flash Backend Bridge.xyz IBEX + | | | | | + | 1. Start KYC | | | | + |----------------->| 2. bridgeInitKyc | | | + | |------------------>| 3. Create Customer | | + | | |-------------------->| | + | | | 4. Create KYC Link | | + | | |-------------------->| | + | | 5. KYC Link | | | + | |<------------------| | | + | 6. Complete KYC | | | | + |----------------->| | | | + | (Persona Flow) | | | | + | | | 7. kyc.approved | | + | | |<--------------------| | + | | | 8. Create USDT Addr | | + | | |---------------------------------------->| + | | | 9. Create Virt Acc | | + | | |-------------------->| | + | 10. View Bank Det| | | | + |<-----------------| | | | + | 11. Transfer USD | | | | + |----------------------------------------------------------->| | + | | | | 12. Convert USD | + | | | | 13. Send USDT | + | | | |------------------>| + | | | | | + | | | 14. Crypto Webhook | | + | | |<----------------------------------------| + | | 15. Notify User | | | + |<-----------------| | | | +``` + +### Steps + +1. **Initiate KYC**: User clicks "Deposit USD" in the app. +2. **GraphQL Mutation**: App calls `bridgeInitiateKyc`. +3. **Bridge Customer**: Flash creates a Bridge customer if one doesn't exist. +4. **KYC Link**: Flash requests a KYC link from Bridge. +5. **Redirect**: App opens the KYC link (Persona). +6. **Verification**: User completes identity verification. +7. **KYC Webhook**: Bridge sends `kyc.approved` webhook to Flash. +8. **USDT Address**: Flash requests a unique USDT receive address from IBEX. +9. **Virtual Account**: Flash creates a Bridge virtual account linked to the receive address. +10. **Display Details**: User sees bank name, routing number, and account number in the app. +11. **Bank Transfer**: User initiates a transfer from their banking app. +12. **Conversion**: Bridge receives USD and converts it to USDT. +13. **Settlement**: Bridge sends USDT to the user's on-chain address. +14. **IBEX Webhook**: IBEX detects the incoming USDT and notifies Flash. +15. **Credit**: Flash credits the user's USDT wallet and sends a push notification. + +--- + +## Off-Ramp Flow (USDT -> USD) + +This flow allows users to withdraw USDT from their Flash wallet to their external bank account. + +### Sequence Diagram + +```ascii +User Flash App Flash Backend Bridge.xyz Bank + | (Check for KYC, if complete, skip to 12. | | + | 1. Start KYC | | | | + |----------------->| 2. bridgeInitKyc | | | + | |------------------>| 3. Create Customer | | + | | |-------------------->| | + | | | 4. Create KYC Link | | + | | |-------------------->| | + | | 5. KYC Link | | | + | |<------------------| | | + | 6. Complete KYC | | | | + |----------------->| | | | + | (Persona Flow) | | | | + | | | 7. kyc.approved | | + | | |<--------------------| | + | 8. Link Bank | | | | + |----------------->| 9. bridgeAddExtAcc| | | + | |------------------>| 10. Get Link URL | | + | | |-------------------->| | + | | 11. Link URL | | | + | |<------------------| | | + | 12. Auth Bank | | | | + |----------------->| | | | + | (Plaid Flow) | | | | + | | | 13. ext_acc.verified| | + | | |<--------------------| | + | 14. Withdraw | | | | + |----------------->| 15. bridgeRequest | | | + | | Withdrawal | | | + | |------------------>| 16. Store pending | | + | | 17. Confirm screen| withdrawal | | + | |<------------------| | | + | 18. Confirm | | | | + |----------------->| 19. bridgeInitWith| | | + | | (withdrawalId)| 20. Create Transfer | | + | |------------------>|-------------------->| | + | | 21. Pending | | | + |<-----------------| | | | + | | | | 22. Convert USDT | + | | | | 23. Send ACH | + | | | |------------------>| + | | | 24. trans.completed | | + | | |<--------------------| | + | 25. Funds Arrive | | | | + |<-------------------------------------------------------------------------------| +``` + +### Steps + +1. **Link Bank**: User chooses to add a bank account. +2. **GraphQL Mutation**: App calls `bridgeAddExternalAccount`. +3. **Link URL**: Flash requests a hosted link URL from Bridge. +4. **Redirect**: App opens the Bridge/Plaid flow. +5. **Authentication**: User logs into their bank and selects an account. +6. **Verification Webhook**: Bridge notifies Flash when the external account is verified. +7. **Request Withdrawal**: User enters amount and selects the linked bank account. +8. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. +9. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. +10. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. +11. **User Confirms or Cancels**: + - **Confirm**: App calls `bridgeInitiateWithdrawal` with `withdrawalId`. Flash re-checks balance, then creates a transfer in Bridge from the user's Ethereum USDT address to the external account. + - **Cancel**: App calls `bridgeCancelWithdrawalRequest` with `withdrawalId`. The pending record is marked `cancelled` and a push notification is sent. +12. **Pending State**: After initiation, app shows the withdrawal as "Pending". +13. **Conversion**: Bridge converts USDT from the user's balance to USD. +14. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. +15. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. +16. **Completion**: User receives funds in their bank account (usually 1-3 business days). + +## Fee Structure + +- **Bridge Fees**: Bridge.xyz charges fees for conversion and transfers (see Bridge.xyz documentation for current rates). +- **Flash Fee**: Flash charges a **0.5%** service fee on all Bridge transactions, which is included in the total amount shown to the user. diff --git a/docs/bridge-integration/WEBHOOKS.md b/docs/bridge-integration/WEBHOOKS.md new file mode 100644 index 000000000..f11308bd6 --- /dev/null +++ b/docs/bridge-integration/WEBHOOKS.md @@ -0,0 +1,69 @@ +# Bridge.xyz Webhook Handling + +Flash receives real-time updates from Bridge.xyz via webhooks. These webhooks are used to update KYC status, confirm deposits, and track withdrawal progress. + +## Webhook Endpoint + +The webhook server listens on the configured port (default: `4009`) and expects POST requests at the following endpoints: + +- `POST /kyc` +- `POST /deposit` +- `POST /transfer` +- `POST /external-account` + +## Signature Verification + +All incoming webhooks from Bridge.xyz are signed using asymmetric RSA-SHA256. Flash verifies these signatures using the public keys provided by Bridge.xyz. + +### Verification Process + +1. Retrieve the signature header from `X-Webhook-Signature`. +2. Parse the timestamp and signature from the header format: `t=,v0=`. +3. Verify that the timestamp is within the allowed skew (default: 5 minutes) to prevent replay attacks. +4. Construct the signed payload by concatenating the timestamp and the exact raw request body: `timestamp + "." + rawBody`. +5. Hash the signed payload with SHA-256. +6. Verify the Base64 `v0` signature against that digest using RSA-SHA256 and the appropriate Bridge public key (KYC, Deposit, Transfer, or External Account). + +Flash must verify against the raw body captured before JSON parsing. Re-serializing the parsed JSON body changes the signed bytes and must fail signature verification. + +## Event Types + +### KYC Events + +#### `kyc.approved` +Sent when a user's KYC application is approved. +- **Action**: Update user's `bridgeKycStatus` to `approved`. + +#### `kyc.rejected` +Sent when a user's KYC application is rejected. +- **Action**: Update user's `bridgeKycStatus` to `rejected`. + +### Deposit Events + +#### `deposit.completed` +Sent when a USD deposit to a virtual account is successfully converted to USDT and sent to the user's on-chain address. +- **Action**: This event is primarily for tracking. The actual crediting of the user's wallet is handled by the IBEX webhook when the USDT arrives. + +### Transfer Events + +#### `transfer.completed` +Sent when an off-ramp transfer (USDT -> USD) is successfully completed. +- **Action**: Update the withdrawal record status to `completed` and notify the user. + +#### `transfer.failed` +Sent when an off-ramp transfer fails. +- **Action**: Update the withdrawal record status to `failed`, record the reason, and notify the user. + +## Idempotency Handling + +Each webhook event includes a unique `id`. Flash ensures that each event is processed exactly once by: + +1. Checking if the event ID has already been processed in the database. +2. Using a distributed lock (Redis) during processing to prevent race conditions from duplicate deliveries. + +## Response Codes + +- `200 OK`: Event successfully processed or already processed. +- `400 Bad Request`: Invalid payload or missing headers. +- `401 Unauthorized`: Signature verification failed. +- `500 Internal Server Error`: Temporary failure; Bridge.xyz will retry the webhook. diff --git a/docs/plans/2026-05-13-bridge-rebase-pr-ready-design.md b/docs/plans/2026-05-13-bridge-rebase-pr-ready-design.md new file mode 100644 index 000000000..dee60cd28 --- /dev/null +++ b/docs/plans/2026-05-13-bridge-rebase-pr-ready-design.md @@ -0,0 +1,30 @@ +# Bridge Rebase PR-Ready Design + +## Goal +Prepare the rebased Bridge integration branch for review by making the history Linear-scoped, applying only intentional cleanup, and verifying branch-owned changes without expanding lint scope to unrelated code. + +## Constraints +- Every commit in the PR history must reference at least one Linear issue key. +- If multiple commits belong to one Linear issue, squash them unless separation materially improves reviewability. +- Do not lint code that this branch did not change or create. +- Preserve the current rebased branch as a safety point. +- Do not import the broad ForgeMini scratch formatting/dependency churn; cherry-pick only intentional fixes. + +## Approach +Work from a new safety branch, reset the index against `origin/main`, and recommit the branch diff into Linear-scoped slices. Use existing Bridge Linear issues for core feature, security-audit fixes, reconciliation/replay tooling, account wallet creation, and PR-readiness cleanup. After history rewrite, apply the proven Bridge unit/idempotency fix selectively and verify build/unit/focused tests plus scoped lint only on files changed by the branch. + +## Commit grouping +- `ENG-297`: core Bridge parity/integration surface and docs that support the launch path. +- `ENG-276`: deposit reconciliation and replay/backfill tooling. +- `ENG-348`: ERPNext audit-row writer support, if present in the diff. +- `ENG-376`: webhook/request idempotency hardening and retry-safety fixes. +- `ENG-394`: ETH-USDT Cash Wallet creation for raw account creation. +- Existing audit issues (`ENG-278`, `ENG-280`, `ENG-281`, `ENG-282`, `ENG-283`, `ENG-284`, `ENG-285`, `ENG-349`, `ENG-363`, etc.) stay mapped in commit messages where their changes are separable. + +## Verification +- `yarn build` +- full unit suite if available in this worktree +- focused Bridge unit suites +- typecheck, with baseline failures documented separately if reproduced on `origin/main` +- scoped ESLint only for branch-created or branch-modified files, never unrelated files +- SDL diff check and commit regenerated schema/supergraph output only when branch-owned changes require it diff --git a/docs/plans/2026-05-13-bridge-rebase-pr-ready.md b/docs/plans/2026-05-13-bridge-rebase-pr-ready.md new file mode 100644 index 000000000..43f3a608f --- /dev/null +++ b/docs/plans/2026-05-13-bridge-rebase-pr-ready.md @@ -0,0 +1,130 @@ +# Bridge Rebase PR-Ready Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the rebased Bridge integration branch PR-ready with Linear-scoped commits and scoped verification. + +**Architecture:** Preserve the completed rebase as a safety point, then use a cleanup branch to rewrite the diff into issue-scoped commits. Apply only targeted fixes from scratch worktrees and verify branch-owned behavior without linting unrelated code. + +**Tech Stack:** TypeScript, GraphQL, Jest, Yarn, Git, Linear issue keys, Bridge.xyz integration code. + +--- + +### Task 1: Create safety branch and planning docs + +**Files:** +- Create: `docs/plans/2026-05-13-bridge-rebase-pr-ready-design.md` +- Create: `docs/plans/2026-05-13-bridge-rebase-pr-ready.md` + +**Step 1:** Switch to a cleanup branch at current rebased HEAD. + +Run: `git switch -c tmp/bridge-rebase-pr-ready` +Expected: branch points to the rebased candidate HEAD. + +**Step 2:** Add the design and implementation plan docs. + +Run: `git status --short` +Expected: only the two new docs are untracked. + +**Step 3:** Commit with a Linear key. + +Run: `git add docs/plans && git commit -m "ENG-297 docs: document Bridge rebase PR-readiness plan"` +Expected: commit succeeds and includes only plan docs. + +### Task 2: Rewrite existing branch history into Linear-scoped commits + +**Files:** +- Modify: all files in the current `origin/main..HEAD` diff. + +**Step 1:** Capture current HEAD. + +Run: `git branch safety/bridge-rebase-before-history-rewrite HEAD` +Expected: safety ref exists. + +**Step 2:** Soft reset to `origin/main`. + +Run: `git reset --soft origin/main && git restore --staged .` +Expected: full branch diff is unstaged working tree changes. + +**Step 3:** Stage and commit logical groups. + +Use path-based staging and `git add -p` where needed. Each commit message must start with or include a Linear issue key. + +Expected commit groups: +- `ENG-297 feat(bridge): add core Bridge parity integration` +- `ENG-276 feat(bridge): add reconciliation and replay tooling` +- `ENG-348 feat(bridge): add ERPNext audit rows for Bridge movements` +- `ENG-376 fix(bridge): harden Bridge request idempotency` +- `ENG-394 feat(accounts): create ETH-USDT Cash Wallet for new accounts` +- Audit fixes mapped to their issue keys where separable. + +**Step 4:** Verify all commits have Linear keys. + +Run: `git log --format='%h %s' origin/main..HEAD | awk '!/ENG-[0-9]+|OPS-[0-9]+|COM-[0-9]+/ {print}'` +Expected: no output. + +### Task 3: Apply targeted Bridge unit/idempotency fix + +**Files:** +- Modify: `src/services/bridge/index.ts` +- Modify: `test/flash/unit/services/bridge/index.spec.ts` + +**Step 1:** Cherry-pick only intentional hunks from ForgeMini scratch worktree. + +Expected changes: +- Mock `@services/ibex/client` in Bridge service unit test. +- Before creating a new withdrawal row, call `BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer(accountId, externalAccountId, amount)`. +- Reuse an existing pending withdrawal row to derive the same idempotency key on retry. + +**Step 2:** Run focused test. + +Run: `yarn jest test/flash/unit/services/bridge/index.spec.ts --runInBand` +Expected: Bridge service unit suite passes. + +**Step 3:** Commit. + +Run: `git add src/services/bridge/index.ts test/flash/unit/services/bridge/index.spec.ts && git commit -m "ENG-376 fix(bridge): reuse pending withdrawal idempotency rows"` +Expected: commit succeeds. + +### Task 4: Verify PR readiness without unrelated lint + +**Files:** +- Test/build commands operate on the branch. +- Lint command must be limited to files changed by `origin/main..HEAD`. + +**Step 1:** Run build. + +Run: `yarn build` +Expected: pass, or document branch-independent baseline failure. + +**Step 2:** Run focused Bridge tests. + +Run: focused Jest commands for Bridge service/client/webhook suites. +Expected: pass. + +**Step 3:** Run full unit suite if dependencies are present. + +Run: repository unit test command. +Expected: pass, or document failing suites with ownership. + +**Step 4:** Run scoped lint only on branch-owned files. + +Run: construct changed-file list with `git diff --name-only origin/main...HEAD` and pass only supported TS/JS files to ESLint. +Expected: no branch-owned lint failures, or documented remaining branch-owned fixes. + +**Step 5:** Check SDL/schema drift. + +Run: repository SDL generation/check command. +Expected: either clean, or commit required generated schema artifacts with the relevant ENG key. + +### Task 5: Prepare PR branch + +**Step 1:** Show final summary. + +Run: `git status --short --branch`, `git log --oneline origin/main..HEAD`, and `git diff --shortstat origin/main...HEAD`. +Expected: clean worktree and Linear-scoped commits. + +**Step 2:** Push only after explicit approval if force-updating `feature/bridge-integration`. + +Run when approved: `git push --force-with-lease origin HEAD:feature/bridge-integration` +Expected: remote branch updates safely. diff --git a/docs/plans/2026-05-29-usdt-cent-scale-boundary.md b/docs/plans/2026-05-29-usdt-cent-scale-boundary.md new file mode 100644 index 000000000..5e6c9fb18 --- /dev/null +++ b/docs/plans/2026-05-29-usdt-cent-scale-boundary.md @@ -0,0 +1,431 @@ +# USDT Cent Scale Boundary Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Flash app-facing USDT amounts use USD-cent scale, so `$1.00` USDT is represented as `100`, while converting to IBEX USDT micro-units only at provider boundaries. + +**Architecture:** Keep `USDTAmount` internally precise in IBEX-compatible micro-USDT units because provider balances and invoices need six decimals. Add explicitly named USD-cent conversion helpers on `USDTAmount`, use those helpers at GraphQL/app boundaries, and leave `USDTAmount.toIbex()` plus IBEX/Bridge service code as the provider-facing conversion point. The implementation must make each boundary choose a named unit; no app/mobile/API code should pass raw USDT micros by accident. + +**Tech Stack:** TypeScript, Jest, GraphQL type resolvers, Flash `MoneyAmount` domain classes, IBEX adapter. + +--- + +## Branch And Safety Constraints + +- Work directly in `/Users/dread/Documents/Island-Bitcoin/Flash/flash`. +- The checkout is already on `tmp/bridge-rebase-pr-ready`; do not create a child branch. +- Preserve existing dirty/untracked files unless they are part of this task. +- Before editing, run: + +```bash +git status --short --branch +git diff -- src/domain/shared/MoneyAmount.ts src/app/wallets/usd-wallet-amount.ts src/graphql/shared/types/scalar/usd-cents.ts src/graphql/shared/types/object/usdt-wallet.ts test/flash/unit/app/wallets/usd-wallet-amount.spec.ts test/flash/unit/app/payments/send-intraledger.spec.ts test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts +``` + +Expected: branch is `tmp/bridge-rebase-pr-ready`. Existing unrelated changes may be present, but the listed USDT unit files should be understood before editing. + +## Core Invariant + +- App/API/mobile cent scale: + - `$1.00 USD` -> `100` + - `$1.00 USDT` -> `100` +- USDT provider scale inside `USDTAmount`: + - `$1.00 USDT` -> `1_000_000` micro-USDT + - `1` USD cent -> `10_000` micro-USDT +- IBEX boundary: + - `USDTAmount.toIbex()` still returns major USDT, e.g. `194.46`. + - IBEX parser code still uses `USDTAmount.fromNumber(...)` for major USDT balances. + +## Unit Boundary Table + +| Layer | Canonical unit | Allowed helpers | +| --- | --- | --- | +| Mobile/API stable-wallet inputs | USD-equivalent cents | `USDAmount.cents`, `USDTAmount.usdCents` | +| GraphQL wallet balance output | cents; fractional cents only when the field type is `FractionalCentAmount` | `USDAmount.asCents`, `USDTAmount.asUsdCents` | +| Domain/provider storage for USDT | micro-USDT | `USDTAmount.smallestUnits` | +| IBEX request payloads | major USDT number | `USDTAmount.toIbex` | +| IBEX response parsing | major USDT number -> micro-USDT domain amount | `USDTAmount.fromNumber` | + +Rules: +- `USDTAmount.smallestUnits(...)` is provider/internal only. +- `USDTAmount.usdCents(...)` is app/API/mobile input only. +- `USDTAmount.asUsdCents(...)` is app/API/mobile output only. +- Generic helpers such as `MoneyAmount.from(...)`, `asPaymentAmount()`, GraphQL scalar parsing, bridge quote/withdrawal resolvers, and mobile mutation adapters must be audited before implementation is considered complete. + +## Quantization Policy + +Provider-originated USDT balances can contain sub-cent values because IBEX supports six decimals and one USD cent equals `10_000` micro-USDT. + +- Do not quantize before provider validation, balance checks, reconciliation, invoice/payment creation, or persistence. These flows stay in exact micro-USDT. +- For `FractionalCentAmount` balance outputs, expose exact fractional cents to four decimals because `1` micro-USDT equals `0.0001` cent. Example: `9_147_993` micros -> `914.7993` cents. +- For integer-cent inputs from mobile/API, reject fractional cent input unless an existing scalar explicitly supports fractional cents for that mutation. +- For any output field that is contractually integer cents, do not silently round sub-cent USDT. Either return an error for non-cent-clean values or add a separate fractional-cent field/type. + +## Rollout Contract + +This plan targets the development branch `tmp/bridge-rebase-pr-ready`. No production Flash Mobile client currently ships IBEX USDT code that depends on the old micro-USDT app/API semantics, so this is not a production backwards-compatibility migration. + +Rules: +- Treat the USD-cent USDT contract as the canonical contract for this development branch. +- Do not add legacy micro-USDT compatibility code, version flags, or dual-parse heuristics just for old clients unless a concrete caller is found during the audit. +- If the audit finds an existing non-production caller inside this branch that still sends micro-USDT through an app/API path, update that caller to the cent-scale contract. +- If a production mixed-client window is later introduced, stop and write a separate rollout plan before shipping. + +## Numeric Safety Policy + +Existing GraphQL balance fields return JavaScript `number`, so this plan keeps that shape unless the schema is intentionally changed. That has a safe-integer limit. + +- Before adding new `Number(...)` conversions, check whether the field already returns `number`. +- Add at least one max-range test around the largest realistic USDT wallet balance expected by product. +- If a converted cent value can exceed `Number.MAX_SAFE_INTEGER`, stop and change the wire type to a string/BigInt-compatible scalar instead of silently returning an unsafe number. + +## Task 1: Add Red Tests For USDT Cent-Scale Input + +**Files:** +- Modify: `test/flash/unit/app/wallets/usd-wallet-amount.spec.ts` +- Modify: `test/flash/unit/app/payments/send-intraledger.spec.ts` + +**Step 1: Update wallet amount expectations** + +Change the USDT case in `usd-wallet-amount.spec.ts` so an input of `19446` represents `$194.46` USDT. + +Expected assertions: + +```ts +expect(result).toBeInstanceOf(USDTAmount) +expect(result.asSmallestUnits()).toBe("194460000") +expect(result.toIbex()).toBe(194.46) +``` + +**Step 2: Update intraledger expectations** + +In `send-intraledger.spec.ts`, rename the test from micro-unit semantics to cent-scale semantics and update: + +```ts +expect(mockAddInvoice.mock.calls[0][0].amount.asSmallestUnits()).toBe("194460000") +expect(mockAddInvoice.mock.calls[0][0].amount.toIbex()).toBe(194.46) +``` + +**Step 3: Run the focused tests and confirm failure** + +```bash +TEST='test/flash/unit/app/wallets/usd-wallet-amount.spec.ts test/flash/unit/app/payments/send-intraledger.spec.ts' yarn test:unit --runInBand +``` + +Expected: failures still show USDT input `19446` being treated as micro-USDT. + +## Task 2: Add Explicit USDT USD-Cent Helpers + +**Files:** +- Modify: `src/domain/shared/MoneyAmount.ts` + +**Step 1: Add constants near `USDTAmount`** + +```ts +const USDT_MICROS_PER_MAJOR_UNIT = 1_000_000n +const USDT_MICROS_PER_USD_CENT = 10_000n +``` + +Use these constants in `USDTAmount.fromNumber`, `asNumber`, and new helpers to remove magic numbers. + +**Step 2: Add constructor for app/API cent input** + +Add: + +```ts +static usdCents(cents: string | bigint): USDTAmount | BigIntConversionError { + try { + const centAmt = new Money(cents.toString(), "USDTUsdCents", Round.HALF_TO_EVEN) + return new USDTAmount( + centAmt.multiply(USDT_MICROS_PER_USD_CENT.toString()).toFixed(0), + ) + } catch (error) { + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) + } +} +``` + +If `Money.multiply(...)` does not accept the value cleanly in this form, use the same local style as `USDAmount.dollars(...)`: create a `USDTAmount.smallestUnits(USDT_MICROS_PER_USD_CENT)` multiplier and multiply money values. + +**Step 3: Add serializer for app/API cent output** + +Add: + +```ts +asUsdCents(): string { + return this.money.divide(USDT_MICROS_PER_USD_CENT.toString()).toFixed(0) +} +``` + +Public app/API output should stay integer-cent based. Provider-originated sub-cent USDT remains exact internally as micro-USDT and is rounded when crossing into public cent fields. + +**Step 4: Run the domain compile target indirectly** + +```bash +TEST='test/flash/unit/app/wallets/usd-wallet-amount.spec.ts' yarn test:unit --runInBand +``` + +Expected: it may still fail until Task 3 wires the helper into the app input path, but TypeScript/Jest should compile. + +## Task 3: Wire App-Facing USDT Input To Cent Scale + +**Files:** +- Modify: `src/app/wallets/usd-wallet-amount.ts` + +**Step 1: Change USDT input conversion** + +Replace: + +```ts +if (currency === WalletCurrency.Usdt) return USDTAmount.smallestUnits(raw) +``` + +With: + +```ts +if (currency === WalletCurrency.Usdt) return USDTAmount.usdCents(raw) +``` + +**Step 2: Run focused tests** + +```bash +TEST='test/flash/unit/app/wallets/usd-wallet-amount.spec.ts test/flash/unit/app/payments/send-intraledger.spec.ts' yarn test:unit --runInBand +``` + +Expected: the updated cent-scale tests pass. Mixed-currency tests remain unchanged. + +## Task 4: Add Red Tests For GraphQL USDT Serialization + +**Files:** +- Modify: `test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts` +- Test gap to inspect: there may not be an existing `UsdtWallet.balance` unit spec. If absent, do not create a broad GraphQL integration suite just for this; cover the scalar and keep the resolver change simple. + +**Step 1: Update `USDCents` scalar test** + +Add or update a test proving USDT serializes as USD cents, not micro-USDT: + +```ts +const amount = USDTAmount.smallestUnits("9147993") +if (amount instanceof Error) throw amount + +expect(USDCentsScalar.serialize(amount)).toBe(914.7993) +``` + +This represents `9.147993 USDT`, which should be `914.7993` cents. + +**Step 2: Run scalar test and confirm failure** + +```bash +TEST='test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts' yarn test:unit --runInBand +``` + +Expected: failure currently returns `9147993`. + +## Task 5: Wire GraphQL USDT Output To Cent Scale + +**Files:** +- Modify: `src/graphql/shared/types/scalar/usd-cents.ts` +- Modify: `src/graphql/shared/types/object/usdt-wallet.ts` + +**Step 1: Update scalar serialization** + +Replace: + +```ts +return Number(value.asSmallestUnits()) +``` + +With: + +```ts +return Number(value.asUsdCents()) +``` + +**Step 2: Update USDT wallet balance resolver** + +Replace: + +```ts +return Number(balance.asSmallestUnits(8)) +``` + +With: + +```ts +return Number(balance.asUsdCents()) +``` + +**Step 3: Run scalar test** + +```bash +TEST='test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts' yarn test:unit --runInBand +``` + +Expected: scalar test passes. + +## Task 6: Audit For Remaining App-Facing Micro-USDT Leaks + +**Files:** +- Inspect only unless tests reveal a real gap. + +**Step 1: Search for USDT smallest-unit usage** + +```bash +rg "asSmallestUnits\\(|USDTAmount\\.smallestUnits|asNumber\\(|toIbex\\(" src test/flash/unit +``` + +**Step 2: Search all GraphQL USDT response surfaces** + +```bash +rg "USDTAmount|WalletCurrency.*Usdt|UsdtWallet|walletCurrency|balance|amount" src/graphql test/flash/unit/graphql +``` + +For each resolver/serializer that can return a USDT amount, document whether it is: +- mobile/API-facing stable-wallet cents, +- provider/internal micros, +- provider major units, +- unrelated non-USDT data. + +Add focused tests for every mobile-visible USDT amount field that changes unit semantics. Do not rely on scalar tests alone if a resolver bypasses the scalar. + +**Step 3: Search constructor/parser/payment helper seams** + +```bash +rg "MoneyAmount\\.from|asPaymentAmount\\(|parseValue\\(|parseLiteral\\(|normalizePaymentAmount|PaymentAmount|USDCents" src test/flash/unit +``` + +**Step 4: Classify each hit** + +Provider/internal uses that should stay micro/major: +- `src/services/ibex/client.ts` +- `src/services/bridge/index.ts` +- cash-wallet cutover fee/audit internals +- persistence or provider parsing code + +App/API-facing uses that should be cent-scale: +- GraphQL wallet balances +- `USDCents` scalar serialization +- wallet amount input helpers used by mobile mutations +- `MoneyAmount.from(..., WalletCurrency.Usdt)` if the caller is app/API/mobile-facing +- `asPaymentAmount()` only if the caller is provider-facing or explicitly expects provider major units +- GraphQL mutation parsing paths that use `USDCents.parseValue` before wallet currency is known + +**Step 5: Patch confirmed app/API leaks** + +Do not broadly replace every `USDTAmount.smallestUnits`. That method is still correct for IBEX provider data, fee audit data, and stored micro-USDT values. + +Specific expected decisions: +- If `MoneyAmount.from(..., WalletCurrency.Usdt)` is used by app/API/mobile code, change USDT there to `USDTAmount.usdCents(...)`. If it is used for persistence/provider rehydration, split it into two named constructors instead of changing it globally. +- Do not change `USDCents.parseValue` to return `USDTAmount`, because the scalar does not know wallet currency. Move currency-specific amount construction into resolvers/helpers that already know the wallet currency, or add a separate USDT-aware input path. +- Audit bridge quote/withdrawal paths and add tests proving mobile cent-scale inputs convert exactly once before provider calls, or proving those paths are provider-only and should keep current units. +- Audit all `asPaymentAmount()` usage. If USDT reaches app/API/mobile through it, override or fence it; otherwise document it as provider-facing only. + +## Task 7: Add Boundary Tests From Review + +**Files:** +- Modify or add focused tests only where the corresponding code path exists. + +**Step 1: Domain helper tests** + +Add tests for: +- `USDTAmount.usdCents("0")` -> `0` micros -> `0` IBEX. +- `USDTAmount.usdCents("1")` -> `10000` micros -> `0.01` IBEX. +- `USDTAmount.usdCents("100")` -> `1000000` micros -> `1` IBEX. +- negative input behavior, matching existing `Money`/amount class conventions. +- `USDTAmount.smallestUnits("19446").asUsdCents()` -> `"2"`. +- `USDTAmount.smallestUnits("19999").asUsdCents()` -> `"2"`. +- A large amount that remains within GraphQL number safety if the API still returns `number`; if the realistic upper bound is unsafe, change the plan to use a string scalar before implementing. + +**Step 2: Generic constructor/parser tests** + +If `MoneyAmount.from(..., WalletCurrency.Usdt)` is changed or split, add a test that locks the selected semantics. + +If GraphQL mutation input parsing is changed, add a test proving USDT input parsing is cent-scale and USD input parsing is unchanged. + +**Step 3: GraphQL output tests** + +Add resolver/serialization tests for every mobile-visible GraphQL USDT amount field discovered in Task 6. At minimum: +- `USDCents` serialization for a cent-clean USDT value. +- `USDCents` or `FractionalCentAmount` serialization for a non-cent-clean USDT value, according to the quantization policy. +- `UsdtWallet.balance` returns cent-scale/fractional-cent-scale, not raw micro-USDT. + +**Step 4: Development-branch contract test** + +Add a test proving the development-branch contract is unambiguous: +- USDT app/API input `100` means `$1.00` / `1_000_000` micro-USDT. +- There is no silent compatibility path that interprets the same input as `100` micro-USDT. +- If an existing branch-local caller still sends micro-USDT through an app/API path, update that caller and cover it with a focused test. + +**Step 5: Provider boundary tests** + +Add or update one focused provider-boundary test proving a mobile/API input of `19446` cents becomes: + +```ts +amount.asSmallestUnits() === "194460000" +amount.toIbex() === 194.46 +``` + +And that provider-originated `USDTAmount.fromNumber("194.46")` still yields the same internal micros. + +## Task 8: Full Verification + +**Files:** +- All modified files. + +**Step 1: Run focused tests** + +```bash +TEST='test/flash/unit/app/wallets/usd-wallet-amount.spec.ts test/flash/unit/app/payments/send-intraledger.spec.ts test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts' yarn test:unit --runInBand +``` + +Expected: all pass. + +**Step 2: Run build** + +```bash +yarn build +``` + +Expected: build completes successfully. + +**Step 3: Review diff** + +```bash +git diff -- src/domain/shared/MoneyAmount.ts src/app/wallets/usd-wallet-amount.ts src/graphql/shared/types/scalar/usd-cents.ts src/graphql/shared/types/object/usdt-wallet.ts test/flash/unit/app/wallets/usd-wallet-amount.spec.ts test/flash/unit/app/payments/send-intraledger.spec.ts test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts +``` + +Expected: no unrelated files, no IBEX adapter conversion behavior changed except through existing `USDTAmount.toIbex()`. + +## Task 9: Commit Directly On `tmp/bridge-rebase-pr-ready` + +**Files:** +- Stage only the plan and the intentional USDT unit files. + +**Step 1: Stage explicit paths** + +```bash +git add docs/plans/2026-05-29-usdt-cent-scale-boundary.md \ + src/domain/shared/MoneyAmount.ts \ + src/app/wallets/usd-wallet-amount.ts \ + src/graphql/shared/types/scalar/usd-cents.ts \ + src/graphql/shared/types/object/usdt-wallet.ts \ + test/flash/unit/app/wallets/usd-wallet-amount.spec.ts \ + test/flash/unit/app/payments/send-intraledger.spec.ts \ + test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts +``` + +**Step 2: Commit** + +```bash +git commit -m "fix: expose usdt wallet amounts in usd cents" +``` + +## Review Questions For Dual-Model Review + +1. Does adding `USDTAmount.usdCents(...)` and `USDTAmount.asUsdCents(...)` preserve a clear app/API boundary without corrupting provider-facing IBEX precision? +2. Should `USDTAmount.asPaymentAmount()` remain inherited from `MoneyAmount`, or does it create an app-facing micro-unit leak that needs a targeted override? +3. Are GraphQL `USDCents`, `UsdtWallet.balance`, transaction history, and payment/invoice mutation paths fully inventoried for mobile-visible unit semantics? +4. Is the quantization policy correct: exact micros internally, fractional cents only on `FractionalCentAmount`, no silent rounding on integer-cent contracts? +5. Are there bridge withdrawal or quote paths that accept mobile cent-scale USDT input but bypass `usdWalletAmountFromInput`? 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/package.json b/package.json index 46f163741..ac7fd4652 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,18 @@ "test:unit": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/unit/jest.config.js --bail --verbose $TEST", "test:legacy-integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/legacy-integration/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", "test:integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/integration/jest.config.js --bail --runInBand --verbose $TEST", + "test:bridge-sandbox-e2e": ". ./.env && { [ ! -f ./.env.local ] || . ./.env.local; } && RUN_BRIDGE_SANDBOX_E2E=true LOGLEVEL=warn jest --config ./test/flash/bridge-sandbox-e2e/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", + "test:bridge-sandbox-e2e:ci": ". ./.env && { [ ! -f ./.env.local ] || . ./.env.local; } && RUN_BRIDGE_SANDBOX_E2E=true LOGLEVEL=warn jest --config ./test/flash/bridge-sandbox-e2e/jest.config.js --bail --runInBand --verbose $TEST", "build-docs": "npx spectaql spectaql-config.yml -1", "fix-yaml": "prettier --write '**/*.(yaml|yml)'", "check-yaml": "prettier --check '**/*.(yaml|yml)'", "watch-main": ". ./.env && nodemon -V -e ts,graphql -w ./src --exec yarn run start-main", "start-main": ". ./.env && yarn run build && node --inspect -r ./lib/services/tracing.js ./lib/servers/graphql-main-server.js", "migrate-mongo-up": "migrate-mongo up -f './src/migrations/migrate-mongo-config.js'", - "gen-test-jwt": "ts-node ./dev/bin/gen-test-jwt.ts" + "gen-test-jwt": "ts-node ./dev/bin/gen-test-jwt.ts", + "bridge-webhook": ". ./.env && ts-node --transpile-only -r tsconfig-paths/register src/servers/bridge-webhook-server.ts --configPath dev/config/base-config.yaml", + "replay-bridge-events": "yarn build && node lib/scripts/replay-bridge-events.js", + "reconcile-bridge-ibex-deposits": "yarn build && node lib/scripts/reconcile-bridge-ibex-deposits.js" }, "engines": { "node": ">=20.18.1 <21" @@ -38,6 +43,7 @@ "@google-cloud/storage": "^7.19.0", "@grpc/grpc-js": "^1.9.3", "@grpc/proto-loader": "^0.8.1", + "@lnflash/bridge-mcp": "github:lnflash/bridge-mcp", "@opentelemetry/api": "^1.6.0", "@opentelemetry/core": "^1.17.0", "@opentelemetry/exporter-trace-otlp-http": "^0.43.0", @@ -82,7 +88,6 @@ "graphql": "^16.8.0", "graphql-middleware": "^6.1.33", "graphql-query-complexity": "^0.12.0", - "graphql-query-complexity-apollo-plugin": "^1.0.2", "graphql-redis-subscriptions": "^2.6.0", "graphql-relay": "^0.10.0", "graphql-shield": "^7.6.4", @@ -90,7 +95,7 @@ "graphql-ws": "^5.13.1", "gt3-server-node-express-sdk": "https://github.com/GaloyMoney/gt3-server-node-express-bypass#master", "i18n": "^0.15.1", - "ibex-client": "^3.0.0", + "ibex-client": "github:lnflash/ibex-client#28f4a784cb59e033f49257f22a437b68c95fd94b", "invoices": "^3.0.0", "ioredis": "^5.3.2", "ioredis-cache": "^2.0.0", @@ -177,11 +182,11 @@ "eslint-plugin-jest": "^27.4.0", "eslint-plugin-prettier": "^5.0.0", "graphql-subscriptions": "^2.0.0", - "grpc-tools": "^1.12.4", "grpc_tools_node_protoc_ts": "^5.3.3", + "grpc-tools": "^1.12.4", "jest": "^29.7.0", - "jest-junit": "^16.0.0", "jest_workaround": "^0.79.19", + "jest-junit": "^16.0.0", "lodash.difference": "^4.5.0", "lodash.find": "^4.6.0", "madge": "^6.1.0", diff --git a/src/app/accounts/create-account.ts b/src/app/accounts/create-account.ts index 67b28fc04..17ea2c1f2 100644 --- a/src/app/accounts/create-account.ts +++ b/src/app/accounts/create-account.ts @@ -10,8 +10,13 @@ import { } from "@services/mongoose" import { recordExceptionInCurrentSpan } from "@services/tracing" -import { ErrorLevel } from "@domain/shared" -import { RepositoryError } from "@domain/errors" +import { ErrorLevel, WalletCurrency } from "@domain/shared" + +const requiredCashWalletCurrencies: WalletCurrency[] = [ + WalletCurrency.Usd, + WalletCurrency.Usdt, +] +const defaultCashWalletCurrency = WalletCurrency.Usdt const initializeCreatedAccount = async ({ account, @@ -29,24 +34,29 @@ const initializeCreatedAccount = async ({ currency, }) - const walletsEnabledConfig = config.initialWallets + const walletsEnabledConfig = Array.from( + new Set([...config.initialWallets, ...requiredCashWalletCurrencies]), + ) // Create all wallets const enabledWallets: Partial> = {} for (const currency of walletsEnabledConfig) { const wallet = await newWallet(currency) - if (wallet instanceof RepositoryError) { + if (wallet instanceof Error) { recordExceptionInCurrentSpan({ error: wallet, level: ErrorLevel.Critical, - attributes: { accountId: account.id } + attributes: { accountId: account.id, currency }, }) + if (requiredCashWalletCurrencies.includes(currency)) return wallet + continue } - else enabledWallets[currency] = wallet + + enabledWallets[currency] = wallet } - // Set default wallet to USD - const defaultWalletId = enabledWallets[walletsEnabledConfig[0]]?.id + // Set ETH-USDT as the active Cash Wallet while preserving USD for migration. + const defaultWalletId = enabledWallets[defaultCashWalletCurrency]?.id if (defaultWalletId === undefined) { return new ConfigError("NoWalletsEnabledInConfigError") diff --git a/src/app/accounts/mark-account-for-deletion.ts b/src/app/accounts/mark-account-for-deletion.ts index d8207eeeb..6e3619988 100644 --- a/src/app/accounts/mark-account-for-deletion.ts +++ b/src/app/accounts/mark-account-for-deletion.ts @@ -25,17 +25,26 @@ export const markAccountForDeletion = async ({ if (wallets instanceof Error) return wallets for (const wallet of wallets) { - const balance = await getBalanceForWallet({ walletId: wallet.id }) + const balance = await getBalanceForWallet({ + walletId: wallet.id, + currency: wallet.currency, + }) if (balance instanceof Error) return balance - if (balance.isGreaterThan(USDAmount.ZERO) && cancelIfPositiveBalance) { + if ( + balance instanceof USDAmount && + balance.isGreaterThan(USDAmount.ZERO) && + cancelIfPositiveBalance + ) { return new AccountHasPositiveBalanceError( `The new phone is associated with an account with a non empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}, cancelIfPositiveBalance: ${cancelIfPositiveBalance}`, ) } + const balanceDisplay = + balance instanceof USDAmount ? balance.asDollars() : balance.asNumber() addEventToCurrentSpan(`deleting_wallet`, { walletId: wallet.id, currency: wallet.currency, - balance: balance.asDollars(), + balance: balanceDisplay, }) } diff --git a/src/app/authentication/login.ts b/src/app/authentication/login.ts index 736eb90ed..c87b3705c 100644 --- a/src/app/authentication/login.ts +++ b/src/app/authentication/login.ts @@ -309,7 +309,10 @@ export const loginDeviceUpgradeWithPhone = async ({ if (deviceWallets instanceof Error) return deviceWallets let deviceAccountHasBalance = false for (const wallet of deviceWallets) { - const balance = await getBalanceForWallet({ walletId: wallet.id }) + const balance = await getBalanceForWallet({ + walletId: wallet.id, + currency: wallet.currency, + }) if (balance instanceof Error) return balance if (!balance.isZero()) { deviceAccountHasBalance = true diff --git a/src/app/bridge/get-withdrawal-flash-fee-notice.ts b/src/app/bridge/get-withdrawal-flash-fee-notice.ts new file mode 100644 index 000000000..fd7ba3891 --- /dev/null +++ b/src/app/bridge/get-withdrawal-flash-fee-notice.ts @@ -0,0 +1,12 @@ +import { getI18nInstance } from "@config" +import { getLanguageOrDefault } from "@domain/locale" + +export const BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE = + "notification.bridgeWithdrawal.flashFeeNotice" + +export const getBridgeWithdrawalFlashFeeNotice = (locale: UserLanguage): string => + getI18nInstance().__({ phrase: BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE, locale }) + +export const getBridgeWithdrawalFlashFeeNoticeForUser = ( + user?: Pick, +): string => getBridgeWithdrawalFlashFeeNotice(getLanguageOrDefault(user?.language ?? "")) diff --git a/src/app/bridge/send-deposit-notification.ts b/src/app/bridge/send-deposit-notification.ts new file mode 100644 index 000000000..9845f676a --- /dev/null +++ b/src/app/bridge/send-deposit-notification.ts @@ -0,0 +1,98 @@ +import { getI18nInstance } from "@config" +import { checkedToAccountId } from "@domain/accounts" +import { getLanguageOrDefault } from "@domain/locale" +import { + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + NotificationsServiceError, +} from "@domain/notifications" +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +const i18n = getI18nInstance() + +const formatDepositAmount = (amount: string, currency: string): string => + `${amount} ${currency.toUpperCase()}` + +export const sendBridgeDepositNotification = async ({ + accountId: accountIdRaw, + amount, + currency, +}: { + accountId: string + amount: string + currency: string +}): Promise => { + const accountId = checkedToAccountId(accountIdRaw) + if (accountId instanceof Error) return accountId + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) return user + + const locale = getLanguageOrDefault(user.language) + const formattedAmount = formatDepositAmount(amount, currency) + const phraseBase = "notification.bridgeDeposit" + + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) + const body = i18n.__( + { phrase: `${phraseBase}.body`, locale }, + { amount: formattedAmount }, + ) + + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.Payments, + notificationSettings: account.notificationSettings, + data: { + type: "bridge_deposit_completed", + amount, + currency: currency == "usdt" ? "USD" : currency.toUpperCase(), + }, + }) + + if (result instanceof NotificationsServiceError) return result + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + return true + } + + return true +} + +export const sendBridgeDepositNotificationBestEffort = async ( + args: Parameters[0], +): Promise => { + const result = await sendBridgeDepositNotification(args) + + if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { + const accountId = checkedToAccountId(args.accountId) + if (accountId instanceof Error) return + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return + + await removeDeviceTokens({ + userId: account.kratosUserId, + deviceTokens: result.tokens, + }) + return + } + + if (result instanceof Error) { + baseLogger.warn( + { accountId: args.accountId, error: result }, + "Failed to send Bridge deposit push notification", + ) + } +} diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts new file mode 100644 index 000000000..07003a18f --- /dev/null +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -0,0 +1,112 @@ +import { getI18nInstance } from "@config" +import { checkedToAccountId } from "@domain/accounts" +import { getLanguageOrDefault } from "@domain/locale" +import { + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + NotificationsServiceError, +} from "@domain/notifications" +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +const i18n = getI18nInstance() + +const formatWithdrawalAmount = (amount: string, currency: string): string => + `${amount} ${currency.toUpperCase()}` + +export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" | "cancelled" + +export const sendBridgeWithdrawalNotification = async ({ + accountId: accountIdRaw, + amount, + currency, + outcome, + failureReason, +}: { + accountId: string + amount: string + currency: string + outcome: BridgeWithdrawalNotificationOutcome + failureReason?: string +}): Promise => { + const accountId = checkedToAccountId(accountIdRaw) + if (accountId instanceof Error) return accountId + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) return user + + const locale = getLanguageOrDefault(user.language) + const formattedAmount = formatWithdrawalAmount(amount, currency) + const phraseBase = `notification.bridgeWithdrawal.${outcome}` + + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) + const bodyPhrase = + outcome === "failed" && failureReason + ? `${phraseBase}.bodyWithReason` + : `${phraseBase}.body` + const body = i18n.__( + { phrase: bodyPhrase, locale }, + { + amount: formattedAmount, + reason: failureReason ?? "", + }, + ) + + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.Cashout, + notificationSettings: account.notificationSettings, + data: { + type: `bridge_withdrawal_${outcome}`, + amount, + currency: currency == "usdt" ? "USD" : currency.toUpperCase(), + ...(failureReason ? { failureReason } : {}), + }, + }) + + if (result instanceof NotificationsServiceError) return result + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + return true + } + + return true +} + +export const sendBridgeWithdrawalNotificationBestEffort = async ( + args: Parameters[0], +): Promise => { + const result = await sendBridgeWithdrawalNotification(args) + + if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { + const accountId = checkedToAccountId(args.accountId) + if (accountId instanceof Error) return + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return + + await removeDeviceTokens({ + userId: account.kratosUserId, + deviceTokens: result.tokens, + }) + return + } + + if (result instanceof Error) { + baseLogger.warn( + { accountId: args.accountId, outcome: args.outcome, error: result }, + "Failed to send Bridge withdrawal push notification", + ) + } +} diff --git a/src/app/cash-wallet-cutover/amount-conversion.ts b/src/app/cash-wallet-cutover/amount-conversion.ts new file mode 100644 index 000000000..fd6bd4e09 --- /dev/null +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -0,0 +1,54 @@ +import { InvalidCashWalletCutoverAmountError } from "./errors" + +const USDT_MICROS_PER_USD_CENT = 10_000n + +const parseNonNegativeInteger = ( + value: string, +): bigint | InvalidCashWalletCutoverAmountError => { + if (!/^\d+$/.test(value)) { + return new InvalidCashWalletCutoverAmountError( + `Invalid non-negative integer amount: ${value}`, + ) + } + return BigInt(value) +} + +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() +} + +export const feeUsdCentsToUsdtMicros = usdCentsToUsdtMicros + +export const usdtMicrosToUsdCentsCeil = ( + usdtMicros: string, +): string | InvalidCashWalletCutoverAmountError => { + const parsed = parseNonNegativeInteger(usdtMicros) + if (parsed instanceof Error) return parsed + if (parsed === 0n) return "0" + return ((parsed + USDT_MICROS_PER_USD_CENT - 1n) / USDT_MICROS_PER_USD_CENT).toString() +} + +export const destinationShortfallUsdtMicros = ({ + targetUsdtMicros, + startingUsdtMicros, + currentUsdtMicros, +}: { + targetUsdtMicros: string + startingUsdtMicros: string + currentUsdtMicros: string +}): string | InvalidCashWalletCutoverAmountError => { + const target = parseNonNegativeInteger(targetUsdtMicros) + if (target instanceof Error) return target + const starting = parseNonNegativeInteger(startingUsdtMicros) + if (starting instanceof Error) return starting + const current = parseNonNegativeInteger(currentUsdtMicros) + if (current instanceof Error) return current + + const received = current > starting ? current - starting : 0n + if (received >= target) return "0" + return (target - received).toString() +} diff --git a/src/app/cash-wallet-cutover/cashout-routing.ts b/src/app/cash-wallet-cutover/cashout-routing.ts new file mode 100644 index 000000000..84e939166 --- /dev/null +++ b/src/app/cash-wallet-cutover/cashout-routing.ts @@ -0,0 +1,103 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletsRepository, CashWalletCutoverRepository } from "@services/mongoose" + +import { + CashWalletMissingUsdtWalletError, + CashWalletCutoverPreflightError, +} from "./errors" +import { CashWalletCutoverRoute, evaluateCashWalletCutoverGuard } from "./guard" + +type CashoutRoutingMigrationsRepository = { + getConfig: () => Promise + findMigrationByAccountId: (args: { + accountId: AccountId + cutoverVersion: number + runId: string + }) => Promise +} + +type CashoutRoutingWalletsRepository = { + findById: (walletId: WalletId) => Promise + listByAccountId: (accountId: AccountId) => Promise +} + +export type CashoutWalletSelection = { + route: CashWalletCutoverRoute + userWalletId: WalletId + flashWalletId: WalletId +} + +// Resolves the source (user) and destination (Flash bank-owner) wallets for a +// Cashout V1 offer from the cutover guard — NOT from the client-supplied walletId. +// Pre-cutover this returns the legacy USD wallets unchanged; post-cutover it returns +// the account's USDT wallet and the bank-owner's USDT wallet so the debit settles in +// ETH-USDT. The guard blocks the cashout (returns an error) while a migration is +// in-flight or has failed. +export const resolveCashoutWalletSelection = async ({ + accountId, + requestedUserWalletId, + bankOwnerUsdWalletId, + migrationsRepo = CashWalletCutoverRepository(), + walletsRepo = WalletsRepository(), +}: { + accountId: AccountId + requestedUserWalletId: WalletId + bankOwnerUsdWalletId: WalletId + migrationsRepo?: CashoutRoutingMigrationsRepository + walletsRepo?: CashoutRoutingWalletsRepository +}): Promise => { + const cutover = await migrationsRepo.getConfig() + if (cutover instanceof Error) return cutover + + let migration: CashWalletMigration | null | undefined + if (cutover.state === "in_progress") { + if (!cutover.runId) return new CashWalletCutoverPreflightError() + + const foundMigration = await migrationsRepo.findMigrationByAccountId({ + accountId, + cutoverVersion: cutover.cutoverVersion, + runId: cutover.runId, + }) + if (foundMigration instanceof Error) return foundMigration + migration = foundMigration + } + + const decision = evaluateCashWalletCutoverGuard({ cutover, migration }) + if (decision instanceof Error) return decision + + if (decision.route === "legacy_usd") { + return { + route: "legacy_usd", + userWalletId: requestedUserWalletId, + flashWalletId: bankOwnerUsdWalletId, + } + } + + const userWallets = await walletsRepo.listByAccountId(accountId) + if (userWallets instanceof Error) return userWallets + const userUsdtWallet = userWallets.find((w) => w.currency === WalletCurrency.Usdt) + if (!userUsdtWallet) { + return new CashWalletMissingUsdtWalletError( + `No USDT wallet found for account ${accountId}`, + ) + } + + const bankOwnerWallet = await walletsRepo.findById(bankOwnerUsdWalletId) + if (bankOwnerWallet instanceof Error) return bankOwnerWallet + const bankOwnerWallets = await walletsRepo.listByAccountId(bankOwnerWallet.accountId) + if (bankOwnerWallets instanceof Error) return bankOwnerWallets + const bankOwnerUsdtWallet = bankOwnerWallets.find( + (w) => w.currency === WalletCurrency.Usdt, + ) + if (!bankOwnerUsdtWallet) { + return new CashWalletMissingUsdtWalletError( + "No USDT wallet found for the Flash bank-owner account", + ) + } + + return { + route: "usdt", + userWalletId: userUsdtWallet.id, + flashWalletId: bankOwnerUsdtWallet.id, + } +} diff --git a/src/app/cash-wallet-cutover/client-capability.ts b/src/app/cash-wallet-cutover/client-capability.ts new file mode 100644 index 000000000..e30b95fb5 --- /dev/null +++ b/src/app/cash-wallet-cutover/client-capability.ts @@ -0,0 +1,37 @@ +export const CASH_WALLET_USDT_CLIENT_CAPABILITY = "cash-wallet-usdt-v1" + +export type CashWalletPresentation = "legacy_compat" | "usdt" + +export type CashWalletClientCapabilities = { + cashWalletPresentation: CashWalletPresentation + hasUsdtCashWalletSupport: boolean +} + +export const DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES: CashWalletClientCapabilities = { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, +} + +export const parseCashWalletClientCapabilities = ( + headers: Record, +): CashWalletClientCapabilities => { + const values = Object.entries(headers).flatMap(([key, raw]) => { + if (key.toLowerCase() !== "x-flash-client-capabilities") return [] + if (typeof raw === "string") return [raw] + if (Array.isArray(raw)) + return raw.filter((value): value is string => typeof value === "string") + return [] + }) + + const capabilities = values + .flatMap((value) => value.split(",")) + .map((value) => value.trim().toLowerCase()) + + const hasUsdtCashWalletSupport = capabilities.includes( + CASH_WALLET_USDT_CLIENT_CAPABILITY, + ) + + if (!hasUsdtCashWalletSupport) return DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES + + return { cashWalletPresentation: "usdt", hasUsdtCashWalletSupport } +} diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts new file mode 100644 index 000000000..14304c10b --- /dev/null +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -0,0 +1,78 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +export type CashWalletCutoverDiscoveryStatus = + | "legacy_default" + | "already_usdt" + | "residual_legacy_usd" + | "missing_legacy_usd" + | "missing_destination_usdt" + +export type CashWalletCutoverDiscovery = { + status: CashWalletCutoverDiscoveryStatus + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId?: WalletId + destinationUsdtWalletId?: WalletId + previousDefaultWalletId: WalletId +} + +export const classifyCashWalletsForCutover = ({ + account, + wallets, +}: { + account: Account + wallets: Wallet[] +}): CashWalletCutoverDiscovery => { + const legacyUsdWallet = wallets.find( + (wallet) => + wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usd, + ) + const destinationUsdtWallet = wallets.find( + (wallet) => + wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usdt, + ) + + const base = { + accountId: account.id, + accountUuid: account.uuid, + legacyUsdWalletId: legacyUsdWallet?.id, + destinationUsdtWalletId: destinationUsdtWallet?.id, + previousDefaultWalletId: account.defaultWalletId, + } + + if (!legacyUsdWallet) return { ...base, status: "missing_legacy_usd" } + if (!destinationUsdtWallet) return { ...base, status: "missing_destination_usdt" } + + if (account.defaultWalletId === legacyUsdWallet.id) { + return { ...base, status: "legacy_default" } + } + + if (account.defaultWalletId === destinationUsdtWallet.id) { + return { ...base, status: "already_usdt" } + } + + return { ...base, status: "residual_legacy_usd" } +} + +export const discoverCashWalletCutoverAccounts = async ({ + accountsRepo, + walletsRepo, +}: { + accountsRepo: Pick + walletsRepo: Pick +}): Promise => { + const accounts = accountsRepo.listUnlockedAccounts() + if (accounts instanceof Error) return accounts + + const discoveries: CashWalletCutoverDiscovery[] = [] + + for await (const account of accounts) { + const wallets = await walletsRepo.listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + discoveries.push(classifyCashWalletsForCutover({ account, wallets })) + } + + return discoveries +} diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts new file mode 100644 index 000000000..373dcd742 --- /dev/null +++ b/src/app/cash-wallet-cutover/errors.ts @@ -0,0 +1,11 @@ +import { DomainError, ValidationError } from "@domain/shared" + +export class InvalidCashWalletCutoverAmountError extends ValidationError {} +export class InvalidCashWalletMigrationTransitionError extends ValidationError {} +export class InvalidCashWalletCutoverStateTransitionError extends ValidationError {} +export class CashWalletCutoverInProgressError extends ValidationError {} +export class CashWalletMigrationFailedError extends DomainError {} +export class CashWalletMissingLegacyUsdWalletError extends DomainError {} +export class CashWalletMissingUsdtWalletError extends DomainError {} +export class CashWalletCutoverPreflightError extends DomainError {} +export class CashWalletCutoverTreasuryInsufficientBalanceError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/executor.ts b/src/app/cash-wallet-cutover/executor.ts new file mode 100644 index 000000000..5df68aa00 --- /dev/null +++ b/src/app/cash-wallet-cutover/executor.ts @@ -0,0 +1,39 @@ +type RunnableCashWalletMigrationStatus = Exclude< + CashWalletMigrationStatus, + | "complete" + | "failed" + | "requires_operator_review" + | "skipped_already_migrated" + | "rollback_started" + | "rolled_back" +> + +type CashWalletMigrationStepHandler = ( + migration: CashWalletMigration, +) => Promise + +export type CashWalletMigrationStepHandlers = Record< + RunnableCashWalletMigrationStatus, + CashWalletMigrationStepHandler +> + +const terminalStatuses: CashWalletMigrationStatus[] = [ + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", +] + +export const executeCashWalletMigrationStep = async ({ + migration, + handlers, +}: { + migration: CashWalletMigration + handlers: CashWalletMigrationStepHandlers +}): Promise => { + if (terminalStatuses.includes(migration.status)) return migration + + return handlers[migration.status as RunnableCashWalletMigrationStatus](migration) +} diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts new file mode 100644 index 000000000..214968d4c --- /dev/null +++ b/src/app/cash-wallet-cutover/guard.ts @@ -0,0 +1,77 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, +} from "./errors" +import { CashWalletClientCapabilities } from "./client-capability" + +export { CashWalletCutoverInProgressError, CashWalletMigrationFailedError } + +export type CashWalletCutoverRoute = "legacy_usd" | "usdt" +export type CashWalletCutoverPresentation = "legacy_usd" | "legacy_usd_compat" | "usdt" + +export type CashWalletCutoverDecision = { + presentation: CashWalletCutoverPresentation +} + +const ACTIVE_STATUSES: CashWalletMigrationStatus[] = [ + "started", + "provisioned", + "balance_read", + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", + "rollback_started", +] + +export const evaluateCashWalletCutoverGuard = ({ + cutover, + migration, +}: { + cutover: CashWalletCutoverConfig + migration?: CashWalletMigration | null +}): { route: CashWalletCutoverRoute } | ApplicationError => { + if (cutover.state === "pre") return { route: "legacy_usd" } + if (cutover.state === "complete") return { route: "usdt" } + + if (!migration || migration.status === "not_started") return { route: "legacy_usd" } + if ( + migration.status === "complete" || + migration.status === "skipped_already_migrated" + ) { + return { route: "usdt" } + } + if (migration.status === "failed" || migration.status === "requires_operator_review") { + return new CashWalletMigrationFailedError() + } + if (ACTIVE_STATUSES.includes(migration.status)) { + return new CashWalletCutoverInProgressError() + } + + return { route: "legacy_usd" } +} + +export const evaluateCashWalletCutoverPresentation = ({ + cutover, + migration, + client, +}: { + cutover: CashWalletCutoverConfig + migration?: CashWalletMigration | null + client: CashWalletClientCapabilities +}): CashWalletCutoverDecision | ApplicationError => { + const guard = evaluateCashWalletCutoverGuard({ cutover, migration }) + if (guard instanceof Error) return guard + + if (guard.route === "legacy_usd") { + return { presentation: "legacy_usd" } + } + + return { + presentation: client.hasUsdtCashWalletSupport ? "usdt" : "legacy_usd_compat", + } +} diff --git a/src/app/cash-wallet-cutover/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts new file mode 100644 index 000000000..b09756c1d --- /dev/null +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -0,0 +1,177 @@ +import { + completeCashWalletMigration, + createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, + markCashWalletMigrationBalanceMoveSent, + markCashWalletMigrationFeeReimbursed, + provisionCashWalletMigrationDestination, + recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, + startCashWalletMigration, + verifyCashWalletMigrationBalanceMove, + verifyCashWalletMigrationLegacyZero, +} from "./worker" +import { CashWalletMigrationStepHandlers } from "./executor" + +type CashWalletMigrationTransitionRepository = Parameters< + typeof startCashWalletMigration +>[0]["migrationsRepo"] + +type CashWalletMigrationHandlerServices = { + now(): Date + provisioningService: Parameters< + typeof provisionCashWalletMigrationDestination + >[0]["provisioningService"] + balanceReader: { + readSourceBalanceUsdCents( + migration: CashWalletMigration, + ): Promise + readDestinationBalanceUsdtMicros( + migration: CashWalletMigration, + ): Promise + } + invoiceService: Parameters< + typeof createCashWalletMigrationBalanceMoveInvoice + >[0]["invoiceService"] & + Parameters[0]["invoiceService"] + paymentService: Parameters< + typeof sendCashWalletMigrationBalanceMovePayment + >[0]["paymentService"] + balanceVerifier: Parameters< + typeof verifyCashWalletMigrationBalanceMove + >[0]["balanceVerifier"] + feeService: { + readFeeAmountUsdtMicros( + migration: CashWalletMigration, + ): Promise + } + treasuryService: { + getTreasuryWalletId(): Promise + } + pointerService: Parameters< + typeof flipCashWalletMigrationDefaultPointer + >[0]["pointerService"] + legacyWalletVerifier: Parameters< + typeof verifyCashWalletMigrationLegacyZero + >[0]["legacyWalletVerifier"] +} + +export const createCashWalletMigrationStepHandlers = ({ + migrationsRepo, + services, +}: { + migrationsRepo: CashWalletMigrationTransitionRepository + services: CashWalletMigrationHandlerServices +}): CashWalletMigrationStepHandlers => ({ + not_started: (migration) => + startCashWalletMigration({ + migration, + migrationsRepo, + startedAt: services.now(), + }), + started: (migration) => + provisionCashWalletMigrationDestination({ + migration, + migrationsRepo, + provisioningService: services.provisioningService, + }), + provisioned: async (migration) => { + const sourceBalanceUsdCents = + await services.balanceReader.readSourceBalanceUsdCents(migration) + if (sourceBalanceUsdCents instanceof Error) return sourceBalanceUsdCents + const destinationStartingBalanceUsdtMicros = + await services.balanceReader.readDestinationBalanceUsdtMicros(migration) + if (destinationStartingBalanceUsdtMicros instanceof Error) { + return destinationStartingBalanceUsdtMicros + } + return recordCashWalletMigrationBalance({ + migration, + migrationsRepo, + sourceBalanceUsdCents, + destinationStartingBalanceUsdtMicros, + }) + }, + balance_read: (migration) => { + if (migration.destinationAmountUsdtMicros === "0") { + return flipCashWalletMigrationDefaultPointer({ + migration, + migrationsRepo, + pointerService: services.pointerService, + }) + } + + return createCashWalletMigrationBalanceMoveInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + }) + }, + invoice_created: (migration) => + sendCashWalletMigrationBalanceMovePayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + invoiceService: services.invoiceService, + now: services.now, + }), + balance_move_sending: (migration) => + markCashWalletMigrationBalanceMoveSent({ migration, migrationsRepo }), + balance_move_sent: (migration) => + verifyCashWalletMigrationBalanceMove({ + migration, + migrationsRepo, + balanceVerifier: services.balanceVerifier, + }), + balance_move_verified: async (migration) => { + const feeAmountUsdtMicros = + await services.feeService.readFeeAmountUsdtMicros(migration) + if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + if (feeAmountUsdtMicros === "0") { + return skipCashWalletMigrationFeeReimbursement({ + migration, + migrationsRepo, + }) + } + return createCashWalletMigrationFeeReimbursementInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + feeAmountUsdtMicros, + }) + }, + fee_reimbursement_invoice_created: async (migration) => { + const treasuryWalletId = await services.treasuryService.getTreasuryWalletId() + if (treasuryWalletId instanceof Error) return treasuryWalletId + return sendCashWalletMigrationFeeReimbursementPayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + invoiceService: services.invoiceService, + now: services.now, + treasuryWalletId, + }) + }, + fee_reimbursement_sending: (migration) => + markCashWalletMigrationFeeReimbursed({ migration, migrationsRepo }), + fee_reimbursed: (migration) => + flipCashWalletMigrationDefaultPointer({ + migration, + migrationsRepo, + pointerService: services.pointerService, + }), + pointer_flipped: (migration) => + verifyCashWalletMigrationLegacyZero({ + migration, + migrationsRepo, + legacyWalletVerifier: services.legacyWalletVerifier, + }), + legacy_zero_verified: (migration) => + completeCashWalletMigration({ + migration, + migrationsRepo, + completedAt: services.now(), + }), +}) diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts new file mode 100644 index 000000000..89ad096c5 --- /dev/null +++ b/src/app/cash-wallet-cutover/index.ts @@ -0,0 +1,25 @@ +export * from "./amount-conversion" +export * from "./client-capability" +export * from "./errors" +export * from "./presentation" +export * from "./presentation-for-account" +export * from "./state-machine" +export { + evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, +} from "./guard" +export * from "./discovery" +export * from "./preflight" +export * from "./planner" +export * from "./migration-records" +export * from "./prepare" +export * from "./worker" +export * from "./executor" +export * from "./runner" +export * from "./handlers" +export * from "./runtime-services" +export * from "./orchestrator" +export * from "./lifecycle" +export * from "./preview" +export * from "./provision-usdt-wallets" +export * from "./recipient-routing" diff --git a/src/app/cash-wallet-cutover/index.types.d.ts b/src/app/cash-wallet-cutover/index.types.d.ts new file mode 100644 index 000000000..3f16650b7 --- /dev/null +++ b/src/app/cash-wallet-cutover/index.types.d.ts @@ -0,0 +1,67 @@ +type CashWalletCutoverState = "pre" | "in_progress" | "complete" + +type CashWalletMigrationStatus = + | "not_started" + | "started" + | "provisioned" + | "balance_read" + | "invoice_created" + | "balance_move_sending" + | "balance_move_sent" + | "balance_move_verified" + | "fee_reimbursement_invoice_created" + | "fee_reimbursement_sending" + | "fee_reimbursed" + | "pointer_flipped" + | "legacy_zero_verified" + | "complete" + | "failed" + | "requires_operator_review" + | "skipped_already_migrated" + | "rollback_started" + | "rolled_back" + +type CashWalletCutoverConfig = { + state: CashWalletCutoverState + scheduledAt?: Date + startedAt?: Date + completedAt?: Date + pausedAt?: Date + pauseReason?: string + updatedBy?: string + cutoverVersion: number + runId?: string + updatedAt: Date +} + +type CashWalletMigration = { + id: string + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId?: WalletId + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string + feeAmountUsdCents?: string + feeAmountUsdtMicros?: string + balanceMoveInvoicePaymentRequest?: string + balanceMoveInvoicePaymentHash?: string + balanceMovePaymentTransactionId?: string + feeReimbursementInvoicePaymentRequest?: string + feeReimbursementInvoicePaymentHash?: string + feeReimbursementPaymentTransactionId?: string + estimatedFee?: boolean + idempotencyKey: string + attempts: number + lastError?: string + lockedAt?: Date + lockedBy?: string + startedAt?: Date + completedAt?: Date + updatedAt: Date +} diff --git a/src/app/cash-wallet-cutover/lifecycle.ts b/src/app/cash-wallet-cutover/lifecycle.ts new file mode 100644 index 000000000..aa624a614 --- /dev/null +++ b/src/app/cash-wallet-cutover/lifecycle.ts @@ -0,0 +1,187 @@ +import { CashWalletCutoverRepository } from "@services/mongoose" + +import { + CashWalletCutoverInProgressError, + CashWalletCutoverPreflightError, + CashWalletMigrationFailedError, + InvalidCashWalletCutoverStateTransitionError, +} from "./errors" + +const migrationStatuses: CashWalletMigrationStatus[] = [ + "not_started", + "started", + "provisioned", + "balance_read", + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", + "legacy_zero_verified", + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", +] + +type CashWalletCutoverLifecycleRepository = { + getConfig: () => Promise + updateConfig: ( + patch: Partial, + actor?: string, + ) => Promise + listRunnableMigrations: ({ + cutoverVersion, + runId, + limit, + }: { + cutoverVersion: number + runId: string + limit?: number + }) => Promise + countByStatus: ({ + cutoverVersion, + runId, + status, + }: { + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + }) => Promise +} + +export type CashWalletCutoverStatusReport = { + config: CashWalletCutoverConfig + countsByStatus: Partial> +} + +export const startPrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + actor, + now = new Date(), + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + actor: string + now?: Date + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + if (config.state === "complete") { + return new InvalidCashWalletCutoverStateTransitionError( + "Cash wallet cutover is already complete", + ) + } + + if (config.state === "in_progress") { + if (config.runId === runId && config.cutoverVersion === cutoverVersion) return config + return new CashWalletCutoverInProgressError( + "Cash wallet cutover is already in progress", + ) + } + + const runnable = await migrationsRepo.listRunnableMigrations({ + cutoverVersion, + runId, + limit: 1, + }) + if (runnable instanceof Error) return runnable + if (runnable.length === 0) { + return new CashWalletCutoverPreflightError( + `Cash wallet cutover has no runnable migrations for runId=${runId} cutoverVersion=${cutoverVersion}. Run 'prepare' before starting.`, + ) + } + + return migrationsRepo.updateConfig( + { + state: "in_progress", + cutoverVersion, + runId, + startedAt: now, + scheduledAt: undefined, + pausedAt: undefined, + pauseReason: undefined, + }, + actor, + ) +} + +export const completePrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + actor, + now = new Date(), + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + actor: string + now?: Date + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const failedCount = await migrationsRepo.countByStatus({ + cutoverVersion, + runId, + status: "failed", + }) + if (failedCount instanceof Error) return failedCount + if (failedCount > 0) return new CashWalletMigrationFailedError() + + const reviewCount = await migrationsRepo.countByStatus({ + cutoverVersion, + runId, + status: "requires_operator_review", + }) + if (reviewCount instanceof Error) return reviewCount + if (reviewCount > 0) return new CashWalletMigrationFailedError() + + const runnable = await migrationsRepo.listRunnableMigrations({ + cutoverVersion, + runId, + limit: 1, + }) + if (runnable instanceof Error) return runnable + if (runnable.length > 0) return new CashWalletCutoverInProgressError() + + return migrationsRepo.updateConfig( + { + state: "complete", + cutoverVersion, + runId, + completedAt: now, + }, + actor, + ) +} + +export const getPrimaryCashWalletCutoverStatus = async ({ + cutoverVersion, + runId, + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + const countsByStatus: Partial> = {} + + for (const status of migrationStatuses) { + const count = await migrationsRepo.countByStatus({ cutoverVersion, runId, status }) + if (count instanceof Error) return count + if (count > 0) countsByStatus[status] = count + } + + return { config, countsByStatus } +} diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts new file mode 100644 index 000000000..9ac33b8d0 --- /dev/null +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -0,0 +1,28 @@ +import { PrimaryCashWalletMigrationPlan } from "./planner" + +type CashWalletMigrationRecordsRepository = { + upsertMigration( + args: PrimaryCashWalletMigrationPlan, + ): Promise +} + +export type { CashWalletMigrationRecordsRepository } + +export const upsertPrimaryCashWalletMigrationRecords = async ({ + migrationsRepo, + plans, +}: { + migrationsRepo: CashWalletMigrationRecordsRepository + plans: PrimaryCashWalletMigrationPlan[] +}): Promise => { + const migrations: CashWalletMigration[] = [] + + for (const plan of plans) { + const migration = await migrationsRepo.upsertMigration(plan) + if (migration instanceof Error) return migration + + migrations.push(migration) + } + + return migrations +} diff --git a/src/app/cash-wallet-cutover/operator-dashboard.ts b/src/app/cash-wallet-cutover/operator-dashboard.ts new file mode 100644 index 000000000..63d4f8faa --- /dev/null +++ b/src/app/cash-wallet-cutover/operator-dashboard.ts @@ -0,0 +1,951 @@ +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { CashWalletCutoverDiscovery } from "./discovery" +import { CashWalletCutoverPreflightReport } from "./preflight" + +export type CashWalletCutoverOperatorManifestAccount = { + batchRunId?: string + index?: number + phone?: string + username?: string + accountId: AccountId + accountUuid?: AccountUuid + expectedUsdWalletId?: WalletId + expectedUsdtWalletId?: WalletId +} + +export type OperatorBalanceStatus = "loading" | "fresh" | "error" + +export type OperatorBalance = { + currency: WalletCurrency + display: string + minorUnits: string + minorUnitsNumber: number + status?: OperatorBalanceStatus + error?: string +} + +export type OperatorWallet = { + id: WalletId + currency: WalletCurrency + expected: boolean + balance: OperatorBalance +} + +export type OperatorAccount = { + batchRunId?: string + index?: number + phone?: string + username?: string + accountId: AccountId + accountUuid?: AccountUuid + expectedUsdWalletId?: WalletId + expectedUsdtWalletId?: WalletId + watchlisted: boolean + defaultWalletId?: WalletId + defaultWalletCurrency?: WalletCurrency + walletCount: number + usdWallets: OperatorWallet[] + usdtWallets: OperatorWallet[] + migrationStatus: CashWalletMigrationStatus | "none" + migrationUpdatedAt?: string + cutoverBalanceAudit?: OperatorCutoverBalanceAudit + anomalies: string[] +} + +export type OperatorCutoverBalanceAudit = { + status: "loading" | "shortfall" | "verified" + sourceUsdCents: number + expectedMinimumUsdtMicros: number + destinationStartingBalanceUsdtMicros: number + currentDestinationBalanceUsdtMicros: number + finalDeltaUsdtMicros: number + roundingSubsidyUsdtMicros: number + shortfallUsdtMicros: number +} + +export type OperatorTreasuryAccount = { + accountId: AccountId + accountUuid?: AccountUuid + role?: string + defaultWalletId?: WalletId + defaultWalletCurrency?: WalletCurrency + walletCount: number + usdWallets: OperatorWallet[] + usdtWallets: OperatorWallet[] + anomalies: string[] +} + +export type OperatorTreasurySummary = { + accounts: number + wallets: number + usdTotalCents: number + usdtTotalMicros: number +} + +export type OperatorReconciliationSummary = { + customerTotalCents: number + treasuryTotalCents: number + systemTotalCents: number +} + +export type CashWalletCutoverOperatorSnapshot = { + generatedAt: string + cutover: { + state: CashWalletCutoverState + cutoverVersion: number + runId?: string + updatedAt?: string + } + preflight?: CashWalletCutoverPreflightReport + summary: { + accounts: number + wallets: { + current: number + target: number + usd: number + usdt: number + missingUsdt: number + } + fundedUsdOnlyAccounts: number + usdTotalCents: number + usdtTotalMicros: number + anomalies: number + watchlistAnomalies: number + canStart: boolean + blockers: number + watchlistAccounts: number + migrationStatuses: Record + } + accounts: OperatorAccount[] + treasury: { + accounts: OperatorTreasuryAccount[] + summary: OperatorTreasurySummary + } + reconciliation: OperatorReconciliationSummary +} + +type AccountManifestRecord = { + index?: number + phone?: string + username?: string + accountId?: string + account?: { id?: string } + id?: string + accountUuid?: string + usdWalletId?: string + usdtWalletId?: string +} + +type ManifestShape = { + runId?: string + accounts?: AccountManifestRecord[] + created?: AccountManifestRecord[] +} + +type BuildSnapshotArgs = { + manifestAccounts: CashWalletCutoverOperatorManifestAccount[] + discoveredAccounts?: CashWalletCutoverDiscovery[] + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: { + getConfig: () => Promise + findMigrationByAccountId: (args: { + accountId: AccountId + cutoverVersion: number + runId: string + }) => Promise + } + getBalanceForWallet: (args: { + walletId: WalletId + currency?: WalletCurrency + }) => Promise + migrationLookup?: { + cutoverVersion: number + runId: string + } + preflightReport?: CashWalletCutoverPreflightReport + balanceReadAttempts?: number + balanceMode?: "live" | "structural" + treasuryAccountIds?: AccountId[] + now?: Date +} + +export const parseCashWalletCutoverOperatorManifest = ( + input: ManifestShape | AccountManifestRecord[], +): CashWalletCutoverOperatorManifestAccount[] => { + const batchRunId = Array.isArray(input) ? undefined : input.runId + const records = Array.isArray(input) ? input : (input.accounts ?? input.created ?? []) + + const accounts = records.map((record) => { + const accountId = record.accountId ?? record.account?.id ?? record.id + if (!accountId) { + throw new Error("Operator manifest record is missing accountId") + } + + return { + batchRunId, + index: record.index, + phone: record.phone, + username: record.username, + accountId: accountId as AccountId, + accountUuid: record.accountUuid as AccountUuid | undefined, + expectedUsdWalletId: record.usdWalletId as WalletId | undefined, + expectedUsdtWalletId: record.usdtWalletId as WalletId | undefined, + } + }) + + const seen = new Set() + for (const account of accounts) { + if (seen.has(account.accountId)) { + throw new Error(`Duplicate operator manifest accountId: ${account.accountId}`) + } + seen.add(account.accountId) + } + + return accounts +} + +const csvHeaders = [ + "generatedAt", + "cutoverState", + "cutoverVersion", + "cutoverRunId", + "cutoverUpdatedAt", + "watchlisted", + "batchRunId", + "index", + "phone", + "username", + "accountId", + "accountUuid", + "defaultWalletId", + "defaultWalletCurrency", + "expectedUsdWalletId", + "expectedUsdtWalletId", + "walletCount", + "usdWalletIds", + "usdBalanceDisplays", + "usdBalanceMinorUnits", + "usdBalanceStatuses", + "usdtWalletIds", + "usdtBalanceDisplays", + "usdtBalanceMinorUnits", + "usdtBalanceStatuses", + "migrationStatus", + "migrationUpdatedAt", + "cutoverBalanceAudit", + "anomalies", +] + +const csvValue = (value: unknown): string => { + if (value === undefined || value === null) return "" + const text = String(value) + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +const walletIdsCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.id).join(";") + +const walletBalanceDisplaysCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.display).join(";") + +const walletBalanceMinorUnitsCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.minorUnits).join(";") + +const walletBalanceStatusesCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.status ?? "").join(";") + +const cutoverBalanceAuditCsv = (audit?: OperatorCutoverBalanceAudit) => { + if (!audit) return "" + return [ + `status=${audit.status}`, + `expectedMinimumUsdtMicros=${audit.expectedMinimumUsdtMicros}`, + `finalDeltaUsdtMicros=${audit.finalDeltaUsdtMicros}`, + `roundingSubsidyUsdtMicros=${audit.roundingSubsidyUsdtMicros}`, + `shortfallUsdtMicros=${audit.shortfallUsdtMicros}`, + ].join(";") +} + +export const formatCashWalletCutoverOperatorSnapshotCsv = ( + snapshot: CashWalletCutoverOperatorSnapshot, +): string => { + const rows = snapshot.accounts.map((account) => + [ + snapshot.generatedAt, + snapshot.cutover.state, + snapshot.cutover.cutoverVersion, + snapshot.cutover.runId, + snapshot.cutover.updatedAt, + account.watchlisted, + account.batchRunId, + account.index, + account.phone, + account.username, + account.accountId, + account.accountUuid, + account.defaultWalletId, + account.defaultWalletCurrency, + account.expectedUsdWalletId, + account.expectedUsdtWalletId, + account.walletCount, + walletIdsCsv(account.usdWallets), + walletBalanceDisplaysCsv(account.usdWallets), + walletBalanceMinorUnitsCsv(account.usdWallets), + walletBalanceStatusesCsv(account.usdWallets), + walletIdsCsv(account.usdtWallets), + walletBalanceDisplaysCsv(account.usdtWallets), + walletBalanceMinorUnitsCsv(account.usdtWallets), + walletBalanceStatusesCsv(account.usdtWallets), + account.migrationStatus, + account.migrationUpdatedAt, + cutoverBalanceAuditCsv(account.cutoverBalanceAudit), + account.anomalies.join(";"), + ].map(csvValue), + ) + + return [csvHeaders, ...rows].map((row) => row.join(",")).join("\n") +} + +const describeError = (error: Error): string => { + const message = error.message?.split("\n")[0]?.trim() + if (message) return message + + if (error.name && error.name !== "Error") return error.name + + const rendered = String(error) + return rendered && rendered !== "[object Object]" ? rendered : "Unknown error" +} + +const balanceError = (wallet: Wallet, error: Error): OperatorBalance => ({ + currency: wallet.currency, + display: "error", + minorUnits: "0", + minorUnitsNumber: 0, + status: "error", + error: describeError(error), +}) + +export const formatOperatorBalance = ( + wallet: Wallet, + balance: USDAmount | USDTAmount | ApplicationError, +): OperatorBalance => { + if (balance instanceof Error) return balanceError(wallet, balance) + + if (wallet.currency === WalletCurrency.Usdt && balance instanceof USDTAmount) { + const micros = balance.asSmallestUnits() + return { + currency: WalletCurrency.Usdt, + display: `${(Number(micros) / 1_000_000).toFixed(2)} USDT`, + minorUnits: micros, + minorUnitsNumber: Number(micros), + status: "fresh", + } + } + + if (wallet.currency === WalletCurrency.Usd && balance instanceof USDAmount) { + const cents = balance.asCents() + return { + currency: WalletCurrency.Usd, + display: `$${balance.asDollars(2)}`, + minorUnits: cents, + minorUnitsNumber: Number(cents), + status: "fresh", + } + } + + return { + currency: wallet.currency, + display: "unexpected currency", + minorUnits: "0", + minorUnitsNumber: 0, + status: "error", + error: `Expected ${wallet.currency} balance`, + } +} + +const loadingBalance = (wallet: Wallet): OperatorBalance => ({ + currency: wallet.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", +}) + +const summarizeWallet = async ({ + wallet, + expectedWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, +}: { + wallet: Wallet + expectedWalletId?: WalletId + getBalanceForWallet: BuildSnapshotArgs["getBalanceForWallet"] + balanceReadAttempts: number + balanceMode: BuildSnapshotArgs["balanceMode"] +}): Promise => { + if (balanceMode === "structural") { + return { + id: wallet.id, + currency: wallet.currency, + expected: expectedWalletId === undefined || expectedWalletId === wallet.id, + balance: loadingBalance(wallet), + } + } + + let balance: USDAmount | USDTAmount | ApplicationError = new Error( + "Balance read was not attempted", + ) as ApplicationError + + for (let attempt = 0; attempt < balanceReadAttempts; attempt++) { + balance = await getBalanceForWallet({ + walletId: wallet.id, + currency: wallet.currency, + }) + if (!(balance instanceof Error)) break + } + + return { + id: wallet.id, + currency: wallet.currency, + expected: expectedWalletId === undefined || expectedWalletId === wallet.id, + balance: formatOperatorBalance(wallet, balance), + } +} + +const increment = (record: Record, key: string) => { + record[key] = (record[key] ?? 0) + 1 +} + +const accountsForDashboard = ({ + manifestAccounts, + discoveredAccounts, +}: { + manifestAccounts: CashWalletCutoverOperatorManifestAccount[] + discoveredAccounts?: CashWalletCutoverDiscovery[] +}): OperatorAccountInput[] => { + const manifestByAccountId = new Map( + manifestAccounts.map((account) => [account.accountId, account]), + ) + + if (!discoveredAccounts) { + return manifestAccounts.map((account) => ({ ...account, watchlisted: true })) + } + + const merged = discoveredAccounts.map((discovery) => { + const manifestAccount = manifestByAccountId.get(discovery.accountId) + return { + ...manifestAccount, + accountId: discovery.accountId, + accountUuid: manifestAccount?.accountUuid ?? discovery.accountUuid, + expectedUsdWalletId: + manifestAccount?.expectedUsdWalletId ?? discovery.legacyUsdWalletId, + expectedUsdtWalletId: + manifestAccount?.expectedUsdtWalletId ?? discovery.destinationUsdtWalletId, + watchlisted: manifestAccount !== undefined, + } + }) + + const discoveredAccountIds = new Set(merged.map((account) => account.accountId)) + const missingManifestAccounts = manifestAccounts + .filter((account) => !discoveredAccountIds.has(account.accountId)) + .map((account) => ({ ...account, watchlisted: true })) + + return [...merged, ...missingManifestAccounts] +} + +type OperatorAccountInput = CashWalletCutoverOperatorManifestAccount & { + watchlisted: boolean +} + +const usdTotalCentsForAccounts = ( + accounts: Array<{ usdWallets: OperatorWallet[] }>, +): number => + accounts.reduce( + (sum, account) => + sum + + account.usdWallets.reduce( + (walletSum, wallet) => walletSum + wallet.balance.minorUnitsNumber, + 0, + ), + 0, + ) + +const usdtTotalMicrosForAccounts = ( + accounts: Array<{ usdtWallets: OperatorWallet[] }>, +): number => + accounts.reduce( + (sum, account) => + sum + + account.usdtWallets.reduce( + (walletSum, wallet) => walletSum + wallet.balance.minorUnitsNumber, + 0, + ), + 0, + ) + +const parseIntegerAmount = (value?: string): number | undefined => { + if (value === undefined || !/^\d+$/.test(value)) return undefined + return Number(value) +} + +const computeCutoverBalanceAudit = ({ + migration, + usdtWallets, +}: { + migration?: CashWalletMigration | null + usdtWallets: OperatorWallet[] +}): OperatorCutoverBalanceAudit | undefined => { + if (!migration || migration.status !== "complete") return undefined + + const sourceUsdCents = parseIntegerAmount(migration.sourceBalanceUsdCents) + const expectedMinimumUsdtMicros = parseIntegerAmount( + migration.destinationAmountUsdtMicros, + ) + const destinationStartingBalanceUsdtMicros = parseIntegerAmount( + migration.destinationStartingBalanceUsdtMicros, + ) + + if ( + sourceUsdCents === undefined || + expectedMinimumUsdtMicros === undefined || + destinationStartingBalanceUsdtMicros === undefined + ) { + return undefined + } + + const destinationWallet = usdtWallets.find( + (wallet) => wallet.id === migration.destinationUsdtWalletId, + ) + if (!destinationWallet) return undefined + + if (destinationWallet.balance.status === "loading") { + return { + status: "loading", + sourceUsdCents, + expectedMinimumUsdtMicros, + destinationStartingBalanceUsdtMicros, + currentDestinationBalanceUsdtMicros: 0, + finalDeltaUsdtMicros: 0, + roundingSubsidyUsdtMicros: 0, + shortfallUsdtMicros: 0, + } + } + + const currentDestinationBalanceUsdtMicros = + destinationWallet.balance.minorUnitsNumber + const finalDeltaUsdtMicros = Math.max( + 0, + currentDestinationBalanceUsdtMicros - destinationStartingBalanceUsdtMicros, + ) + const shortfallUsdtMicros = Math.max( + 0, + expectedMinimumUsdtMicros - finalDeltaUsdtMicros, + ) + const roundingSubsidyUsdtMicros = Math.max( + 0, + finalDeltaUsdtMicros - expectedMinimumUsdtMicros, + ) + + return { + status: shortfallUsdtMicros > 0 ? "shortfall" : "verified", + sourceUsdCents, + expectedMinimumUsdtMicros, + destinationStartingBalanceUsdtMicros, + currentDestinationBalanceUsdtMicros, + finalDeltaUsdtMicros, + roundingSubsidyUsdtMicros, + shortfallUsdtMicros, + } +} + +export const refreshOperatorAccountCutoverBalanceAudit = < + T extends { + expectedUsdtWalletId?: WalletId + usdtWallets: OperatorWallet[] + cutoverBalanceAudit?: OperatorCutoverBalanceAudit + }, +>( + account: T, +): T => { + const audit = account.cutoverBalanceAudit + if (!audit) return account + + const destinationWallet = + account.usdtWallets.find((wallet) => wallet.id === account.expectedUsdtWalletId) ?? + account.usdtWallets.find((wallet) => wallet.expected) ?? + account.usdtWallets[0] + if (!destinationWallet) return account + + if (destinationWallet.balance.status === "loading") { + return { + ...account, + cutoverBalanceAudit: { + ...audit, + status: "loading", + currentDestinationBalanceUsdtMicros: 0, + finalDeltaUsdtMicros: 0, + roundingSubsidyUsdtMicros: 0, + shortfallUsdtMicros: 0, + }, + } + } + + const currentDestinationBalanceUsdtMicros = + destinationWallet.balance.minorUnitsNumber + const finalDeltaUsdtMicros = Math.max( + 0, + currentDestinationBalanceUsdtMicros - + audit.destinationStartingBalanceUsdtMicros, + ) + const shortfallUsdtMicros = Math.max( + 0, + audit.expectedMinimumUsdtMicros - finalDeltaUsdtMicros, + ) + const roundingSubsidyUsdtMicros = Math.max( + 0, + finalDeltaUsdtMicros - audit.expectedMinimumUsdtMicros, + ) + + return { + ...account, + cutoverBalanceAudit: { + ...audit, + status: shortfallUsdtMicros > 0 ? "shortfall" : "verified", + currentDestinationBalanceUsdtMicros, + finalDeltaUsdtMicros, + roundingSubsidyUsdtMicros, + shortfallUsdtMicros, + }, + } +} + +const combinedTotalCents = ({ + usdTotalCents, + usdtTotalMicros, +}: { + usdTotalCents: number + usdtTotalMicros: number +}) => usdTotalCents + usdtTotalMicros / 10_000 + +const treasurySummary = ( + accounts: OperatorTreasuryAccount[], +): OperatorTreasurySummary => ({ + accounts: accounts.length, + wallets: accounts.reduce((sum, account) => sum + account.walletCount, 0), + usdTotalCents: usdTotalCentsForAccounts(accounts), + usdtTotalMicros: usdtTotalMicrosForAccounts(accounts), +}) + +const reconciliationSummary = ({ + customerUsdTotalCents, + customerUsdtTotalMicros, + treasury, +}: { + customerUsdTotalCents: number + customerUsdtTotalMicros: number + treasury: OperatorTreasurySummary +}): OperatorReconciliationSummary => { + const customerTotalCents = combinedTotalCents({ + usdTotalCents: customerUsdTotalCents, + usdtTotalMicros: customerUsdtTotalMicros, + }) + const treasuryTotalCents = combinedTotalCents({ + usdTotalCents: treasury.usdTotalCents, + usdtTotalMicros: treasury.usdtTotalMicros, + }) + return { + customerTotalCents, + treasuryTotalCents, + systemTotalCents: customerTotalCents + treasuryTotalCents, + } +} + +const summarizeTreasuryAccount = async ({ + accountId, + accountsRepo, + walletsRepo, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, +}: { + accountId: AccountId + accountsRepo: BuildSnapshotArgs["accountsRepo"] + walletsRepo: BuildSnapshotArgs["walletsRepo"] + getBalanceForWallet: BuildSnapshotArgs["getBalanceForWallet"] + balanceReadAttempts: number + balanceMode: BuildSnapshotArgs["balanceMode"] +}): Promise => { + const account = await accountsRepo.findById(accountId) + if (account instanceof Error) { + return { + accountId, + walletCount: 0, + usdWallets: [], + usdtWallets: [], + anomalies: ["missing_account"], + } + } + + const rawWallets = await walletsRepo.listByAccountId(account.id) + if (rawWallets instanceof Error) throw rawWallets + + const cashWallets = rawWallets.filter((wallet) => wallet.type === WalletType.Checking) + const usdWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usd, + ) + const usdtWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usdt, + ) + const defaultWallet = cashWallets.find((wallet) => wallet.id === account.defaultWalletId) + + const [usdWallets, usdtWallets] = await Promise.all([ + Promise.all( + usdWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + Promise.all( + usdtWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + ]) + + return { + accountId: account.id, + accountUuid: account.uuid, + role: account.role ?? "funder", + defaultWalletId: account.defaultWalletId, + defaultWalletCurrency: defaultWallet?.currency, + walletCount: rawWallets.length, + usdWallets, + usdtWallets, + anomalies: [...usdWallets, ...usdtWallets].some( + (wallet) => wallet.balance.error !== undefined, + ) + ? ["balance_error"] + : [], + } +} + +export const buildCashWalletCutoverOperatorSnapshot = async ({ + manifestAccounts, + discoveredAccounts, + accountsRepo, + walletsRepo, + migrationsRepo, + getBalanceForWallet, + migrationLookup, + preflightReport, + balanceReadAttempts = 1, + balanceMode = "live", + treasuryAccountIds = [], + now = new Date(), +}: BuildSnapshotArgs): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) throw config + const lookup = + migrationLookup ?? + (config.runId + ? { + cutoverVersion: config.cutoverVersion, + runId: config.runId, + } + : undefined) + + const accounts: OperatorAccount[] = [] + const migrationStatuses: Record = {} + const operatorAccounts = accountsForDashboard({ manifestAccounts, discoveredAccounts }) + + for (const dashboardAccount of operatorAccounts) { + const anomalies: string[] = [] + const account = await accountsRepo.findById(dashboardAccount.accountId) + if (account instanceof Error) { + increment(migrationStatuses, "none") + accounts.push({ + ...dashboardAccount, + walletCount: 0, + usdWallets: [], + usdtWallets: [], + migrationStatus: "none", + anomalies: ["missing_account"], + }) + continue + } + + const rawWallets = await walletsRepo.listByAccountId(account.id) + if (rawWallets instanceof Error) throw rawWallets + + const cashWallets = rawWallets.filter((wallet) => wallet.type === WalletType.Checking) + const usdWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usd, + ) + const usdtWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usdt, + ) + const defaultWallet = cashWallets.find( + (wallet) => wallet.id === account.defaultWalletId, + ) + + if (usdWalletsRaw.length === 0) anomalies.push("missing_usd") + if (usdtWalletsRaw.length === 0) anomalies.push("missing_usdt") + if (usdWalletsRaw.length > 1) anomalies.push("duplicate_usd") + if (usdtWalletsRaw.length > 1) anomalies.push("duplicate_usdt") + if (!defaultWallet) anomalies.push("default_not_cash") + + const [usdWallets, usdtWallets] = await Promise.all([ + Promise.all( + usdWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + expectedWalletId: dashboardAccount.expectedUsdWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + Promise.all( + usdtWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + expectedWalletId: dashboardAccount.expectedUsdtWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + ]) + + if ( + [...usdWallets, ...usdtWallets].some((wallet) => wallet.balance.error !== undefined) + ) { + anomalies.push("balance_error") + } + if ([...usdWallets, ...usdtWallets].some((wallet) => !wallet.expected)) { + anomalies.push("unexpected_wallet_id") + } + + let migrationStatus: CashWalletMigrationStatus | "none" = "none" + let migrationUpdatedAt: string | undefined + let migration: CashWalletMigration | null = null + if (lookup) { + const migrationResult = await migrationsRepo.findMigrationByAccountId({ + accountId: account.id, + cutoverVersion: lookup.cutoverVersion, + runId: lookup.runId, + }) + if (migrationResult instanceof Error) throw migrationResult + migration = migrationResult + if (migration) { + migrationStatus = migration.status + migrationUpdatedAt = migration.updatedAt?.toISOString() + if (migration.status === "failed") anomalies.push("migration_failed") + if (migration.status === "requires_operator_review") { + anomalies.push("migration_requires_review") + } + } + } + increment(migrationStatuses, migrationStatus) + + accounts.push({ + ...dashboardAccount, + accountUuid: account.uuid ?? dashboardAccount.accountUuid, + defaultWalletId: account.defaultWalletId, + defaultWalletCurrency: defaultWallet?.currency, + walletCount: rawWallets.length, + usdWallets, + usdtWallets, + migrationStatus, + migrationUpdatedAt, + cutoverBalanceAudit: computeCutoverBalanceAudit({ migration, usdtWallets }), + anomalies, + }) + } + + const treasuryAccounts = await Promise.all( + treasuryAccountIds.map((accountId) => + summarizeTreasuryAccount({ + accountId, + accountsRepo, + walletsRepo, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ) + const treasury = treasurySummary(treasuryAccounts) + const usdTotalCents = usdTotalCentsForAccounts(accounts) + const usdtTotalMicros = usdtTotalMicrosForAccounts(accounts) + const missingUsdt = accounts.filter( + (account) => account.usdtWallets.length === 0, + ).length + const blockers = accounts.filter( + (account) => + account.anomalies.includes("missing_usd") || + account.anomalies.includes("missing_usdt"), + ).length + + const reconciliation = reconciliationSummary({ + customerUsdTotalCents: usdTotalCents, + customerUsdtTotalMicros: usdtTotalMicros, + treasury, + }) + + return { + generatedAt: now.toISOString(), + cutover: { + state: config.state, + cutoverVersion: config.cutoverVersion, + runId: config.runId, + updatedAt: config.updatedAt?.toISOString(), + }, + preflight: preflightReport, + summary: { + accounts: accounts.length, + wallets: { + current: accounts.reduce((sum, account) => sum + account.walletCount, 0), + target: accounts.length * 2, + usd: accounts.reduce((sum, account) => sum + account.usdWallets.length, 0), + usdt: accounts.reduce((sum, account) => sum + account.usdtWallets.length, 0), + missingUsdt, + }, + fundedUsdOnlyAccounts: accounts.filter( + (account) => + account.usdtWallets.length === 0 && + account.usdWallets.some((wallet) => wallet.balance.minorUnitsNumber > 0), + ).length, + usdTotalCents, + usdtTotalMicros, + anomalies: accounts.filter((account) => account.anomalies.length > 0).length, + watchlistAnomalies: accounts.filter( + (account) => account.watchlisted && account.anomalies.length > 0, + ).length, + canStart: blockers === 0, + blockers, + watchlistAccounts: accounts.filter((account) => account.watchlisted).length, + migrationStatuses, + }, + accounts, + treasury: { + accounts: treasuryAccounts, + summary: treasury, + }, + reconciliation, + } +} diff --git a/src/app/cash-wallet-cutover/orchestrator.ts b/src/app/cash-wallet-cutover/orchestrator.ts new file mode 100644 index 000000000..3e58b17c6 --- /dev/null +++ b/src/app/cash-wallet-cutover/orchestrator.ts @@ -0,0 +1,55 @@ +import { CashWalletCutoverRepository } from "@services/mongoose" + +import { createCashWalletMigrationStepHandlers } from "./handlers" +import { createCashWalletMigrationRuntimeServices } from "./runtime-services" +import { executeCashWalletMigrationStep } from "./executor" +import { runCashWalletMigrationBatch } from "./runner" + +type PrimaryCashWalletCutoverBatchRepository = Parameters< + typeof runCashWalletMigrationBatch +>[0]["migrationsRepo"] & + Parameters[0]["migrationsRepo"] + +type PrimaryCashWalletCutoverRuntimeServices = Parameters< + typeof createCashWalletMigrationStepHandlers +>[0]["services"] + +export const runPrimaryCashWalletCutoverBatch = ({ + cutoverVersion, + runId, + workerId, + limit, + stepDelayMs, + lockStaleBefore, + migrationsRepo = CashWalletCutoverRepository(), + runtimeServices = createCashWalletMigrationRuntimeServices(), +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + stepDelayMs?: number + lockStaleBefore: Date + migrationsRepo?: PrimaryCashWalletCutoverBatchRepository + runtimeServices?: PrimaryCashWalletCutoverRuntimeServices +}) => { + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services: runtimeServices, + }) + + return runCashWalletMigrationBatch({ + cutoverVersion, + runId, + workerId, + limit, + stepDelayMs, + lockStaleBefore, + migrationsRepo, + executor: (migration) => + executeCashWalletMigrationStep({ + migration, + handlers, + }), + }) +} diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts new file mode 100644 index 000000000..0a1e2031c --- /dev/null +++ b/src/app/cash-wallet-cutover/planner.ts @@ -0,0 +1,41 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + +type PrimaryCashWalletMigrationPlan = { + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId: WalletId + cutoverVersion: number + runId: string + idempotencyKey: string +} + +export type { PrimaryCashWalletMigrationPlan } + +export const buildPrimaryCashWalletMigrationPlan = ({ + cutoverVersion, + runId, + discoveries, +}: { + cutoverVersion: number + runId: string + discoveries: CashWalletCutoverDiscovery[] +}): PrimaryCashWalletMigrationPlan[] => + discoveries.flatMap((discovery) => { + if (discovery.status !== "legacy_default") return [] + if (!discovery.legacyUsdWalletId || !discovery.destinationUsdtWalletId) return [] + + return [ + { + accountId: discovery.accountId, + accountUuid: discovery.accountUuid, + legacyUsdWalletId: discovery.legacyUsdWalletId, + destinationUsdtWalletId: discovery.destinationUsdtWalletId, + previousDefaultWalletId: discovery.previousDefaultWalletId, + cutoverVersion, + runId, + idempotencyKey: `cash-wallet-cutover:${runId}:${discovery.accountId}`, + }, + ] + }) diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts new file mode 100644 index 000000000..df05bfddf --- /dev/null +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -0,0 +1,53 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + +type CashWalletCutoverPreflightBlocker = { + accountId: AccountId + reason: "missing_legacy_usd" | "missing_destination_usdt" +} + +type CashWalletCutoverPreflightReport = { + cutoverVersion: number + runId: string + totalAccounts: number + migrationCandidates: number + alreadyUsdt: number + residualLegacyUsd: number + blockers: number + blockerAccounts: CashWalletCutoverPreflightBlocker[] + canStart: boolean +} + +export type { CashWalletCutoverPreflightReport } + +export const buildCashWalletCutoverPreflightReport = ({ + cutoverVersion, + runId, + discoveries, +}: { + cutoverVersion: number + runId: string + discoveries: CashWalletCutoverDiscovery[] +}): CashWalletCutoverPreflightReport => { + const blockerAccounts = discoveries.flatMap(({ accountId, status }) => { + if (status !== "missing_legacy_usd" && status !== "missing_destination_usdt") { + return [] + } + + return [{ accountId, reason: status }] + }) + + return { + cutoverVersion, + runId, + totalAccounts: discoveries.length, + migrationCandidates: discoveries.filter(({ status }) => status === "legacy_default") + .length, + alreadyUsdt: discoveries.filter(({ status }) => status === "already_usdt").length, + residualLegacyUsd: discoveries.filter( + ({ status }) => status === "residual_legacy_usd", + ).length, + blockers: blockerAccounts.length, + blockerAccounts, + canStart: blockerAccounts.length === 0, + } +} diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts new file mode 100644 index 000000000..35793b208 --- /dev/null +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -0,0 +1,63 @@ +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" +import { + upsertPrimaryCashWalletMigrationRecords, + CashWalletMigrationRecordsRepository, +} from "./migration-records" + +type PreparePrimaryCashWalletCutoverResult = { + report: CashWalletCutoverPreflightReport + plannedMigrations: PrimaryCashWalletMigrationPlan[] + migrations: CashWalletMigration[] +} + +export const preparePrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + accountsRepo, + walletsRepo, + migrationsRepo, +}: { + cutoverVersion: number + runId: string + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: CashWalletMigrationRecordsRepository +}): Promise => { + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + if (!report.canStart) { + return { report, plannedMigrations: [], migrations: [] } + } + + const plannedMigrations = buildPrimaryCashWalletMigrationPlan({ + cutoverVersion, + runId, + discoveries, + }) + + const migrations = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: plannedMigrations, + }) + if (migrations instanceof Error) return migrations + + return { report, plannedMigrations, migrations } +} diff --git a/src/app/cash-wallet-cutover/presentation-for-account.ts b/src/app/cash-wallet-cutover/presentation-for-account.ts new file mode 100644 index 000000000..53483ea19 --- /dev/null +++ b/src/app/cash-wallet-cutover/presentation-for-account.ts @@ -0,0 +1,94 @@ +import { WalletsRepository, CashWalletCutoverRepository } from "@services/mongoose" + +import { CashWalletClientCapabilities } from "./client-capability" +import { CashWalletCutoverPreflightError } from "./errors" +import { evaluateCashWalletCutoverPresentation } from "./guard" +import { + CashWalletPresentationResult, + resolveCashWalletPresentation, +} from "./presentation" + +type CashWalletPresentationMigrationsRepository = { + getConfig: () => Promise + findMigrationByAccountId: ({ + accountId, + cutoverVersion, + runId, + }: { + accountId: AccountId + cutoverVersion: number + runId: string + }) => Promise +} + +type CashWalletPresentationWalletsRepository = { + listByAccountId: (accountId: AccountId) => Promise +} + +export const resolveCashWalletPresentationForAccount = async ({ + account, + client, + migrationsRepo = CashWalletCutoverRepository(), + walletsRepo = WalletsRepository(), +}: { + account: Account + client: CashWalletClientCapabilities + migrationsRepo?: CashWalletPresentationMigrationsRepository + walletsRepo?: CashWalletPresentationWalletsRepository +}): Promise => { + const cutover = await migrationsRepo.getConfig() + if (cutover instanceof Error) return cutover + + let migration: CashWalletMigration | null | undefined + if (cutover.state === "in_progress") { + if (!cutover.runId) return new CashWalletCutoverPreflightError() + + const foundMigration = await migrationsRepo.findMigrationByAccountId({ + accountId: account.id, + cutoverVersion: cutover.cutoverVersion, + runId: cutover.runId, + }) + if (foundMigration instanceof Error) return foundMigration + migration = foundMigration + } + + const decision = evaluateCashWalletCutoverPresentation({ + cutover, + migration, + client, + }) + if (decision instanceof Error) return decision + + const wallets = await walletsRepo.listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + return resolveCashWalletPresentation({ decision, wallets }) +} + +export const resolveCashWalletMutationWalletIdForAccount = async ({ + account, + walletId, + client, + migrationsRepo, + walletsRepo, +}: { + account: Account + walletId: WalletId + client: CashWalletClientCapabilities + migrationsRepo?: CashWalletPresentationMigrationsRepository + walletsRepo?: CashWalletPresentationWalletsRepository +}): Promise => { + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client, + migrationsRepo, + walletsRepo, + }) + if (presentation instanceof Error) return presentation + + if (walletId === presentation.legacyUsdWallet?.id) { + return presentation.activeSettlementWallet.id + } + + return walletId +} diff --git a/src/app/cash-wallet-cutover/presentation.ts b/src/app/cash-wallet-cutover/presentation.ts new file mode 100644 index 000000000..873c0e415 --- /dev/null +++ b/src/app/cash-wallet-cutover/presentation.ts @@ -0,0 +1,123 @@ +import { WalletCurrency } from "@domain/shared" + +import { CashWalletCutoverDecision } from "./guard" +import { + CashWalletMissingLegacyUsdWalletError, + CashWalletMissingUsdtWalletError, +} from "./errors" + +export type CashWalletPresentationResult = { + wallets: Wallet[] + defaultWalletId: WalletId + legacyUsdWallet?: Wallet + activeSettlementWallet: Wallet +} + +export const resolveCashWalletPresentation = ({ + decision, + wallets, +}: { + decision: CashWalletCutoverDecision + wallets: Wallet[] +}): CashWalletPresentationResult | ApplicationError => { + const legacyUsdWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + const usdtWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) + const nonCashWallets = wallets.filter( + (wallet) => + wallet.currency !== WalletCurrency.Usd && wallet.currency !== WalletCurrency.Usdt, + ) + + if (decision.presentation === "usdt") { + if (!usdtWallet) return new CashWalletMissingUsdtWalletError() + + return { + wallets: [...nonCashWallets, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + } + + if (!legacyUsdWallet) return new CashWalletMissingLegacyUsdWalletError() + + if (decision.presentation === "legacy_usd_compat") { + if (!usdtWallet) return new CashWalletMissingUsdtWalletError() + + return { + wallets: [...nonCashWallets, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + } + + return { + wallets: [...nonCashWallets, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: legacyUsdWallet, + } +} + +const cashWalletHistoryWalletIds = ({ + selectedWalletIds, + presentation, +}: { + selectedWalletIds: WalletId[] + presentation: CashWalletPresentationResult +}): WalletId[] => { + const { legacyUsdWallet, activeSettlementWallet } = presentation + if (!legacyUsdWallet) return Array.from(new Set(selectedWalletIds)) + + const cashWalletIds = new Set([legacyUsdWallet.id, activeSettlementWallet.id]) + + return Array.from( + new Set( + selectedWalletIds.flatMap((walletId) => + cashWalletIds.has(walletId) + ? [activeSettlementWallet.id, legacyUsdWallet.id] + : [walletId], + ), + ), + ) +} + +export const cashWalletHistoryWalletIdsForPresentation = ({ + walletIds, + presentation, +}: { + walletIds?: WalletId[] + presentation: CashWalletPresentationResult +}): WalletId[] => { + const selectedWalletIds = walletIds ?? presentation.wallets.map((wallet) => wallet.id) + + return cashWalletHistoryWalletIds({ selectedWalletIds, presentation }) +} + +export const cashWalletHistoryWalletsForPresentation = ({ + wallets, + presentation, +}: { + wallets: Wallet[] + presentation: CashWalletPresentationResult +}): Wallet[] => { + const historyWalletIds = cashWalletHistoryWalletIds({ + selectedWalletIds: wallets.map((wallet) => wallet.id), + presentation, + }) + + const walletById = new Map( + [ + ...presentation.wallets, + presentation.legacyUsdWallet, + presentation.activeSettlementWallet, + ...wallets, + ] + .filter((wallet): wallet is Wallet => Boolean(wallet)) + .map((wallet) => [wallet.id, wallet]), + ) + + return historyWalletIds + .map((walletId) => walletById.get(walletId)) + .filter((wallet): wallet is Wallet => Boolean(wallet)) +} diff --git a/src/app/cash-wallet-cutover/preview.ts b/src/app/cash-wallet-cutover/preview.ts new file mode 100644 index 000000000..2cca59bc9 --- /dev/null +++ b/src/app/cash-wallet-cutover/preview.ts @@ -0,0 +1,54 @@ +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" + +export const previewPrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + accountsRepo = AccountsRepository(), + walletsRepo = WalletsRepository(), +}: { + cutoverVersion: number + runId: string + accountsRepo?: Pick + walletsRepo?: Pick +}): Promise< + | { + report: CashWalletCutoverPreflightReport + plannedMigrations: PrimaryCashWalletMigrationPlan[] + } + | RepositoryError +> => { + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + if (!report.canStart) { + return { report, plannedMigrations: [] } + } + + return { + report, + plannedMigrations: buildPrimaryCashWalletMigrationPlan({ + cutoverVersion, + runId, + discoveries, + }), + } +} diff --git a/src/app/cash-wallet-cutover/provision-usdt-wallets.ts b/src/app/cash-wallet-cutover/provision-usdt-wallets.ts new file mode 100644 index 000000000..b66a1be53 --- /dev/null +++ b/src/app/cash-wallet-cutover/provision-usdt-wallets.ts @@ -0,0 +1,160 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { InvalidCashWalletCutoverStateTransitionError } from "./errors" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" + +type ProvisionedCashWalletUsdtWallet = { + accountId: AccountId + walletId?: WalletId +} + +type FailedCashWalletUsdtWalletProvision = { + accountId: AccountId + error: string +} + +type ProvisionPrimaryCashWalletUsdtWalletsResult = { + before: CashWalletCutoverPreflightReport + after: CashWalletCutoverPreflightReport + eligible: number + provisioned: ProvisionedCashWalletUsdtWallet[] + failed: FailedCashWalletUsdtWalletProvision[] + dryRun: boolean +} + +type CashWalletCutoverProvisioningRepository = { + getConfig: () => Promise +} + +type AddWalletIfNonexistent = ({ + accountId, + type, + currency, +}: { + accountId: AccountId + type: WalletType + currency: WalletCurrency +}) => Promise + +const defaultSleep = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)) + +const errorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) return error.message + return String(error) +} + +const isRateLimitError = (error: unknown): boolean => + errorMessage(error).toLowerCase().includes("too many requests") + +export type { ProvisionPrimaryCashWalletUsdtWalletsResult } + +export const provisionPrimaryCashWalletUsdtWallets = async ({ + cutoverVersion, + runId, + accountsRepo, + walletsRepo, + migrationsRepo, + addWalletIfNonexistent, + provisionLimit, + provisionDelayMs = 0, + provisionRetryDelayMs = 60_000, + maxProvisionAttempts = 5, + dryRun = false, + sleep = defaultSleep, +}: { + cutoverVersion: number + runId: string + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: CashWalletCutoverProvisioningRepository + addWalletIfNonexistent: AddWalletIfNonexistent + provisionLimit?: number + provisionDelayMs?: number + provisionRetryDelayMs?: number + maxProvisionAttempts?: number + dryRun?: boolean + sleep?: (delayMs: number) => Promise +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + if (config.state !== "pre") { + return new InvalidCashWalletCutoverStateTransitionError( + "Cash wallet USDT provisioning can only run before cutover start", + ) + } + + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const before = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + const eligibleDiscoveries = discoveries + .filter(({ status }) => status === "missing_destination_usdt") + .slice(0, provisionLimit) + const provisioned: ProvisionedCashWalletUsdtWallet[] = [] + const failed: FailedCashWalletUsdtWalletProvision[] = [] + + if (!dryRun) { + for (const [index, discovery] of eligibleDiscoveries.entries()) { + let wallet: Wallet | ApplicationError = new Error("Provisioning was not attempted") + const attempts = Math.max(1, maxProvisionAttempts) + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + wallet = await addWalletIfNonexistent({ + accountId: discovery.accountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + + if (!(wallet instanceof Error)) break + if (!isRateLimitError(wallet) || attempt === attempts) break + + await sleep(provisionRetryDelayMs) + } + + if (wallet instanceof Error) { + failed.push({ accountId: discovery.accountId, error: errorMessage(wallet) }) + } else { + provisioned.push({ accountId: discovery.accountId, walletId: wallet.id }) + } + + if (provisionDelayMs > 0 && index < eligibleDiscoveries.length - 1) { + await sleep(provisionDelayMs) + } + } + } + + const afterDiscoveries = dryRun + ? discoveries + : await discoverCashWalletCutoverAccounts({ accountsRepo, walletsRepo }) + if (afterDiscoveries instanceof Error) return afterDiscoveries + + const after = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries: afterDiscoveries, + }) + + return { + before, + after, + eligible: eligibleDiscoveries.length, + provisioned, + failed, + dryRun, + } +} diff --git a/src/app/cash-wallet-cutover/recipient-routing.ts b/src/app/cash-wallet-cutover/recipient-routing.ts new file mode 100644 index 000000000..84a96eabb --- /dev/null +++ b/src/app/cash-wallet-cutover/recipient-routing.ts @@ -0,0 +1,43 @@ +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { CashWalletClientCapabilities } from "./client-capability" +import { resolveCashWalletMutationWalletIdForAccount } from "./presentation-for-account" + +type RecipientRoutingAccountsRepository = { + findById(accountId: AccountId): Promise +} + +type RecipientRoutingWalletsRepository = { + findById(walletId: WalletId): Promise + listByAccountId(accountId: AccountId): Promise +} + +type ResolveMutationWalletIdForAccount = + typeof resolveCashWalletMutationWalletIdForAccount + +export const resolveCashWalletRecipientMutationWalletId = async ({ + recipientWalletId, + client, + accountsRepo = AccountsRepository(), + walletsRepo = WalletsRepository(), + resolveMutationWalletIdForAccount = resolveCashWalletMutationWalletIdForAccount, +}: { + recipientWalletId: WalletId + client: CashWalletClientCapabilities + accountsRepo?: RecipientRoutingAccountsRepository + walletsRepo?: RecipientRoutingWalletsRepository + resolveMutationWalletIdForAccount?: ResolveMutationWalletIdForAccount +}): Promise => { + const recipientWallet = await walletsRepo.findById(recipientWalletId) + if (recipientWallet instanceof Error) return recipientWallet + + const recipientAccount = await accountsRepo.findById(recipientWallet.accountId) + if (recipientAccount instanceof Error) return recipientAccount + + return resolveMutationWalletIdForAccount({ + account: recipientAccount, + walletId: recipientWalletId, + client, + walletsRepo, + }) +} diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts new file mode 100644 index 000000000..70789c8c9 --- /dev/null +++ b/src/app/cash-wallet-cutover/runner.ts @@ -0,0 +1,142 @@ +type CashWalletMigrationBatchRepository = { + listRunnableMigrations(args: { + cutoverVersion: number + runId: string + limit?: number + }): Promise + acquireMigrationLock(args: { + id: string + workerId: string + staleBefore: Date + cutoverVersion: number + runId: string + }): Promise + releaseMigrationLock(args: { + id: string + workerId: string + cutoverVersion: number + runId: string + }): Promise + markMigrationFailed(args: { + id: string + workerId: string + cutoverVersion: number + runId: string + error: Error + status: "failed" | "requires_operator_review" + }): Promise +} + +type CashWalletMigrationBatchExecutor = ( + migration: CashWalletMigration, +) => Promise + +type CashWalletMigrationBatchResult = { + attempted: number + advanced: number + failed: number + skipped: number +} + +type SleepFn = (delayMs: number) => Promise + +const sleep = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)) + +const AMBIGUOUS_SIDE_EFFECT_STATUSES: CashWalletMigrationStatus[] = [ + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", +] + +const failureStatusForMigration = ( + status: CashWalletMigrationStatus, +): "failed" | "requires_operator_review" => + AMBIGUOUS_SIDE_EFFECT_STATUSES.includes(status) ? "requires_operator_review" : "failed" + +export const runCashWalletMigrationBatch = async ({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo, + executor, + stepDelayMs = 0, + sleep: sleepFn = sleep, +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + lockStaleBefore: Date + migrationsRepo: CashWalletMigrationBatchRepository + executor: CashWalletMigrationBatchExecutor + stepDelayMs?: number + sleep?: SleepFn +}): Promise => { + const migrations = await migrationsRepo.listRunnableMigrations({ + cutoverVersion, + runId, + limit, + }) + if (migrations instanceof Error) return migrations + + const result: CashWalletMigrationBatchResult = { + attempted: 0, + advanced: 0, + failed: 0, + skipped: 0, + } + + for (const [index, migration] of migrations.entries()) { + result.attempted += 1 + + const locked = await migrationsRepo.acquireMigrationLock({ + id: migration.id, + workerId, + staleBefore: lockStaleBefore, + cutoverVersion, + runId, + }) + if (locked instanceof Error) { + result.skipped += 1 + continue + } + + const step = await executor(locked) + if (step instanceof Error) { + result.failed += 1 + const marked = await migrationsRepo.markMigrationFailed({ + id: locked.id, + workerId, + cutoverVersion, + runId, + error: step, + status: failureStatusForMigration(locked.status), + }) + if (marked instanceof Error) return marked + continue + } else { + result.advanced += 1 + } + + await migrationsRepo.releaseMigrationLock({ + id: locked.id, + workerId, + cutoverVersion, + runId, + }) + + if (stepDelayMs > 0 && index < migrations.length - 1) { + await sleepFn(stepDelayMs) + } + } + + return result +} diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts new file mode 100644 index 000000000..4f2766689 --- /dev/null +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -0,0 +1,331 @@ +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" +import { WalletType } from "@domain/wallets" +import { AccountsRepository } from "@services/mongoose" +import Ibex from "@services/ibex/client" +import { UnexpectedIbexResponse } from "@services/ibex/errors" +import { getFunderWalletId } from "@services/ledger/caching" + +import { + CashWalletMigrationFailedError, + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" +import { destinationShortfallUsdtMicros } from "./amount-conversion" + +const CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS = 15 * 60 +const CUTOVER_IBEX_RATE_LIMIT_RETRY_DELAY_MS = 60_000 +const CUTOVER_IBEX_RATE_LIMIT_MAX_ATTEMPTS = 5 + +type SleepFn = (delayMs: number) => Promise + +type RuntimeServiceDependencies = { + now?: () => Date + addWalletIfNonexistent?: typeof addWalletIfNonexistent + updateDefaultWalletId?: typeof updateDefaultWalletId + getBalanceForWallet?: typeof getBalanceForWallet + createInvoice?: typeof Ibex.addInvoice + createNoAmountInvoice?: typeof Ibex.addInvoice + payInvoice?: typeof Ibex.payInvoice + accountsRepo?: Pick, "findById"> + getTreasuryWalletId?: () => Promise + maxRateLimitAttempts?: number + rateLimitRetryDelayMs?: number + sleep?: SleepFn +} + +const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount +const isUsdtAmount = (amount: unknown): amount is USDTAmount => + amount instanceof USDTAmount + +const ibexInvoiceToDomainInvoice = (response: Awaited>) => { + if (response instanceof Error) return response + + const invoiceString = response.invoice?.bolt11 + if (!invoiceString) return new UnexpectedIbexResponse("Could not find invoice.") + + const decodedInvoice = decodeInvoice(invoiceString) + if (decodedInvoice instanceof Error) return decodedInvoice + + return decodedInvoice +} + +const sleep: SleepFn = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)) + +const errorMessage = (error: Error): string => error.message || String(error) + +const isIbexRateLimitError = (error: Error): boolean => + errorMessage(error).toLowerCase().includes("too many requests") + +const withIbexRateLimitRetry = async ({ + operation, + maxAttempts, + retryDelayMs, + sleepFn, +}: { + operation: () => Promise + maxAttempts: number + retryDelayMs: number + sleepFn: SleepFn +}): Promise => { + for (let attempt = 1; ; attempt += 1) { + const result = await operation() + + if ( + !(result instanceof Error) || + !isIbexRateLimitError(result) || + attempt >= maxAttempts + ) { + return result + } + + await sleepFn(retryDelayMs) + } +} + +export const createCashWalletMigrationRuntimeServices = ( + deps: RuntimeServiceDependencies = {}, +) => { + const addWallet = deps.addWalletIfNonexistent ?? addWalletIfNonexistent + const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId + const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet + const invoiceForRecipient = deps.createInvoice ?? Ibex.addInvoice + const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice + const payInvoice = deps.payInvoice ?? Ibex.payInvoice + const accountsRepo = deps.accountsRepo ?? AccountsRepository() + const rateLimitRetry = { + maxAttempts: Math.max( + 1, + deps.maxRateLimitAttempts ?? CUTOVER_IBEX_RATE_LIMIT_MAX_ATTEMPTS, + ), + retryDelayMs: + deps.rateLimitRetryDelayMs ?? CUTOVER_IBEX_RATE_LIMIT_RETRY_DELAY_MS, + sleepFn: deps.sleep ?? sleep, + } + + return { + now: deps.now ?? (() => new Date()), + provisioningService: { + ensureDestinationWallet: async ({ + accountId, + destinationUsdtWalletId, + }: { + accountId: AccountId + destinationUsdtWalletId: WalletId + }): Promise => { + const wallet = await addWallet({ + accountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + if (wallet instanceof Error) return wallet + if (wallet.id !== destinationUsdtWalletId) return new InvalidWalletId() + return true + }, + }, + balanceReader: { + 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() + }, + 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() + }, + }, + invoiceService: { + createInvoice: ({ + recipientWalletId, + amount, + memo, + }: { + recipientWalletId: WalletId + amount: string + memo: string + }) => { + const usdtAmount = USDTAmount.smallestUnits(amount) + if (usdtAmount instanceof Error) return Promise.resolve(usdtAmount) + + return withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + invoiceForRecipient({ + accountId: recipientWalletId as IbexAccountId, + amount: usdtAmount, + memo, + expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + }), + }).then(ibexInvoiceToDomainInvoice) + }, + createNoAmountInvoice: ({ + recipientWalletId, + memo, + }: { + recipientWalletId: WalletId + memo: string + }) => + withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + noAmountInvoiceForRecipient({ + accountId: recipientWalletId, + amount: USDTAmount.ZERO, + memo, + expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + }), + }).then(ibexInvoiceToDomainInvoice), + }, + paymentService: { + payInvoice: async ({ + senderWalletId, + paymentRequest, + senderAmountUsdCents, + }: { + senderWalletId: WalletId + paymentRequest: string + senderAmountUsdCents?: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> => { + const send = + senderAmountUsdCents === undefined + ? undefined + : USDAmount.cents(senderAmountUsdCents) + if (send instanceof Error) return send + + const payment = await withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + payInvoice({ + accountId: senderWalletId as IbexAccountId, + invoice: paymentRequest as Bolt11, + send, + }), + }) + if (payment instanceof Error) return payment + + const transactionId = payment.transaction?.id + if (!transactionId) { + return new UnexpectedIbexResponse("Payment transaction id not found") + } + return { transactionId: transactionId as IbexTransactionId } + }, + }, + balanceVerifier: { + verifyBalanceMove: async ({ + legacyUsdWalletId, + }: { + legacyUsdWalletId: WalletId + }): Promise => { + const balance = await balanceForWallet({ + walletId: legacyUsdWalletId, + currency: WalletCurrency.Usd, + }) + if (balance instanceof Error) return balance + if (!isUsdAmount(balance) || !balance.isZero()) { + return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") + } + return true + }, + }, + feeService: { + readFeeAmountUsdtMicros: async ( + migration: CashWalletMigration, + ): Promise => { + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before reading fee amount", + ) + } + + if (migration.destinationAmountUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "destinationAmountUsdtMicros is required before reading fee amount", + ) + } + + if (migration.destinationStartingBalanceUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "destinationStartingBalanceUsdtMicros is required before reading fee amount", + ) + } + + 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") + } + + return destinationShortfallUsdtMicros({ + targetUsdtMicros: migration.destinationAmountUsdtMicros, + startingUsdtMicros: migration.destinationStartingBalanceUsdtMicros, + currentUsdtMicros: currentBalance.asSmallestUnits(), + }) + }, + }, + treasuryService: { + getTreasuryWalletId: deps.getTreasuryWalletId ?? getFunderWalletId, + }, + pointerService: { + flipDefaultWallet: async ({ + accountId, + destinationWalletId, + }: { + accountId: AccountId + destinationWalletId: WalletId + }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> => { + const account = await accountsRepo.findById(accountId) + if (account instanceof Error) return account + + const previousDefaultWalletId = account.defaultWalletId + const updated = await updateDefaultWallet({ + accountId, + walletId: destinationWalletId, + }) + if (updated instanceof Error) return updated + + return { previousDefaultWalletId } + }, + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: async ({ + legacyUsdWalletId, + }: { + legacyUsdWalletId: WalletId + }): Promise => { + const balance = await balanceForWallet({ + walletId: legacyUsdWalletId, + currency: WalletCurrency.Usd, + }) + if (balance instanceof Error) return balance + if (!isUsdAmount(balance) || !balance.isZero()) { + return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") + } + return true + }, + }, + } +} diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts new file mode 100644 index 000000000..9a950e065 --- /dev/null +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -0,0 +1,49 @@ +import { InvalidCashWalletMigrationTransitionError } from "./errors" + +const transitions: Partial< + Record +> = { + not_started: ["started"], + started: ["provisioned", "failed"], + provisioned: ["balance_read", "failed", "skipped_already_migrated"], + balance_read: ["invoice_created", "pointer_flipped", "failed"], + invoice_created: [ + "invoice_created", + "balance_move_sending", + "failed", + "requires_operator_review", + ], + balance_move_sending: ["balance_move_sent", "failed", "requires_operator_review"], + balance_move_sent: ["balance_move_verified", "failed", "requires_operator_review"], + balance_move_verified: [ + "fee_reimbursement_invoice_created", + "fee_reimbursed", + "failed", + "requires_operator_review", + ], + fee_reimbursement_invoice_created: [ + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "failed", + "requires_operator_review", + ], + fee_reimbursement_sending: ["fee_reimbursed", "failed", "requires_operator_review"], + fee_reimbursed: ["pointer_flipped", "failed"], + pointer_flipped: ["legacy_zero_verified", "failed"], + legacy_zero_verified: ["complete", "failed"], + rollback_started: ["rolled_back", "failed"], +} + +export const assertCanTransition = ( + from: CashWalletMigrationStatus, + to: CashWalletMigrationStatus, +): true | InvalidCashWalletMigrationTransitionError => { + if (transitions[from]?.includes(to)) return true + return new InvalidCashWalletMigrationTransitionError( + `Invalid migration transition: ${from} -> ${to}`, + ) +} + +export const nextResumeStatus = ( + status: CashWalletMigrationStatus, +): CashWalletMigrationStatus => status diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts new file mode 100644 index 000000000..f0b860031 --- /dev/null +++ b/src/app/cash-wallet-cutover/worker.ts @@ -0,0 +1,597 @@ +import { decodeInvoice } from "@domain/bitcoin/lightning" + +import { assertCanTransition } from "./state-machine" +import { + feeUsdCentsToUsdtMicros, + usdCentsToUsdtMicros, + usdtMicrosToUsdCentsCeil, +} from "./amount-conversion" +import { + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" + +type CashWalletMigrationTransitionRepository = { + transitionMigration(args: { + id: string + from: CashWalletMigrationStatus + to: CashWalletMigrationStatus + cutoverVersion: number + runId: string + patch?: Partial + }): Promise +} + +type CashWalletMigrationInvoiceService = { + createInvoice(args: { + recipientWalletId: WalletId + amount: string + memo: string + }): Promise +} + +type CashWalletMigrationNoAmountInvoiceService = { + createNoAmountInvoice(args: { + recipientWalletId: WalletId + memo: string + }): Promise +} + +type CashWalletMigrationPaymentService = { + payInvoice(args: { + senderWalletId: WalletId + paymentRequest: string + senderAmountUsdCents?: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> +} + +type CashWalletMigrationBalanceVerifier = { + verifyBalanceMove(args: { + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + transactionId: IbexTransactionId + }): Promise +} + +type CashWalletMigrationPointerService = { + flipDefaultWallet(args: { + accountId: AccountId + destinationWalletId: WalletId + }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> +} + +type CashWalletMigrationLegacyWalletVerifier = { + verifyLegacyWalletZero(args: { + legacyUsdWalletId: WalletId + }): Promise +} + +type CashWalletMigrationProvisioningService = { + ensureDestinationWallet(args: { + accountId: AccountId + destinationUsdtWalletId: WalletId + }): Promise +} + +const CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS = 30 * 1000 + +const isInvoicePaymentRequestStale = ({ + paymentRequest, + now, + safetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, +}: { + paymentRequest: string + now: Date + safetyWindowMs?: number +}): boolean => { + const invoice = decodeInvoice(paymentRequest) + if (invoice instanceof Error) return true + + return invoice.expiresAt.getTime() <= now.getTime() + safetyWindowMs +} + +export const startCashWalletMigration = async ({ + migration, + migrationsRepo, + startedAt, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + startedAt: Date +}): Promise => { + const transition = assertCanTransition(migration.status, "started") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "started", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { startedAt }, + }) +} + +export const provisionCashWalletMigrationDestination = async ({ + migration, + provisioningService, + migrationsRepo, +}: { + migration: CashWalletMigration + provisioningService: CashWalletMigrationProvisioningService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "provisioned") + if (transition instanceof Error) return transition + + const provisioned = await provisioningService.ensureDestinationWallet({ + accountId: migration.accountId, + destinationUsdtWalletId: migration.destinationUsdtWalletId, + }) + if (provisioned instanceof Error) return provisioned + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "provisioned", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const recordCashWalletMigrationBalance = async ({ + migration, + migrationsRepo, + sourceBalanceUsdCents, + destinationStartingBalanceUsdtMicros, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + sourceBalanceUsdCents: string + destinationStartingBalanceUsdtMicros: string +}): Promise => { + const destinationAmountUsdtMicros = usdCentsToUsdtMicros(sourceBalanceUsdCents) + if (destinationAmountUsdtMicros instanceof Error) return destinationAmountUsdtMicros + + const transition = assertCanTransition(migration.status, "balance_read") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_read", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + sourceBalanceUsdCents, + destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros, + }, + }) +} + +export const sendCashWalletMigrationBalanceMovePayment = async ({ + migration, + paymentService, + invoiceService, + migrationsRepo, + now = () => new Date(), + invoicePaymentSafetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, +}: { + migration: CashWalletMigration + paymentService: CashWalletMigrationPaymentService + invoiceService?: CashWalletMigrationNoAmountInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository + now?: () => Date + invoicePaymentSafetyWindowMs?: number +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_sending") + if (transition instanceof Error) return transition + + if (migration.balanceMoveInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMoveInvoicePaymentRequest is required before balance move payment sending", + ) + } + + if (migration.sourceBalanceUsdCents === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "sourceBalanceUsdCents is required before balance move payment sending", + ) + } + + let payableMigration = migration + if ( + invoiceService && + isInvoicePaymentRequestStale({ + paymentRequest: migration.balanceMoveInvoicePaymentRequest, + now: now(), + safetyWindowMs: invoicePaymentSafetyWindowMs, + }) + ) { + const refreshedMigration = await createCashWalletMigrationBalanceMoveInvoice({ + migration, + invoiceService, + migrationsRepo, + }) + if (refreshedMigration instanceof Error) return refreshedMigration + payableMigration = refreshedMigration + } + + if (payableMigration.balanceMoveInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMoveInvoicePaymentRequest is required before balance move payment sending", + ) + } + + if (payableMigration.sourceBalanceUsdCents === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "sourceBalanceUsdCents is required before balance move payment sending", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: payableMigration.legacyUsdWalletId, + paymentRequest: payableMigration.balanceMoveInvoicePaymentRequest, + senderAmountUsdCents: payableMigration.sourceBalanceUsdCents, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: payableMigration.id, + from: payableMigration.status, + to: "balance_move_sending", + cutoverVersion: payableMigration.cutoverVersion, + runId: payableMigration.runId, + patch: { + balanceMovePaymentTransactionId: payment.transactionId, + }, + }) +} + +export const markCashWalletMigrationBalanceMoveSent = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_sent") + if (transition instanceof Error) return transition + + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before marking balance move sent", + ) + } + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_sent", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const verifyCashWalletMigrationBalanceMove = async ({ + migration, + balanceVerifier, + migrationsRepo, +}: { + migration: CashWalletMigration + balanceVerifier: CashWalletMigrationBalanceVerifier + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_verified") + if (transition instanceof Error) return transition + + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before verifying balance move", + ) + } + + const verified = await balanceVerifier.verifyBalanceMove({ + legacyUsdWalletId: migration.legacyUsdWalletId, + destinationUsdtWalletId: migration.destinationUsdtWalletId, + sourceBalanceUsdCents: migration.sourceBalanceUsdCents, + destinationAmountUsdtMicros: migration.destinationAmountUsdtMicros, + transactionId: migration.balanceMovePaymentTransactionId as IbexTransactionId, + }) + if (verified instanceof Error) return verified + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_verified", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const createCashWalletMigrationBalanceMoveInvoice = async ({ + migration, + invoiceService, + migrationsRepo, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationNoAmountInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "invoice_created") + if (transition instanceof Error) return transition + + if (migration.destinationAmountUsdtMicros === undefined) { + return new InvalidCashWalletCutoverAmountError( + "destinationAmountUsdtMicros is required before balance move invoice creation", + ) + } + + const invoice = await invoiceService.createNoAmountInvoice({ + recipientWalletId: migration.destinationUsdtWalletId, + memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:balance-move`, + }) + if (invoice instanceof Error) return invoice + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "invoice_created", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + balanceMoveInvoicePaymentRequest: invoice.paymentRequest, + balanceMoveInvoicePaymentHash: invoice.paymentHash, + }, + }) +} + +export const createCashWalletMigrationFeeReimbursementInvoice = async ({ + migration, + invoiceService, + migrationsRepo, + feeAmountUsdtMicros, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository + feeAmountUsdtMicros: string +}): Promise => { + 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`, + }) + if (invoice instanceof Error) return invoice + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursement_invoice_created", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + feeAmountUsdCents, + feeAmountUsdtMicros, + feeReimbursementInvoicePaymentRequest: invoice.paymentRequest, + feeReimbursementInvoicePaymentHash: invoice.paymentHash, + }, + }) +} + +export const skipCashWalletMigrationFeeReimbursement = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursed") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursed", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }, + }) +} + +export const sendCashWalletMigrationFeeReimbursementPayment = async ({ + migration, + treasuryWalletId, + paymentService, + invoiceService, + migrationsRepo, + now = () => new Date(), + invoicePaymentSafetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, +}: { + migration: CashWalletMigration + treasuryWalletId: WalletId + paymentService: CashWalletMigrationPaymentService + invoiceService?: CashWalletMigrationInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository + now?: () => Date + invoicePaymentSafetyWindowMs?: number +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursement_sending") + if (transition instanceof Error) return transition + + if (migration.feeReimbursementInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementInvoicePaymentRequest is required before fee reimbursement sending", + ) + } + + let payableMigration = migration + if ( + invoiceService && + isInvoicePaymentRequestStale({ + paymentRequest: migration.feeReimbursementInvoicePaymentRequest, + now: now(), + safetyWindowMs: invoicePaymentSafetyWindowMs, + }) + ) { + if (migration.feeAmountUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeAmountUsdtMicros is required before fee reimbursement invoice refresh", + ) + } + + const refreshedMigration = await createCashWalletMigrationFeeReimbursementInvoice({ + migration, + invoiceService, + migrationsRepo, + feeAmountUsdtMicros: migration.feeAmountUsdtMicros, + }) + if (refreshedMigration instanceof Error) return refreshedMigration + payableMigration = refreshedMigration + } + + if (payableMigration.feeReimbursementInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementInvoicePaymentRequest is required before fee reimbursement sending", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: treasuryWalletId, + paymentRequest: payableMigration.feeReimbursementInvoicePaymentRequest, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: payableMigration.id, + from: payableMigration.status, + to: "fee_reimbursement_sending", + cutoverVersion: payableMigration.cutoverVersion, + runId: payableMigration.runId, + patch: { + feeReimbursementPaymentTransactionId: payment.transactionId, + }, + }) +} + +export const markCashWalletMigrationFeeReimbursed = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursed") + if (transition instanceof Error) return transition + + if (migration.feeReimbursementPaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementPaymentTransactionId is required before marking fee reimbursed", + ) + } + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursed", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const flipCashWalletMigrationDefaultPointer = async ({ + migration, + pointerService, + migrationsRepo, +}: { + migration: CashWalletMigration + pointerService: CashWalletMigrationPointerService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "pointer_flipped") + if (transition instanceof Error) return transition + + const pointer = await pointerService.flipDefaultWallet({ + accountId: migration.accountId, + destinationWalletId: migration.destinationUsdtWalletId, + }) + if (pointer instanceof Error) return pointer + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "pointer_flipped", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + previousDefaultWalletId: pointer.previousDefaultWalletId, + }, + }) +} + +export const verifyCashWalletMigrationLegacyZero = async ({ + migration, + legacyWalletVerifier, + migrationsRepo, +}: { + migration: CashWalletMigration + legacyWalletVerifier: CashWalletMigrationLegacyWalletVerifier + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "legacy_zero_verified") + if (transition instanceof Error) return transition + + const verified = await legacyWalletVerifier.verifyLegacyWalletZero({ + legacyUsdWalletId: migration.legacyUsdWalletId, + }) + if (verified instanceof Error) return verified + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "legacy_zero_verified", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const completeCashWalletMigration = async ({ + migration, + migrationsRepo, + completedAt, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + completedAt: Date +}): Promise => { + const transition = assertCanTransition(migration.status, "complete") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "complete", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { completedAt }, + }) +} diff --git a/src/app/errors.ts b/src/app/errors.ts index 89f68e8ae..4740230f4 100644 --- a/src/app/errors.ts +++ b/src/app/errors.ts @@ -19,6 +19,7 @@ import * as PubSubErrors from "@domain/pubsub/errors" import * as CaptchaErrors from "@domain/captcha/errors" import * as AuthenticationErrors from "@domain/authentication/errors" import * as UserErrors from "@domain/users/errors" +import * as CashWalletCutoverErrors from "@app/cash-wallet-cutover/errors" import * as LedgerFacadeErrors from "@services/ledger/domain/errors" import * as KratosErrors from "@services/kratos/errors" @@ -27,6 +28,7 @@ import * as SvixErrors from "@services/svix/errors" import * as IbexErrors from "@services/ibex/errors" import * as ErpNextErrors from "@services/frappe/errors" +import * as BridgeErrors from "@services/bridge/errors" export const ApplicationErrors = { ...SharedErrors, @@ -50,6 +52,7 @@ export const ApplicationErrors = { ...CaptchaErrors, ...AuthenticationErrors, ...UserErrors, + ...CashWalletCutoverErrors, ...KratosErrors, ...LedgerFacadeErrors, @@ -59,4 +62,5 @@ export const ApplicationErrors = { // Flash Errors ...IbexErrors, ...ErpNextErrors, + ...BridgeErrors, } as const diff --git a/src/app/index.ts b/src/app/index.ts index b006e8446..46afb0463 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -14,6 +14,7 @@ import * as WalletsMod from "./wallets" import * as PaymentsMod from "./payments" import * as MerchantsMod from "./merchants" import * as SwapMod from "./swap" +import * as CashWalletCutoverMod from "./cash-wallet-cutover" const allFunctions = { Accounts: { ...AccountsMod }, @@ -30,6 +31,7 @@ const allFunctions = { Payments: { ...PaymentsMod }, Merchants: { ...MerchantsMod }, Swap: { ...SwapMod }, + CashWalletCutover: { ...CashWalletCutoverMod }, } as const let subModule: keyof typeof allFunctions @@ -60,4 +62,5 @@ export const { Payments, Merchants, Swap, + CashWalletCutover, } = allFunctions diff --git a/src/app/offers/CashoutManager.ts b/src/app/offers/CashoutManager.ts index 60770ef74..946d16efc 100644 --- a/src/app/offers/CashoutManager.ts +++ b/src/app/offers/CashoutManager.ts @@ -1,7 +1,8 @@ import Storage from "./storage/Redis" import ValidOffer, { InitiatedCashout } from "./ValidOffer" -import { USDAmount, ValidationError } from "@domain/shared" +import { USDAmount, USDTAmount, ValidationError } from "@domain/shared" import { CacheServiceError } from "@domain/cache" +import { resolveCashoutWalletSelection } from "@app/cash-wallet-cutover/cashout-routing" import { getBankOwnerIbexAccount } from "@services/ledger/caching" import Ibex from "@services/ibex/client" import { UnexpectedIbexResponse } from "@services/ibex/errors" @@ -25,12 +26,36 @@ const CashoutManager = { userPayment: USDAmount, bankAccountId: string, ): Promise => { - const flashWallet = await getBankOwnerIbexAccount() + const bankOwnerUsdWalletId = await getBankOwnerIbexAccount() - const invoiceResp = await Ibex.addInvoice({ - accountId: flashWallet, + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof RepositoryError) return new ValidationError(wallet) + const account = await AccountsRepository().findById(wallet.accountId) + if (account instanceof RepositoryError) return new ValidationError(account) + if (!account.erpParty) return new Error("Could not find erpParty for account") + + // Source (user) and destination (Flash bank-owner) wallets are resolved from the + // cutover guard — not from the client-supplied walletId, which is trusted only for + // wallet-level auth. Post-cutover this routes the debit to the account's USDT wallet + // and the bank-owner's USDT wallet; pre-cutover it stays on the legacy USD wallets. + const selection = await resolveCashoutWalletSelection({ + accountId: account.id, + requestedUserWalletId: walletId, + bankOwnerUsdWalletId, + }) + if (selection instanceof Error) return selection + + // 1 USDT = 1 USD; the JMD/USD payout math below stays USD-denominated regardless. + const paymentAmount = + selection.route === "usdt" + ? USDTAmount.usdCents(userPayment.asCents()) + : userPayment + if (paymentAmount instanceof Error) return paymentAmount + + const invoiceResp = await Ibex.addInvoice({ + accountId: selection.flashWalletId, memo: "User withdraw to bank", - amount: userPayment, + amount: paymentAmount, expiration: config.duration, }) if (invoiceResp instanceof Error) return invoiceResp @@ -43,12 +68,6 @@ const CashoutManager = { const exchangeRate = config.jmd.sell // todo: get from price server const jmdPayout = usdPayout.convertAtRate(exchangeRate) - const wallet = await WalletsRepository().findById(walletId) - if (wallet instanceof RepositoryError) return new ValidationError(wallet) - const account = await AccountsRepository().findById(wallet.accountId) - if (account instanceof RepositoryError) return new ValidationError(account) - if (!account.erpParty) return new Error("Could not find erpParty for account") - const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty!) if (bankAccounts instanceof BankAccountQueryError) return bankAccounts const bankAccount = bankAccounts.find(b => b.name === bankAccountId) @@ -61,10 +80,10 @@ const CashoutManager = { const validated = await ValidOffer.from({ payment: { - userAcct: walletId, - flashAcct: flashWallet, + userAcct: selection.userWalletId, + flashAcct: selection.flashWalletId, invoice, - amount: userPayment, + amount: paymentAmount, }, payout, }) @@ -78,8 +97,16 @@ const CashoutManager = { executeCashout: async (id: OfferId, walletId: WalletId): Promise => { const offer = await Storage.get(id) if (offer instanceof Error) return offer - - if (walletId !== offer.details.payment.userAcct) return new ValidationError("Offer is not good for provided wallet.") + + // walletId authenticates the caller at the wallet level; the offer's settlement wallet + // may differ from it (e.g. a USDT cash wallet post-cutover while an older client still + // presents the legacy USD walletId). Authorize when both belong to the same account. + const providedWallet = await WalletsRepository().findById(walletId) + if (providedWallet instanceof RepositoryError) return new ValidationError(providedWallet) + const settlementWallet = await WalletsRepository().findById(offer.details.payment.userAcct) + if (settlementWallet instanceof RepositoryError) return new ValidationError(settlementWallet) + if (providedWallet.accountId !== settlementWallet.accountId) + return new ValidationError("Offer is not good for provided wallet.") const validOffer = await ValidOffer.from(offer.details) if (validOffer instanceof Error) return validOffer diff --git a/src/app/offers/Validator.ts b/src/app/offers/Validator.ts index 316e7531b..a41974ece 100644 --- a/src/app/offers/Validator.ts +++ b/src/app/offers/Validator.ts @@ -1,9 +1,22 @@ -import { getBalanceForWallet } from "@app/wallets"; -import { Cashout } from "@config"; -import { AccountValidator, hasErpParty, isActiveAccount, walletBelongsToAccount } from "@domain/accounts"; -import { JMDAmount, USDAmount, ValidationError, ValidationFn, validator } from "@domain/shared"; -import { ValidationInputs } from "./types"; -import ErpNext from "@services/frappe/ErpNext"; +import { getBalanceForWallet } from "@app/wallets" +import { Cashout } from "@config" +import { + AccountValidator, + hasErpParty, + isActiveAccount, + walletBelongsToAccount, +} from "@domain/accounts" +import { + JMDAmount, + USDAmount, + USDTAmount, + ValidationError, + ValidationFn, + validator, +} from "@domain/shared" +import ErpNext from "@services/frappe/ErpNext" + +import { ValidationInputs } from "./types" const config = Cashout.validations @@ -14,6 +27,13 @@ const isBeforeExpiry = async (o: ValidationInputs): Promise => { + if (o.payment.amount instanceof USDTAmount) { + const min = USDTAmount.usdCents(config.minimum.amount) + if (min instanceof Error) return new ValidationError(min) + if (o.payment.amount.isLesserThan(min)) + return new ValidationError(`Minimum cashout is $${min.asNumber(2)}`) + return true + } const min = USDAmount.cents(config.minimum.amount) if (min instanceof Error) return new ValidationError(min) if (o.payment.amount.isLesserThan(min)) @@ -21,7 +41,16 @@ const cashoutMin = async (o: ValidationInputs): Promise else return true } -const cashoutMax: ValidationFn = async (o: ValidationInputs): Promise => { +const cashoutMax: ValidationFn = async ( + o: ValidationInputs, +): Promise => { + if (o.payment.amount instanceof USDTAmount) { + const max = USDTAmount.usdCents(config.maximum.amount) + if (max instanceof Error) return new ValidationError(max) + if (o.payment.amount.isGreaterThan(max)) + return new ValidationError(`Maximum cashout is $${max.asNumber(2)}`) + return true + } const max = USDAmount.cents(config.maximum.amount) if (max instanceof Error) return new ValidationError(max) if (o.payment.amount.isGreaterThan(max)) @@ -29,16 +58,39 @@ const cashoutMax: ValidationFn = async (o: ValidationInputs): else return true } -const isUsd = async (o: ValidationInputs) => { - if (o.wallet.currency !== "USD") - return new ValidationError("Cash out only supports withdrawals from USD wallets") +// Cash out supports a USD source wallet (pre-cutover) or a USDT source wallet +// (post-cutover). The amount currency must match the resolved source wallet. +const isSupportedCashoutWallet = async (o: ValidationInputs) => { + const { currency } = o.wallet + if (currency !== "USD" && currency !== "USDT") + return new ValidationError( + "Cash out only supports withdrawals from USD or USDT wallets", + ) + const amountIsUsdt = o.payment.amount instanceof USDTAmount + if (amountIsUsdt !== (currency === "USDT")) + return new ValidationError("Cashout amount currency does not match the source wallet") return true } -const hasSufficientBalance = async (o: ValidationInputs): Promise => { - const balance = await getBalanceForWallet({ walletId: o.wallet.id }) - if (balance instanceof Error) - return new ValidationError(balance) +const hasSufficientBalance = async ( + o: ValidationInputs, +): Promise => { + const balance = await getBalanceForWallet({ + walletId: o.wallet.id, + currency: o.wallet.currency, + }) + if (balance instanceof Error) return new ValidationError(balance) + if (balance instanceof USDTAmount) { + if (!(o.payment.amount instanceof USDTAmount)) + return new ValidationError( + "Cashout amount currency does not match the source wallet", + ) + if (o.payment.amount.isGreaterThan(balance)) + return new ValidationError("Transfer amount is greater than wallet balance.") + return true + } + if (o.payment.amount instanceof USDTAmount) + return new ValidationError("Cashout amount currency does not match the source wallet") else if (o.payment.amount.isGreaterThan(balance)) return new ValidationError("Transfer amount is greater than wallet balance.") else return true @@ -48,22 +100,28 @@ const accountLevel = async (o: ValidationInputs) => { return AccountValidator(o.account).isLevel(config.accountLevel) } -// Much of this logic is checked server-side in erpnext, but we want to catch it as early as possible -const verifyBankAccount = async (o: ValidationInputs): Promise => { +// Much of this logic is checked server-side in erpnext, but we want to catch it as early as possible +const verifyBankAccount = async ( + o: ValidationInputs, +): Promise => { const erpParty = o.account.erpParty - if (!erpParty) return new ValidationError("Account does not have an associated erpParty") + if (!erpParty) + return new ValidationError("Account does not have an associated erpParty") const banks = await ErpNext.getBankAccountsByCustomer(erpParty) - if (banks instanceof Error) return new ValidationError("Could not confirm bank account for user") - const bankAccount = banks.find(b => b.name === o.payout.bankAccountId) + if (banks instanceof Error) + return new ValidationError("Could not confirm bank account for user") + const bankAccount = banks.find((b) => b.name === o.payout.bankAccountId) if (!bankAccount) return new ValidationError("Bank account does not belong to user") const payoutCurrency = o.payout.amount instanceof JMDAmount ? "JMD" : "USD" if (bankAccount.currency !== payoutCurrency) - return new ValidationError(`Bank account currency (${bankAccount.currency}) does not match payout currency (${payoutCurrency})`) + return new ValidationError( + `Bank account currency (${bankAccount.currency}) does not match payout currency (${payoutCurrency})`, + ) return true } -export const CashoutValidator = validator([ - isUsd, +export const CashoutValidator = validator([ + isSupportedCashoutWallet, cashoutMin, cashoutMax, isActiveAccount, @@ -74,4 +132,4 @@ export const CashoutValidator = validator([ hasErpParty, verifyBankAccount, // TODO daily/weekly/monthly volume limits -]) \ No newline at end of file +]) diff --git a/src/app/offers/storage/OffersSerde.ts b/src/app/offers/storage/OffersSerde.ts new file mode 100644 index 000000000..cf3ff040e --- /dev/null +++ b/src/app/offers/storage/OffersSerde.ts @@ -0,0 +1,41 @@ +import { + MoneyAmount, + USDTAmount, + WalletCurrency, + toMoneyAmountFromJSON, +} from "@domain/shared" + +import { CashoutDetails } from "../types" + +/** + * Custom SerDe for CashoutDetails + */ +export const OffersSerde = { + serialize: (data: CashoutDetails): string => { + return JSON.stringify(data, (_, value) => { + if (value instanceof MoneyAmount || value instanceof USDTAmount) + return value.toJson() + else if (typeof value === "bigint") return value.toString() + else return value + }) + }, + + deserialize: (json: string): CashoutDetails => { + return JSON.parse(json, (key: string, value: unknown) => { + if (key === "expiresAt" && typeof value === "string") return new Date(value) + + if ( + ["amount", "servicefee", "exchangerate"].includes(key.toLowerCase()) && + Array.isArray(value) + ) { + const amount = + value[1] === WalletCurrency.Usdt + ? USDTAmount.smallestUnits(value[0] as string) + : toMoneyAmountFromJSON(value as [string, string]) + if (amount instanceof Error) throw amount + return amount + } + return value + }) + }, +} diff --git a/src/app/offers/storage/Redis.ts b/src/app/offers/storage/Redis.ts index 13dedb5f6..cd401da3d 100644 --- a/src/app/offers/storage/Redis.ts +++ b/src/app/offers/storage/Redis.ts @@ -1,59 +1,39 @@ -import { parseRepositoryError } from "../../../services/mongoose/utils" -import PersistedOffer from "./PersistedOffer" -import ValidOffer from "../ValidOffer" -import { RedisCacheService } from "@services/cache" -import { CacheServiceError, CacheUndefinedError, OfferNotFound } from "@domain/cache" -import { baseLogger } from "@services/logger" import { randomUUID } from "crypto" -import { JMDAmount, MoneyAmount, USDAmount, toMoneyAmountFromJSON } from "@domain/shared" -import { CashoutDetails } from "../types" -/** - * Custom SerDe for CashoutDetails - */ -const OffersSerde = { - serialize: (data: CashoutDetails): string => { - return JSON.stringify(data, (_, value) => { - if (value instanceof MoneyAmount) return value.toJson() - else if (typeof value === "bigint") return value.toString(); - else return value - }); - }, +import { CacheServiceError, CacheUndefinedError, OfferNotFound } from "@domain/cache" +import { RedisCacheService } from "@services/cache" - deserialize: (json: string) => { - return JSON.parse( - json, - (key: string, value: any) => { - if (['amount', 'servicefee', 'exchangerate'].includes(key.toLowerCase()) && Array.isArray(value)) { - return toMoneyAmountFromJSON(value as [string, string]) - } - return value; - }) - } -} +import ValidOffer from "../ValidOffer" + +import { parseRepositoryError } from "../../../services/mongoose/utils" + +import { OffersSerde } from "./OffersSerde" +import PersistedOffer from "./PersistedOffer" const Redis = { add: async (o: ValidOffer): Promise => { const id = randomUUID() as OfferId // could use hash of offer details with getOrSet - const result= await RedisCacheService().set({ + const result = await RedisCacheService().set({ key: `offers:${id}`, value: OffersSerde.serialize(o.details), - ttlSecs: 3600 as Seconds - }); + ttlSecs: 3600 as Seconds, + }) if (result instanceof CacheServiceError) return result return new PersistedOffer(id, o.details) }, - + get: async (id: OfferId): Promise => { try { - const result: string | CacheServiceError = await RedisCacheService().get({ key: `offers:${id}`}) + const result: string | CacheServiceError = await RedisCacheService().get({ + key: `offers:${id}`, + }) if (result instanceof CacheUndefinedError) return new OfferNotFound() if (result instanceof CacheServiceError) return result else return new PersistedOffer(id, OffersSerde.deserialize(result)) } catch (err) { return parseRepositoryError(err) } - } + }, } -export default Redis \ No newline at end of file +export default Redis diff --git a/src/app/offers/types.ts b/src/app/offers/types.ts index 107df62ed..e39af9926 100644 --- a/src/app/offers/types.ts +++ b/src/app/offers/types.ts @@ -1,4 +1,4 @@ -import { USDAmount, JMDAmount } from "@domain/shared" +import { USDAmount, USDTAmount, JMDAmount } from "@domain/shared" // Full details in a cashout transaction export type CashoutDetails = { @@ -6,7 +6,8 @@ export type CashoutDetails = { readonly userAcct: WalletId, readonly flashAcct: WalletId, readonly invoice: LnInvoice, - readonly amount: USDAmount, + // USD pre-cutover; USDT once the source account has migrated to the cash wallet. + readonly amount: USDAmount | USDTAmount, }, readonly payout: { readonly bankAccountId: string, 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/app/payments/send-intraledger.ts b/src/app/payments/send-intraledger.ts index fb7017d09..b38633361 100644 --- a/src/app/payments/send-intraledger.ts +++ b/src/app/payments/send-intraledger.ts @@ -7,6 +7,7 @@ import { } from "@app/prices" import { removeDeviceTokens } from "@app/users/remove-device-tokens" import { validateIsBtcWallet, validateIsUsdWallet } from "@app/wallets" +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" import { InvalidLightningPaymentFlowBuilderStateError, @@ -15,11 +16,12 @@ import { } from "@domain/payments" import { AccountLevel, AccountValidator } from "@domain/accounts" import { DisplayAmountsConverter } from "@domain/fiat" -import { BigIntConversionError, checkedToUsdPaymentAmount, ErrorLevel, paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" +import { checkedToUsdPaymentAmount, ErrorLevel, paymentAmountFromNumber, ValidationError, WalletCurrency } from "@domain/shared" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { ResourceExpiredLockServiceError } from "@domain/lock" import { checkedToWalletId, SettlementMethod } from "@domain/wallets" import { DeviceTokensNotRegisteredNotificationsServiceError } from "@domain/notifications" +import { MismatchedCurrencyForWalletError } from "@domain/errors" import { LockService } from "@services/lock" import { LedgerService } from "@services/ledger" @@ -74,8 +76,12 @@ const intraledgerPaymentSendWalletId = async ({ kratosUserId: recipientUserId, } = recipientAccount - const amount = USDAmount.cents(uncheckedAmount.toString()) - if (amount instanceof BigIntConversionError) return amount + if (senderWallet.currency !== recipientWallet.currency) { + return new MismatchedCurrencyForWalletError() + } + + const amount = usdWalletAmountFromInput(uncheckedAmount.toString(), senderWallet.currency) + if (amount instanceof Error) return amount const invoiceResp = await Ibex.addInvoice({ accountId: recipientWalletId, amount, @@ -132,7 +138,7 @@ export const intraledgerPaymentSendWalletIdForBtcWallet = async ( export const intraledgerPaymentSendWalletIdForUsdWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, ): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId) + const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) return validated instanceof Error ? validated : intraledgerPaymentSendWalletId(args) } diff --git a/src/app/payments/send-lightning.ts b/src/app/payments/send-lightning.ts index 730de6755..880205c86 100644 --- a/src/app/payments/send-lightning.ts +++ b/src/app/payments/send-lightning.ts @@ -172,7 +172,7 @@ export const payNoAmountInvoiceByWalletIdForBtcWallet = async ( export const payNoAmountInvoiceByWalletIdForUsdWallet = async ( args: PayNoAmountInvoiceByWalletIdArgs, ): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId) + const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) return validated instanceof Error ? validated : payNoAmountInvoiceByWalletId(args) } @@ -278,7 +278,10 @@ const validateNoAmountInvoicePaymentInputs = async ({ const inputPaymentAmount = senderWallet.currency === WalletCurrency.Btc ? checkedToBtcPaymentAmount(amount) - : checkedToUsdPaymentAmount(amount) + : checkedToUsdPaymentAmount( + amount, + senderWallet.currency as typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt, + ) if (inputPaymentAmount instanceof Error) return inputPaymentAmount return { diff --git a/src/app/wallets/add-invoice-for-wallet.ts b/src/app/wallets/add-invoice-for-wallet.ts index 9c5f4bcaf..cc2dee028 100644 --- a/src/app/wallets/add-invoice-for-wallet.ts +++ b/src/app/wallets/add-invoice-for-wallet.ts @@ -23,9 +23,11 @@ import { import Ibex from "@services/ibex/client" import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" import { decodeInvoice } from "@domain/bitcoin/lightning/ln-invoice" -import { USDAmount } from "@domain/shared" + import { AddInvoiceResponse201 } from "ibex-client" +import { usdWalletAmountFromInput } from "./usd-wallet-amount" + import { validateIsBtcWallet, validateIsUsdWallet } from "./validate" const defaultBtcExpiration = DEFAULT_EXPIRATIONS["BTC"].delayMinutes @@ -49,7 +51,9 @@ const addInvoiceForSelf = async ({ const limitOk = await checkSelfWalletIdRateLimits(wallet.accountId) if (limitOk instanceof Error) return limitOk - const checkedAmount = amount ? USDAmount.cents(amount.toString()) : undefined + const checkedAmount = amount + ? usdWalletAmountFromInput(amount.toString(), wallet.currency) + : undefined if (checkedAmount instanceof Error) return checkedAmount const resp = await Ibex.addInvoice({ amount: checkedAmount, @@ -80,7 +84,7 @@ export const addInvoiceForSelfForUsdWallet = async ( const expiresIn = checkedToMinutes(args.expiresIn || ibexReceiveDefaultExpirationMinutes) if (expiresIn instanceof Error) return expiresIn - const validated = await validateIsUsdWallet(walletId) + const validated = await validateIsUsdWallet(walletId, { includeUsdt: true }) if (validated instanceof Error) return validated return addInvoiceForSelf({ ...args, walletId, expiresIn }) } @@ -136,7 +140,7 @@ const addInvoiceForRecipient = async ({ const limitOk = await checkRecipientWalletIdRateLimits(wallet.accountId) if (limitOk instanceof Error) return limitOk - const checkedAmount = USDAmount.cents(amount.toString()) + const checkedAmount = usdWalletAmountFromInput(amount.toString(), wallet.currency) if (checkedAmount instanceof Error) return checkedAmount const resp = await Ibex.addInvoice({ amount: checkedAmount, @@ -166,7 +170,7 @@ export const addInvoiceForRecipientForUsdWallet = async ( const expiresIn = checkedToMinutes(args.expiresIn || ibexReceiveDefaultExpirationMinutes) if (expiresIn instanceof Error) return expiresIn - const validated = await validateIsUsdWallet(recipientWalletId) + const validated = await validateIsUsdWallet(recipientWalletId, { includeUsdt: true }) if (validated instanceof Error) return validated return addInvoiceForRecipient({ ...args, recipientWalletId, expiresIn }) diff --git a/src/app/wallets/get-balance-for-wallet.ts b/src/app/wallets/get-balance-for-wallet.ts index 7a7ce6128..46ddf6cad 100644 --- a/src/app/wallets/get-balance-for-wallet.ts +++ b/src/app/wallets/get-balance-for-wallet.ts @@ -1,18 +1,21 @@ import { UnknownLedgerError } from "@domain/ledger" -import { USDAmount } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" export const getBalanceForWallet = async ({ walletId, + currency, }: { walletId: WalletId -}): Promise => { - // return LedgerService().getWalletBalance(walletId) - try { - const resp = await Ibex.getAccountDetails(walletId) + currency?: WalletCurrency +}): Promise => { + try { + const resp = await Ibex.getAccountDetails(walletId, currency) if (resp instanceof IbexError) { - if (resp.httpCode === 404) return USDAmount.ZERO + if (resp.httpCode === 404) { + return currency === WalletCurrency.Usdt ? USDTAmount.ZERO : USDAmount.ZERO + } return resp } if (resp.balance === undefined) return new UnexpectedIbexResponse("Balance not found") diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index e8925dd70..46dce9e7e 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -1,9 +1,10 @@ import { PartialResult } from "@app/partial-result" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" import { baseLogger } from "@services/logger" import { GResponse200 } from "ibex-client" -import { ConnectionArguments, ConnectionCursor } from "graphql-relay" +import { ConnectionArguments } from "graphql-relay" export const getTransactionsForWallets = async ({ wallets, @@ -13,46 +14,90 @@ export const getTransactionsForWallets = async ({ paginationArgs?: PaginationArgs }): Promise>> => { const walletIds = wallets.map((wallet) => wallet.id) - - const ibexCalls = await Promise.all(walletIds - .map(id => Ibex.getAccountTransactions({ - account_id: id, - ...toIbexPaginationArgs(paginationArgs) - })) + + const ibexCalls = await Promise.all( + walletIds.map((id) => + Ibex.getAccountTransactions({ + account_id: id, + ...toIbexPaginationArgs(paginationArgs), + }), + ), ) - const transactions = ibexCalls.flatMap(resp => { - if (resp instanceof IbexError) return [] + const transactions = ibexCalls.flatMap((resp) => { + if (resp instanceof IbexError) return [] else return toWalletTransactions(resp) }) return PartialResult.ok({ slice: transactions, - total: transactions.length + total: transactions.length, }) } +const currencyFromIbexCurrencyId = ( + currencyId: number | undefined, +): WalletCurrency | undefined => { + if (currencyId === USDAmount.currencyId) return WalletCurrency.Usd + if (currencyId === USDTAmount.currencyId) return WalletCurrency.Usdt + return undefined +} + export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] => { - return ibexResp.map(trx => { - const currency = (trx.currencyId === 3 ? "USD" : "BTC") as WalletCurrency // WalletCurrency: "USD" | "BTC", + return ibexResp.map((trx) => { + const currency = currencyFromIbexCurrencyId(trx.currencyId) - const settlementDisplayPrice: WalletMinorUnitDisplayPrice = { - base: trx.exchangeRateCurrencySats ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) : 0n, + if (!currency) { + baseLogger.error( + `Failed to parse Ibex transaction currency. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`, + ) + return { + walletId: (trx.accountId || "") as WalletId, + settlementAmount: 0 as Satoshis, + settlementFee: 0 as Satoshis, + settlementCurrency: WalletCurrency.Usd, + settlementDisplayAmount: `${trx.amount}`, + settlementDisplayFee: `${trx.networkFee}`, + settlementDisplayPrice: { + base: 0n, + offset: 0n, + displayCurrency: "USD" as DisplayCurrency, + walletCurrency: WalletCurrency.Usd, + }, + createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), + id: trx.id || "null", + status: "success" as TxStatus, + memo: null, + initiationVia: { type: "unknown" }, + settlementVia: { type: "unknown" }, + } as UnknownTypeTransaction + } + + const settlementDisplayPrice: WalletMinorUnitDisplayPrice< + WalletCurrency, + DisplayCurrency + > = { + base: trx.exchangeRateCurrencySats + ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) + : 0n, offset: 0n, // what is this? displayCurrency: "USD" as DisplayCurrency, - walletCurrency: currency + walletCurrency: currency, } const baseTrx: BaseWalletTransaction = { - walletId: (trx.accountId || "") as WalletId, + walletId: (trx.accountId || "") as WalletId, settlementAmount: toSettlementAmount(trx.amount, trx.transactionTypeId, currency), - settlementFee: asCurrency(trx.networkFee, currency), - settlementCurrency: currency, - settlementDisplayAmount: `${trx.amount}`, - settlementDisplayFee: `${trx.networkFee}`, + settlementFee: toSettlementMinorUnit(trx.networkFee, currency), + settlementCurrency: currency, + settlementDisplayAmount: toSettlementDisplayAmount( + trx.amount, + trx.transactionTypeId, + ), + settlementDisplayFee: `${trx.networkFee}`, settlementDisplayPrice: settlementDisplayPrice, createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), // should always return - id: trx.id || "null", // "LedgerTransactionId" - this is likely unused + id: trx.id || "null", // "LedgerTransactionId" - this is likely unused status: "success" as TxStatus, // assuming Ibex returns on completed memo: null, // query transaction details } @@ -63,68 +108,107 @@ export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] return { ...baseTrx, // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: 'lightning', paymentHash: "", pubkey: "" }, - settlementVia: { type: 'lightning', revealedPreImage: undefined } + // we need to query the transaction details for each trx individually. + initiationVia: { type: "lightning", paymentHash: "", pubkey: "" }, + settlementVia: { type: "lightning", revealedPreImage: undefined }, } as WalletLnSettledTransaction case 3: case 4: + case 10: return { ...baseTrx, // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: 'onchain', address: "" }, - settlementVia: { type: 'onchain', transactionHash: '', vout: undefined } + // we need to query the transaction details for each trx individually. + initiationVia: { type: "onchain", address: "" }, + settlementVia: { type: "onchain", transactionHash: "", vout: undefined }, } as WalletOnChainSettledTransaction // assuming Ibex only gives us settled default: - baseLogger.error(`Failed to parse Ibex transaction type. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId}`) - return { + baseLogger.error( + `Failed to parse Ibex transaction type. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId}`, + ) + return { ...baseTrx, - initiationVia: { type: 'unknown' }, - settlementVia: { type: 'unknown' } + initiationVia: { type: "unknown" }, + settlementVia: { type: "unknown" }, } as UnknownTypeTransaction } }) } -const asCurrency = (amount: number | undefined, currency: WalletCurrency): Satoshis | UsdCents => { - return currency === "USD" ? amount as UsdCents : amount as Satoshis +type SettlementMinorUnitAmount = Satoshis | UsdCents | UsdtMicros + +const toUsdtMicros = (amount: number): UsdtMicros => { + const usdtAmount = USDTAmount.fromNumber(amount.toString()) + if (usdtAmount instanceof Error) { + baseLogger.error({ err: usdtAmount, amount }, "Failed to parse IBEX USDT amount") + return 0 as UsdtMicros + } + return Number(usdtAmount.asSmallestUnits()) as UsdtMicros +} + +const zeroSettlementMinorUnit = (currency: WalletCurrency): SettlementMinorUnitAmount => { + if (currency === WalletCurrency.Usd) return 0 as UsdCents + if (currency === WalletCurrency.Usdt) return 0 as UsdtMicros + return 0 as Satoshis +} + +const toSettlementMinorUnit = ( + amount: number | undefined, + currency: WalletCurrency, +): SettlementMinorUnitAmount => { + if (amount === undefined) return zeroSettlementMinorUnit(currency) + if (currency === WalletCurrency.Usd) return amount as UsdCents + if (currency === WalletCurrency.Usdt) return toUsdtMicros(amount) + return amount as Satoshis } const toSettlementAmount = ( - ibexAmount: number | undefined, - transactionTypeId: number | undefined, - currency: WalletCurrency -): Satoshis | UsdCents => { + ibexAmount: number | undefined, + transactionTypeId: number | undefined, + currency: WalletCurrency, +): SettlementMinorUnitAmount => { if (ibexAmount === undefined) { baseLogger.warn("Ibex did not return transaction amount") - return asCurrency(ibexAmount, currency) + return toSettlementMinorUnit(ibexAmount, currency) } // When sending, make negative - const amt = (transactionTypeId === 2 || transactionTypeId === 4) - ? -1 * ibexAmount - : ibexAmount - return asCurrency(amt, currency) + const amt = + transactionTypeId === 2 || transactionTypeId === 4 || transactionTypeId === 10 + ? -1 * ibexAmount + : ibexAmount + return toSettlementMinorUnit(amt, currency) +} + +const toSettlementDisplayAmount = ( + ibexAmount: number | undefined, + transactionTypeId: number | undefined, +): string => { + if (ibexAmount === undefined) return `${ibexAmount}` + const amount = + transactionTypeId === 2 || transactionTypeId === 4 || transactionTypeId === 10 + ? -1 * ibexAmount + : ibexAmount + return `${amount}` } enum SortOrder { RECENT = "settledAt", - OLDEST = "-settledAt" + OLDEST = "-settledAt", } type IbexPaginationArgs = { - page?: number | undefined; // ibex default (0) start at page 0 - limit?: number | undefined; // ibex default (0) returns all - sort?: SortOrder | undefined; // defaults to SortOrder.RECENT + page?: number | undefined // ibex default (0) start at page 0 + limit?: number | undefined // ibex default (0) returns all + sort?: SortOrder | undefined // defaults to SortOrder.RECENT } export function toIbexPaginationArgs( - args: ConnectionArguments | undefined + args: ConnectionArguments | undefined, ): IbexPaginationArgs { const DEFAULTS = { - page: 0, - limit: 0, - sort: SortOrder.RECENT, + page: 0, + limit: 0, + sort: SortOrder.RECENT, } // Prefer 'first' over 'last') @@ -132,13 +216,13 @@ export function toIbexPaginationArgs( return { ...DEFAULTS, limit: args.first, - sort: SortOrder.RECENT, + sort: SortOrder.RECENT, } } else if (args && args.last != null) { return { ...DEFAULTS, limit: args.last, - sort: SortOrder.OLDEST, + sort: SortOrder.OLDEST, } } else return DEFAULTS } diff --git a/src/app/wallets/index.ts b/src/app/wallets/index.ts index 3839fed5a..94210271c 100644 --- a/src/app/wallets/index.ts +++ b/src/app/wallets/index.ts @@ -17,6 +17,7 @@ export * from "./settle-payout-txn" export * from "./update-legacy-on-chain-receipt" export * from "./update-pending-invoices" export * from "./validate" +export * from "./usd-wallet-amount" import { WalletsRepository } from "@services/mongoose" diff --git a/src/app/wallets/send-on-chain.ts b/src/app/wallets/send-on-chain.ts index 28deaa0e7..fddf17f32 100644 --- a/src/app/wallets/send-on-chain.ts +++ b/src/app/wallets/send-on-chain.ts @@ -3,7 +3,8 @@ import { NETWORK } from "@config" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { checkedToOnChainAddress } from "@domain/bitcoin/onchain" import { UnsupportedCurrencyError } from "@domain/errors" -import { ErrorLevel, USDAmount } from "@domain/shared" +import { ErrorLevel } from "@domain/shared" +import { UsdWalletAmount } from "@services/ibex/types" import { OnchainUsdPaymentValidator } from "@domain/wallets" import { AccountsRepository, WalletsRepository } from "@services/mongoose" @@ -19,15 +20,15 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null - amount?: number | FractionalCentAmount | USDAmount + amount?: number | FractionalCentAmount | UsdWalletAmount } type PayOnChainByUsdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { - amount: USDAmount + amount: UsdWalletAmount } type PayOnChainByWalletIdForUsdWalletArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { - amount: number | FractionalCentAmount | USDAmount + amount: number | FractionalCentAmount | UsdWalletAmount } /* @@ -75,7 +76,11 @@ export const payOnChainByWalletId = async ({ }) if (validationResult instanceof Error) return validationResult - const resp = await Ibex.sendOnchain(args) + const resp = await Ibex.sendOnchain({ + accountId: args.accountId, + address: args.address, + amount: amount.toIbex(), + }) if (resp instanceof IbexError) return resp let status = IbexAdaptor.toPaymentSendStatus(resp.status) diff --git a/src/app/wallets/usd-wallet-amount.ts b/src/app/wallets/usd-wallet-amount.ts new file mode 100644 index 000000000..41b376f1f --- /dev/null +++ b/src/app/wallets/usd-wallet-amount.ts @@ -0,0 +1,31 @@ +import { UnsupportedCurrencyError } from "@domain/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { WalletsRepository } from "@services/mongoose" + +export type UsdWalletAmount = USDAmount | USDTAmount + +export const usdWalletAmountFromInput = ( + amount: string | number, + currency: WalletCurrency, +): UsdWalletAmount | ApplicationError => { + const raw = amount.toString() + + if (currency === WalletCurrency.Usd) return USDAmount.cents(raw) + if (currency === WalletCurrency.Usdt) return USDTAmount.usdCents(raw) + + return new UnsupportedCurrencyError(`USD wallet amount unsupported for ${currency}`) +} + +export const usdWalletAmountFromWalletId = async ({ + walletId, + amount, +}: { + walletId: WalletId + amount: string | number +}): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + return usdWalletAmountFromInput(amount, wallet.currency) +} diff --git a/src/app/wallets/validate.ts b/src/app/wallets/validate.ts index a214ada91..3b22093f5 100644 --- a/src/app/wallets/validate.ts +++ b/src/app/wallets/validate.ts @@ -9,7 +9,7 @@ export const validateIsBtcWallet = async ( const wallet = await WalletsRepository().findById(walletId) if (wallet instanceof Error) return wallet - if (wallet.currency === WalletCurrency.Usd) { + if (wallet.currency !== WalletCurrency.Btc) { return new MismatchedCurrencyForWalletError() } return true @@ -17,11 +17,28 @@ export const validateIsBtcWallet = async ( export const validateIsUsdWallet = async ( walletId: WalletId, + args?: { includeUsdt?: boolean }, ): Promise => { const wallet = await WalletsRepository().findById(walletId) if (wallet instanceof Error) return wallet - if (wallet.currency === WalletCurrency.Btc) { + const isAllowed = + wallet.currency === WalletCurrency.Usd || + (args?.includeUsdt === true && wallet.currency === WalletCurrency.Usdt) + + if (!isAllowed) { + return new MismatchedCurrencyForWalletError() + } + return true +} + +export const validateIsUsdtWallet = async ( + walletId: WalletId, +): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + if (wallet.currency !== WalletCurrency.Usdt) { return new MismatchedCurrencyForWalletError() } return true diff --git a/src/config/env.ts b/src/config/env.ts index 1216528c0..fe2397f8a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -124,6 +124,10 @@ export const env = createEnv({ MATTERMOST_WEBHOOK_URL: z.string().min(1).optional(), + ALERT_PAGERDUTY_ROUTING_KEY: z.string().min(1).optional(), + ALERT_SLACK_WEBHOOK_URL: z.string().url().optional(), + ALERT_DISCORD_WEBHOOK_URL: z.string().url().optional(), + PROXY_CHECK_APIKEY: z.string().min(1).optional(), SVIX_SECRET: z.string().optional(), @@ -231,6 +235,10 @@ export const env = createEnv({ MATTERMOST_WEBHOOK_URL: process.env.MATTERMOST_WEBHOOK_URL, + ALERT_PAGERDUTY_ROUTING_KEY: process.env.ALERT_PAGERDUTY_ROUTING_KEY, + ALERT_SLACK_WEBHOOK_URL: process.env.ALERT_SLACK_WEBHOOK_URL, + ALERT_DISCORD_WEBHOOK_URL: process.env.ALERT_DISCORD_WEBHOOK_URL, + PROXY_CHECK_APIKEY: process.env.PROXY_CHECK_APIKEY, SVIX_SECRET: process.env.SVIX_SECRET, diff --git a/src/config/index.ts b/src/config/index.ts index 2b070e1a0..749a0933f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -188,6 +188,9 @@ export const NEXTCLOUD_URL = env.NEXTCLOUD_URL export const NEXTCLOUD_USER = env.NEXTCLOUD_USER export const NEXTCLOUD_PASSWORD = env.NEXTCLOUD_PASSWORD export const MATTERMOST_WEBHOOK_URL = env.MATTERMOST_WEBHOOK_URL +export const ALERT_PAGERDUTY_ROUTING_KEY = env.ALERT_PAGERDUTY_ROUTING_KEY +export const ALERT_SLACK_WEBHOOK_URL = env.ALERT_SLACK_WEBHOOK_URL +export const ALERT_DISCORD_WEBHOOK_URL = env.ALERT_DISCORD_WEBHOOK_URL export const PROXY_CHECK_APIKEY = env.PROXY_CHECK_APIKEY export const NOSTR_PRIVATE_KEY = env.NOSTR_PRIVATE_KEY diff --git a/src/config/locales/en.json b/src/config/locales/en.json index c9bd32b95..4fe922f16 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -40,6 +40,26 @@ "cashout": { "body": "Your cashout of {{amount}} has been deposited to your bank account.", "title": "Cashout" + }, + "bridgeDeposit": { + "body": "Your deposit of {{amount}} has been added to your account.", + "title": "Deposit received" + }, + "bridgeWithdrawal": { + "flashFeeNotice": "Shown fees and amounts are estimates. Final fees may differ.", + "cancelled": { + "body": "Your withdrawal of {{amount}} has been cancelled.", + "title": "Withdrawal cancelled" + }, + "completed": { + "body": "Your withdrawal of {{amount}} has been sent to your bank account.", + "title": "Withdrawal complete" + }, + "failed": { + "body": "Your withdrawal of {{amount}} could not be completed.", + "bodyWithReason": "Your withdrawal of {{amount}} could not be completed: {{reason}}.", + "title": "Withdrawal failed" + } } } } \ No newline at end of file diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 122b16b47..0fc877e9d 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -36,6 +36,26 @@ "bodyDisplayCurrency": "+{{baseCurrencyAmount}}{{baseCurrencyName}} ({{displayCurrencyAmount}})", "title": "Transacción {{walletCurrency}}" } + }, + "bridgeDeposit": { + "body": "Su depósito de {{amount}} se agregó a su cuenta.", + "title": "Depósito recibido" + }, + "bridgeWithdrawal": { + "flashFeeNotice": "Las comisiones y montos mostrados son estimados. Las comisiones finales pueden variar.", + "cancelled": { + "body": "Su retiro de {{amount}} ha sido cancelado.", + "title": "Retiro cancelado" + }, + "completed": { + "body": "Su retiro de {{amount}} se envió a su cuenta bancaria.", + "title": "Retiro completado" + }, + "failed": { + "body": "No se pudo completar su retiro de {{amount}}.", + "bodyWithReason": "No se pudo completar su retiro de {{amount}}: {{reason}}.", + "title": "Retiro fallido" + } } } -} +} \ No newline at end of file diff --git a/src/config/schema.ts b/src/config/schema.ts index 294de40ea..e0fc4e879 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -385,7 +385,7 @@ export const configSchema = { additionalProperties: false, default: { initialStatus: "active", - initialWallets: ["USD"], + initialWallets: ["USD", "USDT"], enablePhoneCheck: false, enableIpCheck: false, enableIpProxyCheck: false, @@ -649,6 +649,63 @@ export const configSchema = { }, }, }, + bridge: { + type: "object", + properties: { + enabled: { type: "boolean" }, + apiKey: { type: "string" }, + baseUrl: { type: "string" }, + minWithdrawalAmount: { type: "number" }, + developerFeePercent: { type: "number" }, + withdrawalFeeEstimate: { + type: "object", + properties: { + bridgeFixedFeePercent: { type: "number", default: 0.6 }, + usdtTransferGasLimit: { type: "integer", default: 65000 }, + gasPriceBufferMultiplier: { type: "number", default: 1.5 }, + ethereumGasRpcUrls: { + type: "array", + items: { type: "string" }, + default: [ + "https://ethereum-rpc.publicnode.com", + "https://eth.llamarpc.com", + "https://cloudflare-eth.com", + ], + }, + ethUsdPriceUrl: { + type: "string", + default: + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd", + }, + timeoutMs: { type: "integer", default: 3000 }, + cacheTtlMs: { type: "integer", default: 60000 }, + fallbackGasPriceGwei: { type: "number", default: 30 }, + ethUsdFallback: { type: "number", default: 3000 }, + }, + }, + timeoutMs: { type: "integer", default: 10000 }, + webhook: { + type: "object", + properties: { + port: { type: "integer" }, + publicKeys: { + type: "object", + properties: { + kyc: { type: "string" }, + deposit: { type: "string" }, + transfer: { type: "string" }, + external_account: { type: "string" }, + }, + required: ["kyc", "deposit", "transfer", "external_account"], + }, + timestampSkewMs: { type: "integer" }, + replaySecret: { type: "string" }, + }, + required: ["port", "publicKeys", "timestampSkewMs"], + }, + }, + required: ["enabled", "apiKey", "baseUrl", "developerFeePercent", "webhook"], + }, exchangeRates: { type: "object", }, @@ -666,6 +723,14 @@ export const configSchema = { properties: { url: { type: "string" }, credentials: { type: "object" }, + erpnext: { + type: "object", + properties: { + accounts: { + type: "object", + }, + }, + }, }, required: ["url", "credentials"], }, @@ -718,6 +783,7 @@ export const configSchema = { "exchangeRates", "cashout", "ibex", + "bridge", ], additionalProperties: false, } as const diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 25796ac47..594aa67cb 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -30,6 +30,43 @@ type IbexConfig = { webhook: WebhookServer } +type BridgeWebhookPublicKeys = { + kyc: string + deposit: string + transfer: string + external_account: string +} + +type BridgeWebhook = { + port: number + publicKeys: BridgeWebhookPublicKeys + timestampSkewMs: number + replaySecret?: string +} + +type BridgeWithdrawalFeeEstimateConfig = { + bridgeFixedFeePercent?: number + usdtTransferGasLimit?: number + gasPriceBufferMultiplier?: number + ethereumGasRpcUrls?: string[] + ethUsdPriceUrl?: string + timeoutMs?: number + cacheTtlMs?: number + fallbackGasPriceGwei?: number + ethUsdFallback?: number +} + +type BridgeConfig = { + enabled: boolean + apiKey: string + baseUrl: string + minWithdrawalAmount: number + developerFeePercent: number + withdrawalFeeEstimate?: BridgeWithdrawalFeeEstimateConfig + timeoutMs?: number + webhook: BridgeWebhook +} + type CashoutEmail = { to: string from: string @@ -172,7 +209,8 @@ type YamlSchema = { skipFeeProbeConfig: { pubkey: string[]; chanId: string[] } smsAuthUnsupportedCountries: string[] whatsAppAuthUnsupportedCountries: string[] - ibex: IbexConfig, + ibex: IbexConfig + bridge: BridgeConfig exchangeRates: StaticRates cashout: { enabled: boolean @@ -212,12 +250,12 @@ type FrappeConfig = { type CurrencyCode = string type PriceSpread = { - bid: number, - ask: number, + bid: number + ask: number } type StaticRates = { [key: CurrencyCode]: { [key: CurrencyCode]: PriceSpread } -} \ No newline at end of file +} diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 0ac041840..4d0058c9c 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -3,7 +3,7 @@ import fs from "fs" import path from "path" import Ajv from "ajv" -import yaml from "js-yaml" +import { load as loadYaml } from "js-yaml" import { I18n } from "i18n" import { baseLogger } from "@services/logger" @@ -21,47 +21,46 @@ import { AccountLevel } from "@domain/accounts" import mergeWith from "lodash.mergewith" +import yargs from "yargs" + import { configSchema } from "./schema" import { ConfigError } from "./error" -import yargs from "yargs"; - -const argv: any = yargs(process.argv.slice(2)) - .option("configPath", { +const argv = + // .help() + yargs(process.argv.slice(2)).option("configPath", { alias: "c", type: "array", description: "Paths to YAML configuration files", demandOption: true, - }) - // .help() - .argv; + }).argv as { configPath: string[] } // replaces array with override const merge = (defaultConfig: unknown, customConfig: unknown) => mergeWith(defaultConfig, customConfig, (a, b) => (Array.isArray(b) ? b : undefined)) export const mergeYamls = (filePaths: string[]): Record => { - const mergedConfig: Record = {}; + const mergedConfig: Record = {} filePaths.forEach((filePath) => { try { - const resolvedPath = path.resolve(filePath); - const fileContent = fs.readFileSync(resolvedPath, "utf8"); - const parsedConfig = yaml.load(fileContent) as Record; + const resolvedPath = path.resolve(filePath) + const fileContent = fs.readFileSync(resolvedPath, "utf8") + const parsedConfig = loadYaml(fileContent) as Record merge(mergedConfig, parsedConfig) - baseLogger.info(`Successfully loaded config from ${resolvedPath}`); + baseLogger.info(`Successfully loaded config from ${resolvedPath}`) } catch (err) { - baseLogger.warn({ err, filePath }, `Failed to load config from ${filePath}`); + baseLogger.warn({ err, filePath }, `Failed to load config from ${filePath}`) } - }); + }) - return mergedConfig; -}; + return mergedConfig +} -const paths = argv.configPath.map((p: string) => path.resolve(p)) -const yamlConfigInit = mergeYamls(paths) +const paths = argv.configPath.map((p: string) => path.resolve(p)) +const yamlConfigInit = mergeYamls(paths) // TODO: fix errors // const ajv = new Ajv({ allErrors: true, strict: "log" }) @@ -269,8 +268,10 @@ export const getTestAccounts = (config = yamlConfig): TestAccount[] => export const getCronConfig = (config = yamlConfig): CronConfig => config.cronConfig -export const getDefaultFCMTopics = (config = yamlConfig): string[] => config.fcmTopics.filter(t => t.default).map(t => t.name) -export const getFCMTopics = (config = yamlConfig): string[] => config.fcmTopics.map(t => t.name) +export const getDefaultFCMTopics = (config = yamlConfig): string[] => + config.fcmTopics.filter((t) => t.default).map((t) => t.name) +export const getFCMTopics = (config = yamlConfig): string[] => + config.fcmTopics.map((t) => t.name) export const getCaptcha = (config = yamlConfig): CaptchaConfig => config.captcha @@ -359,7 +360,7 @@ const { ask } = yamlConfig.exchangeRates["USD"]["JMD"] const sellRate = JMDAmount.dollars(ask) if (sellRate instanceof BigIntConversionError) throw sellRate export const ExchangeRates = { - jmd: { sell: sellRate } + jmd: { sell: sellRate }, } export const Cashout = { @@ -384,12 +385,13 @@ export const Cashout = { to: yamlConfig.cashout.email.to, from: yamlConfig.cashout.email.from, subject: yamlConfig.cashout.email.subject, - } - + }, } export const SendGridConfig = yamlConfig.sendgrid as SendGridConfig export const IbexConfig = yamlConfig.ibex as IbexConfig -export const FrappeConfig = yamlConfig.frappe as FrappeConfig \ No newline at end of file +export const BridgeConfig = yamlConfig.bridge as BridgeConfig + +export const FrappeConfig = yamlConfig.frappe as FrappeConfig diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 1f4de02b8..1b39d3c32 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -1,5 +1,7 @@ type AccountError = import("./errors").AccountError +type BridgeCustomerId = import("@domain/primitives/bridge").BridgeCustomerId + type CurrencyRatio = number & { readonly brand: unique symbol } type AccountLevel = (typeof import("./index").AccountLevel)[keyof typeof import("./index").AccountLevel] @@ -84,6 +86,10 @@ type Account = { // temp role?: string erpParty?: string // Lookup key to Customer in ERPNext. Required for Account level > 1 + // Bridge integration: + bridgeCustomerId?: BridgeCustomerId + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" + bridgeEthereumAddress?: string } // deprecated @@ -129,10 +135,10 @@ type LimitsCheckerFn = (args: LimiterCheckInputs) => Promise[]) => Promise< | { - volumeTotalLimit: UsdPaymentAmount - volumeUsed: UsdPaymentAmount - volumeRemaining: UsdPaymentAmount - } + volumeTotalLimit: UsdPaymentAmount + volumeUsed: UsdPaymentAmount + volumeRemaining: UsdPaymentAmount + } | ValidationError > @@ -144,15 +150,15 @@ type AccountLimitsChecker = { type AccountLimitsVolumes = | { - volumesIntraledger: LimitsVolumesFn - volumesWithdrawal: LimitsVolumesFn - volumesTradeIntraAccount: LimitsVolumesFn - } + volumesIntraledger: LimitsVolumesFn + volumesWithdrawal: LimitsVolumesFn + volumesTradeIntraAccount: LimitsVolumesFn + } | ValidationError type AccountValidator = { isActive(): true | ValidationError - isLevel(accountLevel: number): true | ValidationError + isLevel(accountLevel: number): true | ValidationError validateWalletForAccount(wallet: Wallet): true | ValidationError } @@ -168,6 +174,19 @@ interface IAccountsRepository { // listBusinessesForMap(): Promise findByNpub(npub: Npub): Promise update(account: Account): Promise + + updateBridgeFields( + id: AccountId, + fields: { + bridgeCustomerId?: BridgeCustomerId + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" + bridgeEthereumAddress?: string + }, + ): Promise + + findByBridgeEthereumAddress(address: string): Promise + + findByBridgeCustomerId(customerId: BridgeCustomerId): Promise } type AdminRole = "dealer" | "funder" | "bankowner" | "editor" diff --git a/src/domain/bitcoin/lightning/invoice-expiration.ts b/src/domain/bitcoin/lightning/invoice-expiration.ts index 7b65574a9..1cef263ec 100644 --- a/src/domain/bitcoin/lightning/invoice-expiration.ts +++ b/src/domain/bitcoin/lightning/invoice-expiration.ts @@ -42,7 +42,11 @@ export const DEFAULT_EXPIRATIONS = { JMD: { delay: defaultTimeToExpiryInSeconds, delayMinutes: (defaultTimeToExpiryInSeconds / SECS_PER_MIN) as Minutes, - } + }, + USDT: { + delay: defaultTimeToExpiryInSeconds, + delayMinutes: (defaultTimeToExpiryInSeconds / SECS_PER_MIN) as Minutes, + }, } export const invoiceExpirationForCurrency = ( 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/domain/primitives/bridge.ts b/src/domain/primitives/bridge.ts new file mode 100644 index 000000000..00305d3d7 --- /dev/null +++ b/src/domain/primitives/bridge.ts @@ -0,0 +1,23 @@ +// Bridge domain primitives - branded ID types for type safety + +export type BridgeCustomerId = string & { readonly brand: unique symbol } +export type BridgeVirtualAccountId = string & { readonly brand: unique symbol } +export type BridgeExternalAccountId = string & { readonly brand: unique symbol } +export type BridgeTransferId = string & { readonly brand: unique symbol } + +// Helper functions to create branded IDs +export const toBridgeCustomerId = (id: string): BridgeCustomerId => { + return id as BridgeCustomerId +} + +export const toBridgeVirtualAccountId = (id: string): BridgeVirtualAccountId => { + return id as BridgeVirtualAccountId +} + +export const toBridgeExternalAccountId = (id: string): BridgeExternalAccountId => { + return id as BridgeExternalAccountId +} + +export const toBridgeTransferId = (id: string): BridgeTransferId => { + return id as BridgeTransferId +} diff --git a/src/domain/pubsub/index.ts b/src/domain/pubsub/index.ts index a64a52b7b..d27047df1 100644 --- a/src/domain/pubsub/index.ts +++ b/src/domain/pubsub/index.ts @@ -5,6 +5,7 @@ export const PubSubDefaultTriggers = { UserPriceUpdate: "USER_PRICE_UPDATE", AccountUpdate: "ACCOUNT_UPDATE", LnPaymentStatus: "LN_PAYMENT_STATUS", + BridgeReconciliationUpdate: "BRIDGE_RECONCILIATION_UPDATE", } as const export const customPubSubTrigger = ({ diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts index 694bc0dc1..6b89ab13e 100644 --- a/src/domain/shared/MoneyAmount.ts +++ b/src/domain/shared/MoneyAmount.ts @@ -1,6 +1,6 @@ import { Money, Round, PRECISION_M } from "./bigint-money" import { BigIntConversionError, UnsupportedCurrencyError } from "./errors" -import { ExchangeCurrencyUnit, WalletCurrency } from "./primitives" +import { WalletCurrency } from "./primitives" export abstract class MoneyAmount { readonly money: Money @@ -60,6 +60,8 @@ export abstract class MoneyAmount { static from(amount: number | string, currency: WalletCurrency): MoneyAmount | Error { if (currency === WalletCurrency.Usd) return USDAmount.cents(amount.toString()) else if (currency === WalletCurrency.Jmd) return JMDAmount.cents(amount.toString()) + else if (currency === WalletCurrency.Usdt) + return USDTAmount.smallestUnits(amount.toString()) else return new UnsupportedCurrencyError(`Could not read currency: ${currency}`) } } @@ -75,7 +77,9 @@ export class USDAmount extends MoneyAmount { try { return new USDAmount(cents) } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) } } @@ -87,9 +91,10 @@ export class USDAmount extends MoneyAmount { if (cents instanceof BigIntConversionError) return cents // should never happen return new USDAmount(cents.money.multiply(dollarAmt).toFixed(2)) } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) } - } static ZERO = new USDAmount(0) @@ -102,12 +107,12 @@ export class USDAmount extends MoneyAmount { return this.money.divide(100).toFixed(precision) } - // const jmdLiability = { - // amount: BigInt(usdLiability.asCents()) * exchangeRate / 100n, - // currency: "JMD", - // } + // const jmdLiability = { + // amount: BigInt(usdLiability.asCents()) * exchangeRate / 100n, + // currency: "JMD", + // } // Rate is the ratio at which one currency can be exchanged for another. - // T:USD + // T:USD convertAtRate(rate: T): T { const converted = rate.money.multiply(this.money).divide(100) return rate.getInstance(converted) @@ -133,7 +138,9 @@ export class JMDAmount extends MoneyAmount { try { return new JMDAmount(c) } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) } } @@ -141,9 +148,10 @@ export class JMDAmount extends MoneyAmount { try { return new JMDAmount(BigInt(d) * 100n) } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) } - } asCents(precision: number = 0): string { @@ -170,7 +178,9 @@ export class BtcAmount extends MoneyAmount { try { return new BtcAmount(c) } catch (error) { - return new BigIntConversionError(error instanceof Error ? error.message : String(error)) + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) } } @@ -182,3 +192,72 @@ export class BtcAmount extends MoneyAmount { return new BtcAmount(amount) as this } } + +const USDT_MICROS_PER_MAJOR_UNIT = 1_000_000n +const USDT_MICROS_PER_USD_CENT = 10_000n + +export class USDTAmount extends MoneyAmount { + static currencyId: IbexCurrencyId = 29 as IbexCurrencyId + + private constructor(amount: Money | bigint | string | number) { + super(amount, WalletCurrency.Usdt) + } + + static smallestUnits(units: string | bigint): USDTAmount | BigIntConversionError { + try { + return new USDTAmount(units) + } catch (error) { + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) + } + } + + static usdCents(cents: string | bigint): USDTAmount | BigIntConversionError { + try { + const centAmt = new Money(cents.toString(), "USDTUsdCents", Round.HALF_TO_EVEN) + const multiplier = USDTAmount.smallestUnits(USDT_MICROS_PER_USD_CENT) + if (multiplier instanceof BigIntConversionError) return multiplier + return new USDTAmount(multiplier.money.multiply(centAmt).toFixed(0)) + } catch (error) { + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) + } + } + + static fromNumber(d: number | string): USDTAmount | BigIntConversionError { + try { + const usdtAmt = new Money(d.toString(), "USDT", Round.HALF_TO_EVEN) + const multiplier = USDTAmount.smallestUnits(USDT_MICROS_PER_MAJOR_UNIT) + if (multiplier instanceof BigIntConversionError) return multiplier + return new USDTAmount(multiplier.money.multiply(usdtAmt).toFixed(0)) + } catch (error) { + return new BigIntConversionError( + error instanceof Error ? error.message : String(error), + ) + } + } + + static ZERO = new USDTAmount(0) + + asSmallestUnits(precision: number = 0): string { + return this.money.toFixed(precision) + } + + asUsdCents(): string { + return this.money.divide(USDT_MICROS_PER_USD_CENT.toString()).toFixed(0) + } + + asNumber(precision: number = 6): string { + return this.money.divide(USDT_MICROS_PER_MAJOR_UNIT.toString()).toFixed(precision) + } + + toIbex(): number { + return Number(this.asNumber(8)) + } + + getInstance(amount: Money): this { + return new USDTAmount(amount) as this + } +} diff --git a/src/domain/shared/amount.ts b/src/domain/shared/amount.ts index 0e867aad4..2f5438784 100644 --- a/src/domain/shared/amount.ts +++ b/src/domain/shared/amount.ts @@ -90,9 +90,16 @@ export const UsdPaymentAmount = (cents: bigint): UsdPaymentAmount => { } } -export const checkedToUsdPaymentAmount = ( +export function checkedToUsdPaymentAmount( amount: number | null, -): UsdPaymentAmount | ValidationError => { +): UsdPaymentAmount | ValidationError +export function checkedToUsdPaymentAmount< + T extends typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt, +>(amount: number | null, currency: T): PaymentAmount | ValidationError +export function checkedToUsdPaymentAmount( + amount: number | null, + currency: typeof WalletCurrency.Usd | typeof WalletCurrency.Usdt = WalletCurrency.Usd, +): UsdPaymentAmount | PaymentAmount | ValidationError { if (amount === null) { return new InvalidUsdPaymentAmountError() } @@ -105,7 +112,7 @@ export const checkedToUsdPaymentAmount = ( return new InvalidUsdPaymentAmountError() } if (!(amount && amount > 0)) return new InvalidUsdPaymentAmountError() - return paymentAmountFromNumber({ amount, currency: WalletCurrency.Usd }) + return paymentAmountFromNumber({ amount, currency }) } export const paymentAmountFromNumber = ({ diff --git a/src/domain/shared/index.ts b/src/domain/shared/index.ts index d7b3f887c..72ced5a95 100644 --- a/src/domain/shared/index.ts +++ b/src/domain/shared/index.ts @@ -9,6 +9,7 @@ export * from "./errors" export * from "./error-parsers" export * from "./error-parsers-unknown" export * from "./validation" +export { USDTAmount } from "./MoneyAmount" export const setErrorWarn = (error: DomainError): DomainError => { error.level = ErrorLevel.Warn diff --git a/src/domain/shared/index.types.d.ts b/src/domain/shared/index.types.d.ts index 4e2015b95..a8e6d7ec1 100644 --- a/src/domain/shared/index.types.d.ts +++ b/src/domain/shared/index.types.d.ts @@ -49,6 +49,7 @@ type WalletDescriptor = PartialWalletDescriptor & { type BtcPaymentAmount = PaymentAmount<"BTC"> type UsdPaymentAmount = PaymentAmount<"USD"> +type UsdtMicros = number & { readonly brand: unique symbol } type RequireField = T & Required> diff --git a/src/domain/shared/primitives.ts b/src/domain/shared/primitives.ts index 4bc41e1d2..8a23e05c1 100644 --- a/src/domain/shared/primitives.ts +++ b/src/domain/shared/primitives.ts @@ -5,6 +5,7 @@ export const WalletCurrency = { Usd: "USD", Jmd: "JMD", Btc: "BTC", + Usdt: "USDT", } as const export const ExchangeCurrencyUnit = { diff --git a/src/domain/wallets/index.types.d.ts b/src/domain/wallets/index.types.d.ts index 6aa876f61..ea93eac15 100644 --- a/src/domain/wallets/index.types.d.ts +++ b/src/domain/wallets/index.types.d.ts @@ -71,8 +71,8 @@ type PartialBaseWalletTransaction = { type BaseWalletTransaction = { readonly walletId: WalletId | undefined - readonly settlementAmount: Satoshis | UsdCents - readonly settlementFee: Satoshis | UsdCents + readonly settlementAmount: Satoshis | UsdCents | UsdtMicros + readonly settlementFee: Satoshis | UsdCents | UsdtMicros readonly settlementCurrency: WalletCurrency readonly settlementDisplayAmount: DisplayCurrencyMajorAmount @@ -189,7 +189,7 @@ interface IWalletsRepository { accountId, type, currency, - }: NewWalletInfo): Promise + }: NewWalletInfo): Promise findById(walletId: WalletId): Promise listByAccountId(accountId: AccountId): Promise @@ -200,7 +200,15 @@ interface IWalletsRepository { walletCurrency: WalletCurrency, ): Promise - upsertExternal({ accountId, currency, lnurlp }: { accountId: AccountId, currency: WalletCurrency, lnurlp: Lnurl }): Promise + upsertExternal({ + accountId, + currency, + lnurlp, + }: { + accountId: AccountId + currency?: WalletCurrency + lnurlp: Lnurl + }): Promise findExternalByAccountId(accountId: AccountId): Promise } @@ -239,3 +247,13 @@ type OnChainFeeCalculator = { } intraLedgerFees(): PaymentAmountInAllCurrencies } + +type PaymentInputValidatorConfig = ( + walletId: WalletId, +) => Promise + +type PaymentInputValidator = { + validatePaymentInput: ( + args: ValidatePaymentInputArgs, + ) => Promise | ValidationError | RepositoryError> +} diff --git a/src/domain/wallets/payment-input-validator.ts b/src/domain/wallets/payment-input-validator.ts index eefe1b093..0491b3786 100644 --- a/src/domain/wallets/payment-input-validator.ts +++ b/src/domain/wallets/payment-input-validator.ts @@ -1,4 +1,10 @@ -import { USDAmount, ValidationError, isUsdWallet, validator } from "@domain/shared" +import { + USDAmount, + USDTAmount, + ValidationError, + WalletCurrency, + validator, +} from "@domain/shared" import { isActiveAccount, walletBelongsToAccount } from "@domain/accounts" import { SendOnchainArgs } from "@services/ibex/types" @@ -8,23 +14,35 @@ import { SendOnchainArgs } from "@services/ibex/types" // else return true // } -const checkOnchainMin = async (o: { amount: USDAmount }) => { +const isUsdWalletForOnChainPayment = async (o: { wallet: Wallet }) => { + if ( + o.wallet.currency === WalletCurrency.Usd || + o.wallet.currency === WalletCurrency.Usdt + ) { + return true + } + return new ValidationError(`Expected USD, got ${o.wallet.currency}`) +} + +const checkOnchainMin = async (o: { amount: USDAmount | USDTAmount }) => { // TODO: Currently relying on Ibex to enforce dust limits // const { dustThreshold } = getOnChainWalletConfig() // const minBtc = BtcAmount.sats(dustThreshold.toString()) // const btcPrice = await PriceService().getUsdCentRealTimePrice(_) // if (btcPrice instanceof PriceServiceError) return new ValidationError(btcPrice) // const minUsd = minBtc.convertAtRate(MoneyAmount.from("50000", WalletCurrency.Usd)) - const minUsd = USDAmount.ZERO - return o.amount.isGreaterThan(minUsd) - ? true - : new ValidationError(`Amount must be greater than ${minUsd.asDollars()}`) + const isGreaterThanZero = + o.amount instanceof USDTAmount + ? o.amount.isGreaterThan(USDTAmount.ZERO) + : o.amount.isGreaterThan(USDAmount.ZERO) + + return isGreaterThanZero ? true : new ValidationError("Amount must be greater than 0") } type SendOnchainArgsWithContext = SendOnchainArgs & { wallet: Wallet; account: Account } export const OnchainUsdPaymentValidator = validator([ - isUsdWallet, + isUsdWalletForOnChainPayment, isActiveAccount, walletBelongsToAccount, checkOnchainMin, diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 070a0d7c7..fcf3f6ce1 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -3,6 +3,7 @@ import { GT } from "@graphql/index" import AccountUpdateLevelMutation from "@graphql/admin/root/mutation/account-update-level" import AccountUpdateStatusMutation from "@graphql/admin/root/mutation/account-update-status" import BusinessUpdateMapInfoMutation from "@graphql/admin/root/mutation/business-update-map-info" +import CashWalletCutoverUpdateMutation from "@graphql/admin/root/mutation/cash-wallet-cutover-update" import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" @@ -13,8 +14,7 @@ import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" export const mutationFields = { - unauthed: { - }, + unauthed: {}, authed: { userUpdatePhone: UserUpdatePhoneMutation, accountUpdateLevel: AccountUpdateLevelMutation, @@ -25,6 +25,7 @@ export const mutationFields = { businessDeleteMapInfo: BusinessDeleteMapInfoMutation, sendNotification: SendNotificationMutation, cashoutNotificationSend: sendCashoutSettledNotification, + cashWalletCutoverUpdate: CashWalletCutoverUpdateMutation, }, } diff --git a/src/graphql/admin/queries.ts b/src/graphql/admin/queries.ts index f18b8836a..deafdfd9f 100644 --- a/src/graphql/admin/queries.ts +++ b/src/graphql/admin/queries.ts @@ -1,4 +1,5 @@ import { GT } from "@graphql/index" +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" import AllLevelsQuery from "./root/query/all-levels" import LightningInvoiceQuery from "./root/query/lightning-invoice" @@ -15,6 +16,7 @@ import AccountDetailsByAccountId from "./root/query/account-details-by-account-i import MerchantsPendingApprovalQuery from "./root/query/merchants-pending-approval-listing" import IdDocumentReadUrlQuery from "./root/query/id-document-read-url" import NotificationTopicsQuery from "./root/query/notification-topics" +import BridgeReconciliationOrphansQuery from "./root/query/bridge-reconciliation-orphans" export const queryFields = { unauthed: {}, @@ -34,6 +36,8 @@ export const queryFields = { merchantsPendingApproval: MerchantsPendingApprovalQuery, idDocumentReadUrl: IdDocumentReadUrlQuery, notificationTopics: NotificationTopicsQuery, + bridgeReconciliationOrphans: BridgeReconciliationOrphansQuery, + cashWalletCutover: CashWalletCutoverQuery, }, } diff --git a/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts b/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts new file mode 100644 index 000000000..ff2df55e8 --- /dev/null +++ b/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts @@ -0,0 +1,61 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import CashWalletCutoverPayload from "@graphql/admin/types/payload/cash-wallet-cutover" +import CashWalletCutoverState from "@graphql/shared/types/scalar/cash-wallet-cutover-state" +import Timestamp from "@graphql/shared/types/scalar/timestamp" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +const CashWalletCutoverUpdateInput = GT.Input({ + name: "CashWalletCutoverUpdateInput", + fields: () => ({ + state: { type: GT.NonNull(CashWalletCutoverState) }, + scheduledAt: { type: Timestamp }, + cutoverVersion: { type: GT.Int }, + runId: { type: GT.String }, + pauseReason: { type: GT.String }, + }), +}) + +const CashWalletCutoverUpdateMutation = GT.Field< + null, + GraphQLAdminContext, + { + input: { + state: CashWalletCutoverState | Error + scheduledAt?: Date | Error + cutoverVersion?: number + runId?: string + pauseReason?: string + } + } +>({ + type: GT.NonNull(CashWalletCutoverPayload), + args: { + input: { type: GT.NonNull(CashWalletCutoverUpdateInput) }, + }, + resolve: async (_, { input }, ctx) => { + if (input.state instanceof Error) { + return { errors: [{ message: input.state.message }] } + } + if (input.scheduledAt instanceof Error) { + return { errors: [{ message: input.scheduledAt.message }] } + } + + const patch: Partial = { + state: input.state, + scheduledAt: input.scheduledAt, + cutoverVersion: input.cutoverVersion, + runId: input.runId, + pauseReason: input.pauseReason, + } + + const result = await CashWalletCutoverRepository().updateConfig(patch, ctx.user.id) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { errors: [], cashWalletCutover: result } + }, +}) + +export default CashWalletCutoverUpdateMutation diff --git a/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts new file mode 100644 index 000000000..48234d1be --- /dev/null +++ b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts @@ -0,0 +1,42 @@ +import { GT } from "@graphql/index" +import BridgeReconciliationOrphanObject from "@graphql/admin/types/object/bridge-reconciliation-orphan" +import { findOrphans } from "@services/mongoose/bridge-reconciliation-orphan" + +const BridgeReconciliationOrphansQuery = GT.Field({ + type: GT.NonNullList(BridgeReconciliationOrphanObject), + args: { + status: { type: GT.String, defaultValue: null }, + orphanType: { type: GT.String, defaultValue: null }, + limit: { type: GT.Int, defaultValue: 50 }, + }, + resolve: async ( + _: unknown, + { + status, + orphanType, + limit, + }: { status?: string; orphanType?: string; limit?: number }, + ) => { + const result = await findOrphans({ + status: status as "unmatched" | "resolved" | undefined, + orphanType: orphanType as + | "bridge_without_ibex" + | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" + | undefined, + limit: limit ?? 50, + }) + + if (result instanceof Error) throw result + + return result.map((o) => ({ + ...o, + detectedAt: o.detectedAt.toISOString(), + resolvedAt: o.resolvedAt?.toISOString() ?? null, + triageContext: JSON.stringify(o.triageContext), + })) + }, +}) + +export default BridgeReconciliationOrphansQuery diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index ab1413521..46da132ac 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -110,6 +110,21 @@ type BTCWallet implements Wallet { walletCurrency: WalletCurrency! } +type BridgeReconciliationOrphan { + amount: String + currency: String + customerId: String + detectedAt: String! + id: ID! + orphanKey: String! + orphanType: String! + resolvedAt: String + status: String! + transferId: String + triageContext: String! + txHash: String +} + input BusinessDeleteMapInfoInput { username: Username! } @@ -121,6 +136,38 @@ input BusinessUpdateMapInfoInput { username: Username! } +type CashWalletCutover { + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +type CashWalletCutoverPayload { + cashWalletCutover: CashWalletCutover + errors: [Error!]! +} + +enum CashWalletCutoverState { + COMPLETE + IN_PROGRESS + PRE +} + +input CashWalletCutoverUpdateInput { + cutoverVersion: Int + pauseReason: String + runId: String + scheduledAt: Timestamp + state: CashWalletCutoverState! +} + input CashoutNotificationSendInput { accountId: String! amount: Int! @@ -263,6 +310,7 @@ type Mutation { accountUpdateStatus(input: AccountUpdateStatusInput!): AccountDetailPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! + cashWalletCutoverUpdate(input: CashWalletCutoverUpdateInput!): CashWalletCutoverPayload! cashoutNotificationSend(input: CashoutNotificationSendInput!): SuccessPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! @@ -319,6 +367,8 @@ type Query { accountDetailsByUserPhone(phone: Phone!): AuditedAccount! accountDetailsByUsername(username: Username!): AuditedAccount! allLevels: [AccountLevel!]! + bridgeReconciliationOrphans(limit: Int = 50, orphanType: String = null, status: String = null): [BridgeReconciliationOrphan!]! + cashWalletCutover: CashWalletCutover! idDocumentReadUrl( """Storage key of the ID document file""" fileKey: String! @@ -551,6 +601,50 @@ type UsdWallet implements Wallet { walletCurrency: WalletCurrency! } +""" +A wallet belonging to an account which contains a USDT balance and a list of transactions. +""" +type UsdtWallet implements Wallet { + accountId: ID! + balance: FractionalCentAmount + id: ID! + isExternal: Boolean! + lnurlp: Lnurl + + """An unconfirmed incoming onchain balance.""" + pendingIncomingBalance: SignedAmount! + transactions( + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + transactionsByAddress( + """Returns the items that include this address.""" + address: OnChainAddress! + + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + walletCurrency: WalletCurrency! +} + input UserUpdatePhoneInput { accountUuid: ID! phone: Phone! @@ -614,6 +708,7 @@ interface Wallet { enum WalletCurrency { BTC USD + USDT } """Unique identifier of a wallet""" diff --git a/src/graphql/admin/types/index.ts b/src/graphql/admin/types/index.ts index 566bf6c92..95ebebcf9 100644 --- a/src/graphql/admin/types/index.ts +++ b/src/graphql/admin/types/index.ts @@ -1,5 +1,6 @@ import BtcWallet from "@graphql/shared/types/object/btc-wallet" import GraphQLApplicationError from "@graphql/shared/types/object/graphql-application-error" import UsdWallet from "@graphql/shared/types/object/usd-wallet" +import UsdtWallet from "@graphql/shared/types/object/usdt-wallet" -export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet] +export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet, UsdtWallet] diff --git a/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts b/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts new file mode 100644 index 000000000..7ce3bc480 --- /dev/null +++ b/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" + +const BridgeReconciliationOrphanObject = GT.Object({ + name: "BridgeReconciliationOrphan", + fields: () => ({ + id: { type: GT.NonNullID }, + orphanKey: { type: GT.NonNull(GT.String) }, + orphanType: { type: GT.NonNull(GT.String) }, + status: { type: GT.NonNull(GT.String) }, + txHash: { type: GT.String }, + transferId: { type: GT.String }, + customerId: { type: GT.String }, + amount: { type: GT.String }, + currency: { type: GT.String }, + detectedAt: { type: GT.NonNull(GT.String) }, + resolvedAt: { type: GT.String }, + triageContext: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeReconciliationOrphanObject diff --git a/src/graphql/admin/types/payload/cash-wallet-cutover.ts b/src/graphql/admin/types/payload/cash-wallet-cutover.ts new file mode 100644 index 000000000..5f209898c --- /dev/null +++ b/src/graphql/admin/types/payload/cash-wallet-cutover.ts @@ -0,0 +1,13 @@ +import { GT } from "@graphql/index" +import IError from "@graphql/shared/types/abstract/error" +import CashWalletCutoverObject from "@graphql/shared/types/object/cash-wallet-cutover" + +const CashWalletCutoverPayload = GT.Object({ + name: "CashWalletCutoverPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + cashWalletCutover: { type: CashWalletCutoverObject }, + }), +}) + +export default CashWalletCutoverPayload diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 2af9f8ffc..3fbcc6d6d 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -37,13 +37,28 @@ import { UnauthorizedIPMetadataCountryError, IbexError, InvalidLnurlError, + CustomApolloError, } from "@graphql/error" import { baseLogger } from "@services/logger" -const assertUnreachable = (x: any): never => { +const assertUnreachable = (x: unknown): never => { throw new Error(`This should never compile with ${x}`) } +const bridgeGqlError = ({ + code, + message, +}: { + code: string + message: string +}): CustomApolloError => + new CustomApolloError({ + code, + message, + forwardToClient: true, + logger: baseLogger, + }) + export const mapError = (error: ApplicationError): CustomApolloError => { const errorName = error.name as ApplicationErrorKey let message = "" @@ -471,10 +486,160 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "UnexpectedIbexResponse": return new IbexError(baseLogger) case "OfferNotFound": - return new NotFoundError({ - message: "Offer not available. Try again.", - logger: baseLogger + return new NotFoundError({ + message: "Offer not available. Try again.", + logger: baseLogger, + }) + + case "BridgeInvalidAmountError": + message = + error.message || "Amount must be strictly positive with at most 6 decimal places" + return bridgeGqlError({ + code: "BRIDGE_INVALID_AMOUNT", + message, + }) + + case "BridgeBelowMinimumWithdrawalError": + message = error.message || "Withdrawal amount is below the minimum" + return bridgeGqlError({ + code: "BRIDGE_BELOW_MINIMUM_WITHDRAWAL", + message, + }) + + case "BridgeDisabledError": + message = "Bridge integration is currently disabled" + return bridgeGqlError({ + code: "BRIDGE_DISABLED", + message, + }) + + case "BridgeAccountLevelError": + message = error.message || "Bridge requires at least a Personal account (Level 1+)" + return bridgeGqlError({ + code: "BRIDGE_ACCOUNT_LEVEL_ERROR", + message, + }) + + case "BridgeKycPendingError": + message = "KYC verification is pending" + return bridgeGqlError({ + code: "BRIDGE_KYC_PENDING", + message, + }) + + case "BridgeKycRejectedError": + message = "KYC verification was rejected" + return bridgeGqlError({ + code: "BRIDGE_KYC_REJECTED", + message, + }) + + case "BridgeKycOffboardedError": + message = "Your account has been offboarded from Bridge. Please contact support." + return bridgeGqlError({ + code: "BRIDGE_KYC_OFFBOARDED", + message, }) + + case "BridgeKycTierCeilingExceededError": + message = error.message || "Withdrawal amount exceeds the KYC tier ceiling" + return bridgeGqlError({ + code: "BRIDGE_KYC_TIER_CEILING_EXCEEDED", + message, + }) + + case "BridgeCustomerNotFoundError": + message = "Bridge customer not found" + return bridgeGqlError({ + code: "BRIDGE_CUSTOMER_NOT_FOUND", + message, + }) + + case "BridgeInsufficientFundsError": + message = "Insufficient funds for withdrawal" + return bridgeGqlError({ + code: "BRIDGE_INSUFFICIENT_FUNDS", + message, + }) + + case "BridgeWithdrawalNetAmountTooLowError": + message = error.message || "Withdrawal amount must exceed estimated customer fees" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", + message, + }) + + case "BridgeWithdrawalNotFoundError": + message = error.message || "Withdrawal request not found" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_NOT_FOUND", + message, + }) + + case "BridgeWithdrawalAlreadyInitiatedError": + message = + error.message || + "Withdrawal has already been submitted to Bridge and cannot be cancelled" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", + message, + }) + + case "BridgeRateLimitError": + message = "Rate limit exceeded, please try again later" + return bridgeGqlError({ + code: "BRIDGE_RATE_LIMIT", + message, + }) + + case "BridgeTimeoutError": + message = "Request timed out" + return bridgeGqlError({ + code: "BRIDGE_TIMEOUT", + message, + }) + + case "BridgeTransferFailedError": + message = error.message || "Transfer failed" + return bridgeGqlError({ + code: "BRIDGE_TRANSFER_FAILED", + message, + }) + + case "BridgeDepositInstructionsMissingError": + message = + error.message || + "Bridge did not return crypto deposit instructions for this withdrawal" + return bridgeGqlError({ + code: "BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", + message, + }) + + case "BridgeWebhookValidationError": + message = "Invalid webhook signature" + return bridgeGqlError({ + code: "BRIDGE_WEBHOOK_VALIDATION", + message, + }) + + case "BridgeApiError": + message = error.message || "Bridge API error" + return bridgeGqlError({ + code: "BRIDGE_API_ERROR", + message, + }) + + case "BridgePlaidNotAvailableError": + message = error.message || "Plaid bank account linking is not available" + return bridgeGqlError({ + code: "BRIDGE_PLAID_NOT_AVAILABLE", + message, + }) + + case "BridgeError": + message = error.message || "Bridge API error" + return bridgeGqlError({ code: "BRIDGE_ERROR", message }) + // ---------- // Unhandled below here // ---------- @@ -733,6 +898,45 @@ 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 }) + + case "CashWalletMigrationFailedError": + message = "Cash Wallet migration needs support review." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverPreflightError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverTreasuryInsufficientBalanceError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMissingLegacyUsdWalletError": + message = "Legacy USD Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMissingUsdtWalletError": + message = "USDT Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletCutoverAmountError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletMigrationTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletCutoverStateTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + case "UnknownCaptchaError": message = `Unknown error occurred (code: ${error.name}${ error.message ? ": " + error.message : "" @@ -745,15 +949,15 @@ export const mapError = (error: ApplicationError): CustomApolloError => { } // Move to CustomApolloError class? -export const apolloErrorResponse = (e: CustomApolloError): { errors: IError[] } => { +export const apolloErrorResponse = (e: CustomApolloError): { errors: IError[] } => { return { errors: [ { message: e.message, path: e.path, - code: e.extensions.code - } - ] + code: e.extensions.code, + }, + ], } } @@ -762,12 +966,12 @@ export const mapAndParseErrorForGqlResponse = (err: ApplicationError): IError => return { message: mappedError.message, path: mappedError.path, - code: mappedError.extensions.code + code: mappedError.extensions.code, } } export const mapToGqlErrorList = (err: ApplicationError): IError[] => { - if (err instanceof ValidationError && err.errors) return err.errors.map(mapAndParseErrorForGqlResponse) + if (err instanceof ValidationError && err.errors) + return err.errors.map(mapAndParseErrorForGqlResponse) else return [mapAndParseErrorForGqlResponse(err)] - } diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 8e0ccc102..a1f91bd05 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" @@ -61,6 +62,13 @@ import RequestCashoutMutation from "./root/mutation/offers/request-cash-out" import InitiateCashoutMutation from "./root/mutation/offers/initiate-cash-out" import IdDocumentUploadUrlGenerateMutation from "./root/mutation/id-document-upload-url-generate" import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet" +import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc" +import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account" +import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account" +import BridgeCreateExternalAccountMutation from "./root/mutation/bridge-create-external-account" +import BridgeRequestWithdrawalMutation from "./root/mutation/bridge-request-withdrawal" +import BridgeInitiateWithdrawalMutation from "./root/mutation/bridge-initiate-withdrawal" +import BridgeCancelWithdrawalRequestMutation from "./root/mutation/bridge-cancel-withdrawal-request" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -115,6 +123,13 @@ export const mutationFields = { idDocumentUploadUrlGenerate: IdDocumentUploadUrlGenerateMutation, updateExternalWallet: UpdateExternalWalletMutation, + bridgeInitiateKyc: BridgeInitiateKycMutation, + bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, + bridgeAddExternalAccount: BridgeAddExternalAccountMutation, + bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation, + bridgeRequestWithdrawal: BridgeRequestWithdrawalMutation, + bridgeInitiateWithdrawal: BridgeInitiateWithdrawalMutation, + bridgeCancelWithdrawalRequest: BridgeCancelWithdrawalRequestMutation, }, atWalletLevel: { @@ -133,6 +148,7 @@ export const mutationFields = { lnInvoicePaymentSend: LnInvoicePaymentSendMutation, lnNoAmountInvoicePaymentSend: LnNoAmountInvoicePaymentSendMutation, lnNoAmountUsdInvoicePaymentSend: LnNoAmountUsdInvoicePaymentSendMutation, + lnurlPaymentSend: LnurlPaymentSendMutation, onChainAddressCreate: OnChainAddressCreateMutation, onChainAddressCurrent: OnChainAddressCurrentMutation, diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index d30d84a32..7bb5a98ab 100644 --- a/src/graphql/public/queries.ts +++ b/src/graphql/public/queries.ts @@ -1,4 +1,5 @@ import { GT } from "@graphql/index" +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" import MeQuery from "@graphql/public/root/query/me" import GlobalsQuery from "@graphql/public/root/query/globals" @@ -16,11 +17,17 @@ import BusinessMapMarkersQuery from "@graphql/public/root/query/business-map-mar import AccountDefaultWalletQuery from "@graphql/public/root/query/account-default-wallet" import AccountDefaultWalletIdQuery from "@graphql/public/root/query/account-default-wallet-id" import LnInvoicePaymentStatusQuery from "@graphql/public/root/query/ln-invoice-payment-status" + import NpubByUserNameQuery from "./root/query/username-npub-query" import IsFlashNpubQuery from "./root/query/is-flash-npub-query" import TransactionDetailsQuery from "./root/query/transaction-details" import LatestAccountUpgradeRequestQuery from "./root/query/account-upgrade-request" import SupportedBanksQuery from "./root/query/supported-banks" +import BridgeKycStatusQuery from "./root/query/bridge-kyc-status" +import BridgeVirtualAccountQuery from "./root/query/bridge-virtual-account" +import BridgeExternalAccountsQuery from "./root/query/bridge-external-accounts" +import BridgeWithdrawalRequestQuery from "./root/query/bridge-withdrawal-request" +import BridgeWithdrawalsQuery from "./root/query/bridge-withdrawals" export const queryFields = { unauthed: { @@ -39,12 +46,18 @@ export const queryFields = { npubByUsername: NpubByUserNameQuery, isFlashNpub: IsFlashNpubQuery, supportedBanks: SupportedBanksQuery, + cashWalletCutover: CashWalletCutoverQuery, }, authed: { atAccountLevel: { me: MeQuery, transactionDetails: TransactionDetailsQuery, latestAccountUpgradeRequest: LatestAccountUpgradeRequestQuery, + bridgeKycStatus: BridgeKycStatusQuery, + bridgeVirtualAccount: BridgeVirtualAccountQuery, + bridgeExternalAccounts: BridgeExternalAccountsQuery, + bridgeWithdrawalRequest: BridgeWithdrawalRequestQuery, + bridgeWithdrawals: BridgeWithdrawalsQuery, }, atWalletLevel: { onChainTxFee: OnChainTxFeeQuery, diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts new file mode 100644 index 000000000..2877471e2 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -0,0 +1,37 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeAddExternalAccountPayload = GT.Object({ + name: "BridgeAddExternalAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + externalAccount: { type: BridgeExternalAccountLink }, + }), +}) + +const bridgeAddExternalAccount = GT.Field({ + type: GT.NonNull(BridgeAddExternalAccountPayload), + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.addExternalAccount(domainAccount.id) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { externalAccount: result, errors: [] } + }, +}) + +export default bridgeAddExternalAccount diff --git a/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts b/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts new file mode 100644 index 000000000..1cdd9d9f1 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts @@ -0,0 +1,52 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeCancelWithdrawalRequestInput = GT.Input({ + name: "BridgeCancelWithdrawalRequestInput", + fields: () => ({ + withdrawalId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeCancelWithdrawalRequestPayload = GT.Object({ + name: "BridgeCancelWithdrawalRequestPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + withdrawal: { type: BridgeWithdrawal }, + }), +}) + +const bridgeCancelWithdrawalRequest = GT.Field({ + type: GT.NonNull(BridgeCancelWithdrawalRequestPayload), + args: { + input: { type: GT.NonNull(BridgeCancelWithdrawalRequestInput) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + const { withdrawalId } = args.input + + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.cancelWithdrawalRequest( + domainAccount.id, + withdrawalId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { withdrawal: result, errors: [] } + }, +}) + +export default bridgeCancelWithdrawalRequest diff --git a/src/graphql/public/root/mutation/bridge-create-external-account.ts b/src/graphql/public/root/mutation/bridge-create-external-account.ts new file mode 100644 index 000000000..0b56798a0 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-create-external-account.ts @@ -0,0 +1,79 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import BridgeCreateExternalAccountInput from "@graphql/public/types/input/bridge-create-external-account-input" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeCreateExternalAccountPayload = GT.Object({ + name: "BridgeCreateExternalAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + externalAccount: { type: BridgeExternalAccount }, + }), +}) + +const bridgeCreateExternalAccount = GT.Field({ + type: GT.NonNull(BridgeCreateExternalAccountPayload), + args: { + input: { type: GT.NonNull(BridgeCreateExternalAccountInput) }, + }, + resolve: async ( + _, + { + input, + }: { + input: { + bankName: string + accountNumber: string + routingNumber: string + accountOwnerName: string + checkingOrSavings?: string + streetLine1: string + city: string + state: string + postalCode: string + country: string + } + }, + { domainAccount }: GraphQLPublicContextAuth, + ) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.createExternalAccount(domainAccount.id, { + account_owner_name: input.accountOwnerName, + bank_name: input.bankName, + currency: "usd", + account_type: "us", + account: { + account_number: input.accountNumber, + routing_number: input.routingNumber, + checking_or_savings: + (input.checkingOrSavings as "checking" | "savings") ?? "checking", + }, + address: { + street_line_1: input.streetLine1, + city: input.city, + state: input.state, + postal_code: input.postalCode, + country: input.country, + }, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { externalAccount: result, errors: [] } + }, +}) + +export default bridgeCreateExternalAccount diff --git a/src/graphql/public/root/mutation/bridge-create-virtual-account.ts b/src/graphql/public/root/mutation/bridge-create-virtual-account.ts new file mode 100644 index 000000000..a7eb22fe2 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-create-virtual-account.ts @@ -0,0 +1,37 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeCreateVirtualAccountPayload = GT.Object({ + name: "BridgeCreateVirtualAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + virtualAccount: { type: BridgeVirtualAccount }, + }), +}) + +const bridgeCreateVirtualAccount = GT.Field({ + type: GT.NonNull(BridgeCreateVirtualAccountPayload), + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.createVirtualAccount(domainAccount.id) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { virtualAccount: result, errors: [] } + }, +}) + +export default bridgeCreateVirtualAccount diff --git a/src/graphql/public/root/mutation/bridge-initiate-kyc.ts b/src/graphql/public/root/mutation/bridge-initiate-kyc.ts new file mode 100644 index 000000000..bf88890d9 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-initiate-kyc.ts @@ -0,0 +1,55 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeKycLink from "@graphql/public/types/object/bridge-kyc-link" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeInitiateKycPayload = GT.Object({ + name: "BridgeInitiateKycPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + kycLink: { type: BridgeKycLink }, + }), +}) + +const BridgeInitiateKycInput = GT.Input({ + name: "BridgeInitiateKycInput", + fields: () => ({ + email: { type: GT.String }, + type: { type: GT.String }, + full_name: { type: GT.String }, + }), +}) + +const bridgeInitiateKyc = GT.Field({ + type: GT.NonNull(BridgeInitiateKycPayload), + args: { + input: { type: GT.NonNull(BridgeInitiateKycInput) }, + }, + resolve: async (_, { input }, { domainAccount }: GraphQLPublicContextAuth) => { + const { email, type, full_name } = input + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.initiateKyc({ + accountId: domainAccount.id, + email, + type, + full_name, + }) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { kycLink: result, errors: [] } + }, +}) + +export default bridgeInitiateKyc diff --git a/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts b/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts new file mode 100644 index 000000000..855136aaf --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts @@ -0,0 +1,49 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeInitiateWithdrawalInput = GT.Input({ + name: "BridgeInitiateWithdrawalInput", + fields: () => ({ + withdrawalId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeInitiateWithdrawalPayload = GT.Object({ + name: "BridgeInitiateWithdrawalPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + withdrawal: { type: BridgeWithdrawal }, + }), +}) + +const bridgeInitiateWithdrawal = GT.Field({ + type: GT.NonNull(BridgeInitiateWithdrawalPayload), + args: { + input: { type: GT.NonNull(BridgeInitiateWithdrawalInput) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + const { withdrawalId } = args.input + + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.initiateWithdrawal(domainAccount.id, withdrawalId) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { withdrawal: result, errors: [] } + }, +}) + +export default bridgeInitiateWithdrawal diff --git a/src/graphql/public/root/mutation/bridge-request-withdrawal.ts b/src/graphql/public/root/mutation/bridge-request-withdrawal.ts new file mode 100644 index 000000000..2aba8f945 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-request-withdrawal.ts @@ -0,0 +1,75 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { + BridgeDisabledError, + BridgeAccountLevelError, + BridgeInvalidAmountError, + BridgeBelowMinimumWithdrawalError, +} from "@services/bridge/errors" + +const BridgeRequestWithdrawalInput = GT.Input({ + name: "BridgeRequestWithdrawalInput", + fields: () => ({ + amount: { type: GT.NonNull(GT.String) }, + externalAccountId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeRequestWithdrawalPayload = GT.Object({ + name: "BridgeRequestWithdrawalPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + withdrawal: { type: BridgeWithdrawal }, + }), +}) + +const bridgeRequestWithdrawal = GT.Field({ + type: GT.NonNull(BridgeRequestWithdrawalPayload), + args: { + input: { type: GT.NonNull(BridgeRequestWithdrawalInput) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + const { amount, externalAccountId } = args.input + + if (!/^\d+(\.\d{1,6})?$/.test(amount) || parseFloat(amount) <= 0) { + return { + errors: [mapAndParseErrorForGqlResponse(new BridgeInvalidAmountError())], + } + } + + if (parseFloat(amount) < BridgeConfig.minWithdrawalAmount) { + return { + errors: [ + mapAndParseErrorForGqlResponse( + new BridgeBelowMinimumWithdrawalError(BridgeConfig.minWithdrawalAmount), + ), + ], + } + } + + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.requestWithdrawal( + domainAccount.id, + amount, + externalAccountId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { withdrawal: result, errors: [] } + }, +}) + +export default bridgeRequestWithdrawal diff --git a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts index 7c5b4dd2a..cf663acc1 100644 --- a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts @@ -1,4 +1,5 @@ import { Payments } from "@app" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import { checkedToWalletId } from "@domain/wallets" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { GT } from "@graphql/index" @@ -9,7 +10,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import dedent from "dedent" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" // import { RequestInit, Response } from 'node-fetch' -import { EmailService } from "@services/email" const IntraLedgerUsdPaymentSendInput = GT.Input({ name: "IntraLedgerUsdPaymentSendInput", @@ -34,7 +34,11 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { + resolve: async ( + _, + args, + { domainAccount, cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -52,11 +56,23 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field({ extensions: { complexity: 120, }, @@ -37,7 +31,7 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ args: { input: { type: GT.NonNull(LnNoAmountUsdInvoiceFeeProbeInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest, amount } = args.input for (const input of [walletId, paymentRequest, amount]) { @@ -46,6 +40,15 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ } } + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + // FLASH FORK: create IBEX fee estimation instead of Galoy fee estimation // const { result: feeSatAmount, error } = // await Payments.getNoAmountLightningFeeEstimationForUsdWallet({ @@ -55,13 +58,20 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ // }) // TODO: Move Ibex call to Payments interface - const checkedAmount = USDAmount.cents(amount.toString()) - if (checkedAmount instanceof BigIntConversionError) return checkedAmount - const resp: IbexFeeEstimation | IbexError = await Ibex.getLnFeeEstimation({ - invoice: paymentRequest as Bolt11, - send: checkedAmount, + const checkedAmount = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, + amount: amount.toString(), }) - if (resp instanceof IbexError) return { errors: [mapAndParseErrorForGqlResponse(resp)] } + if (checkedAmount instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(checkedAmount)] } + } + const resp: IbexFeeEstimation | IbexError = + await Ibex.getLnFeeEstimation({ + invoice: paymentRequest as Bolt11, + send: checkedAmount, + }) + if (resp instanceof IbexError) + return { errors: [mapAndParseErrorForGqlResponse(resp)] } // if (resp.amount === undefined) return new UnexpectedIbexResponse("Unable to parse fee.") // const feeSatAmount: PaymentAmount = { diff --git a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts index 0ffbb8aca..12298ec34 100644 --- a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts @@ -13,10 +13,11 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract // FLASH FORK: import ibex dependencies import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { usdWalletAmountFromWalletId } from "@app/wallets" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" -import { BigIntConversionError, checkedToUsdPaymentAmount, paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" const LnNoAmountUsdInvoicePaymentInput = GT.Input({ name: "LnNoAmountUsdInvoicePaymentInput", @@ -63,7 +64,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnNoAmountUsdInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest, amount, memo } = args.input if (walletId instanceof InputValidationError) { @@ -90,19 +91,38 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< if (!domainAccount) throw new Error("Authentication required") // eslint-disable-next-line @typescript-eslint/no-explicit-any + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } - const usCents = USDAmount.cents(amount.toString()) - if (usCents instanceof BigIntConversionError) return usCents + const usCents = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, + amount: amount.toString(), + }) + if (usCents instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(usCents)], + } + } const PayLightningInvoice = await Ibex.payInvoice({ invoice: paymentRequest as Bolt11, - accountId: walletId, + accountId: routedWalletId, send: usCents, }) if (PayLightningInvoice instanceof IbexError) { return { status: "failed", - errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)] + errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)], } } diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.ts b/src/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.ts index 65fd9cc7d..6a0a32787 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.ts @@ -1,6 +1,7 @@ import dedent from "dedent" import { Wallets } from "@app" +import { resolveCashWalletRecipientMutationWalletId } from "@app/cash-wallet-cutover" import { GT } from "@graphql/index" import Memo from "@graphql/shared/types/scalar/memo" @@ -36,7 +37,10 @@ const LnUsdInvoiceCreateOnBehalfOfRecipientInput = GT.Input({ }), }) -const LnUsdInvoiceCreateOnBehalfOfRecipientMutation = GT.Field({ +const LnUsdInvoiceCreateOnBehalfOfRecipientMutation = GT.Field< + null, + GraphQLPublicContext +>({ extensions: { complexity: 120, }, @@ -48,7 +52,7 @@ const LnUsdInvoiceCreateOnBehalfOfRecipientMutation = GT.Field({ args: { input: { type: GT.NonNull(LnUsdInvoiceCreateOnBehalfOfRecipientInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { cashWalletClientCapabilities }) => { const { recipientWalletId, amount, memo, descriptionHash, expiresIn } = args.input for (const input of [recipientWalletId, amount, memo, descriptionHash, expiresIn]) { if (input instanceof Error) { @@ -56,8 +60,16 @@ const LnUsdInvoiceCreateOnBehalfOfRecipientMutation = GT.Field({ } } - const invoice = await Wallets.addInvoiceForRecipientForUsdWallet({ + const routedRecipientWalletId = await resolveCashWalletRecipientMutationWalletId({ recipientWalletId, + client: cashWalletClientCapabilities, + }) + if (routedRecipientWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedRecipientWalletId)] } + } + + const invoice = await Wallets.addInvoiceForRecipientForUsdWallet({ + recipientWalletId: routedRecipientWalletId, amount, memo, descriptionHash, diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-create.ts b/src/graphql/public/root/mutation/ln-usd-invoice-create.ts index 2cbedab73..064a5d4d3 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-create.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-create.ts @@ -9,6 +9,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import LnInvoicePayload from "@graphql/public/types/payload/ln-invoice" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { Wallets } from "@app/index" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" const LnUsdInvoiceCreateInput = GT.Input({ @@ -18,7 +19,10 @@ const LnUsdInvoiceCreateInput = GT.Input({ type: GT.NonNull(WalletId), description: "Wallet ID for a USD wallet belonging to the current user.", }, - amount: { type: GT.NonNull(FractionalCentAmount), description: "Amount in USD cents." }, + amount: { + type: GT.NonNull(FractionalCentAmount), + description: "Amount in USD cents.", + }, memo: { type: Memo, description: "Optional memo for the lightning invoice." }, expiresIn: { type: Minutes, @@ -27,7 +31,7 @@ const LnUsdInvoiceCreateInput = GT.Input({ }), }) -const LnUsdInvoiceCreateMutation = GT.Field({ +const LnUsdInvoiceCreateMutation = GT.Field({ extensions: { complexity: 120, }, @@ -39,7 +43,7 @@ const LnUsdInvoiceCreateMutation = GT.Field({ args: { input: { type: GT.NonNull(LnUsdInvoiceCreateInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, amount, memo, expiresIn } = args.input for (const input of [walletId, amount, memo, expiresIn]) { @@ -48,8 +52,17 @@ const LnUsdInvoiceCreateMutation = GT.Field({ } } - const invoice = await Wallets.addInvoiceForSelfForUsdWallet({ + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + + const invoice = await Wallets.addInvoiceForSelfForUsdWallet({ + walletId: routedWalletId, amount, memo, expiresIn, diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts index 8c2223ff9..c1a06e346 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts @@ -1,26 +1,16 @@ -import { InvalidFeeProbeStateError } from "@domain/bitcoin/lightning" - -// import { Payments } from "@app" - import { GT } from "@graphql/index" import WalletId from "@graphql/shared/types/scalar/wallet-id" import CentAmountPayload from "@graphql/public/types/payload/cent-amount" import LnPaymentRequest from "@graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import { checkedToWalletId } from "@domain/wallets" -import { normalizePaymentAmount } from "../../../shared/root/mutation" - -// FLASH FORK: import ibex dependencies import Ibex from "@services/ibex/client" -import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" -import { ValidationError, WalletCurrency } from "@domain/shared" -import { baseLogger } from "@services/logger" -import IError from "@graphql/shared/types/abstract/error" -import USDCentsScalar from "@graphql/shared/types/scalar/usd-cents" -import CentAmount from "@graphql/public/types/scalar/cent-amount" +import { IbexError } from "@services/ibex/errors" +import { WalletsRepository } from "@services/mongoose" // import { IbexRoutes } from "../../../../services/ibex/Routes" // import { requestIBexPlugin } from "../../../../services/ibex/IbexHelper" @@ -64,7 +54,7 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< args: { input: { type: GT.NonNull(LnUsdInvoiceFeeProbeInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest } = args.input if (walletId instanceof Error) { @@ -79,6 +69,15 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< if (walletIdChecked instanceof Error) return { errors: [mapAndParseErrorForGqlResponse(walletIdChecked)] } + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId: walletIdChecked, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + // FLASH FORK: create IBEX fee estimation instead of Galoy fee estimation // const { result: feeSatAmount, error } = // await Payments.getLightningFeeEstimationForUsdWallet({ @@ -86,12 +85,18 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< // uncheckedPaymentRequest: paymentRequest, // }) + const wallet = await WalletsRepository().findById(routedWalletId) + if (wallet instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(wallet)] } + } + const resp = await Ibex.getLnFeeEstimation({ invoice: paymentRequest as Bolt11, - // send: { currencyId: USDollars.currencyId }, + currency: wallet.currency, }) - if (resp instanceof IbexError) return { errors: [mapAndParseErrorForGqlResponse(resp)] } - + if (resp instanceof IbexError) + return { errors: [mapAndParseErrorForGqlResponse(resp)] } + return { errors: [], invoiceAmount: resp.invoice, 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/root/mutation/onchain-payment-send-all.ts b/src/graphql/public/root/mutation/onchain-payment-send-all.ts index b6a2f2003..c40b2c50a 100644 --- a/src/graphql/public/root/mutation/onchain-payment-send-all.ts +++ b/src/graphql/public/root/mutation/onchain-payment-send-all.ts @@ -10,6 +10,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import { Wallets } from "@app" import { getBalanceForWallet } from "@app/wallets" +import { USDAmount, WalletCurrency } from "@domain/shared" const OnChainPaymentSendAllInput = GT.Input({ name: "OnChainPaymentSendAllInput", @@ -62,8 +63,14 @@ const OnChainPaymentSendAllMutation = GT.Field< return { errors: [{ message: speed.message }] } } - const amount = await getBalanceForWallet({ walletId }) + 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" }] } + } const result = await Wallets.payOnChainByWalletId({ senderAccount: domainAccount, diff --git a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index bdd8f4b83..f85b02c55 100644 --- a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -11,7 +11,8 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { Wallets } from "@app/index" -import { BigIntConversionError, USDAmount } from "@domain/shared" +import { usdWalletAmountFromWalletId } from "@app/wallets" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" const OnChainUsdPaymentSendInput = GT.Input({ name: "OnChainUsdPaymentSendInput", @@ -47,7 +48,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -67,26 +68,46 @@ const OnChainUsdPaymentSendMutation = GT.Field< } if (!domainAccount) throw new Error("Authentication required") - const usdAmount = USDAmount.cents(amount.toString()) - if (usdAmount instanceof BigIntConversionError) return usdAmount - + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } + + const usdAmount = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, + amount: amount.toString(), + }) + if (usdAmount instanceof Error) { + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(usdAmount)], + } + } + const result = await Wallets.payOnChainByWalletId({ senderAccount: domainAccount, - senderWalletId: walletId, + senderWalletId: routedWalletId, amount: usdAmount, address, speed, memo, }) if (result instanceof Error) { - return { - status: PaymentSendStatus.Failure.value, - errors: [mapAndParseErrorForGqlResponse(result)] + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(result)], } } return { - status: result.status.value, - errors: [] + status: result.status.value, + errors: [], } }, }) diff --git a/src/graphql/public/root/query/account-default-wallet-id.ts b/src/graphql/public/root/query/account-default-wallet-id.ts index 23763edda..f91ebc4a1 100644 --- a/src/graphql/public/root/query/account-default-wallet-id.ts +++ b/src/graphql/public/root/query/account-default-wallet-id.ts @@ -1,10 +1,11 @@ +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" import Username from "@graphql/shared/types/scalar/username" import WalletId from "@graphql/shared/types/scalar/wallet-id" import { AccountsRepository } from "@services/mongoose" -const AccountDefaultWalletIdQuery = GT.Field({ +const AccountDefaultWalletIdQuery = GT.Field({ deprecationReason: "will be migrated to AccountDefaultWalletId", type: GT.NonNull(WalletId), args: { @@ -12,7 +13,7 @@ const AccountDefaultWalletIdQuery = GT.Field({ type: GT.NonNull(Username), }, }, - resolve: async (_, args) => { + resolve: async (_, args, { cashWalletClientCapabilities }) => { const { username } = args if (username instanceof Error) { @@ -24,8 +25,13 @@ const AccountDefaultWalletIdQuery = GT.Field({ throw mapError(account) } - const walletId = account.defaultWalletId - return walletId + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId }, }) diff --git a/src/graphql/public/root/query/account-default-wallet.ts b/src/graphql/public/root/query/account-default-wallet.ts index 1387c0169..8d5b12f47 100644 --- a/src/graphql/public/root/query/account-default-wallet.ts +++ b/src/graphql/public/root/query/account-default-wallet.ts @@ -1,4 +1,4 @@ -import { Wallets } from "@app" +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" import { CouldNotFindWalletFromUsernameAndCurrencyError } from "@domain/errors" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" @@ -7,7 +7,7 @@ import WalletCurrency from "@graphql/shared/types/scalar/wallet-currency" import PublicWallet from "@graphql/public/types/abstract/public-wallet" import { AccountsRepository } from "@services/mongoose" -const AccountDefaultWalletQuery = GT.Field({ +const AccountDefaultWalletQuery = GT.Field({ type: GT.NonNull(PublicWallet), args: { username: { @@ -15,7 +15,7 @@ const AccountDefaultWalletQuery = GT.Field({ }, walletCurrency: { type: WalletCurrency }, }, - resolve: async (_, args) => { + resolve: async (_, args, { cashWalletClientCapabilities }) => { const { username, walletCurrency } = args if (username instanceof Error) { @@ -27,16 +27,21 @@ const AccountDefaultWalletQuery = GT.Field({ throw mapError(account) } - const wallets = await Wallets.listWalletsByAccountId(account.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) if (!walletCurrency) { - return wallets.find((wallet) => wallet.id === account.defaultWalletId) + return presentation.wallets.find( + (wallet) => wallet.id === presentation.defaultWalletId, + ) } - const wallet = wallets.find((wallet) => wallet.currency === walletCurrency) + const wallet = presentation.wallets.find( + (wallet) => wallet.currency === walletCurrency, + ) if (!wallet) { throw mapError(new CouldNotFindWalletFromUsernameAndCurrencyError(username)) } diff --git a/src/graphql/public/root/query/bridge-external-accounts.ts b/src/graphql/public/root/query/bridge-external-accounts.ts new file mode 100644 index 000000000..d58b9b41c --- /dev/null +++ b/src/graphql/public/root/query/bridge-external-accounts.ts @@ -0,0 +1,26 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError } from "@services/bridge/errors" + +const bridgeExternalAccounts = GT.Field({ + type: GT.List(BridgeExternalAccount), + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + const result = await BridgeService.getExternalAccounts(domainAccount.id) + if (result instanceof Error) { + throw mapAndParseErrorForGqlResponse(result) + } + + return result + }, +}) + +export default bridgeExternalAccounts diff --git a/src/graphql/public/root/query/bridge-kyc-status.ts b/src/graphql/public/root/query/bridge-kyc-status.ts new file mode 100644 index 000000000..fab9cc4b3 --- /dev/null +++ b/src/graphql/public/root/query/bridge-kyc-status.ts @@ -0,0 +1,25 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError } from "@services/bridge/errors" + +const bridgeKycStatus = GT.Field({ + type: GT.String, + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + const result = await BridgeService.getKycStatus(domainAccount.id) + if (result instanceof Error) { + throw mapAndParseErrorForGqlResponse(result) + } + + return result + }, +}) + +export default bridgeKycStatus diff --git a/src/graphql/public/root/query/bridge-virtual-account.ts b/src/graphql/public/root/query/bridge-virtual-account.ts new file mode 100644 index 000000000..41e877ef4 --- /dev/null +++ b/src/graphql/public/root/query/bridge-virtual-account.ts @@ -0,0 +1,39 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeAccountLevelError, BridgeDisabledError } from "@services/bridge/errors" + +const bridgeVirtualAccount = GT.Field({ + type: BridgeVirtualAccount, + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + if (domainAccount.level <= 0) { + throw mapAndParseErrorForGqlResponse(new BridgeAccountLevelError()) + } + + // KYC exists but not yet approved + if (domainAccount.bridgeKycStatus !== "approved") { + return { + pending: true, + message: "KYC verification is pending. Please wait for approval.", + } + } + + // KYC approved — return existing virtual account + const result = await BridgeService.getVirtualAccount(domainAccount.id) + if (result instanceof Error) { + throw mapAndParseErrorForGqlResponse(result) + } + + return result + }, +}) + +export default bridgeVirtualAccount diff --git a/src/graphql/public/root/query/bridge-withdrawal-request.ts b/src/graphql/public/root/query/bridge-withdrawal-request.ts new file mode 100644 index 000000000..70a251ded --- /dev/null +++ b/src/graphql/public/root/query/bridge-withdrawal-request.ts @@ -0,0 +1,32 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import { BridgeDisabledError } from "@services/bridge/errors" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { presentBridgeWithdrawal } from "@services/bridge/withdrawal-fees" +import { RepositoryError } from "@domain/errors" + +const bridgeWithdrawalRequest = GT.Field({ + type: BridgeWithdrawal, + args: { + id: { type: GT.NonNull(GT.ID) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + const withdrawal = await BridgeAccountsRepo.findWithdrawalById(args.id) + if (withdrawal instanceof RepositoryError) return null + + // Ownership check — never expose another account's withdrawal + if (withdrawal.accountId !== (domainAccount.id as string)) return null + + return presentBridgeWithdrawal(withdrawal) + }, +}) + +export default bridgeWithdrawalRequest diff --git a/src/graphql/public/root/query/bridge-withdrawals.ts b/src/graphql/public/root/query/bridge-withdrawals.ts new file mode 100644 index 000000000..1995670e9 --- /dev/null +++ b/src/graphql/public/root/query/bridge-withdrawals.ts @@ -0,0 +1,26 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError } from "@services/bridge/errors" + +const bridgeWithdrawals = GT.Field({ + type: GT.List(BridgeWithdrawal), + resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + const result = await BridgeService.getWithdrawals(domainAccount.id) + if (result instanceof Error) { + throw mapAndParseErrorForGqlResponse(result) + } + + return result + }, +}) + +export default bridgeWithdrawals diff --git a/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts b/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts index 90b96e51d..63977876f 100644 --- a/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts +++ b/src/graphql/public/root/query/on-chain-usd-tx-fee-query.ts @@ -1,11 +1,7 @@ import { PayoutSpeed as DomainPayoutSpeed } from "@domain/bitcoin/onchain" -import { paymentAmountFromNumber, USDAmount, ValidationError, WalletCurrency } from "@domain/shared" - -// import { Wallets } from "@app" +import { usdWalletAmountFromWalletId } from "@app/wallets" import { GT } from "@graphql/index" -import { mapError } from "@graphql/error-map" - import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import OnChainAddress from "@graphql/shared/types/scalar/on-chain-address" import PayoutSpeed from "@graphql/public/types/scalar/payout-speed" @@ -13,8 +9,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import OnChainUsdTxFee from "@graphql/public/types/object/onchain-usd-tx-fee" -import { normalizePaymentAmount } from "../../../shared/root/mutation" - // FLASH FORK: import ibex dependencies import Ibex from "@services/ibex/client" @@ -46,7 +40,10 @@ const OnChainUsdTxFeeQuery = GT.Field({ // speed, // }) - const send = USDAmount.cents(amount.toString()) + const send = await usdWalletAmountFromWalletId({ + walletId, + amount: amount.toString(), + }) if (send instanceof Error) return send const resp = await Ibex.estimateOnchainFee(send, address) diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 2935c0472..19d14781a 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -241,6 +241,125 @@ input BankAccountInput { currency: String! } +type BridgeAddExternalAccountPayload { + errors: [Error!]! + externalAccount: BridgeExternalAccountLink +} + +input BridgeCancelWithdrawalRequestInput { + withdrawalId: ID! +} + +type BridgeCancelWithdrawalRequestPayload { + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +input BridgeCreateExternalAccountInput { + accountNumber: String! + accountOwnerName: String! + bankName: String! + checkingOrSavings: String = "checking" + city: String! + country: String! + postalCode: String! + routingNumber: String! + state: String! + streetLine1: String! +} + +type BridgeCreateExternalAccountPayload { + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + +type BridgeCreateVirtualAccountPayload { + errors: [Error!]! + virtualAccount: BridgeVirtualAccount +} + +type BridgeExternalAccount { + accountNumberLast4: String! + bankName: String! + id: ID! + status: String! +} + +type BridgeExternalAccountLink { + expiresAt: String! + linkUrl: String! +} + +input BridgeInitiateKycInput { + email: String + full_name: String + type: String +} + +type BridgeInitiateKycPayload { + errors: [Error!]! + kycLink: BridgeKycLink +} + +input BridgeInitiateWithdrawalInput { + withdrawalId: ID! +} + +type BridgeInitiateWithdrawalPayload { + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +type BridgeKycLink { + kycLink: String! + tosLink: String! +} + +input BridgeRequestWithdrawalInput { + amount: String! + externalAccountId: ID! +} + +type BridgeRequestWithdrawalPayload { + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + +type BridgeVirtualAccount { + accountNumber: String + accountNumberLast4: String + bankName: String + id: ID + kycLink: String + message: String + pending: Boolean + routingNumber: String + tosLink: String +} + +type BridgeWithdrawal { + amount: String! + bridgeDeveloperFee: String + bridgeExchangeFee: String + bridgeTransferId: String + createdAt: String! + currency: String! + estimatedBridgeFee: String + estimatedBridgeFeePercent: String + estimatedCustomerFee: String + estimatedGasBuffer: String + externalAccountId: String + failureReason: String + finalAmount: String + flashFee: String + flashFeeIsEstimate: Boolean! + flashFeeNotice: String + flashFeePercent: String + id: ID! + status: String! + subtotalAmount: String +} + type BuildInformation { commitHash: String helmRevision: Int @@ -294,6 +413,25 @@ input CaptchaRequestAuthCodeInput { validationCode: String! } +type CashWalletCutover { + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +enum CashWalletCutoverState { + COMPLETE + IN_PROGRESS + PRE +} + type CashoutOffer { """The rate used when withdrawing to a JMD bank account""" exchangeRate: JMDCents @@ -795,6 +933,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! @@ -853,6 +1005,13 @@ type Mutation { accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! + bridgeAddExternalAccount: BridgeAddExternalAccountPayload! + bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! + bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! + bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! + bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! + bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! + bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! @@ -952,6 +1111,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! @@ -1221,9 +1386,15 @@ type PublicWallet { type Query { accountDefaultWallet(username: Username!, walletCurrency: WalletCurrency): PublicWallet! + bridgeExternalAccounts: [BridgeExternalAccount] + bridgeKycStatus: String + bridgeVirtualAccount: BridgeVirtualAccount + bridgeWithdrawalRequest(id: ID!): BridgeWithdrawal + bridgeWithdrawals: [BridgeWithdrawal] btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] businessMapMarkers: [MapMarker!]! + cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload @@ -1577,6 +1748,50 @@ type UsdWallet implements Wallet { walletCurrency: WalletCurrency! } +""" +A wallet belonging to an account which contains a USDT balance and a list of transactions. +""" +type UsdtWallet implements Wallet { + accountId: ID! + balance: FractionalCentAmount + id: ID! + isExternal: Boolean! + lnurlp: Lnurl + + """An unconfirmed incoming onchain balance.""" + pendingIncomingBalance: SignedAmount! + transactions( + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + transactionsByAddress( + """Returns the items that include this address.""" + address: OnChainAddress! + + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + walletCurrency: WalletCurrency! +} + type User { """Bank accounts available for cashout""" bankAccounts: [BankAccount!]! @@ -1849,6 +2064,7 @@ interface Wallet { enum WalletCurrency { BTC USD + USDT } """Unique identifier of a wallet""" diff --git a/src/graphql/public/types/index.ts b/src/graphql/public/types/index.ts index a134f1349..9d9acada0 100644 --- a/src/graphql/public/types/index.ts +++ b/src/graphql/public/types/index.ts @@ -4,6 +4,7 @@ import BtcWallet from "../../shared/types/object/btc-wallet" import GraphQLApplicationError from "../../shared/types/object/graphql-application-error" import UsdWallet from "../../shared/types/object/usd-wallet" +import UsdtWallet from "../../shared/types/object/usdt-wallet" import ConsumerAccount from "./object/consumer-account" import OneDayAccountLimit from "./object/one-day-account-limit" @@ -17,5 +18,6 @@ export const ALL_INTERFACE_TYPES = [ // BusinessAccount, BtcWallet, UsdWallet, + UsdtWallet, OneDayAccountLimit, ] diff --git a/src/graphql/public/types/input/bridge-create-external-account-input.ts b/src/graphql/public/types/input/bridge-create-external-account-input.ts new file mode 100644 index 000000000..cf477ce56 --- /dev/null +++ b/src/graphql/public/types/input/bridge-create-external-account-input.ts @@ -0,0 +1,19 @@ +import { GT } from "@graphql/index" + +const BridgeCreateExternalAccountInput = GT.Input({ + name: "BridgeCreateExternalAccountInput", + fields: () => ({ + bankName: { type: GT.NonNull(GT.String) }, + accountNumber: { type: GT.NonNull(GT.String) }, + routingNumber: { type: GT.NonNull(GT.String) }, + accountOwnerName: { type: GT.NonNull(GT.String) }, + checkingOrSavings: { type: GT.String, defaultValue: "checking" }, + streetLine1: { type: GT.NonNull(GT.String) }, + city: { type: GT.NonNull(GT.String) }, + state: { type: GT.NonNull(GT.String) }, + postalCode: { type: GT.NonNull(GT.String) }, + country: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeCreateExternalAccountInput diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts new file mode 100644 index 000000000..5d51c664d --- /dev/null +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeExternalAccountLink = GT.Object({ + name: "BridgeExternalAccountLink", + fields: () => ({ + linkUrl: { type: GT.NonNull(GT.String) }, + expiresAt: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExternalAccountLink diff --git a/src/graphql/public/types/object/bridge-external-account.ts b/src/graphql/public/types/object/bridge-external-account.ts new file mode 100644 index 000000000..92cae18ad --- /dev/null +++ b/src/graphql/public/types/object/bridge-external-account.ts @@ -0,0 +1,13 @@ +import { GT } from "@graphql/index" + +const BridgeExternalAccount = GT.Object({ + name: "BridgeExternalAccount", + fields: () => ({ + id: { type: GT.NonNullID, resolve: (obj) => obj.bridgeExternalAccountId }, + bankName: { type: GT.NonNull(GT.String) }, + accountNumberLast4: { type: GT.NonNull(GT.String) }, + status: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExternalAccount diff --git a/src/graphql/public/types/object/bridge-kyc-link.ts b/src/graphql/public/types/object/bridge-kyc-link.ts new file mode 100644 index 000000000..9f0071dd9 --- /dev/null +++ b/src/graphql/public/types/object/bridge-kyc-link.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeKycLink = GT.Object({ + name: "BridgeKycLink", + fields: () => ({ + kycLink: { type: GT.NonNull(GT.String) }, + tosLink: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeKycLink diff --git a/src/graphql/public/types/object/bridge-virtual-account.ts b/src/graphql/public/types/object/bridge-virtual-account.ts new file mode 100644 index 000000000..02a8c99b2 --- /dev/null +++ b/src/graphql/public/types/object/bridge-virtual-account.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" + +const BridgeVirtualAccount = GT.Object({ + name: "BridgeVirtualAccount", + fields: () => ({ + id: { + type: GT.ID, + resolve: (src) => src.virtualAccountId ?? src.bridgeVirtualAccountId, + }, + bankName: { type: GT.String }, + routingNumber: { type: GT.String }, + accountNumber: { type: GT.String }, + accountNumberLast4: { type: GT.String }, + pending: { type: GT.Boolean }, + message: { type: GT.String }, + kycLink: { type: GT.String }, + tosLink: { type: GT.String }, + }), +}) + +export default BridgeVirtualAccount diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts new file mode 100644 index 000000000..8b1740bf6 --- /dev/null +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -0,0 +1,39 @@ +import { GT } from "@graphql/index" +import { getBridgeWithdrawalFlashFeeNoticeForUser } from "@app/bridge/get-withdrawal-flash-fee-notice" +import { isFlashFeeEstimate } from "@services/bridge/withdrawal-fees" + +const BridgeWithdrawal = GT.Object({ + name: "BridgeWithdrawal", + fields: () => ({ + id: { type: GT.NonNullID }, + amount: { type: GT.NonNull(GT.String) }, + currency: { type: GT.NonNull(GT.String) }, + externalAccountId: { type: GT.String }, + status: { type: GT.NonNull(GT.String) }, + estimatedBridgeFeePercent: { type: GT.String }, + estimatedBridgeFee: { type: GT.String }, + estimatedGasBuffer: { type: GT.String }, + estimatedCustomerFee: { type: GT.String }, + flashFeePercent: { type: GT.String }, + flashFee: { type: GT.String }, + flashFeeIsEstimate: { type: GT.NonNull(GT.Boolean) }, + flashFeeNotice: { + type: GT.String, + resolve: (parent, _, { user }: GraphQLPublicContext) => { + const isEstimate = + parent.flashFeeIsEstimate === true || isFlashFeeEstimate(parent) + if (!isEstimate) return null + return getBridgeWithdrawalFlashFeeNoticeForUser(user) + }, + }, + bridgeDeveloperFee: { type: GT.String }, + bridgeExchangeFee: { type: GT.String }, + subtotalAmount: { type: GT.String }, + finalAmount: { type: GT.String }, + bridgeTransferId: { type: GT.String }, + failureReason: { type: GT.String }, + createdAt: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeWithdrawal diff --git a/src/graphql/public/types/object/business-account.ts b/src/graphql/public/types/object/business-account.ts index 97daf3670..1fd023820 100644 --- a/src/graphql/public/types/object/business-account.ts +++ b/src/graphql/public/types/object/business-account.ts @@ -1,4 +1,8 @@ -import { Accounts, Prices, Wallets } from "@app" +import { Accounts, Prices } from "@app" +import { + cashWalletHistoryWalletIdsForPresentation, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover" import { majorToMinorUnit, @@ -15,8 +19,6 @@ import { checkedConnectionArgs, } from "@graphql/connections" -import { WalletsRepository } from "@services/mongoose" - import IAccount from "../abstract/account" import Wallet from "../../../shared/types/abstract/wallet" @@ -30,7 +32,7 @@ import { TransactionConnection } from "../../../shared/types/object/transaction" import RealtimePrice from "./realtime-price" import { NotificationSettings } from "./notification-settings" -const BusinessAccount = GT.Object({ +const BusinessAccount = GT.Object({ name: "BusinessAccount", interfaces: () => [IAccount], isTypeOf: () => false, @@ -42,15 +44,36 @@ const BusinessAccount = GT.Object({ wallets: { type: GT.NonNullList(Wallet), - resolve: async (source: Account) => { - return Wallets.listWalletsByAccountId(source.id) + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.wallets }, }, defaultWalletId: { type: GT.NonNull(WalletId), - resolve: (source, args, { domainAccount }: { domainAccount: Account }) => - domainAccount.defaultWalletId, + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId + }, }, level: { @@ -60,8 +83,7 @@ const BusinessAccount = GT.Object({ displayCurrency: { type: GT.NonNull(DisplayCurrency), - resolve: (source, args, { domainAccount }: { domainAccount: Account }) => - domainAccount.displayCurrency, + resolve: (source, args, { domainAccount }) => domainAccount.displayCurrency, }, realtimePrice: { @@ -123,21 +145,28 @@ const BusinessAccount = GT.Object({ type: GT.List(WalletId), }, }, - resolve: async (source, args) => { + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs } + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + let { walletIds } = args - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + walletIds = cashWalletHistoryWalletIdsForPresentation({ + walletIds, + presentation, + }) const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ account: source, diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index 53e3e5a17..483b00518 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -1,4 +1,8 @@ -import { Accounts, Prices, Wallets } from "@app" +import { Accounts, Prices } from "@app" +import { + cashWalletHistoryWalletIdsForPresentation, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover" import { majorToMinorUnit, @@ -21,8 +25,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import RealtimePrice from "@graphql/public/types/object/realtime-price" import DisplayCurrency from "@graphql/shared/types/scalar/display-currency" -import { WalletsRepository } from "@services/mongoose" - import { listEndpoints } from "@app/callback" import AccountLevel from "../../../shared/types/scalar/account-level" @@ -54,14 +56,28 @@ const ConsumerAccount = GT.Object({ wallets: { type: GT.NonNullList(Wallet), - resolve: async (source) => { - return Wallets.listWalletsByAccountId(source.id) + resolve: async (source, args, { cashWalletClientCapabilities }) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.wallets }, }, defaultWalletId: { type: GT.NonNull(WalletId), - resolve: (source) => source.defaultWalletId, + resolve: async (source, args, { cashWalletClientCapabilities }) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId + }, }, displayCurrency: { @@ -145,21 +161,24 @@ const ConsumerAccount = GT.Object({ type: GT.List(WalletId), }, }, - resolve: async (source, args) => { + resolve: async (source, args, { cashWalletClientCapabilities }) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs } + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + let { walletIds } = args - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + walletIds = cashWalletHistoryWalletIdsForPresentation({ + walletIds, + presentation, + }) const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ account: source, diff --git a/src/graphql/shared/root/query/cash-wallet-cutover.ts b/src/graphql/shared/root/query/cash-wallet-cutover.ts new file mode 100644 index 000000000..71ca79e82 --- /dev/null +++ b/src/graphql/shared/root/query/cash-wallet-cutover.ts @@ -0,0 +1,14 @@ +import { GT } from "@graphql/index" +import CashWalletCutoverObject from "@graphql/shared/types/object/cash-wallet-cutover" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +const CashWalletCutoverQuery = GT.Field({ + type: GT.NonNull(CashWalletCutoverObject), + resolve: async () => { + const config = await CashWalletCutoverRepository().getConfig() + if (config instanceof Error) throw config + return config + }, +}) + +export default CashWalletCutoverQuery diff --git a/src/graphql/shared/types/object/btc-wallet.ts b/src/graphql/shared/types/object/btc-wallet.ts index 3a29738e2..9991092a0 100644 --- a/src/graphql/shared/types/object/btc-wallet.ts +++ b/src/graphql/shared/types/object/btc-wallet.ts @@ -52,7 +52,10 @@ const BtcWallet = GT.Object({ description: "A balance stored in BTC.", resolve: async (source) => { if (source.type === WalletType.External) return null - const balanceSats = await Wallets.getBalanceForWallet({ walletId: source.id }) + const balanceSats = await Wallets.getBalanceForWallet({ + walletId: source.id, + currency: source.currency, + }) if (balanceSats instanceof Error) { throw mapError(balanceSats) } diff --git a/src/graphql/shared/types/object/cash-wallet-cutover.ts b/src/graphql/shared/types/object/cash-wallet-cutover.ts new file mode 100644 index 000000000..6570e1b69 --- /dev/null +++ b/src/graphql/shared/types/object/cash-wallet-cutover.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" +import Timestamp from "@graphql/shared/types/scalar/timestamp" +import CashWalletCutoverState from "@graphql/shared/types/scalar/cash-wallet-cutover-state" + +const CashWalletCutoverObject = GT.Object({ + name: "CashWalletCutover", + fields: () => ({ + state: { type: GT.NonNull(CashWalletCutoverState) }, + scheduledAt: { type: Timestamp }, + startedAt: { type: Timestamp }, + completedAt: { type: Timestamp }, + pausedAt: { type: Timestamp }, + pauseReason: { type: GT.String }, + cutoverVersion: { type: GT.NonNull(GT.Int) }, + runId: { type: GT.String }, + updatedBy: { type: GT.String }, + updatedAt: { type: GT.NonNull(Timestamp) }, + }), +}) + +export default CashWalletCutoverObject diff --git a/src/graphql/shared/types/object/cash-wallet-history.ts b/src/graphql/shared/types/object/cash-wallet-history.ts new file mode 100644 index 000000000..1d8e74228 --- /dev/null +++ b/src/graphql/shared/types/object/cash-wallet-history.ts @@ -0,0 +1,37 @@ +import { + CashWalletClientCapabilities, + cashWalletHistoryWalletsForPresentation, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover" +import { WalletType } from "@domain/wallets" +import { mapError } from "@graphql/error-map" + +type CashWalletHistoryContext = { + domainAccount?: Account + cashWalletClientCapabilities?: CashWalletClientCapabilities +} + +export const resolveCashWalletHistoryWalletsForWalletObject = async ({ + source, + ctx, +}: { + source: Wallet + ctx: CashWalletHistoryContext +}): Promise => { + if (source.type === WalletType.External) return [source] + + const { domainAccount, cashWalletClientCapabilities } = ctx + if (!domainAccount || !cashWalletClientCapabilities) return [source] + if (domainAccount.id !== source.accountId) return [source] + + const presentation = await resolveCashWalletPresentationForAccount({ + account: domainAccount, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return cashWalletHistoryWalletsForPresentation({ + wallets: [source], + presentation, + }) +} diff --git a/src/graphql/shared/types/object/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index af7e947f0..266c5a8ce 100644 --- a/src/graphql/shared/types/object/usd-wallet.ts +++ b/src/graphql/shared/types/object/usd-wallet.ts @@ -9,8 +9,9 @@ import { mapError } from "@graphql/error-map" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import { Wallets } from "@app" +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" -import { WalletCurrency as WalletCurrencyDomain } from "@domain/shared" +import { WalletCurrency as WalletCurrencyDomain, USDTAmount } from "@domain/shared" import { WalletType } from "@domain/wallets" import IWallet from "../abstract/wallet" @@ -20,8 +21,21 @@ import SignedAmount from "../scalar/signed-amount" import OnChainAddress from "../scalar/on-chain-address" import Lnurl from "../scalar/lnurl" +import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" import { TransactionConnection } from "./transaction" +export const usdtMicrosToUsdCents = (usdtMicros: bigint | number | string): number => { + const [wholeMicros, fractionalMicros] = usdtMicros.toString().split(".") + if (fractionalMicros && !/^0+$/.test(fractionalMicros)) { + throw new Error(`Cannot convert fractional USDT micros ${usdtMicros} to USD cents`) + } + + const amount = USDTAmount.smallestUnits(wholeMicros) + if (amount instanceof Error) throw amount + + return Number(amount.asUsdCents()) +} + const UsdWallet = GT.Object({ name: "UsdWallet", description: @@ -49,12 +63,35 @@ const UsdWallet = GT.Object({ }, balance: { type: FractionalCentAmount, - resolve: async (source) => { + resolve: async (source, args, ctx) => { if (source.type === WalletType.External) return null - const balance = await Wallets.getBalanceForWallet({ walletId: source.id }) + let balanceWallet = source + + if ( + "cashWalletClientCapabilities" in ctx && + ctx.domainAccount?.id === source.accountId + ) { + const presentation = await resolveCashWalletPresentationForAccount({ + account: ctx.domainAccount, + client: ctx.cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + if (source.id === presentation.legacyUsdWallet?.id) { + balanceWallet = presentation.activeSettlementWallet + } + } + + const balance = await Wallets.getBalanceForWallet({ + walletId: balanceWallet.id, + currency: balanceWallet.currency, + }) if (balance instanceof Error) { throw mapError(balance) } + if (balance instanceof USDTAmount) { + return usdtMicrosToUsdCents(balance.asSmallestUnits()) + } return Number(balance.asCents(8)) }, }, @@ -72,14 +109,19 @@ const UsdWallet = GT.Object({ transactions: { type: TransactionConnection, args: connectionArgs, - resolve: async (source, args) => { + resolve: async (source, args, ctx) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs } + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + const { result, error } = await Wallets.getTransactionsForWallets({ - wallets: [source], + wallets, paginationArgs, }) if (error instanceof Error) { @@ -105,7 +147,7 @@ const UsdWallet = GT.Object({ description: "Returns the items that include this address.", }, }, - resolve: async (source, args) => { + resolve: async (source, args, ctx) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs @@ -114,8 +156,13 @@ const UsdWallet = GT.Object({ const { address } = args if (address instanceof Error) throw address + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets: [source], + wallets, addresses: [address], paginationArgs, }) diff --git a/src/graphql/shared/types/object/usdt-wallet.ts b/src/graphql/shared/types/object/usdt-wallet.ts new file mode 100644 index 000000000..64831b1d1 --- /dev/null +++ b/src/graphql/shared/types/object/usdt-wallet.ts @@ -0,0 +1,164 @@ +import { GT } from "@graphql/index" +import { + connectionArgs, + connectionFromPaginatedArray, + checkedConnectionArgs, +} from "@graphql/connections" +import { normalizePaymentAmount } from "@graphql/shared/root/mutation" +import { mapError } from "@graphql/error-map" + +import { Wallets } from "@app" + +import { + USDAmount, + WalletCurrency as WalletCurrencyDomain, + USDTAmount, +} from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" + +import IWallet from "../abstract/wallet" + +import WalletCurrency from "../scalar/wallet-currency" +import SignedAmount from "../scalar/signed-amount" +import OnChainAddress from "../scalar/on-chain-address" + +import Lnurl from "../scalar/lnurl" + +import { resolveCashWalletHistoryWalletsForWalletObject } from "./cash-wallet-history" +import { TransactionConnection } from "./transaction" + +const UsdtWallet = GT.Object({ + name: "UsdtWallet", + description: + "A wallet belonging to an account which contains a USDT balance and a list of transactions.", + interfaces: () => [IWallet], + isTypeOf: (source) => source.currency === WalletCurrencyDomain.Usdt, + fields: () => ({ + id: { + type: GT.NonNullID, + }, + accountId: { + type: GT.NonNullID, + }, + walletCurrency: { + type: GT.NonNull(WalletCurrency), + resolve: (source) => source.currency, + }, + + lnurlp: { + type: Lnurl, + resolve: (source) => source.lnurlp, + }, + isExternal: { + type: GT.NonNull(GT.Boolean), + resolve: (source) => source.type === WalletType.External, + }, + + balance: { + type: FractionalCentAmount, + resolve: async (source) => { + const balance = await Wallets.getBalanceForWallet({ + walletId: source.id, + currency: source.currency, + }) + if (balance instanceof Error) { + throw mapError(balance) + } + if (balance instanceof USDTAmount) { + return Number(balance.asUsdCents()) + } + if (balance instanceof USDAmount) { + return Number(balance.asCents(8)) + } + throw mapError(balance) + }, + }, + pendingIncomingBalance: { + type: GT.NonNull(SignedAmount), + description: "An unconfirmed incoming onchain balance.", + resolve: async (source) => { + const balanceSats = await Wallets.getPendingOnChainBalanceForWallets([source]) + if (balanceSats instanceof Error) { + throw mapError(balanceSats) + } + return normalizePaymentAmount(balanceSats[source.id]).amount + }, + }, + transactions: { + type: TransactionConnection, + args: connectionArgs, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWallets({ + wallets, + paginationArgs, + }) + if (error instanceof Error) { + throw mapError(error) + } + + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + transactionsByAddress: { + type: TransactionConnection, + args: { + ...connectionArgs, + address: { + type: GT.NonNull(OnChainAddress), + description: "Returns the items that include this address.", + }, + }, + resolve: async (source, args, ctx) => { + const paginationArgs = checkedConnectionArgs(args) + if (paginationArgs instanceof Error) { + throw paginationArgs + } + + const { address } = args + if (address instanceof Error) throw address + + const wallets = await resolveCashWalletHistoryWalletsForWalletObject({ + source, + ctx, + }) + + const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + wallets, + addresses: [address], + paginationArgs, + }) + if (error instanceof Error) { + throw mapError(error) + } + + if (!result?.slice) throw error + + return connectionFromPaginatedArray( + result.slice, + result.total, + paginationArgs, + ) + }, + }, + }), +}) + +export default UsdtWallet diff --git a/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts b/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts new file mode 100644 index 000000000..831a377f8 --- /dev/null +++ b/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts @@ -0,0 +1,12 @@ +import { GT } from "@graphql/index" + +const CashWalletCutoverState = GT.Enum({ + name: "CashWalletCutoverState", + values: { + PRE: { value: "pre" }, + IN_PROGRESS: { value: "in_progress" }, + COMPLETE: { value: "complete" }, + }, +}) + +export default CashWalletCutoverState diff --git a/src/graphql/shared/types/scalar/usd-cents.ts b/src/graphql/shared/types/scalar/usd-cents.ts index c285f546b..5991b4045 100644 --- a/src/graphql/shared/types/scalar/usd-cents.ts +++ b/src/graphql/shared/types/scalar/usd-cents.ts @@ -1,4 +1,4 @@ -import { USDAmount } from "@domain/shared" +import { USDAmount, USDTAmount } from "@domain/shared" import { GT } from "@graphql/index" const USDCentsScalar = GT.Scalar({ @@ -16,8 +16,11 @@ const USDCentsScalar = GT.Scalar({ if (value instanceof USDAmount) { return Number(value.asCents(2)) } + if (value instanceof USDTAmount) { + return Number(value.asUsdCents()) + } else throw new Error(`Failed to serialize USDAmount: ${value}`) } }) -export default USDCentsScalar \ No newline at end of file +export default USDCentsScalar diff --git a/src/graphql/shared/types/scalar/wallet-currency.ts b/src/graphql/shared/types/scalar/wallet-currency.ts index 65b9090b6..9cbaf314d 100644 --- a/src/graphql/shared/types/scalar/wallet-currency.ts +++ b/src/graphql/shared/types/scalar/wallet-currency.ts @@ -5,6 +5,7 @@ const WalletCurrency = GT.Enum({ values: { BTC: {}, USD: {}, + USDT: {}, }, }) diff --git a/src/migrations/20260423000000-bridge-virtual-account-unique-accountid.ts b/src/migrations/20260423000000-bridge-virtual-account-unique-accountid.ts new file mode 100644 index 000000000..3b6d676b4 --- /dev/null +++ b/src/migrations/20260423000000-bridge-virtual-account-unique-accountid.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +/* eslint @typescript-eslint/no-var-requires: "off" */ + +/** + * Migration: unique index on bridgevirtualaccounts.accountId + * + * Background + * ---------- + * BridgeService.createVirtualAccount now carries an idempotency guard that + * uses a MongoDB upsert keyed on accountId. The upsert is only atomic against + * concurrent writes if a unique index backs the filter field. Without it two + * racing writes can both pass the findOneAndUpdate "find" phase, both insert, + * and produce duplicate VAs for the same account. + * + * What this migration does + * ------------------------ + * 1. Audits the collection for any existing accountId duplicates. + * 2. For each duplicate group, keeps the oldest document (earliest createdAt) + * and removes the rest. Removed _ids are logged so they can be reconciled + * against Bridge if necessary. + * 3. Drops any existing plain index on accountId (Mongoose created it as + * index:true before this change). + * 4. Creates the new unique index { accountId: 1 }. + * + * Rollback (down) + * --------------- + * Drops the unique index and restores a plain index so the app schema stays + * consistent with a pre-migration schema.ts. + */ + +const COLLECTION = "bridgevirtualaccounts" +const INDEX_NAME = "accountId_1" + +module.exports = { + async up(db) { + const col = db.collection(COLLECTION) + + // ── Step 1: find duplicate accountId groups ────────────────────────────── + const duplicates = await col + .aggregate([ + { $group: { _id: "$accountId", count: { $sum: 1 }, docs: { $push: "$$ROOT" } } }, + { $match: { count: { $gt: 1 } } }, + ]) + .toArray() + + if (duplicates.length > 0) { + console.log( + `[migration] Found ${duplicates.length} accountId(s) with duplicate VA records. Deduplicating...`, + ) + + for (const group of duplicates) { + // Sort ascending by createdAt — keep the winner (index 0), remove the rest + const sorted = group.docs.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ) + const [winner, ...losers] = sorted + const loserIds = losers.map((d) => d._id) + + console.log( + `[migration] accountId=${group._id} — keeping _id=${winner._id} bridgeVAId=${winner.bridgeVirtualAccountId}, removing ${loserIds.length} duplicate(s): ${loserIds.join(", ")}`, + ) + + await col.deleteMany({ _id: { $in: loserIds } }) + } + + console.log("[migration] Deduplication complete.") + } else { + console.log("[migration] No duplicate accountId records found. Proceeding.") + } + + // ── Step 2: drop stale plain index if it exists ────────────────────────── + const existingIndexes = await col.indexes() + const hasPlainIndex = existingIndexes.some( + (idx) => idx.name === INDEX_NAME && !idx.unique, + ) + if (hasPlainIndex) { + await col.dropIndex(INDEX_NAME) + console.log(`[migration] Dropped existing non-unique index "${INDEX_NAME}".`) + } + + // ── Step 3: create unique index ────────────────────────────────────────── + await col.createIndex({ accountId: 1 }, { unique: true, name: INDEX_NAME }) + console.log(`[migration] Created unique index "${INDEX_NAME}" on ${COLLECTION}.`) + }, + + async down(db) { + const col = db.collection(COLLECTION) + + // Drop the unique index + const existingIndexes = await col.indexes() + const hasUniqueIndex = existingIndexes.some( + (idx) => idx.name === INDEX_NAME && idx.unique, + ) + if (hasUniqueIndex) { + await col.dropIndex(INDEX_NAME) + console.log(`[migration] Dropped unique index "${INDEX_NAME}".`) + } + + // Restore plain index so Mongoose schema (pre-change) stays consistent + await col.createIndex({ accountId: 1 }, { name: INDEX_NAME }) + console.log(`[migration] Restored plain index "${INDEX_NAME}" on ${COLLECTION}.`) + }, +} diff --git a/src/scripts/cash-wallet-cutover-dashboard.ts b/src/scripts/cash-wallet-cutover-dashboard.ts new file mode 100644 index 000000000..cbd034b9f --- /dev/null +++ b/src/scripts/cash-wallet-cutover-dashboard.ts @@ -0,0 +1,983 @@ +#!/usr/bin/env node + +import fs from "fs" +import http from "http" + +import express from "express" +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +import { + buildCashWalletCutoverOperatorSnapshot, + CashWalletCutoverOperatorManifestAccount, + CashWalletCutoverOperatorSnapshot, + formatCashWalletCutoverOperatorSnapshotCsv, + formatOperatorBalance, + OperatorBalance, + parseCashWalletCutoverOperatorManifest, + refreshOperatorAccountCutoverBalanceAudit, +} from "@app/cash-wallet-cutover/operator-dashboard" +import { discoverCashWalletCutoverAccounts } from "@app/cash-wallet-cutover/discovery" +import { buildCashWalletCutoverPreflightReport } from "@app/cash-wallet-cutover/preflight" +import { getBalanceForWallet } from "@app/wallets" +import { WalletCurrency } from "@domain/shared" +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { baseLogger } from "@services/logger" +import { getFunderWalletId } from "@services/ledger/caching" + +const BALANCE_TIMEOUT_MS = 7_500 +const BALANCE_READ_ATTEMPTS = 3 +const BALANCE_READ_SPACING_MS = 1_000 + +const args = yargs(hideBin(process.argv)) + .option("port", { type: "number", default: 3450 }) + .option("manifest", { type: "array", string: true, demandOption: true }) + .option("expected-accounts", { type: "number", default: 60 }) + .option("snapshot-ttl-ms", { type: "number", default: 5_000 }) + .option("run-id", { type: "string" }) + .option("cutover-version", { type: "number" }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const readManifestAccounts = (): CashWalletCutoverOperatorManifestAccount[] => { + const accounts = args.manifest.flatMap((manifestPath) => + parseCashWalletCutoverOperatorManifest( + JSON.parse(fs.readFileSync(manifestPath, "utf8")), + ), + ) + + const seen = new Set() + for (const account of accounts) { + if (seen.has(account.accountId)) { + throw new Error(`Duplicate operator dashboard accountId: ${account.accountId}`) + } + seen.add(account.accountId) + } + + if (args["expected-accounts"] && accounts.length !== args["expected-accounts"]) { + throw new Error( + `Expected ${args["expected-accounts"]} operator accounts, loaded ${accounts.length}`, + ) + } + + return accounts +} + +const withBalanceTimeout = (balance: ReturnType) => + Promise.race([ + balance, + new Promise((resolve) => { + setTimeout( + () => resolve(new Error("Balance read timed out") as ApplicationError), + BALANCE_TIMEOUT_MS, + ) + }), + ]) + +let nextBalanceReadAt = 0 + +const readBalanceThrottled = async ( + request: Parameters[0], +) => { + const now = Date.now() + const scheduledAt = Math.max(now, nextBalanceReadAt) + nextBalanceReadAt = scheduledAt + BALANCE_READ_SPACING_MS + + const waitMs = scheduledAt - now + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + + return withBalanceTimeout(getBalanceForWallet(request)) +} + +const shortId = (value?: string) => (value ? value.slice(0, 8) : "-") + +type CachedBalance = OperatorBalance & { + walletId: WalletId + updatedAt?: string +} + +const html = ` + + + + + Cash Wallet Cutover Dashboard + + + +
+
+

Cash Wallet Cutover Dashboard

+
Raw Mongo wallets plus lazy IBEX balances. Presentation filtering is bypassed.
+
+
+ canStart: - + Loading... + + +
+
+
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + +
#PhoneAccountDefaultUSDUSD BalanceUSDTUSDT BalanceAuditMigrationAnomalies
+
+
+ + +` + +const start = async () => { + const manifestAccounts = readManifestAccounts() + const accountsRepo = AccountsRepository() + const walletsRepo = WalletsRepository() + const migrationsRepo = CashWalletCutoverRepository() + const migrationLookup = + args["run-id"] && args["cutover-version"] + ? { runId: args["run-id"], cutoverVersion: args["cutover-version"] } + : undefined + + await setupMongoConnection() + + const loadTreasuryAccountIds = async (): Promise => { + const funderWalletId = await getFunderWalletId() + + const funderWallet = await walletsRepo.findById(funderWalletId) + if (funderWallet instanceof Error) throw funderWallet + + return [funderWallet.accountId] + } + + const treasuryAccountIds = await loadTreasuryAccountIds() + + let cache: + | { + snapshot: CashWalletCutoverOperatorSnapshot + cachedAt: number + } + | undefined + let pending: Promise | undefined + const walletCurrencies = new Map() + const balanceCache = new Map() + const balanceQueue: Array<{ walletId: WalletId; currency: WalletCurrency }> = [] + const queuedBalanceIds = new Set() + let activeBalanceId: WalletId | undefined + let balanceWorker: Promise | undefined + + const registerSnapshotWallets = (snapshot: CashWalletCutoverOperatorSnapshot) => { + for (const account of [...snapshot.accounts, ...snapshot.treasury.accounts]) { + for (const wallet of [...account.usdWallets, ...account.usdtWallets]) { + walletCurrencies.set(wallet.id, wallet.currency) + if (!balanceCache.has(wallet.id)) { + balanceCache.set(wallet.id, { + walletId: wallet.id, + currency: wallet.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", + }) + } + } + } + } + + const runBalanceWorker = () => { + if (balanceWorker) return balanceWorker + + balanceWorker = (async () => { + while (balanceQueue.length > 0) { + const request = balanceQueue.shift() + if (!request) continue + + queuedBalanceIds.delete(request.walletId) + activeBalanceId = request.walletId + balanceCache.set(request.walletId, { + walletId: request.walletId, + currency: request.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", + }) + + const balance = await readBalanceThrottled({ + walletId: request.walletId, + currency: request.currency, + }) + balanceCache.set(request.walletId, { + walletId: request.walletId, + ...formatOperatorBalance( + { id: request.walletId, currency: request.currency } as Wallet, + balance, + ), + updatedAt: new Date().toISOString(), + }) + } + })().finally(() => { + activeBalanceId = undefined + balanceWorker = undefined + if (balanceQueue.length > 0) runBalanceWorker() + }) + + return balanceWorker + } + + const enqueueBalance = ({ + walletId, + currency, + force, + }: { + walletId: WalletId + currency: WalletCurrency + force: boolean + }) => { + const cached = balanceCache.get(walletId) + if (!force && cached && cached.status !== "loading") return + if (queuedBalanceIds.has(walletId) || activeBalanceId === walletId) return + + queuedBalanceIds.add(walletId) + balanceQueue.push({ walletId, currency }) + runBalanceWorker() + } + + const snapshotWithCachedBalances = ( + currentSnapshot: CashWalletCutoverOperatorSnapshot, + ): CashWalletCutoverOperatorSnapshot => ({ + ...currentSnapshot, + accounts: currentSnapshot.accounts.map((account) => + refreshOperatorAccountCutoverBalanceAudit({ + ...account, + usdWallets: account.usdWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + usdtWallets: account.usdtWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + }), + ), + treasury: { + ...currentSnapshot.treasury, + accounts: currentSnapshot.treasury.accounts.map((account) => ({ + ...account, + usdWallets: account.usdWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + usdtWallets: account.usdtWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + })), + }, + }) + + const buildSnapshot = async () => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) throw config + const lookup = + migrationLookup ?? + (config.runId + ? { + cutoverVersion: config.cutoverVersion, + runId: config.runId, + } + : undefined) + const discoveries = lookup + ? await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + : undefined + if (discoveries instanceof Error) throw discoveries + const preflightReport = + lookup && discoveries + ? buildCashWalletCutoverPreflightReport({ + cutoverVersion: lookup.cutoverVersion, + runId: lookup.runId, + discoveries, + }) + : undefined + + const result = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts, + accountsRepo, + walletsRepo, + migrationsRepo, + migrationLookup: lookup, + preflightReport, + discoveredAccounts: discoveries, + treasuryAccountIds, + balanceReadAttempts: BALANCE_READ_ATTEMPTS, + balanceMode: "structural", + getBalanceForWallet: (request) => + readBalanceThrottled({ + walletId: request.walletId, + currency: request.currency ?? WalletCurrency.Usd, + }), + }) + registerSnapshotWallets(result) + return result + } + + const snapshot = async (force: boolean) => { + const now = Date.now() + if (!force && cache && now - cache.cachedAt < args["snapshot-ttl-ms"]) { + return cache.snapshot + } + if (pending) return pending + + pending = buildSnapshot() + .then((result) => { + cache = { snapshot: result, cachedAt: Date.now() } + return result + }) + .finally(() => { + pending = undefined + }) + return pending + } + + const app = express() + app.get("/", (_req, res) => res.type("html").send(html)) + app.get("/api/snapshot", async (req, res) => { + try { + res.json(await snapshot(req.query.refresh === "1")) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard snapshot failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + app.get("/api/balances", async (req, res) => { + try { + await snapshot(false) + const rawWalletIds = + typeof req.query.walletIds === "string" ? req.query.walletIds : "" + const requestedWalletIds = rawWalletIds + ? rawWalletIds + .split(",") + .map((walletId) => walletId.trim()) + .filter(Boolean) + : Array.from(walletCurrencies.keys()) + const force = req.query.refresh === "1" + + for (const rawWalletId of requestedWalletIds) { + const walletId = rawWalletId as WalletId + const currency = walletCurrencies.get(walletId) + if (!currency) continue + enqueueBalance({ walletId, currency, force }) + } + + const balances: Record = {} + for (const rawWalletId of requestedWalletIds) { + const walletId = rawWalletId as WalletId + const cached = balanceCache.get(walletId) + if (cached) balances[walletId] = cached + } + + res.json({ + balances, + queue: { + pending: balanceQueue.length, + active: activeBalanceId, + }, + }) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard balance refresh failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + app.get("/api/balance-status", async (_req, res) => { + const balances = Array.from(balanceCache.values()) + res.json({ + known: walletCurrencies.size, + cached: balances.length, + fresh: balances.filter((balance) => balance.status === "fresh").length, + errors: balances.filter((balance) => balance.status === "error").length, + loading: balances.filter((balance) => balance.status === "loading").length, + queue: { + pending: balanceQueue.length, + active: activeBalanceId, + }, + }) + }) + app.get("/api/export.csv", async (_req, res) => { + try { + const currentSnapshot = await snapshot(false) + const csv = formatCashWalletCutoverOperatorSnapshotCsv( + snapshotWithCachedBalances(currentSnapshot), + ) + const runId = currentSnapshot.cutover.runId ?? "unknown-run" + res + .type("text/csv") + .attachment(`cash-wallet-cutover-${runId}-${Date.now()}.csv`) + .send(csv) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard CSV export failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + + const server = http.createServer(app) + server.listen(args.port, "127.0.0.1", () => { + baseLogger.info( + { port: args.port, accounts: manifestAccounts.length }, + "Cash wallet cutover dashboard listening", + ) + console.log(`Cash wallet cutover dashboard: http://localhost:${args.port}`) + }) +} + +start().catch((error) => { + baseLogger.error({ error }, "Cash wallet cutover dashboard failed") + process.exit(1) +}) diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts new file mode 100644 index 000000000..5e481a074 --- /dev/null +++ b/src/scripts/cash-wallet-cutover.ts @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +import { CashWalletCutover } from "@app" +import { addWalletIfNonexistent } from "@app/accounts" +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { baseLogger } from "@services/logger" + +const args = yargs(hideBin(process.argv)) + .command("preview", "discover accounts and print the migration plan without writes") + .command( + "provision-usdt-wallets", + "create missing destination USDT wallets before preparing migrations", + ) + .command("prepare", "discover accounts and upsert migration records") + .command("start", "mark a prepared cutover run in progress") + .command("run-batch", "run one locked migration worker batch") + .command("status", "print cutover config and migration counts") + .command("complete", "mark cutover complete after all migrations finish") + .demandCommand(1) + .option("cutover-version", { type: "number", demandOption: true }) + .option("run-id", { type: "string", demandOption: true }) + .option("operator", { type: "string", default: "unknown" }) + .option("worker-id", { type: "string", default: `worker-${process.pid}` }) + .option("limit", { type: "number", default: 25 }) + .option("step-delay-ms", { type: "number", default: 0 }) + .option("provision-limit", { type: "number" }) + .option("provision-delay-ms", { type: "number", default: 12_500 }) + .option("provision-retry-delay-ms", { type: "number", default: 60_000 }) + .option("max-provision-attempts", { type: "number", default: 5 }) + .option("dry-run", { type: "boolean", default: false }) + .option("lock-stale-seconds", { type: "number", default: 300 }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const repository = CashWalletCutoverRepository() + +const toJson = (result: unknown) => { + console.log(JSON.stringify(result, null, 2)) +} + +const run = async () => { + const command = args._[0] + const cutoverVersion = args["cutover-version"] + const runId = args["run-id"] + + switch (command) { + case "preview": { + const result = await CashWalletCutover.previewPrimaryCashWalletCutover({ + cutoverVersion, + runId, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "provision-usdt-wallets": { + const result = await CashWalletCutover.provisionPrimaryCashWalletUsdtWallets({ + cutoverVersion, + runId, + accountsRepo: AccountsRepository(), + walletsRepo: WalletsRepository(), + migrationsRepo: repository, + addWalletIfNonexistent, + provisionLimit: args["provision-limit"], + provisionDelayMs: args["provision-delay-ms"], + provisionRetryDelayMs: args["provision-retry-delay-ms"], + maxProvisionAttempts: args["max-provision-attempts"], + dryRun: args["dry-run"], + }) + if (result instanceof Error) throw result + toJson(result) + if (result.failed.length > 0) { + throw new Error( + `Failed to provision ${result.failed.length} destination USDT wallet(s)`, + ) + } + return + } + + case "prepare": { + const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ + cutoverVersion, + runId, + accountsRepo: AccountsRepository(), + walletsRepo: WalletsRepository(), + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "start": { + const result = await CashWalletCutover.startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor: args.operator, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "run-batch": { + const result = await CashWalletCutover.runPrimaryCashWalletCutoverBatch({ + cutoverVersion, + runId, + workerId: args["worker-id"], + limit: args.limit, + stepDelayMs: args["step-delay-ms"], + lockStaleBefore: new Date(Date.now() - args["lock-stale-seconds"] * 1000), + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "status": { + const result = await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion, + runId, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "complete": { + const result = await CashWalletCutover.completePrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor: args.operator, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + default: + throw new Error(`Unsupported cash wallet cutover command: ${command}`) + } +} + +setupMongoConnection() + .then(async (mongoose) => { + await run() + await mongoose?.connection.close() + process.exit(0) + }) + .catch((error) => { + baseLogger.error({ error }, "Cash wallet cutover operator command failed") + process.exit(1) + }) diff --git a/src/scripts/reconcile-bridge-ibex-deposits.ts b/src/scripts/reconcile-bridge-ibex-deposits.ts new file mode 100644 index 000000000..747ac99ca --- /dev/null +++ b/src/scripts/reconcile-bridge-ibex-deposits.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" +import { setupMongoConnection } from "@services/mongodb" +import { baseLogger } from "@services/logger" +import { reconcileBridgeAndIbexDeposits } from "@services/bridge/reconciliation" + +const args = yargs(hideBin(process.argv)) + .option("window-hours", { + type: "number", + default: 0.25, + describe: "Reconciliation window in hours (default: 15 minutes)", + }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const main = async () => { + const windowMs = args["window-hours"] * 60 * 60 * 1000 + const result = await reconcileBridgeAndIbexDeposits({ windowMs }) + if (result instanceof Error) throw result + baseLogger.info(result, "Bridge↔IBEX reconciliation finished") +} + +setupMongoConnection() + .then(async (mongoose) => { + await main() + await mongoose?.connection.close() + process.exit(0) + }) + .catch((error) => { + baseLogger.error({ error }, "Bridge↔IBEX reconciliation failed") + process.exit(1) + }) diff --git a/src/scripts/replay-bridge-events.ts b/src/scripts/replay-bridge-events.ts new file mode 100644 index 000000000..88fe9a3b2 --- /dev/null +++ b/src/scripts/replay-bridge-events.ts @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +/** + * Operator tool: replay missed Bridge webhook events. + * + * Usage: + * BRIDGE_WEBHOOK_REPLAY_SECRET= BRIDGE_WEBHOOK_URL=http://localhost:4009 \ + * node lib/scripts/replay-bridge-events.js \ + * --configPath dev/config/base-config.yaml \ + * --start 2026-05-01T00:00:00Z \ + * --end 2026-05-02T00:00:00Z \ + * [--event-type kyc|deposit|transfer] \ + * [--dry-run] \ + * [--operator "ops@example.com"] + */ + +import { listAllEvents } from "@services/bridge/client" +import { baseLogger } from "@services/logger" +import { setupMongoConnection } from "@services/mongodb" +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +const args = yargs(hideBin(process.argv)) + .option("start", { type: "string", demandOption: true }) + .option("end", { type: "string", demandOption: true }) + .option("event-type", { type: "string", choices: ["kyc", "deposit", "transfer"] }) + .option("transfer-id", { + type: "string", + describe: "Replay only events for this transfer ID", + }) + .option("dry-run", { type: "boolean", default: false }) + .option("operator", { type: "string", default: "unknown" }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const REPLAY_SECRET = process.env.BRIDGE_WEBHOOK_REPLAY_SECRET +const WEBHOOK_URL = process.env.BRIDGE_WEBHOOK_URL ?? "http://localhost:4009" + +if (!REPLAY_SECRET) { + console.error("Error: BRIDGE_WEBHOOK_REPLAY_SECRET environment variable is required") + process.exit(1) +} + +const EVENT_TYPE_FILTER: Record = { + kyc: "kyc.approved", + deposit: "deposit.completed", + transfer: "transfer.completed", +} + +type BridgeReplayEventEnvelope = { + event_id: string + event_type: string + event_object: unknown + event_created_at: string +} + +const toRouteKey = (eventType: string): string | null => { + if (eventType.startsWith("kyc")) return "kyc" + if (eventType.startsWith("deposit")) return "deposit" + if (eventType.startsWith("transfer")) return "transfer" + return null +} + +const extractTransferId = (payload: unknown): string | undefined => { + if (!payload || typeof payload !== "object") return undefined + const candidate = payload as Record + const fromTransferId = candidate.transfer_id + const fromId = candidate.id + const orchestration = candidate.orchestration as Record | undefined + const fromOrchestrationTransferId = orchestration?.transfer_id + + if (typeof fromTransferId === "string") return fromTransferId + if (typeof fromId === "string") return fromId + if (typeof fromOrchestrationTransferId === "string") return fromOrchestrationTransferId + return undefined +} + +const replayEvent = async ( + event: BridgeReplayEventEnvelope, +): Promise<{ status: number; body: unknown }> => { + const routeKey = toRouteKey(event.event_type) + + if (!routeKey) { + baseLogger.warn( + { eventType: event.event_type }, + "Skipping unsupported event type for replay", + ) + return { status: 0, body: { skipped: true, reason: "unsupported event type" } } + } + + const response = await fetch(`${WEBHOOK_URL}/internal/replay`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${REPLAY_SECRET}`, + }, + body: JSON.stringify({ + event_type: event.event_type, + event_object: event.event_object, + event_created_at: event.event_created_at, + operator: args.operator, + time_window_start: args.start, + time_window_end: args.end, + dry_run: args["dry-run"], + }), + }) + + const body = await response.json().catch(() => null) + + return { status: response.status, body } +} + +const main = async () => { + let fetched = 0, + success = 0, + failed = 0, + skipped = 0 + baseLogger.info( + { + start: args.start, + end: args.end, + eventType: args["event-type"], + dryRun: args["dry-run"], + operator: args.operator, + }, + "Starting Bridge webhook replay", + ) + + const bridgeFilter = args["event-type"] + ? EVENT_TYPE_FILTER[args["event-type"] as string] + : undefined + + for await (const event of listAllEvents({ + start: args.start, + end: args.end, + event_type: bridgeFilter, + })) { + const replayEventObject: BridgeReplayEventEnvelope = { + event_id: event.id, + event_type: event.event_type, + event_created_at: event.created_at, + event_object: event.payload, + } + + if (args["transfer-id"]) { + const transferId = extractTransferId(replayEventObject.event_object) + if (transferId !== args["transfer-id"]) { + skipped++ + continue + } + } + + fetched++ + + const { status, body } = await replayEvent(replayEventObject) + + if (status >= 200 && status < 300) { + success++ + baseLogger.info( + { eventId: event.id, eventType: event.event_type, status, response: body }, + "Successfully replayed event", + ) + } else if (status === 0) { + skipped++ + baseLogger.info( + { eventId: event.id, eventType: event.event_type, status, response: body }, + "Skipped event", + ) + } else { + failed++ + baseLogger.error( + { eventId: event.id, eventType: event.event_type, status, response: body }, + "Failed to replay event", + ) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) // small delay to avoid overwhelming the webhook server + } + + baseLogger.info( + { fetched, success, failed, skipped }, + "Completed Bridge webhook replay", + ) +} + +setupMongoConnection() + .then(async (mongoose) => { + await main() + await mongoose?.connection.close() + process.exit(0) + }) + .catch((error) => { + baseLogger.error({ error }, "Error in Bridge webhook replay") + process.exit(1) + }) diff --git a/src/servers/bridge-webhook-server.ts b/src/servers/bridge-webhook-server.ts new file mode 100644 index 000000000..6dd964ab2 --- /dev/null +++ b/src/servers/bridge-webhook-server.ts @@ -0,0 +1,9 @@ +import { startBridgeWebhookServer } from "@services/bridge/webhook-server" +import { baseLogger } from "@services/logger" +import { setupMongoConnection } from "@services/mongodb" + +if (require.main === module) { + setupMongoConnection() + .then(async () => startBridgeWebhookServer()) + .catch((err) => baseLogger.error(err, "bridge webhook server error")) +} diff --git a/src/servers/cron.ts b/src/servers/cron.ts index e139fd212..fe773cf42 100644 --- a/src/servers/cron.ts +++ b/src/servers/cron.ts @@ -1,6 +1,6 @@ import { OnChain, Lightning, Wallets, Payments, Swap } from "@app" -import { getCronConfig, TWO_MONTHS_IN_MS } from "@config" +import { BridgeConfig, getCronConfig, TWO_MONTHS_IN_MS } from "@config" import { ErrorLevel } from "@domain/shared" import { OperationInterruptedError } from "@domain/errors" @@ -20,6 +20,10 @@ import { import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { activateLndHealthCheck, checkAllLndHealth } from "@services/lnd/health" +import { + reconcileBridgeAndIbexDeposits, + reconcileBridgeAndIbexWithdrawals, +} from "@services/bridge/reconciliation" import { elapsedSinceTimestamp, sleep } from "@utils" import { rebalancingInternalChannels } from "@services/lnd/rebalancing" @@ -64,6 +68,26 @@ const swapOutJob = async () => { if (swapResult instanceof Error) throw swapResult } +// Window covers 15 min of events — real-time webhook reconciliation handles everything +// else immediately. This batch pass is only a safety net for missed/delayed webhooks. +const RECONCILE_WINDOW_MS = 15 * 60 * 1000 + +const reconcileBridgeDepositsJob = async () => { + if (!BridgeConfig.enabled) return + + const result = await reconcileBridgeAndIbexDeposits({ windowMs: RECONCILE_WINDOW_MS }) + if (result instanceof Error) throw result +} + +const reconcileBridgeWithdrawalsJob = async () => { + if (!BridgeConfig.enabled) return + + const result = await reconcileBridgeAndIbexWithdrawals({ + windowMs: RECONCILE_WINDOW_MS, + }) + if (result instanceof Error) throw result +} + const main = async () => { console.log("cronjob started") const start = new Date() @@ -84,6 +108,8 @@ const main = async () => { updateLegacyOnChainReceipt, ...(cronConfig.rebalanceEnabled ? [rebalance] : []), ...(cronConfig.swapEnabled ? [swapOutJob] : []), + reconcileBridgeDepositsJob, + reconcileBridgeWithdrawalsJob, deleteExpiredPaymentFlows, deleteExpiredInvoices, deleteLndPaymentsBefore2Months, diff --git a/src/servers/graphql-admin-server.ts b/src/servers/graphql-admin-server.ts index 1a220b07c..52d65959e 100644 --- a/src/servers/graphql-admin-server.ts +++ b/src/servers/graphql-admin-server.ts @@ -1,13 +1,14 @@ +import { createServer } from "http" + import { applyMiddleware } from "graphql-middleware" -import { and, or, rule, shield } from "graphql-shield" -import { Rule, RuleAnd, RuleOr } from "graphql-shield/typings/rules" +import { or, rule, shield } from "graphql-shield" +import { Rule, RuleOr } from "graphql-shield/typings/rules" import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { adminMutationFields, adminQueryFields, gqlAdminSchema } from "@graphql/admin" import { ADMIN_CONFIG } from "@config" import { AuthenticationError, AuthorizationError } from "@graphql/error" -import { createServer } from "http" import express from "express" import { ApolloServerPluginDrainHttpServer } from "apollo-server-core" import { ApolloError, ApolloServer, ExpressContext } from "apollo-server-express" @@ -15,41 +16,48 @@ import { GraphQLError, GraphQLSchema } from "graphql" import PinoHttp from "pino-http" import { mapError } from "@graphql/error-map" import { fieldExtensionsEstimator, simpleEstimator } from "graphql-query-complexity" -import { createComplexityPlugin } from "graphql-query-complexity-apollo-plugin" + import { parseUnknownDomainErrorFromUnknown } from "@domain/shared" -import healthzHandler from "./middlewares/healthz" -import { idempotencyMiddleware } from "./middlewares/idempotency" + import requestIp from "request-ip" -import jwt from 'jsonwebtoken' + +import jwt from "jsonwebtoken" + import { ErpNextRole, ErpNextRoles } from "@services/frappe/Roles" +import { createComplexityPlugin } from "./plugins/complexity" +import healthzHandler from "./middlewares/healthz" +import { idempotencyMiddleware } from "./middlewares/idempotency" + const graphqlLogger = baseLogger.child({ module: "graphql" }) interface JWTPayload { - userId: string; - roles: string[]; + userId: string + roles: string[] } // Parse the "Authorization" header to verify the JWT token and return its payload function parseAuthHeader(authHeader: string | undefined): JWTPayload { - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new AuthenticationError({ message: 'Invalid authorization header', logger: graphqlLogger }); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw new AuthenticationError({ + message: "Invalid authorization header", + logger: graphqlLogger, + }) } try { - const token = authHeader.slice(7); - return jwt.verify(token, ADMIN_CONFIG.ERPNEXT_JWT_SECRET as string) as JWTPayload; // process.env.ERPNEXT_JWT_SECRET + const token = authHeader.slice(7) + return jwt.verify(token, ADMIN_CONFIG.ERPNEXT_JWT_SECRET as string) as JWTPayload // process.env.ERPNEXT_JWT_SECRET } catch (error) { - throw new AuthenticationError({ message: 'Invalid Token', logger: graphqlLogger }); + throw new AuthenticationError({ message: "Invalid Token", logger: graphqlLogger }) } } -export const hasRole = (role: ErpNextRole) => rule({ cache: "contextual" })(( - parent, - args, - ctx: GraphQLAdminContext, -) => { - return ctx.user.roles.includes(role) ? true : new AuthorizationError({ logger: graphqlLogger }) -}) +export const hasRole = (role: ErpNextRole) => + rule({ cache: "contextual" })((parent, args, ctx: GraphQLAdminContext) => { + return ctx.user.roles.includes(role) + ? true + : new AuthorizationError({ logger: graphqlLogger }) + }) // // const ipString = UNSECURE_IP_FROM_REQUEST_OBJECT // // ? req.ip @@ -62,7 +70,7 @@ export const hasRole = (role: ErpNextRole) => rule({ cache: "contextual" })(( // // } // addAttributesToCurrentSpanAndPropagate( // { - // [SemanticAttributes.HTTP_CLIENT_IP]: ip, +// [SemanticAttributes.HTTP_CLIENT_IP]: ip, // [SemanticAttributes.HTTP_USER_AGENT]: req.headers["user-agent"], // }, // next, @@ -99,15 +107,15 @@ const startAdminServer = async ({ cache: "bounded", plugins: apolloPlugins, context: (ctx: ExpressContext) => { - const { authorization } = ctx.req.headers; - const decodedJwt = parseAuthHeader(authorization); + const { authorization } = ctx.req.headers + const decodedJwt = parseAuthHeader(authorization) return { - logger: graphqlLogger, + logger: graphqlLogger, user: { id: decodedJwt.userId, roles: decodedJwt.roles, ip: requestIp.getClientIp(ctx.req), - } + }, } }, formatError: (err) => { @@ -201,9 +209,7 @@ const startAdminServer = async ({ ) console.log( - `in dev mode, ${type} server should be accessed through oathkeeper reverse proxy at ${ - "http://localhost:4002/admin/graphql" - }`, + `in dev mode, ${type} server should be accessed through oathkeeper reverse proxy at ${"http://localhost:4002/admin/graphql"}`, ) resolve({ app, httpServer, apolloServer }) @@ -217,15 +223,17 @@ const startAdminServer = async ({ } export async function startApolloServerForAdminSchema() { - const defaultRule = or(hasRole(ErpNextRoles.SystemManager), hasRole(ErpNextRoles.AccountsManager)) + const defaultRule = or( + hasRole(ErpNextRoles.SystemManager), + hasRole(ErpNextRoles.AccountsManager), + ) const authedQueryFields: { [key: string]: RuleOr } = {} for (const key of Object.keys(adminQueryFields.authed)) { authedQueryFields[key] = defaultRule } - - const mutationRoleOverrides: { [key: string]: Rule } = { + const mutationRoleOverrides: { [key: string]: Rule } = { // sendNotification: hasRole(ErpNextRoles.SystemManager) } @@ -259,4 +267,4 @@ if (require.main === module) { await startApolloServerForAdminSchema() }) .catch((err) => graphqlLogger.error(err, "server error")) -} \ No newline at end of file +} diff --git a/src/servers/graphql-main-server.ts b/src/servers/graphql-main-server.ts index a0c3b1598..dfbeb53e6 100644 --- a/src/servers/graphql-main-server.ts +++ b/src/servers/graphql-main-server.ts @@ -6,7 +6,6 @@ import { AuthorizationError } from "@graphql/error" import { gqlMainSchema, mutationFields, queryFields } from "@graphql/public" import { bootstrap } from "@app/bootstrap" -import { activateLndHealthCheck } from "@services/lnd/health" import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { shield } from "graphql-shield" @@ -20,6 +19,7 @@ import { import { NextFunction, Request, Response } from "express" import { parseIps } from "@domain/accounts-ips" +import { parseCashWalletClientCapabilities } from "@app/cash-wallet-cutover/client-capability" import { startApolloServerForAdminSchema } from "./graphql-admin-server" import { isAuthenticated, startApolloServer } from "./graphql-server" @@ -44,8 +44,12 @@ const setGqlContext = async ( tokenPayload, ip, }) + const cashWalletClientCapabilities = parseCashWalletClientCapabilities(req.headers) - req.gqlContext = gqlContext + req.gqlContext = { + ...gqlContext, + cashWalletClientCapabilities, + } return addAttributesToCurrentSpanAndPropagate( { @@ -56,6 +60,11 @@ const setGqlContext = async ( [SemanticAttributes.HTTP_USER_AGENT]: req.headers["user-agent"], [ACCOUNT_USERNAME]: gqlContext?.domainAccount?.username, [SemanticAttributes.ENDUSER_ID]: tokenPayload?.sub, + "cash_wallet.client_presentation": + cashWalletClientCapabilities.cashWalletPresentation, + "cash_wallet.client_usdt_supported": String( + cashWalletClientCapabilities.hasUsdtCashWalletSupport, + ), }, next, ) @@ -101,9 +110,9 @@ export async function startApolloServerForCoreSchema() { if (require.main === module) { setupMongoConnection(true) .then(async () => { - // activateLndHealthCheck() // + // activateLndHealthCheck() - const res = await bootstrap() + await bootstrap() // if (res instanceof Error) throw res await Promise.race([ diff --git a/src/servers/graphql-server.ts b/src/servers/graphql-server.ts index 4e2dbc538..b630d0cee 100644 --- a/src/servers/graphql-server.ts +++ b/src/servers/graphql-server.ts @@ -16,12 +16,12 @@ import { mapError } from "@graphql/error-map" import { fieldExtensionsEstimator, simpleEstimator } from "graphql-query-complexity" -import { createComplexityPlugin } from "graphql-query-complexity-apollo-plugin" - import jwksRsa from "jwks-rsa" import { parseUnknownDomainErrorFromUnknown } from "@domain/shared" +import { createComplexityPlugin } from "./plugins/complexity" + import authRouter from "./authorization" import kratosCallback from "./event-handlers/kratos" import healthzHandler from "./middlewares/healthz" diff --git a/src/servers/index.files.d.ts b/src/servers/index.files.d.ts index 29513578f..c918274f8 100644 --- a/src/servers/index.files.d.ts +++ b/src/servers/index.files.d.ts @@ -13,6 +13,7 @@ type GraphQLPublicContext = { domainAccount: Account | undefined ip: IpAddress | undefined sessionId: SessionId | undefined + cashWalletClientCapabilities: import("@app/cash-wallet-cutover/client-capability").CashWalletClientCapabilities } type GraphQLPublicContextAuth = Omit & { diff --git a/src/servers/middlewares/session.ts b/src/servers/middlewares/session.ts index 03201a40a..2631ce286 100644 --- a/src/servers/middlewares/session.ts +++ b/src/servers/middlewares/session.ts @@ -1,6 +1,7 @@ import DataLoader from "dataloader" import { Accounts, Transactions } from "@app" +import { DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES } from "@app/cash-wallet-cutover" import { recordExceptionInCurrentSpan } from "@services/tracing" import jsonwebtoken from "jsonwebtoken" @@ -67,8 +68,7 @@ export const sessionPublicContext = async ({ error: txnMetadata, }) return keys.map(() => undefined) - } - else if (txnMetadata instanceof Error) { + } else if (txnMetadata instanceof Error) { recordExceptionInCurrentSpan({ error: txnMetadata, level: txnMetadata.level, @@ -88,5 +88,6 @@ export const sessionPublicContext = async ({ domainAccount, ip, sessionId, + cashWalletClientCapabilities: DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES, } } diff --git a/src/servers/plugins/complexity.ts b/src/servers/plugins/complexity.ts new file mode 100644 index 000000000..5bfc3ee1b --- /dev/null +++ b/src/servers/plugins/complexity.ts @@ -0,0 +1,45 @@ +import { GraphQLSchema } from "graphql" +import { getComplexity, ComplexityEstimator } from "graphql-query-complexity" +import type { + ApolloServerPlugin, + GraphQLRequestListener, +} from "apollo-server-plugin-base" + +interface ComplexityPluginOptions { + schema: GraphQLSchema + estimators: ComplexityEstimator[] + maximumComplexity: number + onComplete?: (complexity: number) => void +} + +export function createComplexityPlugin( + options: ComplexityPluginOptions, +): ApolloServerPlugin { + const { schema, estimators, maximumComplexity, onComplete } = options + + return { + async requestDidStart(): Promise { + return { + async didResolveOperation({ request, document }) { + const complexity = getComplexity({ + schema, + operationName: request.operationName ?? undefined, + query: document, + variables: request.variables ?? {}, + estimators, + }) + + if (onComplete) { + onComplete(complexity) + } + + if (complexity > maximumComplexity) { + throw new Error( + `Query complexity of ${complexity} exceeds maximum allowed complexity of ${maximumComplexity}`, + ) + } + }, + } + }, + } +} diff --git a/src/servers/ws-server.ts b/src/servers/ws-server.ts index 9255a7f46..c98cbb28a 100644 --- a/src/servers/ws-server.ts +++ b/src/servers/ws-server.ts @@ -10,6 +10,7 @@ import jsonwebtoken from "jsonwebtoken" import { parseIps } from "@domain/accounts-ips" import { ErrorLevel } from "@domain/shared" +import { parseCashWalletClientCapabilities } from "@app/cash-wallet-cutover" import jwksRsa from "jwks-rsa" @@ -60,12 +61,16 @@ const getContext = async ( fnName: "getContext", fn: async () => { const connectionParams = ctx.connectionParams + const cashWalletClientCapabilities = parseCashWalletClientCapabilities({ + ...ctx.extra?.request?.headers, + ...connectionParams, + }) // TODO: check if nginx pass the ip to the header // TODO: ip not been used currently for subscription. // implement some rate limiting. const ipString = UNSECURE_IP_FROM_REQUEST_OBJECT - ? connectionParams?.ip ?? ctx.extra?.request?.socket?.remoteAddress + ? (connectionParams?.ip ?? ctx.extra?.request?.socket?.remoteAddress) : connectionParams?.["x-real-ip"] || connectionParams?.["x-forwarded-for"] const ip = parseIps(ipString) @@ -82,10 +87,14 @@ const getContext = async ( sub: kratosCookieRes.kratosUserId, } - return sessionPublicContext({ + const context = await sessionPublicContext({ tokenPayload, ip, }) + return { + ...context, + cashWalletClientCapabilities, + } } const kratosToken = authz?.slice(7) as AuthToken @@ -106,10 +115,14 @@ const getContext = async ( return false } - return sessionPublicContext({ + const context = await sessionPublicContext({ tokenPayload, ip, }) + return { + ...context, + cashWalletClientCapabilities, + } }, })() } diff --git a/src/services/alerts/dedup-key.ts b/src/services/alerts/dedup-key.ts new file mode 100644 index 000000000..2a1cb2584 --- /dev/null +++ b/src/services/alerts/dedup-key.ts @@ -0,0 +1,34 @@ +export const PAGERDUTY_DEDUP_KEY_MAX = 255 + +const OUTAGE_TTL_MS = 30 * 60 * 1000 +const DEFAULT_TTL_MS = 60 * 60 * 1000 + +/** TTL for Slack/Discord first-alert suppression per dedup key class. */ +export const informDedupTtlMs = (dedupKey: string): number => + dedupKey.startsWith("bridge-api") ? OUTAGE_TTL_MS : DEFAULT_TTL_MS + +export const generateDedupKey = { + bridgeApi5xx: () => "bridge-api:5xx", + bridgeApiTimeout: () => "bridge-api:timeout", + bridgeApiNetwork: () => "bridge-api:network", + erpnextDepositAudit: (transferId: string) => `erpnext-audit:deposit:${transferId}`, + erpnextTransferCompletedAudit: (transferId: string) => + `erpnext-audit:transfer-complete:${transferId}`, + erpnextTransferFailedAudit: (transferId: string) => + `erpnext-audit:transfer-failed:${transferId}`, + bridgeWebhookDeposit: (eventId: string) => `bridge-webhook:deposit:${eventId}`, + bridgeWebhookTransfer: (transferId: string, event: string) => + `bridge-webhook:transfer:${transferId}:${event}`, + ibexCryptoReceive: (txHash: string) => `ibex:crypto-receive:${txHash.toLowerCase()}`, + ibexReconcileBridgeWithoutIbex: (txHash: string) => + `ibex:reconcile:bridge-without-ibex:${txHash.toLowerCase()}`, + ibexReconcileBridgeWithoutIbexTransfer: (transferId: string) => + `ibex:reconcile:bridge-without-ibex:transfer:${transferId}`, + ibexReconcileIbexWithoutBridge: (txHash: string) => + `ibex:reconcile:ibex-without-bridge:${txHash.toLowerCase()}`, + ibexReconcileFailed: (txHash: string) => + `ibex:reconcile:failed:${txHash.toLowerCase()}`, +} + +export const normalizeDedupKey = (key: string): string => + key.length <= PAGERDUTY_DEDUP_KEY_MAX ? key : key.slice(0, PAGERDUTY_DEDUP_KEY_MAX) diff --git a/src/services/alerts/discord.ts b/src/services/alerts/discord.ts new file mode 100644 index 000000000..df9ba0bac --- /dev/null +++ b/src/services/alerts/discord.ts @@ -0,0 +1,34 @@ +import { ALERT_DISCORD_WEBHOOK_URL } from "@config" +import { ErrorLevel } from "@domain/shared" +import { recordExceptionInCurrentSpan } from "@services/tracing" +import axios from "axios" + +import { BridgeAlert } from "./index.types" + +// Discord caps message content at 2000 chars; leave headroom. +const DISCORD_CONTENT_MAX = 1900 + +// Discord incoming webhook ({ content }). +export const sendDiscord = async (alert: BridgeAlert): Promise => { + if (!ALERT_DISCORD_WEBHOOK_URL) return + + const label = alert.severity === "critical" ? "[CRITICAL]" : "[WARNING]" + let content = `${label} **Bridge alert** - ${alert.title}\nsource: \`${alert.source}\` | severity: \`${alert.severity}\`` + if (alert.detail) content += `\n${alert.detail}` + if (alert.context) { + content += "\n```json\n" + JSON.stringify(alert.context, null, 2) + "\n```" + } + if (content.length > DISCORD_CONTENT_MAX) { + content = content.slice(0, DISCORD_CONTENT_MAX) + "..." + } + + try { + await axios.post( + ALERT_DISCORD_WEBHOOK_URL, + { content }, + { timeout: 5000, headers: { "Content-Type": "application/json" } }, + ) + } catch (error) { + recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) + } +} diff --git a/src/services/alerts/ibex-bridge-movement.ts b/src/services/alerts/ibex-bridge-movement.ts new file mode 100644 index 000000000..85fee30c7 --- /dev/null +++ b/src/services/alerts/ibex-bridge-movement.ts @@ -0,0 +1,97 @@ +import { generateDedupKey } from "./dedup-key" + +import { alertBridge } from "./index" + +type IbexMovementAlert = { + title: string + detail?: string + context?: Record +} + +const alertIbexMovement = (dedupKey: string, alert: IbexMovementAlert): void => { + alertBridge({ + dedupKey, + source: "ibex", + severity: "warning", + ...alert, + }) +} + +export const alertIbexCryptoReceiveFailure = ({ + txHash, + code, + title, + detail, + context, +}: { + txHash: string + code: string + title: string + detail?: string + context?: Record +}): void => { + alertIbexMovement(generateDedupKey.ibexCryptoReceive(txHash), { + title, + detail, + context: { tx_hash: txHash, code, ...context }, + }) +} + +export const alertIbexReconciliationOrphan = ({ + orphanType, + txHash, + transferId, + reason, + context, +}: { + orphanType: + | "bridge_without_ibex" + | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" + txHash?: string + transferId?: string + reason: string + context?: Record +}): void => { + const dedupKey = + orphanType === "ibex_without_bridge" && txHash + ? generateDedupKey.ibexReconcileIbexWithoutBridge(txHash) + : txHash + ? generateDedupKey.ibexReconcileBridgeWithoutIbex(txHash) + : generateDedupKey.ibexReconcileBridgeWithoutIbexTransfer(transferId ?? "unknown") + + const title = + orphanType === "ibex_without_bridge" + ? "IBEX crypto receive without matching Bridge deposit" + : orphanType === "bridge_without_ibex" + ? "Bridge deposit without matching IBEX crypto receive" + : orphanType === "bridge_transfer_without_ibex_send" + ? "Bridge withdrawal transfer without matching IBEX send" + : "IBEX withdrawal send without Bridge settlement" + + alertIbexMovement(dedupKey, { + title, + detail: reason, + context: { + orphan_type: orphanType, + tx_hash: txHash, + transfer_id: transferId, + ...context, + }, + }) +} + +export const alertIbexReconciliationFailed = ({ + txHash, + detail, +}: { + txHash: string + detail: string +}): void => { + alertIbexMovement(generateDedupKey.ibexReconcileFailed(txHash), { + title: "Bridge↔IBEX reconciliation failed", + detail, + context: { tx_hash: txHash }, + }) +} diff --git a/src/services/alerts/index.ts b/src/services/alerts/index.ts new file mode 100644 index 000000000..95f7a8255 --- /dev/null +++ b/src/services/alerts/index.ts @@ -0,0 +1,46 @@ +import { sendPagerDuty } from "./pagerduty" +import { sendSlack } from "./slack" +import { sendDiscord } from "./discord" +import { normalizeDedupKey } from "./dedup-key" +import { claimInformSlot } from "./inform-dedup" +import { BridgeAlert } from "./index.types" + +export * from "./index.types" +export { generateDedupKey } from "./dedup-key" + +/** + * Fire-and-forget fan-out of a Bridge alert to the configured destinations + * (ENG-361). Returns immediately; delivery is best-effort: each sender catches + * its own errors and no-ops when its credential/URL is unset, so it never throws + * or rejects into the caller (no need to await or handle it). + * + * Routing: + * - critical: page on-call (PagerDuty) + inform (Slack/Mattermost, Discord) + * - warning: inform (Slack/Mattermost, Discord) only + * + * Dedup: + * - PagerDuty: Events API v2 dedup_key groups triggers into one incident. + * - Slack / Discord: first alert per dedup key within TTL only. + */ +export const alertBridge = (alert: BridgeAlert): void => { + const dedupKey = normalizeDedupKey(alert.dedupKey) + const alertWithKey: BridgeAlert = { ...alert, dedupKey } + + const deliver = async () => { + const senders: Promise[] = [] + + if (claimInformSlot(dedupKey)) { + senders.push(sendSlack(alertWithKey), sendDiscord(alertWithKey)) + } + + if (alert.severity === "critical") { + senders.push(sendPagerDuty(alertWithKey)) + } + + if (senders.length > 0) { + await Promise.allSettled(senders) + } + } + + deliver().catch(() => undefined) +} diff --git a/src/services/alerts/index.types.ts b/src/services/alerts/index.types.ts new file mode 100644 index 000000000..b93b6c91c --- /dev/null +++ b/src/services/alerts/index.types.ts @@ -0,0 +1,14 @@ +// Ops alerting for Bridge integration signals (ENG-361). + +export type AlertSeverity = "critical" | "warning" + +export type AlertSource = "bridge-webhook" | "bridge-api" | "ibex" | "erpnext-audit" + +export interface BridgeAlert { + dedupKey: string + source: AlertSource + severity: AlertSeverity + title: string + detail?: string + context?: Record +} diff --git a/src/services/alerts/inform-dedup.ts b/src/services/alerts/inform-dedup.ts new file mode 100644 index 000000000..7e0bc7884 --- /dev/null +++ b/src/services/alerts/inform-dedup.ts @@ -0,0 +1,35 @@ +import { informDedupTtlMs } from "./dedup-key" + +const seenAt = new Map() + +/** + * Returns true when Slack/Discord should fire for this dedup key (first within TTL). + * Subsequent duplicates within the TTL are suppressed. + */ +export const claimInformSlot = (dedupKey: string, nowMs = Date.now()): boolean => { + const ttlMs = informDedupTtlMs(dedupKey) + const lastSentAt = seenAt.get(dedupKey) + + if (lastSentAt !== undefined && nowMs - lastSentAt < ttlMs) { + return false + } + + seenAt.set(dedupKey, nowMs) + pruneExpired(nowMs) + return true +} + +const pruneExpired = (nowMs: number): void => { + if (seenAt.size < 500) return + + for (const [key, sentAt] of seenAt) { + if (nowMs - sentAt >= informDedupTtlMs(key)) { + seenAt.delete(key) + } + } +} + +/** Test helper — clears the in-process inform dedup cache. */ +export const resetInformDedup = (): void => { + seenAt.clear() +} diff --git a/src/services/alerts/pagerduty.ts b/src/services/alerts/pagerduty.ts new file mode 100644 index 000000000..46d8317e5 --- /dev/null +++ b/src/services/alerts/pagerduty.ts @@ -0,0 +1,34 @@ +import { ALERT_PAGERDUTY_ROUTING_KEY } from "@config" +import { ErrorLevel } from "@domain/shared" +import { recordExceptionInCurrentSpan } from "@services/tracing" +import axios from "axios" + +import { BridgeAlert } from "./index.types" + +const PAGERDUTY_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue" + +// PagerDuty Events API v2 triggers a paging incident. "critical" and +// "warning" are both valid PD payload severities, so we pass them through. +export const sendPagerDuty = async (alert: BridgeAlert): Promise => { + if (!ALERT_PAGERDUTY_ROUTING_KEY) return + + try { + await axios.post( + PAGERDUTY_EVENTS_URL, + { + routing_key: ALERT_PAGERDUTY_ROUTING_KEY, + event_action: "trigger", + dedup_key: alert.dedupKey, + payload: { + summary: `[bridge:${alert.source}] ${alert.title}`, + severity: alert.severity, + source: "flash-bridge", + custom_details: { ...alert.context, detail: alert.detail }, + }, + }, + { timeout: 5000, headers: { "Content-Type": "application/json" } }, + ) + } catch (error) { + recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) + } +} diff --git a/src/services/alerts/slack.ts b/src/services/alerts/slack.ts new file mode 100644 index 000000000..10a260088 --- /dev/null +++ b/src/services/alerts/slack.ts @@ -0,0 +1,31 @@ +import { ALERT_SLACK_WEBHOOK_URL } from "@config" +import { ErrorLevel } from "@domain/shared" +import { recordExceptionInCurrentSpan } from "@services/tracing" +import axios from "axios" + +import { BridgeAlert } from "./index.types" + +// Slack / Mattermost-compatible incoming webhook ({ text }). +export const sendSlack = async (alert: BridgeAlert): Promise => { + if (!ALERT_SLACK_WEBHOOK_URL) return + + const icon = alert.severity === "critical" ? ":rotating_light:" : ":warning:" + const lines = [ + `${icon} *Bridge alert* - ${alert.title}`, + `*source:* \`${alert.source}\` *severity:* \`${alert.severity}\``, + ] + if (alert.detail) lines.push(alert.detail) + if (alert.context) { + lines.push("```" + JSON.stringify(alert.context, null, 2) + "```") + } + + try { + await axios.post( + ALERT_SLACK_WEBHOOK_URL, + { text: lines.join("\n") }, + { timeout: 5000, headers: { "Content-Type": "application/json" } }, + ) + } catch (error) { + recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) + } +} diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts new file mode 100644 index 000000000..3449b7181 --- /dev/null +++ b/src/services/bridge/client.ts @@ -0,0 +1,641 @@ +/** + * Bridge.xyz API Client + * Ported from bridge-mcp and extended with Tron/USDT support + */ + +import crypto from "crypto" + +import { BridgeConfig } from "@config" + +import { + BridgeCustomerId, + BridgeTransferId, + BridgeVirtualAccountId, +} from "@domain/primitives/bridge" +import { alertBridge, generateDedupKey } from "@services/alerts" + +import { BridgeTimeoutError } from "./errors" + +// ============ Error Handling ============ + +export class BridgeApiError extends Error { + constructor( + message: string, + public statusCode: number, + public response?: unknown, + ) { + super(message) + this.name = "BridgeApiError" + } +} + +// ============ Request/Response Types ============ + +export interface CreateIndividualCustomerRequest { + type: "individual" + first_name: string + last_name: string + email: string + phone?: string + residential_address?: { + street_line_1: string + street_line_2?: string + city: string + subdivision?: string + postal_code: string + country: string + } + birth_date?: string + signed_agreement_id?: string +} + +export interface CreateBusinessCustomerRequest { + type: "business" + business_name: string + email: string + phone?: string + residential_address?: { + street_line_1: string + street_line_2?: string + city: string + subdivision?: string + postal_code: string + country: string + } + signed_agreement_id?: string +} + +export type CreateCustomerRequest = + | CreateIndividualCustomerRequest + | CreateBusinessCustomerRequest + +export interface Customer { + id: string + type: "individual" | "business" + status?: + | "active" + | "awaiting_questionnaire" + | "rejected" + | "paused" + | "under_review" + | "offboarded" + | "awaiting_ubo" + | "incomplete" + | "not_started" + has_accepted_terms_of_service?: string + created_at: string + updated_at: string + first_name?: string + last_name?: string + email?: string + business_name?: string +} + +export interface KycLink { + kyc_link: string + tos_link: string + customer_id: string +} + +// Extended payment rails to include Tron +export type PaymentRail = + | "ach" + | "wire" + | "ach_push" + | "ach_same_day" + | "arbitrum" + | "avalanche_c_chain" + | "base" + | "bre_b" + | "co_bank_transfer" + | "celo" + | "ethereum" + | "faster_payments" + | "optimism" + | "pix" + | "polygon" + | "sepa" + | "solana" + | "spei" + | "stellar" + | "swift" + | "tempo" + | "tron" + +export type VirtualAccountDestinationPaymentRail = + | "arbitrum" + | "avalanche_c_chain" + | "base" + | "celo" + | "ethereum" + | "optimism" + | "polygon" + | "solana" + | "stellar" + | "tempo" + | "tron" + +export type SourceCurrency = "usd" | "eur" | "mxn" | "brl" | "gbp" | "cop" + +// Extended currencies to include USDT +export type Currency = "usdb" | "usdt" | "dai" | "pyusd" | "usdc" | "eurc" + +export interface CreateVirtualAccountRequest { + developer_fee_percent?: string + source: { + currency: SourceCurrency + } + destination: { + currency: Currency + payment_rail: VirtualAccountDestinationPaymentRail + address?: string + blockchain_memo?: string + bridge_wallet_id?: string + } +} + +export interface CreateExternalAccountRequest { + account_owner_name: string + address: { + street_line_1: string + city: string + country: string + state?: string + postal_code?: string + } + account_type: string | "us" | "iban" | "unknown" | "clabe" | "pix" | "gb" + currency: "usd" | "gbp" | "brl" | "eur" | string + account: { + account_number: string + routing_number: string + checking_or_savings?: "checking" | "savings" + } + bank_name?: string +} + +export interface VirtualAccount { + id: string + status: string + customer_id: string + developer_fee_percent?: string + source_deposit_instructions: { + currency: string + payment_rails: string[] + bank_name: string + bank_beneficiary_address: string + bank_beneficiary_name: string + bank_account_number: string + bank_routing_number: string + } + destination: { + currency: string + payment_rail: string + address?: string + blockchain_memo?: string + bridge_wallet_id?: string + } + created_at: string +} + +export interface ExternalAccount { + id: string + customer_id: string + account_owner_name: string + account_type: string + currency: string + bank_name?: string + account_number_last_4?: string + last_4?: string + routing_number?: string + iban?: string + active?: boolean + created_at: string +} + +export interface ExternalAccountLinkUrl { + link_url: string + expires_at: string +} + +export interface ListResponse { + data: T[] + has_more: boolean + cursor?: string +} + +export type TrasfertSourceCurrency = + | "brl" + | "cop" + | "dai" + | "eur" + | "eurc" + | "gbp" + | "mxn" + | "pyusd" + | "usd" + | "usdb" + | "usdc" + | "usdt" +export interface CreateTransferRequest { + amount?: string + currency?: string + on_behalf_of: string + developer_fee?: string + developer_fee_percent?: string + source: { + payment_rail: PaymentRail | "bridge_wallet" + currency: TrasfertSourceCurrency + from_address?: string + external_account_id?: string + bridge_wallet_id?: string + } + destination: { + payment_rail: PaymentRail | "bridge_wallet" | "ach" + currency: string + to_address?: string + external_account_id?: string + bridge_wallet_id?: string + wire_message?: string + } + dry_run?: boolean + features?: { + flexible_amount?: boolean + static_template?: boolean + allow_any_from_address?: boolean + } +} + +export interface Transfer { + id: string + client_reference_id?: string + amount: string + currency: string + on_behalf_of: string + developer_fee?: string + source: { + payment_rail: string + currency: string + from_address?: string + external_account_id?: string + bridge_wallet_id?: string + } + destination: { + payment_rail: string + currency: string + to_address?: string + external_account_id?: string + bridge_wallet_id?: string + } + state: string + source_deposit_instructions?: { + payment_rail: string + currency: string + amount?: string + bank_name?: string + bank_address?: string + bank_account_number?: string + bank_routing_number?: string + to_address?: string + } + receipt?: { + initial_amount: string + developer_fee: string + exchange_fee: string + subtotal_amount: string + gas_fee?: string + final_amount?: string + exchange_rate?: string + source_tx_hash?: string + destination_tx_hash?: string + remaining_prefunded_balance?: string + url?: string + } + created_at: string + updated_at: string +} + +export interface BridgeIntiateKyc { + email: string + type: "individual" | "business" + full_name?: string +} + +export type BridgeWebhookEventType = + | "kyc" + | "transfer" + | "virtual_account" + | "external_account" + +export interface BridgeWebhookEvent { + id: string + event_type: string + payload: unknown + created_at: string +} + +export interface ListEventsParams { + event_type?: string + after?: string + page_size?: number +} + +type WebhookEventsApiResponse = { + data: Array<{ + event_id?: string + event_type?: string + event_created_at?: string + event_object?: unknown + id?: string + created_at?: string + payload?: unknown + }> + count?: number + has_more?: boolean + cursor?: string +} + +// ============ Bridge Client ============ + +export class BridgeClient { + private apiKey: string + private baseUrl: string + + constructor() { + this.apiKey = BridgeConfig.apiKey + this.baseUrl = BridgeConfig.baseUrl || "https://api.sandbox.bridge.xyz/v0" + } + + private async request( + method: string, + path: string, + body?: unknown, + idempotencyKey?: string, + ): Promise { + const url = `${this.baseUrl}${path}` + const headers: Record = { + "Api-Key": this.apiKey, + "Content-Type": "application/json", + } + + // Bridge rejects Idempotency-Key on GET and DELETE endpoints. + if (!["GET", "DELETE"].includes(method.toUpperCase())) { + if (idempotencyKey) { + headers["Idempotency-Key"] = idempotencyKey + } else { + headers["Idempotency-Key"] = crypto.randomUUID() + } + } + + const timeoutMs = BridgeConfig.timeoutMs ?? 15_000 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + + const responseData = await response.json().catch(() => null) + + if (!response.ok) { + // Only 5xx indicates a Bridge-side outage; 4xx are normal API rejections. + if (response.status >= 500) { + alertBridge({ + dedupKey: generateDedupKey.bridgeApi5xx(), + source: "bridge-api", + severity: "critical", + title: `Bridge API ${response.status} on ${method} ${path}`, + detail: response.statusText, + context: { method, path, status: response.status }, + }) + } + throw new BridgeApiError( + `Bridge API error: ${response.status} ${response.statusText}`, + response.status, + responseData, + ) + } + + return responseData as T + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + alertBridge({ + dedupKey: generateDedupKey.bridgeApiTimeout(), + source: "bridge-api", + severity: "critical", + title: `Bridge API timeout on ${method} ${path}`, + context: { method, path, timeoutMs }, + }) + throw new BridgeTimeoutError() + } + // Network/connectivity failures (5xx already alerted above). + if (!(err instanceof BridgeApiError)) { + alertBridge({ + dedupKey: generateDedupKey.bridgeApiNetwork(), + source: "bridge-api", + severity: "critical", + title: `Bridge API request failed on ${method} ${path}`, + detail: err instanceof Error ? err.message : String(err), + context: { method, path }, + }) + } + throw err + } finally { + clearTimeout(timeoutId) + } + } + + // ============ Customers ============ + + async createCustomer( + data: CreateCustomerRequest, + idempotencyKey?: string, + ): Promise { + return this.request("POST", "/customers", data, idempotencyKey) + } + + async getCustomer(customerId: BridgeCustomerId): Promise { + return this.request("GET", `/customers/${customerId}`) + } + + // ============ KYC ============ + + async createKycLink( + request: BridgeIntiateKyc, + idempotencyKey?: string, + ): Promise { + return this.request("POST", "/kyc_links", request, idempotencyKey) + } + + async getKycLatestLink(customerId: BridgeCustomerId): Promise { + return this.request("GET", `/customers/${customerId}/kyc_links/latest`) + } + + // ============ Virtual Accounts ============ + + async createVirtualAccount( + customerId: BridgeCustomerId, + data: CreateVirtualAccountRequest, + idempotencyKey?: string, + ): Promise { + return this.request( + "POST", + `/customers/${customerId}/virtual_accounts`, + data, + idempotencyKey, + ) + } + + async getVirtualAccount( + customerId: BridgeCustomerId, + virtualAccountId: BridgeVirtualAccountId, + idempotencyKey?: string, + ): Promise { + return this.request( + "GET", + `/customers/${customerId}/virtual_accounts/${virtualAccountId}`, + undefined, + idempotencyKey, + ) + } + + async getVirtualAccountByCustomerId( + customerId: BridgeCustomerId, + ): Promise { + const response = await this.request<{ data: VirtualAccount[] }>( + "GET", + `/customers/${customerId}/virtual_accounts`, + ) + + return response.data as VirtualAccount[] + } + // ============ External Accounts ============ + + async createExternalAccount( + customerId: BridgeCustomerId, + data: CreateExternalAccountRequest, + idempotencyKey: string, + ): Promise { + return this.request( + "POST", + `/customers/${customerId}/external_accounts`, + data, + idempotencyKey, + ) + } + + async getExternalAccountLinkUrl( + customerId: BridgeCustomerId, + ): Promise { + return this.request( + "POST", + `/customers/${customerId}/external_accounts/link`, + ) + } + + async listExternalAccounts( + customerId: BridgeCustomerId, + ): Promise> { + return this.request>( + "GET", + `/customers/${customerId}/external_accounts`, + ) + } + + // ============ Transfers ============ + + async createTransfer( + customerId: BridgeCustomerId, + data: CreateTransferRequest, + idempotencyKey?: string, + ): Promise { + // Note: Bridge API expects on_behalf_of in the body, not in the path + const bodyWithCustomer = { + ...data, + on_behalf_of: customerId, + } + return this.request("POST", "/transfers", bodyWithCustomer, idempotencyKey) + } + + async getTransfer(transferId: BridgeTransferId): Promise { + // Note: Bridge API uses /transfers/{id} not /customers/{id}/transfers/{id} + return this.request("GET", `/transfers/${transferId}`) + } + + async deleteTransfer(transferId: BridgeTransferId): Promise { + return this.request("DELETE", `/transfers/${transferId}`) + } + + // ============ List Events ============ + + async listEvents(params?: ListEventsParams): Promise> { + const queryParams = new URLSearchParams() + + // Bridge webhook events endpoint uses cursor pagination via starting_after. + if (params?.after) queryParams.append("starting_after", params.after) + if (params?.page_size) queryParams.append("limit", params.page_size.toString()) + // Preserve call-site compatibility: derive category from event_type when possible. + if (params?.event_type) { + const category = params.event_type.split(".")[0] + if (category) queryParams.append("category", category) + } + + const suffix = queryParams.toString() ? `?${queryParams.toString()}` : "" + + const response = await this.request( + "GET", + `/webhook_events${suffix}`, + ) + + const mappedData: BridgeWebhookEvent[] = (response.data ?? []).map((event) => ({ + id: event.event_id ?? event.id ?? "", + event_type: event.event_type ?? "", + created_at: event.event_created_at ?? event.created_at ?? "", + payload: event.event_object ?? event.payload ?? {}, + })) + + // Keep legacy ListResponse shape for existing callers. + const limit = params?.page_size ?? 100 + const hasMoreFromCount = typeof response.count === "number" && response.count >= limit + const hasMore = response.has_more ?? hasMoreFromCount + const cursor = response.cursor ?? mappedData[mappedData.length - 1]?.id + + return { + data: mappedData, + has_more: Boolean(hasMore && cursor), + cursor, + } + } +} + +export default new BridgeClient() + +export async function* listAllEvents( + params?: Omit & { start?: string; end?: string }, +): AsyncGenerator { + const client = new BridgeClient() + const startMs = params?.start ? new Date(params.start).getTime() : -Infinity + const endMs = params?.end ? new Date(params.end).getTime() : Infinity + + // Strip start/end: Bridge /webhook_events only supports cursor params; filter locally. + const apiParams = { ...(params ?? {}) } + delete apiParams.start + delete apiParams.end + + let cursor: string | undefined + do { + const page = await client.listEvents({ ...apiParams, after: cursor, page_size: 100 }) + + for (const event of page.data) { + const eventMs = new Date(event.created_at).getTime() + if (eventMs >= startMs && eventMs <= endMs) { + yield event + } + } + + cursor = page.has_more ? page.cursor : undefined + } while (cursor) +} diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts new file mode 100644 index 000000000..eb9c29ef0 --- /dev/null +++ b/src/services/bridge/errors.ts @@ -0,0 +1,195 @@ +import { DomainError, ErrorLevel } from "@domain/shared" + +export class BridgeError extends DomainError { + readonly level: ErrorLevel = ErrorLevel.Warn +} + +export class BridgeApiError extends BridgeError { + readonly statusCode: number + readonly response?: unknown + + constructor(message: string, statusCode: number, response?: unknown) { + super(message) + this.statusCode = statusCode + this.response = response + } +} + +export class BridgeRateLimitError extends BridgeError { + constructor(message: string = "Rate limit exceeded, please try again later") { + super(message) + } +} + +export class BridgeTimeoutError extends BridgeError { + constructor(message: string = "Request timed out") { + super(message) + } +} + +export class BridgeCustomerNotFoundError extends BridgeError { + constructor(message: string = "Bridge customer not found") { + super(message) + } +} + +export class BridgeKycPendingError extends BridgeError { + constructor(message: string = "KYC verification is pending") { + super(message) + } +} + +export class BridgeKycRejectedError extends BridgeError { + constructor(message: string = "KYC verification was rejected") { + super(message) + } +} + +export class BridgeKycOffboardedError extends BridgeError { + constructor( + message: string = "Your account has been offboarded from Bridge. Please contact support.", + ) { + super(message) + } +} + +export class BridgeInsufficientFundsError extends BridgeError { + constructor(message: string = "Insufficient funds for withdrawal") { + super(message) + } +} + +export class BridgeWithdrawalNetAmountTooLowError extends BridgeError { + constructor( + message: string = "Withdrawal amount must exceed estimated customer fees", + ) { + super(message) + } +} + +export class BridgeAccountLevelError extends BridgeError { + constructor( + message: string = "Bridge requires at least a Personal account (Level 1+)", + ) { + super(message) + } +} + +export class BridgeBelowMinimumWithdrawalError extends BridgeError { + constructor(minimum: number) { + super(`Withdrawal amount is below the minimum of ${minimum} USDT`) + } +} + +export class BridgeInvalidAmountError extends BridgeError { + constructor( + message: string = "Amount must be strictly positive with at most 6 decimal places", + ) { + super(message) + } +} + +export class BridgeDisabledError extends BridgeError { + constructor(message: string = "Bridge integration is currently disabled") { + super(message) + } +} + +export class BridgeTransferFailedError extends BridgeError { + constructor(reason: string = "Transfer failed") { + super(reason) + } +} + +export class BridgeDepositInstructionsMissingError extends BridgeError { + constructor( + message: string = "Bridge did not return crypto deposit instructions for this withdrawal", + ) { + super(message) + } +} + +export class BridgeWebhookValidationError extends BridgeError { + constructor(message: string = "Invalid webhook signature") { + super(message) + } +} + +export class BridgeKycTierCeilingExceededError extends BridgeError { + constructor(message: string = "Withdrawal amount exceeds the KYC tier ceiling") { + super(message) + } +} + +export class BridgeWithdrawalNotFoundError extends BridgeError { + constructor(message: string = "Withdrawal request not found") { + super(message) + } +} + +export class BridgeWithdrawalAlreadyInitiatedError extends BridgeError { + constructor( + message: string = "Withdrawal has already been submitted to Bridge and cannot be cancelled", + ) { + super(message) + } +} + +export class BridgePlaidNotAvailableError extends BridgeError { + constructor( + message: string = "Bank account linking via Plaid is not available. Please enter your bank details manually.", + ) { + super(message) + } +} + +/** + * Maps HTTP status codes from Bridge API to domain error types + * + * Checks the response body for specific Bridge error types when applicable. + */ +export const mapBridgeHttpError = ( + statusCode: number, + response?: unknown, +): BridgeError => { + // Bridge returns 422/400 with a specific error type for KYC tier ceiling violations. + if ( + (statusCode === 422 || statusCode === 400) && + typeof response === "object" && + response !== null + ) { + const resp = response as Record + const errorObj = (resp.error ?? resp) as Record | undefined + const errorType = String(errorObj?.type ?? "").toLowerCase() + const errorMessage = String(errorObj?.message ?? resp?.message ?? "").toLowerCase() + + if ( + errorType.includes("kyc_tier_limit") || + errorType.includes("kyc_limit") || + errorType.includes("tier_ceiling") || + (errorMessage.includes("kyc") && + (errorMessage.includes("limit") || + errorMessage.includes("ceiling") || + errorMessage.includes("tier"))) + ) { + const message = + typeof errorObj?.message === "string" + ? errorObj.message + : typeof resp.message === "string" + ? resp.message + : undefined + return new BridgeKycTierCeilingExceededError(message) + } + } + + switch (statusCode) { + case 404: + return new BridgeCustomerNotFoundError() + case 429: + return new BridgeRateLimitError() + case 408: + return new BridgeTimeoutError() + default: + return new BridgeApiError("Bridge API error", statusCode, response) + } +} diff --git a/src/services/bridge/ethereum-gas-estimate.ts b/src/services/bridge/ethereum-gas-estimate.ts new file mode 100644 index 000000000..6b76dff47 --- /dev/null +++ b/src/services/bridge/ethereum-gas-estimate.ts @@ -0,0 +1,216 @@ +import { baseLogger } from "@services/logger" + +export type EthereumGasMarketSnapshot = { + gasPriceGwei: number + ethUsd: number +} + +const DEFAULT_ETH_USD_PRICE_URL = + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" + +type CachedGasMarketSnapshot = { + key: string + expiresAt: number + snapshot: EthereumGasMarketSnapshot +} + +let cachedGasMarketSnapshot: CachedGasMarketSnapshot | undefined + +export const computeEstimatedGasBufferUsd = ({ + gasLimit, + gasPriceGwei, + ethUsd, + bufferMultiplier, +}: { + gasLimit: number + gasPriceGwei: number + ethUsd: number + bufferMultiplier: number +}): string => { + const gasUsd = ((gasLimit * gasPriceGwei * ethUsd) / 1e9) * bufferMultiplier + return gasUsd.toFixed(2) +} + +const parseHexWeiToGwei = (hexWei: string): number | Error => { + const normalized = hexWei.startsWith("0x") ? hexWei.slice(2) : hexWei + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + return new Error(`Invalid gas price response: ${hexWei}`) + } + const wei = BigInt(`0x${normalized}`) + return Number(wei) / 1e9 +} + +export const fetchEthereumGasPriceGwei = async ({ + rpcUrl, + timeoutMs, +}: { + rpcUrl: string + timeoutMs: number +}): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_gasPrice", + params: [], + }), + signal: controller.signal, + }) + + if (!response.ok) { + return new Error(`Ethereum RPC gas price request failed: HTTP ${response.status}`) + } + + const payload = (await response.json()) as { + result?: string + error?: { message?: string } + } + + if (payload.error?.message) { + return new Error(`Ethereum RPC gas price error: ${payload.error.message}`) + } + if (!payload.result) { + return new Error("Ethereum RPC gas price response missing result") + } + + const gasPriceGwei = parseHexWeiToGwei(payload.result) + if (gasPriceGwei instanceof Error) return gasPriceGwei + if (!Number.isFinite(gasPriceGwei) || gasPriceGwei <= 0) { + return new Error(`Invalid gas price gwei value: ${gasPriceGwei}`) + } + + return gasPriceGwei + } catch (error) { + baseLogger.warn({ error, rpcUrl }, "Failed to fetch Ethereum gas price") + return error instanceof Error ? error : new Error(String(error)) + } finally { + clearTimeout(timeout) + } +} + +export const fetchEthereumGasPriceGweiAverage = async ({ + rpcUrls, + timeoutMs, +}: { + rpcUrls: string[] + timeoutMs: number +}): Promise => { + if (rpcUrls.length === 0) { + return new Error("No Ethereum RPC URLs configured for gas price estimate") + } + + const gasPriceResults = await Promise.all( + rpcUrls.map((rpcUrl) => fetchEthereumGasPriceGwei({ rpcUrl, timeoutMs })), + ) + const gasPrices = gasPriceResults.filter( + (result): result is number => !(result instanceof Error), + ) + + if (gasPrices.length === 0) { + return new Error("All Ethereum RPC gas price requests failed") + } + + return gasPrices.reduce((sum, gasPrice) => sum + gasPrice, 0) / gasPrices.length +} + +export const fetchEthUsdPrice = async ({ + url = DEFAULT_ETH_USD_PRICE_URL, + timeoutMs, +}: { + url?: string + timeoutMs: number +}): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) { + return new Error(`ETH/USD price request failed: HTTP ${response.status}`) + } + + const payload = (await response.json()) as { ethereum?: { usd?: number } } + const ethUsd = payload.ethereum?.usd + if (ethUsd == null || !Number.isFinite(ethUsd) || ethUsd <= 0) { + return new Error("ETH/USD price response missing ethereum.usd") + } + + return ethUsd + } catch (error) { + baseLogger.warn({ error, url }, "Failed to fetch ETH/USD price") + return error instanceof Error ? error : new Error(String(error)) + } finally { + clearTimeout(timeout) + } +} + +export const fetchEthereumGasMarketSnapshot = async ({ + rpcUrls, + timeoutMs, + fallbackGasPriceGwei, + ethUsdFallback, + ethUsdPriceUrl = DEFAULT_ETH_USD_PRICE_URL, + cacheTtlMs = 0, +}: { + rpcUrls: string[] + timeoutMs: number + fallbackGasPriceGwei: number + ethUsdFallback: number + ethUsdPriceUrl?: string + cacheTtlMs?: number +}): Promise => { + const cacheKey = JSON.stringify({ + rpcUrls, + timeoutMs, + fallbackGasPriceGwei, + ethUsdFallback, + ethUsdPriceUrl, + }) + const now = Date.now() + if ( + cacheTtlMs > 0 && + cachedGasMarketSnapshot?.key === cacheKey && + cachedGasMarketSnapshot.expiresAt > now + ) { + return cachedGasMarketSnapshot.snapshot + } + + const [gasPriceResult, ethUsdResult] = await Promise.all([ + fetchEthereumGasPriceGweiAverage({ rpcUrls, timeoutMs }), + fetchEthUsdPrice({ url: ethUsdPriceUrl, timeoutMs }), + ]) + + const gasPriceGwei = + gasPriceResult instanceof Error ? fallbackGasPriceGwei : gasPriceResult + const ethUsd = ethUsdResult instanceof Error ? ethUsdFallback : ethUsdResult + + if (gasPriceResult instanceof Error) { + baseLogger.warn( + { fallbackGasPriceGwei, error: gasPriceResult.message }, + "Using fallback Ethereum gas price for withdrawal fee estimate", + ) + } + if (ethUsdResult instanceof Error) { + baseLogger.warn( + { ethUsdFallback, error: ethUsdResult.message }, + "Using fallback ETH/USD price for withdrawal fee estimate", + ) + } + + const snapshot = { gasPriceGwei, ethUsd } + if (cacheTtlMs > 0) { + cachedGasMarketSnapshot = { + key: cacheKey, + expiresAt: now + cacheTtlMs, + snapshot, + } + } + + return snapshot +} diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts new file mode 100644 index 000000000..7c8e0ed38 --- /dev/null +++ b/src/services/bridge/index.ts @@ -0,0 +1,1468 @@ +/** + * Bridge Service Layer + * Orchestrates Bridge API client, repository, and implements business logic + * for USD on/off-ramp functionality via Bridge.xyz + */ + +import crypto from "crypto" + +import { BridgeConfig } from "@config" + +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { AccountsRepository } from "@services/mongoose/accounts" +import { BridgeVirtualAccount } from "@services/mongoose/schema" +import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" +import { baseLogger } from "@services/logger" + +import { RepositoryError } from "@domain/errors" +import { toBridgeCustomerId, toBridgeVirtualAccountId } from "@domain/primitives/bridge" +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" +import { USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { WalletsRepository } from "@services/mongoose/wallets" + +import { IdentityRepository } from "@services/kratos" +import IbexClient from "@services/ibex/client" +import { writeBridgeCashoutPending } from "@services/frappe/BridgeTransferRequestWriter" +import { IbexError } from "@services/ibex/errors" + +import { + BridgeApiError, + BridgeInsufficientFundsError, + BridgeError, + BridgeDisabledError, + BridgeAccountLevelError, + BridgeKycPendingError, + BridgeKycRejectedError, + BridgeKycOffboardedError, + BridgeCustomerNotFoundError, + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, + BridgePlaidNotAvailableError, + BridgeDepositInstructionsMissingError, + BridgeWithdrawalNetAmountTooLowError, +} from "./errors" +import BridgeApiClient, { + type CreateExternalAccountRequest, + type ExternalAccount, +} from "./client" +import { + presentBridgeWithdrawal, + receiptFeesFromTransfer, + resolveWithdrawalCustomerFeeEstimate, + type PresentedBridgeWithdrawal, +} from "./withdrawal-fees" + +const asBridgeRequestWithdrawalError = (error: unknown): BridgeError => { + if (error instanceof BridgeError) return error + if (error instanceof IbexError) { + return new BridgeInsufficientFundsError( + "Unable to verify USDT wallet balance. Ensure IBEX is running and the USDT Cash Wallet is funded.", + ) + } + if (error instanceof RepositoryError) { + return new BridgeError(`Failed to persist withdrawal request: ${error.message}`) + } + if (error instanceof Error) return new BridgeError(error.message) + return new BridgeError(String(error)) +} + +// ============ Types ============ + +type InitiateKycResult = { + kycLink: string + customerId: string + tosLink: string +} + +type CreateVirtualAccountResult = { + virtualAccountId: string + bankName: string + routingNumber: string + accountNumber: string + accountNumberLast4: string +} + +type AddExternalAccountResult = { + linkUrl: string + expiresAt: string +} + +type WithdrawalRequestResult = PresentedBridgeWithdrawal + +type InitiateWithdrawalResult = PresentedBridgeWithdrawal + +type CancelWithdrawalResult = { + id: string + amount: string + currency: string + status: string + createdAt: string +} + +type WithdrawalResult = PresentedBridgeWithdrawal + +type KycStatusResult = + | "open" + | "not_started" + | "incomplete" + | "awaiting_questionnaire" + | "awaiting_ubo" + | "under_review" + | "paused" + | "approved" + | "rejected" + | "offboarded" + | null + +type VirtualAccountResult = { + bridgeVirtualAccountId: string + bankName: string + routingNumber: string + accountNumber: string + accountNumberLast4: string +} | null + +type ExternalAccountResult = { + bridgeExternalAccountId: string + bankName: string + accountNumberLast4: string + status: "pending" | "verified" | "failed" +} + +// ============ Helpers ============ + +export const deriveWithdrawalIdempotencyKey = (rowId: string): string => + crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex") + +const bridgeDepositAddressFromTransfer = (transfer: { + source_deposit_instructions?: { to_address?: string } +}) => transfer.source_deposit_instructions?.to_address + +const ibexPayoutIdFromSendResponse = (response: unknown): string | undefined => { + if (typeof response !== "object" || response === null) return undefined + const typed = response as { + transaction?: { id?: string } + transactionHub?: { id?: string } + transactionId?: string + } + return typed.transaction?.id ?? typed.transactionHub?.id ?? typed.transactionId +} + +const ibexTxHashFromSendResponse = (response: unknown): string | undefined => { + if (typeof response !== "object" || response === null) return undefined + const typed = response as { + txHash?: string + transactionHash?: string + networkTxId?: string + transactionHub?: { txHash?: string; transactionHash?: string; hash?: string } + cryptoTransaction?: { txHash?: string; networkTxId?: string } + } + return ( + typed.txHash ?? + typed.transactionHash ?? + typed.networkTxId ?? + typed.transactionHub?.txHash ?? + typed.transactionHub?.transactionHash ?? + typed.transactionHub?.hash ?? + typed.cryptoTransaction?.txHash ?? + typed.cryptoTransaction?.networkTxId + ) +} + +const bridgeExternalAccountLast4 = (externalAccount: ExternalAccount): string => + externalAccount.account_number_last_4 ?? externalAccount.last_4 ?? "" + +const bridgeExternalAccountIsActive = (externalAccount: ExternalAccount): boolean => + externalAccount.active !== false + +const externalAccountResultFromRecord = (acc: { + bridgeExternalAccountId: string + bankName: string + accountNumberLast4: string + status: string +}): ExternalAccountResult => ({ + bridgeExternalAccountId: acc.bridgeExternalAccountId, + bankName: acc.bankName, + accountNumberLast4: acc.accountNumberLast4, + status: acc.status as "pending" | "verified" | "failed", +}) + +const syncExternalAccountsFromBridge = async ( + accountId: string, + customerId: string, +): Promise => { + const bridgeAccounts = await BridgeApiClient.listExternalAccounts( + toBridgeCustomerId(customerId), + ) + const activeBridgeAccounts = bridgeAccounts.data.filter(bridgeExternalAccountIsActive) + const activeBridgeAccountIds = activeBridgeAccounts.map((acc) => acc.id) + + for (const externalAccount of activeBridgeAccounts) { + const persisted = await BridgeAccountsRepo.createExternalAccount({ + accountId, + bridgeExternalAccountId: externalAccount.id, + bankName: externalAccount.bank_name ?? "", + accountNumberLast4: bridgeExternalAccountLast4(externalAccount), + status: "verified", + }) + if (persisted instanceof Error) return persisted + } + + const staleMarkResult = await BridgeAccountsRepo.markExternalAccountsMissingFromBridge( + accountId, + activeBridgeAccountIds, + ) + if (staleMarkResult instanceof Error) return staleMarkResult + + const localAccounts = + await BridgeAccountsRepo.findExternalAccountsByAccountId(accountId) + if (localAccounts instanceof Error) return localAccounts + + const activeBridgeAccountIdsSet = new Set(activeBridgeAccountIds) + return localAccounts + .filter( + (acc) => + activeBridgeAccountIdsSet.has(acc.bridgeExternalAccountId) && + acc.status === "verified", + ) + .map(externalAccountResultFromRecord) +} + +const ensureEthUsdtCashWallet = async ( + account: Account, +): Promise => { + const wallets = await WalletsRepository().listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + let usdtWallet = wallets.find( + (wallet) => + wallet.currency === WalletCurrency.Usdt && wallet.type === WalletType.Checking, + ) + + if (!usdtWallet) { + const createdWallet = await WalletsRepository().persistNew({ + accountId: account.id, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + if (createdWallet instanceof Error) return createdWallet + usdtWallet = createdWallet + } + + if (account.defaultWalletId !== usdtWallet.id) { + const updatedAccount = await AccountsRepository().update({ + ...account, + defaultWalletId: usdtWallet.id, + }) + if (updatedAccount instanceof Error) return updatedAccount + } + + return usdtWallet +} + +// ============ Guards ============ + +const checkBridgeEnabled = (): true | BridgeDisabledError => { + if (!BridgeConfig.enabled) { + return new BridgeDisabledError() + } + return true +} + +const checkAccountLevel = async ( + accountId: AccountId, +): Promise => { + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + if (account.level < 1) { + const err = new BridgeAccountLevelError() + baseLogger.warn( + { accountId, level: account.level, requiredLevel: 1 }, + "Bridge account level too low", + ) + return err + } + + return account +} + +// ============ Service Methods ============ + +/** + * Initiates KYC process for an account + * - Creates Bridge customer if not exists + * - Returns KYC and TOS links + */ +const initiateKyc = async ({ + accountId, + email, + type, + full_name, +}: { + accountId: AccountId + email: string + type?: "individual" | "business" + full_name: string +}): Promise => { + baseLogger.info({ accountId, operation: "initiateKyc" }, "Bridge operation started") + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + if (account.bridgeKycStatus === "approved") { + return new BridgeError("KYC already approved for this account") + } + + const identity = await IdentityRepository().getIdentity(account.kratosUserId) + + if (identity instanceof Error) return identity + + const useremail = identity.email + + try { + // Create KYC link + const kycLink = await BridgeApiClient.createKycLink({ + email: useremail || email, + type: type || "individual", + full_name: full_name || account.username, + }) + + const result: InitiateKycResult = { + kycLink: kycLink.kyc_link, + customerId: kycLink.customer_id, + tosLink: kycLink.tos_link, + } + + // link the customer Id to the bridge account + const customerId = toBridgeCustomerId(kycLink.customer_id) + + const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + bridgeCustomerId: customerId, + bridgeKycStatus: "open", + }) + + if (updateResult instanceof Error) { + return updateResult + } + + baseLogger.info( + { accountId, operation: "initiateKyc", kycLink }, + "Bridge operation completed", + ) + + return result + } catch (error) { + const bridgeError = error as { + statusCode?: number + response?: { + existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } + } + } + + if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { + // store the customer id and the kyc status + const customerId = toBridgeCustomerId( + bridgeError.response.existing_kyc_link.customer_id, + ) + await AccountsRepository().updateBridgeFields(accountId, { + bridgeCustomerId: customerId, + bridgeKycStatus: "not_started", + }) + + return { + kycLink: bridgeError.response.existing_kyc_link.kyc_link, + customerId: bridgeError.response.existing_kyc_link.customer_id, + tosLink: bridgeError.response.existing_kyc_link.tos_link, + } + } + + baseLogger.error( + { accountId, operation: "initiateKyc", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Creates a virtual account for receiving USD deposits + * - Requires approved KYC + * - Ensures an IBEX ETH-USDT Cash Wallet exists and is the account default + * - Creates IBEX Ethereum USDT receive address + * - Creates Bridge virtual account pointing to Ethereum address + */ +const createVirtualAccount = async ( + accountId: AccountId, +): Promise => { + baseLogger.info( + { accountId, operation: "createVirtualAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + + try { + if (!account.bridgeCustomerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + if (!customerId && !account.bridgeKycStatus) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const customer = await BridgeApiClient.getCustomer(customerId) + + if (customer instanceof Error) { + baseLogger.error( + { accountId, error: customer }, + "Failed to retrieve Bridge customer status", + ) + return customer + } + + const kycStatus = customer.status + + // Check KYC status + if (kycStatus === "offboarded") { + return new BridgeKycOffboardedError() + } + if (kycStatus === "rejected") { + return new BridgeKycRejectedError() + } + if (PENDING_BRIDGE_STATUSES.has(kycStatus!) || (kycStatus as string) === "open") { + return new BridgeKycPendingError() + } + if (kycStatus !== "active" && (kycStatus as string) !== "approved") { + return new BridgeKycPendingError("KYC not yet completed") + } + + // Idempotency guard first: do not mutate wallets/default when a VA already exists + const existingVa = await BridgeAccountsRepo.findVirtualAccountByAccountId( + accountId as string, + ) + if (!(existingVa instanceof RepositoryError)) { + return { + virtualAccountId: existingVa.bridgeVirtualAccountId!, + bankName: existingVa.bankName, + routingNumber: existingVa.routingNumber, + accountNumber: existingVa.accountNumber, + accountNumberLast4: existingVa.accountNumberLast4, + } + } + + const usdtCashWallet = await ensureEthUsdtCashWallet(account) + if (usdtCashWallet instanceof Error) return usdtCashWallet + + // Get or create Ethereum USDT receive address for the ETH-USDT Cash Wallet + let ethereumAddress = account.bridgeEthereumAddress + + if (!ethereumAddress) { + const option = await IbexClient.getEthereumUsdtOption() + if (option instanceof Error) return new BridgeError(option.message) + + option.name = `USDT-ETH ${account.username}-${crypto.randomBytes(4).toString("hex")}` + const receiveInfo = await IbexClient.createCryptoReceiveInfo( + usdtCashWallet.id as IbexAccountId, + option, + ) + if (receiveInfo instanceof Error) return new BridgeError(receiveInfo.message) + + const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + bridgeEthereumAddress: receiveInfo.data.address, + }) + if (updateResult instanceof Error) return updateResult + + ethereumAddress = receiveInfo.data.address + } + + const vaIdempotencyKey = `${accountId}:${crypto.randomUUID()}` + + // Create Bridge virtual account + const virtualAccount = await BridgeApiClient.createVirtualAccount( + customerId, + { + source: { currency: "usd" }, + destination: { + currency: "usdt", + payment_rail: "ethereum", + address: ethereumAddress, + }, + developer_fee_percent: String(BridgeConfig.developerFeePercent), + }, + vaIdempotencyKey, + ) + + const fullAccountNumber = + virtualAccount.source_deposit_instructions.bank_account_number || "" + + // Store virtual account in repository + const repoResult = await BridgeAccountsRepo.createVirtualAccount({ + accountId: accountId as string, + bridgeVirtualAccountId: virtualAccount.id, + bankName: virtualAccount.source_deposit_instructions.bank_name || "", + routingNumber: virtualAccount.source_deposit_instructions.bank_routing_number || "", + accountNumber: fullAccountNumber, + accountNumberLast4: fullAccountNumber.slice(-4), + }) + if (repoResult instanceof Error) return repoResult + + const result: CreateVirtualAccountResult = { + virtualAccountId: virtualAccount.id, + bankName: virtualAccount.source_deposit_instructions.bank_name || "", + routingNumber: virtualAccount.source_deposit_instructions.bank_routing_number || "", + accountNumber: fullAccountNumber, + accountNumberLast4: fullAccountNumber.slice(-4), + } + + baseLogger.info( + { + accountId, + operation: "createVirtualAccount", + virtualAccountId: virtualAccount.id, + }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "createVirtualAccount", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Returns Bridge hosted bank linking URL for adding external accounts + */ +const addExternalAccount = async ( + accountId: AccountId, +): Promise => { + baseLogger.info( + { accountId, operation: "addExternalAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const linkUrl = await BridgeApiClient.getExternalAccountLinkUrl(customerId) + + const result: AddExternalAccountResult = { + linkUrl: linkUrl.link_url, + expiresAt: linkUrl.expires_at, + } + + baseLogger.info( + { accountId, operation: "addExternalAccount" }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "addExternalAccount", error }, + "Bridge operation failed", + ) + + if ( + error instanceof BridgeApiError && + (error.statusCode === 401 || error.statusCode === 403) + ) { + return new BridgePlaidNotAvailableError() + } + + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Creates an external account directly via Bridge API (bypassing Plaid Link). + * Used as a fallback when Plaid Link is unavailable. + */ +const createExternalAccount = async ( + accountId: AccountId, + data: CreateExternalAccountRequest, +): Promise => { + baseLogger.info( + { accountId, operation: "createExternalAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const externalAccount = await BridgeApiClient.createExternalAccount( + customerId, + data, + crypto.randomUUID(), + ) + + const result: ExternalAccountResult = { + bridgeExternalAccountId: externalAccount.id, + bankName: externalAccount.bank_name ?? "", + accountNumberLast4: bridgeExternalAccountLast4(externalAccount), + status: "verified", + } + + // Persist the external account reference in the local repository + const persistResult = await BridgeAccountsRepo.createExternalAccount({ + accountId, + bridgeExternalAccountId: result.bridgeExternalAccountId, + bankName: result.bankName, + accountNumberLast4: result.accountNumberLast4, + status: "verified", + }) + if (persistResult instanceof Error) { + baseLogger.error( + { accountId, operation: "createExternalAccount", error: persistResult }, + "Failed to persist external account locally", + ) + return persistResult + } + + baseLogger.info( + { accountId, operation: "createExternalAccount", result }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "createExternalAccount", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Requests a withdrawal — validates everything and stores a pending record in MongoDB. + * Does NOT call the Bridge API. Returns the pending withdrawal so the frontend can + * display a confirmation screen before the user commits. + */ +const requestWithdrawal = async ( + accountId: AccountId, + amount: string, + externalAccountId: string, +): Promise => { + baseLogger.info( + { accountId, amount, externalAccountId, operation: "requestWithdrawal" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const ethereumAddress = account.bridgeEthereumAddress + if (!ethereumAddress) { + return new Error("Account has no Ethereum address. Create virtual account first.") + } + + const wallets = await WalletsRepository().listByAccountId(accountId) + if (wallets instanceof Error) return wallets + const usdtWallet = wallets.find( + (w) => w.currency === WalletCurrency.Usdt && w.type === WalletType.Checking, + ) + if (!usdtWallet) { + return new BridgeInsufficientFundsError("No USDT wallet found on account") + } + const balance = await getBalanceForWallet({ + walletId: usdtWallet.id, + currency: WalletCurrency.Usdt, + }) + if (balance instanceof Error) { + baseLogger.error( + { accountId, walletId: usdtWallet.id, error: balance, operation: "requestWithdrawal" }, + "Failed to read USDT wallet balance for withdrawal request", + ) + return asBridgeRequestWithdrawalError(balance) + } + + if (!(balance instanceof USDTAmount)) { + return new BridgeInsufficientFundsError("Invalid balance type") + } + const withdrawalAmount = parseFloat(amount) + if (isNaN(withdrawalAmount) || withdrawalAmount <= 0) { + return new BridgeInsufficientFundsError("Invalid withdrawal amount") + } + + const availableBalance = balance.toIbex() + if (availableBalance < withdrawalAmount) { + baseLogger.warn( + { accountId, availableBalance, withdrawalAmount, operation: "requestWithdrawal" }, + "Insufficient USDT balance for withdrawal", + ) + return new BridgeInsufficientFundsError( + `Insufficient USDT balance: available ${availableBalance}, requested ${withdrawalAmount}`, + ) + } + + // CRIT-2 (ENG-281): Bridge is the source of truth. Sync first so + // Dashboard-deleted external accounts cannot remain locally selectable. + const externalAccounts = await syncExternalAccountsFromBridge( + accountId as string, + customerId, + ) + if (externalAccounts instanceof Error) return externalAccounts + + const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, + ) + if (!targetAccount) { + return new BridgeError("External account not found for this account") + } + if (targetAccount.status !== "verified") { + return new BridgeError("External account is not verified") + } + + const feeEstimate = await resolveWithdrawalCustomerFeeEstimate(amount) + if (withdrawalAmount <= parseFloat(feeEstimate.estimatedCustomerFee)) { + return new BridgeWithdrawalNetAmountTooLowError( + `Withdrawal amount ${amount} must exceed estimated customer fees ${feeEstimate.estimatedCustomerFee}`, + ) + } + + const existingWithdrawal = + await BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer( + accountId as string, + externalAccountId, + amount, + ) + if (existingWithdrawal instanceof Error) return existingWithdrawal + + let pendingWithdrawal + if (existingWithdrawal) { + pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( + BridgeAccountsRepo.bridgeWithdrawalRecordId(existingWithdrawal), + feeEstimate, + ) + } else { + pendingWithdrawal = await BridgeAccountsRepo.createWithdrawal({ + accountId: accountId as string, + amount, + currency: "usdt", + externalAccountId, + flashFeePercent: feeEstimate.flashFeePercent, + flashFee: feeEstimate.flashFee, + estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, + estimatedBridgeFee: feeEstimate.estimatedBridgeFee, + estimatedGasBuffer: feeEstimate.estimatedGasBuffer, + estimatedCustomerFee: feeEstimate.estimatedCustomerFee, + status: "pending", + }) + if ( + !(pendingWithdrawal instanceof Error) && + !pendingWithdrawal.estimatedCustomerFee + ) { + pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( + BridgeAccountsRepo.bridgeWithdrawalRecordId(pendingWithdrawal), + feeEstimate, + ) + } + } + if (pendingWithdrawal instanceof Error) { + return asBridgeRequestWithdrawalError(pendingWithdrawal) + } + + baseLogger.info( + { + accountId, + operation: "requestWithdrawal", + withdrawalId: BridgeAccountsRepo.bridgeWithdrawalRecordId(pendingWithdrawal), + }, + "Bridge operation completed", + ) + + return presentBridgeWithdrawal(pendingWithdrawal, feeEstimate) + } catch (error) { + baseLogger.error( + { accountId, operation: "requestWithdrawal", error }, + "Bridge operation failed", + ) + return asBridgeRequestWithdrawalError(error) + } +} + +/** + * Initiates a previously requested withdrawal — fetches the pending record by ID, + * re-checks balance, then submits the transfer to Bridge. + */ +const initiateWithdrawal = async ( + accountId: AccountId, + withdrawalId: string, +): Promise => { + baseLogger.info( + { accountId, withdrawalId, operation: "initiateWithdrawal" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const pendingWithdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) + if (pendingWithdrawal instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + if (pendingWithdrawal.accountId !== (accountId as string)) { + return new BridgeWithdrawalNotFoundError() + } + if ( + pendingWithdrawal.status !== "pending" || + pendingWithdrawal.bridgeTransferId || + pendingWithdrawal.ibexPayoutId + ) { + return new BridgeWithdrawalAlreadyInitiatedError() + } + + const { amount, externalAccountId } = pendingWithdrawal + + const externalAccounts = await syncExternalAccountsFromBridge( + accountId as string, + customerId, + ) + if (externalAccounts instanceof Error) return externalAccounts + const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, + ) + if (!targetAccount) { + return new Error("External account not found") + } + + // Re-check balance at execution time — funds may have changed since the request + const wallets = await WalletsRepository().listByAccountId(accountId) + if (wallets instanceof Error) return wallets + const usdtWallet = wallets.find( + (w) => w.currency === WalletCurrency.Usdt && w.type === WalletType.Checking, + ) + if (!usdtWallet) { + return new BridgeInsufficientFundsError("No USDT wallet found on account") + } + const balance = await getBalanceForWallet({ + walletId: usdtWallet.id, + currency: WalletCurrency.Usdt, + }) + if (balance instanceof Error) return balance + if (!(balance instanceof USDTAmount)) { + return new BridgeInsufficientFundsError("Invalid balance type") + } + const availableBalance = balance.toIbex() + if (availableBalance < parseFloat(amount)) { + return new BridgeInsufficientFundsError( + `Insufficient USDT balance: available ${availableBalance}, requested ${amount}`, + ) + } + + const sendAmount = USDTAmount.fromNumber(amount) + if (sendAmount instanceof Error) return sendAmount + + const idempotencyKey = deriveWithdrawalIdempotencyKey(pendingWithdrawal.id) + + const transfer = await BridgeApiClient.createTransfer( + customerId, + { + amount, + on_behalf_of: customerId, + source: { + payment_rail: "ethereum", + currency: "usdt", + }, + developer_fee_percent: String(BridgeConfig.developerFeePercent), + destination: { + payment_rail: "ach", + currency: "usd", + external_account_id: externalAccountId, + }, + features: { + allow_any_from_address: true, + }, + }, + idempotencyKey, + ) + + const bridgeDepositAddress = bridgeDepositAddressFromTransfer(transfer) + if (!bridgeDepositAddress) { + return new BridgeDepositInstructionsMissingError() + } + + const submitted = await BridgeAccountsRepo.updateWithdrawalTransferId( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + receiptFeesFromTransfer(transfer.receipt), + ) + if (submitted instanceof Error) return submitted + + const sendRequirements = await IbexClient.getCryptoSendRequirements({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + if (sendRequirements instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + sendRequirements.message, + ) + return sendRequirements + } + + const cryptoSendInfo = await IbexClient.createCryptoSendInfo({ + name: `bridge-withdrawal-${pendingWithdrawal.id}`, + requirementsId: sendRequirements.requirementsId, + data: { address: bridgeDepositAddress }, + }) + if (cryptoSendInfo instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + cryptoSendInfo.message, + ) + return cryptoSendInfo + } + if (!cryptoSendInfo.id) { + const error = new Error("IBEX crypto send info did not return id") + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + error.message, + ) + return error + } + + const sendResult = await IbexClient.sendCrypto({ + accountId: usdtWallet.id as IbexAccountId, + cryptoSendInfosId: cryptoSendInfo.id, + amount: sendAmount.toIbex(), + }) + if (sendResult instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + sendResult.message, + ) + return sendResult + } + + const ibexPayoutId = ibexPayoutIdFromSendResponse(sendResult) + if (!ibexPayoutId) { + baseLogger.error( + { + accountId, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + sendResult, + }, + "IBEX crypto send succeeded without transaction id; manual payout linking required", + ) + } + + const updated = await BridgeAccountsRepo.updateWithdrawalOnchainSend( + pendingWithdrawal.id, + ibexPayoutId, + ibexTxHashFromSendResponse(sendResult), + ) + if (updated instanceof Error) return updated + + const auditResult = await writeBridgeCashoutPending({ + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, + accountId: accountId as string, + sourceEventId: updated.id, + sourceEventType: "bridge.withdrawal.usdt_sent", + rawPayload: { + withdrawalId: updated.id, + bridgeTransferId: transfer.id, + ibexPayoutId, + ibexTxHash: updated.ibexTxHash, + }, + }) + if (auditResult instanceof Error) { + baseLogger.warn( + { accountId, withdrawalId, transferId: transfer.id, error: auditResult }, + "Failed to write pending Bridge cashout transfer request", + ) + } + + baseLogger.info( + { + accountId, + operation: "initiateWithdrawal", + transferId: transfer.id, + ibexPayoutId, + }, + "Bridge operation completed", + ) + + return presentBridgeWithdrawal(updated) + } catch (error) { + baseLogger.error( + { accountId, operation: "initiateWithdrawal", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Cancels a pending withdrawal request before it has been submitted to Bridge. + * Fails if the withdrawal already has a bridgeTransferId (transfer in-flight). + */ +const cancelWithdrawalRequest = async ( + accountId: AccountId, + withdrawalId: string, +): Promise => { + baseLogger.info( + { accountId, withdrawalId, operation: "cancelWithdrawalRequest" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + // Verify the withdrawal exists and belongs to this account before attempting cancel + const withdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) + if (withdrawal instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + if (withdrawal.accountId !== (accountId as string)) { + return new BridgeWithdrawalNotFoundError() + } + if (withdrawal.bridgeTransferId) { + return new BridgeWithdrawalAlreadyInitiatedError() + } + + const cancelled = await BridgeAccountsRepo.cancelWithdrawal( + accountId as string, + withdrawalId, + ) + if (cancelled instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + + baseLogger.info( + { accountId, operation: "cancelWithdrawalRequest", withdrawalId }, + "Bridge operation completed", + ) + + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: accountId as string, + amount: cancelled.amount, + currency: cancelled.currency, + outcome: "cancelled", + }) + + return { + id: cancelled.id, + amount: cancelled.amount, + currency: cancelled.currency, + status: cancelled.status, + createdAt: cancelled.createdAt.toISOString(), + } + } catch (error) { + baseLogger.error( + { accountId, operation: "cancelWithdrawalRequest", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Returns KYC status for an account + */ +const getKycStatus = async (accountId: AccountId): Promise => { + baseLogger.info({ accountId, operation: "getKycStatus" }, "Bridge operation started") + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + if (!account.bridgeCustomerId) { + return null + } + + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + + // get the customer status from Bridge API to ensure we have the latest status (in case of updates via Bridge dashboard or webhook events) + + try { + const customer = await BridgeApiClient.getCustomer(customerId) + + if (customer instanceof Error) return customer + + let kycStatus: KycStatusResult = null + + switch (customer.status) { + case "active": + kycStatus = "approved" + break + case "awaiting_questionnaire": + case "not_started": + case "incomplete": + case "under_review": + case "rejected": + case "offboarded": + case "paused": + case "awaiting_ubo": + kycStatus = customer.status + break + default: + kycStatus = "open" + } + if (account.bridgeKycStatus !== kycStatus) { + const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + bridgeKycStatus: kycStatus, + }) + if (updateResult instanceof Error) return updateResult + } + + // check if the customer is approved and don't have the virtual account yet, create the virtual account proactively so that the user doesn't have to wait for it when they try to make a deposit + if (kycStatus === "approved") { + // check if the user has active virtual account, if not create one proactively to avoid delay when user tries to make a deposit + const bridgeVirtualAccounts = + await BridgeApiClient.getVirtualAccountByCustomerId(customerId) + + if (bridgeVirtualAccounts instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: bridgeVirtualAccounts }, + "Failed to retrieve virtual accounts for customer after KYC approval", + ) + } + + const existingVa = await BridgeAccountsRepo.findVirtualAccountByAccountId( + accountId as string, + ) + + const relatedVa = bridgeVirtualAccounts.find( + (va) => va.destination.address === account.bridgeEthereumAddress, + ) + + if (relatedVa?.status === "activated") { + // if there's a related VA on Bridge side but it's not in our repo, create it in our repo to keep them in sync + if (existingVa instanceof RepositoryError) { + const repoResult = await BridgeAccountsRepo.createVirtualAccount({ + accountId: accountId as string, + bridgeVirtualAccountId: relatedVa.id, + bankName: relatedVa.source_deposit_instructions.bank_name || "", + routingNumber: + relatedVa.source_deposit_instructions.bank_routing_number || "", + accountNumber: + relatedVa.source_deposit_instructions.bank_account_number || "", + accountNumberLast4: + relatedVa.source_deposit_instructions.bank_account_number?.slice(-4) || "", + }) + if (repoResult instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: repoResult }, + "Failed to create virtual account in repo after KYC approval", + ) + } else { + baseLogger.info( + { accountId, operation: "getKycStatus", virtualAccountId: relatedVa.id }, + "Proactively updated virtual account in repo after KYC approval", + ) + } + } + } else { + const vaResult = await createVirtualAccount(accountId) + if (vaResult instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: vaResult }, + "Failed to create virtual account after KYC approval", + ) + } else { + baseLogger.info( + { + accountId, + operation: "getKycStatus", + virtualAccountId: vaResult.virtualAccountId, + }, + "Proactively created virtual account after KYC approval", + ) + } + } + } + + baseLogger.info( + { accountId, operation: "getKycStatus", kycStatus: kycStatus }, + "Bridge operation completed", + ) + + return kycStatus + } catch (error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Returns virtual account details for an account + */ +const getVirtualAccount = async ( + accountId: AccountId, +): Promise => { + baseLogger.info( + { accountId, operation: "getVirtualAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const virtualAccount = await BridgeAccountsRepo.findVirtualAccountByAccountId( + accountId as string, + ) + + // Repository returns RepositoryError if not found + if (virtualAccount instanceof RepositoryError) { + baseLogger.info( + { accountId, operation: "getVirtualAccount", result: null }, + "Bridge operation completed - no virtual account", + ) + return null + } + + // check if the virtual account still exists on Bridge side - if not, delete it from our repo and return null + const bridgeVa = await BridgeApiClient.getVirtualAccount( + account.bridgeCustomerId!, + toBridgeVirtualAccountId(virtualAccount.bridgeVirtualAccountId!), + ) + + if (bridgeVa instanceof Error) { + return new BridgeError( + `Failed to retrieve virtual account from Bridge: ${bridgeVa.message}`, + ) + } + + // check if the virtual account is still activated on Bridge side - if not, delete it from our repo and return null + if (bridgeVa.status !== "activated") { + // delete the virtual account from our repo since it's no longer valid + + await BridgeVirtualAccount.deleteOne({ + bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId! as string, + }) + + return null + } + + const result: VirtualAccountResult = { + bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId!, + bankName: virtualAccount.bankName, + routingNumber: virtualAccount.routingNumber, + accountNumber: virtualAccount.accountNumber, + accountNumberLast4: virtualAccount.accountNumberLast4, + } + + baseLogger.info( + { + accountId, + operation: "getVirtualAccount", + virtualAccountId: result!.bridgeVirtualAccountId, + }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "getVirtualAccount", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Returns list of linked external bank accounts + */ +const getExternalAccounts = async ( + accountId: AccountId, +): Promise => { + baseLogger.info( + { accountId, operation: "getExternalAccounts" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const result = await syncExternalAccountsFromBridge(accountId as string, customerId) + if (result instanceof Error) return result + + baseLogger.info( + { accountId, operation: "getExternalAccounts", count: result.length }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "getExternalAccounts", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Returns list of withdrawals + */ +const getWithdrawals = async ( + accountId: AccountId, +): Promise => { + baseLogger.info({ accountId, operation: "getWithdrawals" }, "Bridge operation started") + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const withdrawals = await BridgeAccountsRepo.findWithdrawalsByAccountId( + accountId as string, + ) + if (withdrawals instanceof Error) return withdrawals + + const result: WithdrawalResult[] = withdrawals + .filter((w) => w.bridgeTransferId !== null && w.bridgeTransferId !== undefined) + .map((w) => presentBridgeWithdrawal(w)) + + baseLogger.info( + { accountId, operation: "getWithdrawals", count: result.length }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "getWithdrawals", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +// ============ Export with Tracing ============ + +export default wrapAsyncFunctionsToRunInSpan({ + namespace: "services.bridge", + fns: { + initiateKyc, + createVirtualAccount, + addExternalAccount, + createExternalAccount, + requestWithdrawal, + initiateWithdrawal, + cancelWithdrawalRequest, + getKycStatus, + getVirtualAccount, + getExternalAccounts, + getWithdrawals, + }, +}) diff --git a/src/services/bridge/index.types.d.ts b/src/services/bridge/index.types.d.ts new file mode 100644 index 000000000..728ee499e --- /dev/null +++ b/src/services/bridge/index.types.d.ts @@ -0,0 +1,157 @@ +import { + BridgeCustomerId, + BridgeVirtualAccountId, + BridgeExternalAccountId, + BridgeTransferId, +} from "@domain/primitives/bridge" + +// Bridge API Service Types - Response models from Bridge API + +interface BridgeCustomer { + readonly id: BridgeCustomerId + readonly externalId?: string + readonly email?: string + readonly name?: string + readonly createdAt: string + readonly updatedAt: string +} + +interface BridgeVirtualAccount { + readonly id: BridgeVirtualAccountId + readonly customerId: BridgeCustomerId + readonly accountNumber: string + readonly routingNumber: string + readonly bankName: string + readonly currency: string + readonly status: "active" | "inactive" | "closed" + readonly createdAt: string + readonly updatedAt: string +} + +interface BridgeExternalAccount { + readonly id: BridgeExternalAccountId + readonly customerId: BridgeCustomerId + readonly accountNumber: string + readonly routingNumber: string + readonly accountHolderName: string + readonly bankName: string + readonly accountType: "checking" | "savings" + readonly status: "pending" | "verified" | "failed" + readonly createdAt: string + readonly updatedAt: string +} + +interface BridgeTransfer { + readonly id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly sourceAccountId: BridgeVirtualAccountId | BridgeExternalAccountId + readonly destinationAccountId: BridgeVirtualAccountId | BridgeExternalAccountId + readonly amount: number + readonly currency: string + readonly status: + | "awaiting_funds" + | "in_review" + | "funds_received" + | "payment_submitted" + | "payment_processed" + | "undeliverable" + | "returned" + | "refund_in_flight" + | "refunded" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" + readonly description?: string + readonly createdAt: string + readonly updatedAt: string + readonly completedAt?: string +} + +// Webhook Event Types + +interface BridgeWebhookEvent { + readonly id: string + readonly type: string + readonly timestamp: string + readonly data: Record +} + +interface BridgeKycApprovedEvent extends BridgeWebhookEvent { + readonly type: "kyc.approved" + readonly data: { + readonly customerId: BridgeCustomerId + readonly approvedAt: string + } +} + +interface BridgeKycRejectedEvent extends BridgeWebhookEvent { + readonly type: "kyc.rejected" + readonly data: { + readonly customerId: BridgeCustomerId + readonly reason: string + readonly rejectedAt: string + } +} + +interface BridgeDepositCompletedEvent extends BridgeWebhookEvent { + readonly type: "deposit.completed" + readonly data: { + readonly transferId: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly amount: number + readonly currency: string + readonly completedAt: string + } +} + +interface BridgeTransferCompletedEvent extends BridgeWebhookEvent { + readonly type: "transfer.completed" + readonly data: { + readonly transfer_id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly state: "payment_processed" + readonly amount: string + readonly currency: string + } +} + +interface BridgeTransferPaymentProcessedEvent extends BridgeWebhookEvent { + readonly type: "transfer.payment_processed" + readonly data: { + readonly transfer_id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly state: "payment_processed" + readonly amount: string + readonly currency: string + } +} + +interface BridgeTransferFailedEvent extends BridgeWebhookEvent { + readonly type: "transfer.failed" + readonly data: { + readonly transfer_id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly state: + | "undeliverable" + | "returned" + | "refunded" + | "refund_in_flight" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" + readonly reason?: string + readonly return_reason?: string + readonly amount: string + readonly currency: string + } +} + +type BridgeWebhookEventType = + | BridgeKycApprovedEvent + | BridgeKycRejectedEvent + | BridgeDepositCompletedEvent + | BridgeTransferCompletedEvent + | BridgeTransferPaymentProcessedEvent + | BridgeTransferFailedEvent diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts new file mode 100644 index 000000000..a0d0f9035 --- /dev/null +++ b/src/services/bridge/reconciliation.ts @@ -0,0 +1,502 @@ +import { alertIbexReconciliationOrphan } from "@services/alerts/ibex-bridge-movement" +import { baseLogger } from "@services/logger" +import { findIbexCryptoReceivesSince } from "@services/mongoose/ibex-crypto-receive-log" +import { + upsertBridgeReconciliationOrphan, + resolveOrphansByTxHash, +} from "@services/mongoose/bridge-reconciliation-orphan" +import { + BridgeDeposits, + BridgeWithdrawal, + IbexCryptoReceive, +} from "@services/mongoose/schema" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { PubSubService } from "@services/pubsub" +import { PubSubDefaultTriggers } from "@domain/pubsub" +import { toBridgeTransferId } from "@domain/primitives/bridge" + +import BridgeApiClient from "./client" + +const FIFTEEN_MIN_MS = 15 * 60 * 1000 + +const WITHDRAWAL_TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +type BridgeDepositLike = { + eventId: string + transferId: string + customerId: string + amount: string + currency: string + destinationTxHash?: string + state: string + createdAt: Date +} + +type IbexReceiveLike = { + txHash: string + address: string + amount: string + currency: string + network: string + accountId?: string + receivedAt: Date +} + +type BridgeWithdrawalLike = { + id?: string + _id?: { toString(): string } | string + accountId: string + bridgeTransferId?: string + bridgeDepositAddress?: string + ibexPayoutId?: string + amount: string + currency: string + status: "usdt_sent" | "send_failed" + failureReason?: string + updatedAt: Date + createdAt: Date +} + +const toOrphanKey = (prefix: string, value: string) => `${prefix}:${value.toLowerCase()}` + +export const reconcileBridgeAndIbexDeposits = async ({ + windowMs = FIFTEEN_MIN_MS, +}: { + windowMs?: number +} = {}): Promise< + | { + scannedBridge: number + scannedIbex: number + bridgeWithoutIbex: number + ibexWithoutBridge: number + } + | Error +> => { + try { + const now = new Date() + const since = new Date(now.getTime() - windowMs) + + const bridgeDeposits = (await BridgeDeposits.find({ + createdAt: { $gte: since, $lte: now }, + state: "payment_processed", + }) + .lean() + .exec()) as BridgeDepositLike[] + + const ibexReceivesResult = await findIbexCryptoReceivesSince({ since, until: now }) + if (ibexReceivesResult instanceof Error) return ibexReceivesResult + const ibexReceives = ibexReceivesResult as IbexReceiveLike[] + + const ibexByTxHash = new Map() + for (const record of ibexReceives) { + ibexByTxHash.set(record.txHash.toLowerCase(), record) + } + + const bridgeByTxHash = new Map() + for (const deposit of bridgeDeposits) { + if (!deposit.destinationTxHash) continue + bridgeByTxHash.set(deposit.destinationTxHash.toLowerCase(), deposit) + } + + let bridgeWithoutIbex = 0 + let ibexWithoutBridge = 0 + + for (const deposit of bridgeDeposits) { + if (!deposit.destinationTxHash) { + bridgeWithoutIbex++ + const reason = "Bridge payment_processed has no destinationTxHash" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("bridge-no-tx", deposit.transferId), + orphanType: "bridge_without_ibex", + transferId: deposit.transferId, + bridgeEventId: deposit.eventId, + customerId: deposit.customerId, + amount: deposit.amount, + currency: deposit.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + depositState: deposit.state, + createdAt: deposit.createdAt.toISOString(), + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "bridge_without_ibex", + transferId: deposit.transferId, + reason, + context: { + bridge_event_id: deposit.eventId, + customer_id: deposit.customerId, + amount: deposit.amount, + currency: deposit.currency, + }, + }) + continue + } + + const matchedIbex = ibexByTxHash.get(deposit.destinationTxHash.toLowerCase()) + if (matchedIbex) continue + + bridgeWithoutIbex++ + const reason = + "No IBEX crypto.receive found for Bridge destinationTxHash within window" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("bridge", deposit.destinationTxHash), + orphanType: "bridge_without_ibex", + transferId: deposit.transferId, + txHash: deposit.destinationTxHash, + bridgeEventId: deposit.eventId, + customerId: deposit.customerId, + amount: deposit.amount, + currency: deposit.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + depositState: deposit.state, + createdAt: deposit.createdAt.toISOString(), + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "bridge_without_ibex", + txHash: deposit.destinationTxHash, + transferId: deposit.transferId, + reason, + context: { + bridge_event_id: deposit.eventId, + customer_id: deposit.customerId, + amount: deposit.amount, + currency: deposit.currency, + }, + }) + } + + for (const receive of ibexReceives) { + const matchedBridge = bridgeByTxHash.get(receive.txHash.toLowerCase()) + if (matchedBridge) continue + + ibexWithoutBridge++ + const reason = + "No Bridge deposit payment_processed found for IBEX tx hash within window" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("ibex", receive.txHash), + orphanType: "ibex_without_bridge", + txHash: receive.txHash, + amount: receive.amount, + currency: receive.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + address: receive.address, + network: receive.network, + accountId: receive.accountId, + receivedAt: receive.receivedAt.toISOString(), + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "ibex_without_bridge", + txHash: receive.txHash, + reason, + context: { + amount: receive.amount, + currency: receive.currency, + address: receive.address, + network: receive.network, + account_id: receive.accountId, + }, + }) + } + + const summary = { + scannedBridge: bridgeDeposits.length, + scannedIbex: ibexReceives.length, + bridgeWithoutIbex, + ibexWithoutBridge, + } + + baseLogger.info(summary, "Bridge↔IBEX reconciliation completed") + return summary + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const reconcileBridgeAndIbexWithdrawals = async ({ + windowMs = FIFTEEN_MIN_MS, +}: { + windowMs?: number +} = {}): Promise< + | { + scannedWithdrawals: number + cancelledSendFailedTransfers: number + finalizedCompletedTransfers: number + ibexSendWithoutBridgeSettlement: number + bridgeTransferWithoutIbexSend: number + } + | Error +> => { + try { + const now = new Date() + const since = new Date(now.getTime() - windowMs) + + const withdrawals = (await BridgeWithdrawal.find({ + updatedAt: { $gte: since, $lte: now }, + status: { $in: ["usdt_sent", "send_failed"] }, + bridgeTransferId: { $exists: true }, + }) + .lean() + .exec()) as BridgeWithdrawalLike[] + + let cancelledSendFailedTransfers = 0 + let finalizedCompletedTransfers = 0 + let ibexSendWithoutBridgeSettlement = 0 + let bridgeTransferWithoutIbexSend = 0 + + for (const withdrawal of withdrawals) { + const transferId = withdrawal.bridgeTransferId + if (!transferId) continue + const bridgeTransferId = toBridgeTransferId(transferId) + + if (withdrawal.status === "send_failed") { + try { + await BridgeApiClient.deleteTransfer(bridgeTransferId) + cancelledSendFailedTransfers++ + } catch (error) { + bridgeTransferWithoutIbexSend++ + const reason = "Bridge transfer exists but IBEX crypto send failed" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-send-failed", transferId), + orphanType: "bridge_transfer_without_ibex_send", + transferId, + amount: withdrawal.amount, + currency: withdrawal.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + accountId: withdrawal.accountId, + bridgeDepositAddress: withdrawal.bridgeDepositAddress, + failureReason: withdrawal.failureReason, + deleteTransferError: error instanceof Error ? error.message : String(error), + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "bridge_transfer_without_ibex_send", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } + continue + } + + const transfer = await BridgeApiClient.getTransfer(bridgeTransferId) + if (transfer.state === "payment_processed") { + const finalized = await BridgeAccountsRepo.updateWithdrawalStatus( + bridgeTransferId, + "completed", + ) + if (!(finalized instanceof Error)) finalizedCompletedTransfers++ + continue + } + + if (!WITHDRAWAL_TERMINAL_FAILURE_STATES.has(transfer.state)) continue + + ibexSendWithoutBridgeSettlement++ + const reason = `IBEX crypto send succeeded but Bridge transfer is ${transfer.state}` + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-ibex-sent", transferId), + orphanType: "ibex_send_without_bridge_settlement", + transferId, + customerId: transfer.on_behalf_of, + amount: withdrawal.amount, + currency: withdrawal.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + accountId: withdrawal.accountId, + ibexPayoutId: withdrawal.ibexPayoutId, + bridgeState: transfer.state, + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "ibex_send_without_bridge_settlement", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + ibex_payout_id: withdrawal.ibexPayoutId, + bridge_state: transfer.state, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } + + const summary = { + scannedWithdrawals: withdrawals.length, + cancelledSendFailedTransfers, + finalizedCompletedTransfers, + ibexSendWithoutBridgeSettlement, + bridgeTransferWithoutIbexSend, + } + + baseLogger.info(summary, "Bridge withdrawal reconciliation completed") + return summary + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +type ReconcileByTxHashResult = { + txHash: string + status: "matched" | "unmatched" + orphanType?: "bridge_without_ibex" | "ibex_without_bridge" + transferId?: string + customerId?: string + amount?: string + currency?: string + detectedAt: Date +} + +export const reconcileByTxHash = async ({ + txHash, +}: { + txHash: string +}): Promise => { + const normalizedHash = txHash.toLowerCase() + const now = new Date() + + try { + const [bridgeDeposit, ibexReceive] = await Promise.all([ + BridgeDeposits.findOne({ + destinationTxHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, + state: "payment_processed", + }) + .lean() + .exec(), + IbexCryptoReceive.findOne({ + txHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, + }) + .lean() + .exec(), + ]) + + const pubsub = PubSubService() + + if (bridgeDeposit && ibexReceive) { + await resolveOrphansByTxHash(normalizedHash) + + const event: ReconcileByTxHashResult = { + txHash: normalizedHash, + status: "matched", + transferId: (bridgeDeposit as BridgeDepositLike).transferId, + customerId: (bridgeDeposit as BridgeDepositLike).customerId, + amount: (bridgeDeposit as BridgeDepositLike).amount, + currency: (bridgeDeposit as BridgeDepositLike).currency, + detectedAt: now, + } + + baseLogger.info(event, "Bridge↔IBEX real-time reconciliation: matched") + pubsub.publish({ + trigger: PubSubDefaultTriggers.BridgeReconciliationUpdate, + payload: event, + }) + return event + } + + let orphanType: "bridge_without_ibex" | "ibex_without_bridge" + let orphanKey: string + let triageContext: Record + let transferId: string | undefined + let customerId: string | undefined + let amount: string | undefined + let currency: string | undefined + + if (bridgeDeposit && !ibexReceive) { + orphanType = "bridge_without_ibex" + orphanKey = toOrphanKey("bridge", normalizedHash) + transferId = (bridgeDeposit as BridgeDepositLike).transferId + customerId = (bridgeDeposit as BridgeDepositLike).customerId + amount = (bridgeDeposit as BridgeDepositLike).amount + currency = (bridgeDeposit as BridgeDepositLike).currency + triageContext = { + reason: "Bridge payment_processed has no matching IBEX crypto.receive yet", + txHash: normalizedHash, + depositState: (bridgeDeposit as BridgeDepositLike).state, + createdAt: (bridgeDeposit as BridgeDepositLike).createdAt.toISOString(), + detectedAt: now.toISOString(), + } + } else { + orphanType = "ibex_without_bridge" + orphanKey = toOrphanKey("ibex", normalizedHash) + amount = ibexReceive ? (ibexReceive as IbexReceiveLike).amount : undefined + currency = ibexReceive ? (ibexReceive as IbexReceiveLike).currency : undefined + triageContext = { + reason: "IBEX crypto.receive has no matching Bridge funds_received yet", + txHash: normalizedHash, + address: ibexReceive ? (ibexReceive as IbexReceiveLike).address : undefined, + network: ibexReceive ? (ibexReceive as IbexReceiveLike).network : undefined, + detectedAt: now.toISOString(), + } + } + + await upsertBridgeReconciliationOrphan({ + orphanKey, + orphanType, + txHash: normalizedHash, + transferId, + customerId, + amount, + currency, + triageContext, + }) + + alertIbexReconciliationOrphan({ + orphanType, + txHash: normalizedHash, + transferId, + reason: String(triageContext.reason), + context: { + customer_id: customerId, + amount, + currency, + }, + }) + + const event: ReconcileByTxHashResult = { + txHash: normalizedHash, + status: "unmatched", + orphanType, + transferId, + customerId, + amount, + currency, + detectedAt: now, + } + + baseLogger.info(event, "Bridge↔IBEX real-time reconciliation: unmatched") + pubsub.publish({ + trigger: PubSubDefaultTriggers.BridgeReconciliationUpdate, + payload: event, + }) + return event + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts new file mode 100644 index 000000000..e89060179 --- /dev/null +++ b/src/services/bridge/webhook-server/index.ts @@ -0,0 +1,61 @@ +/** + * Bridge Webhook Server + * Standalone Express server for handling Bridge.xyz webhook events + * + * Runs on port configured in BridgeConfig.webhook.port (default: 4009) + * Routes: /kyc, /deposit, /transfer + */ + +import express from "express" +import { BridgeConfig } from "@config" +import { baseLogger } from "@services/logger" + +import { verifyBridgeSignature } from "./middleware/verify-signature" +import { kycHandler } from "./routes/kyc" +import { depositHandler } from "./routes/deposit" +import { transferHandler } from "./routes/transfer" +import { externalAccountHandler } from "./routes/external-account" +import { replayAuthMiddleware, replayHandler } from "./routes/replay" + +type RawBodyRequest = express.Request & { rawBody?: string } + +export const startBridgeWebhookServer = () => { + const app = express() + + // Middleware - MUST capture raw body for signature verification + app.use( + express.json({ + verify: (req, res, buf) => { + if (res.writableEnded) { + return + } + const rawReq = req as RawBodyRequest + rawReq.rawBody = buf.toString("utf8") + }, + }), + ) + + // Health check + app.get("/health", (req, res) => { + res.status(200).json({ status: "ok", service: "bridge-webhook" }) + }) + + // Webhook routes with signature verification + app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler) + app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler) + app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler) + app.post("/external-account", verifyBridgeSignature("external_account"), externalAccountHandler) + app.post("/internal/replay", replayAuthMiddleware, replayHandler) + + if (!BridgeConfig.webhook.replaySecret && !process.env.BRIDGE_WEBHOOK_REPLAY_SECRET) { + baseLogger.warn( + "replaySecret not configured (neither BridgeConfig.webhook.replaySecret nor BRIDGE_WEBHOOK_REPLAY_SECRET) — /internal/replay will reject all requests with 503", + ) + } + + // Start server + const port = BridgeConfig.webhook.port + app.listen(port, () => { + baseLogger.info({ port }, "Bridge webhook server started") + }) +} diff --git a/src/services/bridge/webhook-server/middleware/verify-signature.ts b/src/services/bridge/webhook-server/middleware/verify-signature.ts new file mode 100644 index 000000000..447ff9aaf --- /dev/null +++ b/src/services/bridge/webhook-server/middleware/verify-signature.ts @@ -0,0 +1,90 @@ +/** + * Bridge Webhook Signature Verification Middleware + * + * Bridge uses asymmetric signature verification (RSA-SHA256), not HMAC. + * Header format: X-Webhook-Signature: t=,v0= + * Signature is computed over: . + */ + +import crypto from "crypto" + +import { Request, Response, NextFunction } from "express" +import { BridgeConfig } from "@config" +import { baseLogger } from "@services/logger" + +type RawBodyRequest = Request & { rawBody?: string } + +export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer" | "external_account") => { + return (req: Request, res: Response, next: NextFunction) => { + const signature = req.headers["x-webhook-signature"] as string + + if (!signature) { + baseLogger.warn("Missing Bridge webhook signature") + return res.status(401).json({ error: "Missing signature" }) + } + + // Parse signature header: t=,v0= + const parts = signature.split(",") + const timestampPart = parts.find((p) => p.startsWith("t=")) + const signaturePart = parts.find((p) => p.startsWith("v0=")) + + if (!timestampPart || !signaturePart) { + baseLogger.warn("Invalid signature format") + return res.status(401).json({ error: "Invalid signature format" }) + } + + const timestamp = timestampPart.slice("t=".length) + const sig = signaturePart.slice("v0=".length) + + // Check timestamp skew (default 5 minutes) + const now = Date.now() + const timestampMs = parseInt(timestamp, 10) + if (isNaN(timestampMs) || !isFinite(timestampMs)) { + baseLogger.warn( + { timestamp }, + "Invalid timestamp format in Bridge webhook signature", + ) + return res.status(401).json({ error: "Invalid signature format" }) + } + + const skew = Math.abs(now - timestampMs) + + if (skew > BridgeConfig.webhook.timestampSkewMs) { + baseLogger.warn({ skew }, "Webhook timestamp too old") + return res.status(401).json({ error: "Timestamp too old" }) + } + + // Verify signature using Bridge public key + const publicKey = BridgeConfig.webhook.publicKeys[publicKeyType] + const rawBody = (req as RawBodyRequest).rawBody + + if (!rawBody) { + baseLogger.warn(`Missing raw body for webhook`) + return res.status(401).json({ error: "Missing raw body" }) + } + + const signedPayload = `${timestamp}.${rawBody}` + + const digest = crypto.createHash("sha256").update(signedPayload).digest() + baseLogger.debug( + { signedPayload, digest: digest.toString("hex") }, + "Verifying Bridge webhook signature", + ) + + try { + const verifier = crypto.createVerify("RSA-SHA256") + verifier.update(digest) + const isValid = verifier.verify(publicKey, sig, "base64") + + if (!isValid) { + baseLogger.warn("Invalid Bridge webhook signature") + return res.status(401).json({ error: "Invalid signature" }) + } + + next() + } catch (error) { + baseLogger.error({ error }, "Error verifying Bridge webhook signature") + return res.status(500).json({ error: "Signature verification failed" }) + } + } +} diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts new file mode 100644 index 000000000..10d5f6108 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -0,0 +1,227 @@ +/** + * Bridge Deposit Webhook Handler + * Handles incoming-funds events from Bridge.xyz via the /deposit route. + * + * Two event categories land here: + * - virtual_account.activity — fiat payments hitting a virtual account + * - bridge_wallet.activity — on-chain/off-chain bridge wallet movements + * + * Both represent money arriving that needs to be logged for reconciliation. + * The actual balance crediting happens when IBEX sends its crypto.received webhook. + */ + +import { Request, Response } from "express" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { createBridgeDeposit } from "@services/mongoose/bridge-deposit-log" +import { reconcileByTxHash } from "@services/bridge/reconciliation" +import { writeBridgeDepositRequest } from "@services/frappe/BridgeTransferRequestWriter" +import { alertBridge, generateDedupKey } from "@services/alerts" +import { alertIbexReconciliationFailed } from "@services/alerts/ibex-bridge-movement" + +type DepositEventObject = { + id: string + amount: string + currency?: string + // Transfer event shape + state?: string + on_behalf_of?: string + developer_fee?: string + receipt?: { + initial_amount?: string + subtotal_amount?: string + final_amount?: string + developer_fee?: string + destination_tx_hash?: string + } + // Virtual account activity shape + type?: string + customer_id?: string + virtual_account_id?: string + deposit_id?: string + subtotal_amount?: string + developer_fee_amount?: string + exchange_fee_amount?: string + destination_payment_rail?: string + // Bridge wallet activity shape + bridge_wallet_id?: string + available_balance?: string + destination?: { + tx_hash?: string + } + payment_route?: { + type?: string + customer_id?: string + transfer_id?: string + deposit_id?: string + virtual_account_id?: string + } +} + +export const depositHandler = async (req: Request, res: Response) => { + const { event_id, event_category, event_object } = req.body + const obj = (event_object ?? {}) as DepositEventObject + + // Normalise from either payload shape. + // Transfer events use on_behalf_of; virtual_account / bridge_wallet use customer_id. + const customerId = obj.on_behalf_of ?? obj.customer_id ?? obj.payment_route?.customer_id + // "state" for transfers, "type" (funds_received / deposit / etc.) for others + const state = obj.state ?? obj.type + const currency = obj.currency ?? "usd" + + if (!obj.id || !event_id) { + baseLogger.warn( + { event_id, event_category, event_object_id: obj.id }, + "Bridge deposit webhook rejected: missing required fields", + ) + return res.status(400).json({ + error: "Invalid payload", + detail: + "Missing one or more required fields: id, event_id", + }) + } + + if (!obj.amount || !customerId) { + baseLogger.warn( + { + event_id, + event_category, + event_object_id: obj.id, + has_amount: Boolean(obj.amount), + has_customer_identifier: Boolean(customerId), + }, + "Bridge deposit webhook acknowledged without deposit row: missing crediting fields", + ) + return res.status(200).json({ + status: "skipped", + reason: "missing_crediting_fields", + }) + } + + try { + const lockKey = `bridge-deposit:${obj.id}:${state ?? "unknown"}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ event_id, id: obj.id, state }, "Duplicate Bridge deposit webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const rxReceipt = obj.receipt + + const developerFee = + asOptionalString(rxReceipt?.developer_fee) ?? + asOptionalString(obj.developer_fee_amount) ?? + asOptionalString(obj.developer_fee) ?? + "0.0" + + baseLogger.info( + { + event_id, + event_category, + id: obj.id, + state, + amount: obj.amount, + currency, + customerId, + developerFee, + subtotalAmount: rxReceipt?.subtotal_amount ?? obj.subtotal_amount, + destinationTxHash: rxReceipt?.destination_tx_hash ?? obj.destination?.tx_hash, + }, + "Bridge deposit event", + ) + + const depositLog = await createBridgeDeposit({ + eventId: event_id, + transferId: obj.id, + customerId, + state: state ?? "unknown", + amount: String(obj.amount), + currency, + developerFee, + subtotalAmount: + asOptionalString(rxReceipt?.subtotal_amount) ?? + asOptionalString(obj.subtotal_amount), + initialAmount: asOptionalString(rxReceipt?.initial_amount), + finalAmount: asOptionalString(rxReceipt?.final_amount), + destinationTxHash: rxReceipt?.destination_tx_hash ?? obj.destination?.tx_hash, + }) + + if (depositLog instanceof Error) { + baseLogger.error( + { error: depositLog, event_id, id: obj.id }, + "Failed to persist bridge deposit log", + ) + return res.status(500).json({ error: "Failed to persist deposit log" }) + } + + // Real-time reconciliation: only trigger for transfer events that have + // reached payment_processed with an on-chain tx hash. + if ( + event_category === "transfer" && + state === "payment_processed" && + rxReceipt?.destination_tx_hash + ) { + const txHash = rxReceipt.destination_tx_hash + reconcileByTxHash({ txHash }).catch((err) => { + baseLogger.error({ err, event_id, id: obj.id }, "Real-time reconciliation failed") + alertIbexReconciliationFailed({ + txHash, + detail: err instanceof Error ? err.message : String(err), + }) + }) + } + + const auditResult = await writeBridgeDepositRequest({ + eventId: event_id, + eventObject: event_object, + rawPayload: req.body, + }) + if (auditResult instanceof Error) { + baseLogger.error( + { error: auditResult, event_id, id: obj.id }, + "Failed to persist Bridge deposit ERPNext audit row", + ) + alertBridge({ + dedupKey: generateDedupKey.erpnextDepositAudit(obj.id), + source: "erpnext-audit", + severity: "critical", + title: "Bridge deposit ERPNext audit write failed", + detail: auditResult.message, + context: { event_id, transfer_id: obj.id }, + }) + return res.status(500).json({ error: "Failed to persist ERPNext audit row" }) + } + + // Idempotency: mark processed only after local and ERPNext writes succeed, so + // provider retries can recover audit gaps after transient ERPNext failures. + const auditLockKey = `bridge-deposit:${event_id}` + const auditLockResult = await LockService().lockIdempotencyKey( + auditLockKey as IdempotencyKey, + ) + if (auditLockResult instanceof Error) { + baseLogger.info({ event_id, id: obj.id, state }, "Duplicate Bridge deposit webhook") + return res.status(200).json({ status: "already_processed" }) + } + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error( + { error, id: obj.id, event_id }, + "Error processing Bridge deposit webhook", + ) + alertBridge({ + dedupKey: generateDedupKey.bridgeWebhookDeposit(event_id), + source: "bridge-webhook", + severity: "critical", + title: "Bridge deposit webhook processing error", + detail: error instanceof Error ? error.message : String(error), + context: { event_id, transfer_id: obj.id }, + }) + return res.status(500).json({ error: "Internal server error" }) + } +} + +const asOptionalString = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined + return String(value) +} diff --git a/src/services/bridge/webhook-server/routes/external-account.ts b/src/services/bridge/webhook-server/routes/external-account.ts new file mode 100644 index 000000000..6618f5a31 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/external-account.ts @@ -0,0 +1,80 @@ +/** + * Bridge External Account Webhook Handler + * Handles external_account.created and external_account.updated events from Bridge.xyz + * + * Fires after a user completes the Plaid bank-linking flow. + * Persists the linked account to MongoDB so it appears in bridgeExternalAccounts queries. + * + * Status mapping: + * active: true → "verified" + * active: false → "failed" + */ + +import { Request, Response } from "express" +import { AccountsRepository } from "@services/mongoose/accounts" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { toBridgeCustomerId } from "@domain/primitives/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" + +const toStatus = (active: boolean | undefined): "pending" | "verified" | "failed" => { + if (active === true) return "verified" + if (active === false) return "failed" + return "pending" +} + +export const externalAccountHandler = async (req: Request, res: Response) => { + const { event_id, event_object } = req.body + const { id, customer_id, bank_name, last_4, active } = event_object ?? {} + + if (!id || !customer_id || !event_id) { + return res.status(400).json({ error: "Invalid payload" }) + } + + try { + const bridgeCustomerId = toBridgeCustomerId(customer_id) + const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) + if (account instanceof Error) { + baseLogger.warn( + { customer_id, event_id }, + "Account not found for Bridge customer — may be a timing issue, Bridge will retry", + ) + return res.status(503).json({ error: "Account not ready" }) + } + + const lockKey = `bridge-external-account:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customer_id, event_id, id }, "Duplicate Bridge external account webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const status = toStatus(active) + + const result = await BridgeAccountsRepo.createExternalAccount({ + accountId: String(account.id), + bridgeExternalAccountId: id, + bankName: bank_name ?? "Unknown", + accountNumberLast4: last_4 ?? "0000", + status, + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, event_id, id, error: result }, + "Failed to persist Bridge external account", + ) + return res.status(500).json({ error: "Failed to persist external account" }) + } + + baseLogger.info( + { accountId: account.id, bridgeExternalAccountId: id, status }, + "Bridge external account persisted", + ) + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error({ error, customer_id, event_id }, "Error processing Bridge external account webhook") + return res.status(500).json({ error: "Internal server error" }) + } +} diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts new file mode 100644 index 000000000..290951527 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -0,0 +1,176 @@ +/** + * Bridge KYC Webhook Handler + * Handles customer status and kyc.* events from Bridge.xyz + * + * Bridge sends events to this endpoint for: + * customer.created → uses event_object.id / event_object.status + * customer.updated.* → uses event_object.id / event_object.status + * external_account.created → uses event_object.customer_id + * (future kyc.* events handled similarly) + * + * Bridge status → internal bridgeKycStatus mapping: + * not_started → "not_started" + * active (approved) → "approved" + * incomplete | awaiting_questionnaire | awaiting_ubo + * | under_review | paused → "pending" + * rejected → "rejected" + * offboarded → "offboarded" + */ + +import { Request, Response } from "express" +import { AccountsRepository } from "@services/mongoose/accounts" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { toBridgeCustomerId } from "@domain/primitives/bridge" +import BridgeService from "@services/bridge" + +export const kycHandler = async (req: Request, res: Response) => { + const { event_id, event_object, event_type } = req.body + + // Bridge uses different field names depending on event type: + // - customer.* events: event_object.id, event_object.status + // - external_account.* events: event_object.customer_id + // - (future kyc.* events: event_object.customer_id, event_object.kyc_status) + const customerId = event_object.customer_id || event_object.id + const status = event_object.kyc_status || event_object.status + const rejectionReasons = event_object.rejection_reasons || [] + + if (!customerId || !event_id) { + return res.status(400).json({ error: "Invalid payload" }) + } + + try { + const bridgeCustomerId = toBridgeCustomerId(customerId) + const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) + if (account instanceof Error) { + baseLogger.warn( + { customerId, event_type, event_id }, + "Account not found for Bridge customer — may be a timing issue, Bridge will retry", + ) + return res.status(503).json({ error: "Account not ready" }) + } + + // Idempotency check — acquire lock after account is found so 503 retries are not blocked + const lockKey = `bridge-kyc:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customerId, event_id }, "Duplicate Bridge KYC webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + + // Map Bridge customer status fields to our internal kyc status + // Bridge customer.status values: not_started, active (approved), rejected, offboarded + if (status === "not_started") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "not_started", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC not started") + } else if (PENDING_BRIDGE_STATUSES.has(status)) { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: status as Account["bridgeKycStatus"], + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info( + { accountId: account.id, customerId, status }, + "Bridge KYC moved to pending", + ) + } else if (status === "active" || status === "approved") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "approved", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC approved") + + const vaResult = await BridgeService.createVirtualAccount(account.id) + if (vaResult instanceof Error) { + baseLogger.error( + { accountId: account.id, error: vaResult }, + "Failed to auto-create virtual account after KYC approval", + ) + } else { + baseLogger.info( + { accountId: account.id, virtualAccountId: vaResult.virtualAccountId }, + "Virtual account auto-created after KYC approval", + ) + } + } else if (status === "rejected") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "rejected", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.warn( + { + accountId: account.id, + customerId, + rejectionReasons, + }, + "Bridge KYC rejected", + ) + } else if (status === "offboarded") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "offboarded", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.warn({ accountId: account.id, customerId }, "Bridge KYC offboarded") + } else { + baseLogger.info( + { accountId: account.id, customerId, status, event_type }, + "Unhandled Bridge customer status — no action taken", + ) + } + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error({ error, customerId }, "Error processing Bridge KYC webhook") + return res.status(500).json({ error: "Internal server error" }) + } +} diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts new file mode 100644 index 000000000..009414c28 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -0,0 +1,268 @@ +import crypto from "crypto" + +import { Request, Response } from "express" + +import { BridgeConfig } from "@config" + +import { baseLogger } from "@services/logger" + +import { createBridgeReplay } from "@services/mongoose/bridge-replay-log" + +import { + isOutboundBridgeWithdrawal, + transferReplayEventTypeForStatus, +} from "../transfer-direction" + +import { depositHandler } from "./deposit" +import { externalAccountHandler } from "./external-account" +import { kycHandler } from "./kyc" +import { transferHandler } from "./transfer" +type RouteKey = "kyc" | "deposit" | "transfer" | "external_account" + +const HANDLERS: Record Promise> = { + kyc: kycHandler, + deposit: depositHandler, + transfer: transferHandler, + external_account: externalAccountHandler, +} + +const DEPOSIT_EVENT_TYPES = new Set([ + "funds_scheduled", + "funds_received", + "payment_submitted", + "payment_processed", + "in_review", + "microdeposit", + "refund_in_flight", + "refunded", + "refund_failed", +]) + +const toRouteKey = (bridgeEventType: string): RouteKey | null => { + if (bridgeEventType.startsWith("kyc")) return "kyc" + if (bridgeEventType.startsWith("transfer")) return "transfer" + if (bridgeEventType.startsWith("external_account")) return "external_account" + if (DEPOSIT_EVENT_TYPES.has(bridgeEventType)) return "deposit" + return null +} + +const resolveReplayEventType = ({ + eventType, + eventObjectStatus, + eventObject, +}: { + eventType: string + eventObjectStatus?: string + eventObject?: Record +}): string => { + const routeFromEventType = toRouteKey(eventType) + if (routeFromEventType) return eventType + + if (eventObjectStatus && DEPOSIT_EVENT_TYPES.has(eventObjectStatus)) { + if (isOutboundBridgeWithdrawal(eventObject)) { + const transferEvent = transferReplayEventTypeForStatus(eventObjectStatus) + if (transferEvent) return transferEvent + } + return eventObjectStatus + } + + const KYC_BRIDGE_STATUSES = new Set([ + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "approved", + "rejected", + "paused", + "offboarded", + ]) + if (eventObjectStatus && KYC_BRIDGE_STATUSES.has(eventObjectStatus)) { + return `kyc.${eventObjectStatus}` + } + + if (eventObjectStatus === "completed" || eventObjectStatus === "failed") { + return `transfer.${eventObjectStatus}` + } + + return eventType +} + +const toHandlerBody = ({ + routeKey, + eventId, + eventType, + eventObject, +}: { + routeKey: RouteKey + eventId: string + eventType: string + eventObject: Record +}): Record => { + if (routeKey === "transfer") { + return { + event: eventType, + data: { + transfer_id: eventObject.transfer_id ?? eventObject.id, + state: eventObject.state, + amount: eventObject.amount, + currency: eventObject.currency, + reason: eventObject.reason, + return_reason: eventObject.return_reason, + }, + } + } + + return { + event_id: eventId, + event_object: eventObject, + } + +} + +export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => { + const secret = + BridgeConfig.webhook.replaySecret ?? process.env.BRIDGE_WEBHOOK_REPLAY_SECRET + if (!secret) { + baseLogger.warn("Replay secret not configured, rejecting replay request") + return res.status(503).json({ error: "Replay secret not configured" }) + } + + const token = (req.headers.authorization ?? "").replace(/^Bearer /, "") + + const valid = + token.length === secret.length && + crypto.timingSafeEqual(Buffer.from(token), Buffer.from(secret)) + + if (!valid) { + baseLogger.warn("Invalid replay token provided") + return res.status(401).json({ error: "Unauthorized" }) + } + + next() +} + +export const replayHandler = async (req: Request, res: Response) => { + const { + event_id, + event_type, + event_object_status, + event_object, + event_created_at, + operator, + time_window_start, + time_window_end, + dry_run = false, + } = req.body + + if (!event_type || !event_object || !event_created_at) { + return res.status(400).json({ + error: + "Missing required fields: event_type, event_object, event_created_at, operator, time_window_start, time_window_end", + }) + } + + if (typeof event_object !== "object" || event_object === null) { + return res.status(400).json({ error: "event_object must be an object" }) + } + + const eventObjectTyped = event_object as Record + + const normalizedEventType = resolveReplayEventType({ + eventType: event_type, + eventObjectStatus: + typeof event_object_status === "string" ? event_object_status : undefined, + eventObject: eventObjectTyped, + }) + + const routeKey = toRouteKey(normalizedEventType) + + if (!routeKey) { + return res.status(400).json({ error: "Unsupported event_type for replay" }) + } + const eventId: string = + typeof event_id === "string" + ? event_id + : typeof eventObjectTyped.event_id === "string" + ? eventObjectTyped.event_id + : typeof eventObjectTyped.id === "string" + ? eventObjectTyped.id + : crypto.randomUUID() + + const logBase = { + eventId, + eventType: routeKey, + eventPayload: event_object, + bridgeEventCreatedAt: new Date(event_created_at), + replayedAt: new Date(), + operator, + timeWindowStart: new Date(time_window_start), + timeWindowEnd: new Date(time_window_end), + dryRun: dry_run, + } + + if (dry_run) { + const dryRunLog = await createBridgeReplay({ + ...logBase, + httpStatus: 0, + httpResponse: { dry_run: true }, + }) + if (dryRunLog instanceof Error) { + baseLogger.error( + { error: dryRunLog }, + "Failed to log bridge dry-run replay attempt", + ) + return res.status(500).json({ error: "Failed to log dry-run replay attempt" }) + } + return res.status(200).json({ + message: "Dry run successful, event not replayed", + log: logBase, + event_id: eventId, + }) + } + + let handlerStatus = 500 + let handlerBody: Record = {} + + const fakeReq = { + body: toHandlerBody({ + routeKey, + eventId, + eventType: normalizedEventType, + eventObject: eventObjectTyped, + }), + headers: {}, + } as unknown as Request + const fakeRes = { + status: function (code: number) { + handlerStatus = code + return this + }, + json: function (body: unknown) { + handlerBody = body as Record + return this + }, + } as unknown as Response + + await HANDLERS[routeKey](fakeReq, fakeRes) + + const logResult = await createBridgeReplay({ + ...logBase, + httpStatus: handlerStatus, + httpResponse: handlerBody, + }) + + if (logResult instanceof Error) { + baseLogger.error({ error: logResult }, "Failed to log bridge replay attempt") + return res + .status(500) + .json({ error: "Failed to log replay attempt", details: logResult.message }) + } + return res.status(handlerStatus).json({ + status: "replayed", + event_id: eventId, + handler_status: handlerStatus, + handler_response: handlerBody, + log_id: logResult.id, + }) +} diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts new file mode 100644 index 000000000..d5ea2402b --- /dev/null +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -0,0 +1,266 @@ +/** + * Bridge Transfer Webhook Handler + * Handles transfer webhook events from Bridge.xyz (transfer.completed, transfer.updated.status_transitioned) + */ + +import { Request, Response } from "express" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { toBridgeTransferId } from "@domain/primitives/bridge" +import { + writeBridgeCashoutCompleted, + writeBridgeCashoutFailed, +} from "@services/frappe/BridgeTransferRequestWriter" +import { alertBridge, generateDedupKey } from "@services/alerts" + +const TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +const TRANSIENT_STATES = new Set(["refund_in_flight"]) + +const transferLockKey = ( + transferId: string, + event: string, + state: string | undefined, +): IdempotencyKey => + `bridge-transfer:${transferId}:${event}:${state ?? ""}` as IdempotencyKey + +const markProcessed = async ( + transferId: string, + event: string, + state: string | undefined, +): Promise<"success" | "already_processed"> => { + const lockResult = await LockService().lockIdempotencyKey( + transferLockKey(transferId, event, state), + ) + if (lockResult instanceof Error) return "already_processed" + return "success" +} + +export const transferHandler = async (req: Request, res: Response) => { + // Bridge webhook payload: { event_id, event_category, event_type, event_object: { id, state, ... } } + const { event_type, event_id, event_object } = req.body + const obj = (event_object ?? {}) as Record + + // Normalise from Bridge's webhook envelope + const event = event_type ?? req.body.event + const transfer_id = (obj.id ?? obj.transfer_id) as string | undefined + const state = (obj.state ?? obj.status) as string | undefined + const amount = obj.amount as string | undefined + const currency = obj.currency as string | undefined + // Bridge transfer events nest failure reasons in source.details or destination + const source = obj.source as Record | undefined + const destination = obj.destination as Record | undefined + const reason = source?.failure_reason as string | undefined + const return_reason = destination?.return_reason as string | undefined + + if (!transfer_id || !event) { + baseLogger.warn( + { event_id, event_category: req.body.event_category, event_type }, + "Bridge transfer webhook rejected: missing transfer_id or event_type", + ) + return res.status(400).json({ error: "Invalid payload" }) + } + + try { + const bridgeTransferId = toBridgeTransferId(transfer_id!) + + if (state && TRANSIENT_STATES.has(state)) { + baseLogger.info( + { transfer_id, state, event }, + "Bridge transfer in transient state — awaiting terminal event", + ) + return res.status(200).json({ status: "ignored_transient_state" }) + } + + const isCompletion = + event === "transfer.completed" || + event === "transfer.payment_processed" || + state === "payment_processed" + + const isFailure = state != null && TERMINAL_FAILURE_STATES.has(state) + + if (!isCompletion && !isFailure) { + baseLogger.info( + { transfer_id, state: state ?? "unknown", event }, + "Bridge transfer event not handled", + ) + return res.status(200).json({ status: "ignored" }) + } + + if (isCompletion) { + const result = await BridgeAccountsRepo.updateWithdrawalStatus( + bridgeTransferId, + "completed", + ) + + if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } + baseLogger.error( + { transfer_id, error: result }, + "Failed to update withdrawal status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info( + { + transfer_id, + state, + amount, + currency, + }, + "Bridge transfer completed", + ) + + const auditResult = await writeBridgeCashoutCompleted({ + transferId: transfer_id, + amount: String(result.amount ?? amount ?? ""), + currency: String(result.currency ?? currency ?? ""), + accountId: result.accountId, + sourceEventId: event_id, + sourceEventType: event, + rawPayload: req.body, + }) + if (auditResult instanceof Error) { + baseLogger.error( + { transfer_id, error: auditResult }, + "Failed to persist Bridge transfer ERPNext audit row", + ) + alertBridge({ + dedupKey: generateDedupKey.erpnextTransferCompletedAudit(transfer_id), + source: "erpnext-audit", + severity: "critical", + title: "Bridge transfer ERPNext audit write failed", + detail: auditResult.message, + context: { transfer_id, event }, + }) + return res.status(500).json({ error: "Failed to persist ERPNext audit row" }) + } + + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "completed", + }) + } else if (isFailure) { + const failureReason = + state === "refund_failed" + ? (return_reason as string | undefined) + : ((reason as string | undefined) ?? (return_reason as string | undefined)) + + const result = await BridgeAccountsRepo.updateWithdrawalStatus( + bridgeTransferId, + "failed", + failureReason, + ) + + if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } + if (result.message.startsWith("Withdrawal already ")) { + baseLogger.info( + { transfer_id, state, error: result.message }, + "Ignoring Bridge transfer failure — withdrawal already terminal", + ) + return res.status(200).json({ status: "already_terminal" }) + } + baseLogger.error( + { transfer_id, error: result }, + "Failed to update withdrawal status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.warn( + { + transfer_id, + state, + amount, + currency, + reason, + return_reason, + }, + "Bridge transfer failed", + ) + + const auditResult = await writeBridgeCashoutFailed({ + transferId: transfer_id, + amount: String(result.amount ?? amount ?? ""), + currency: String(result.currency ?? currency ?? ""), + accountId: result.accountId, + sourceEventId: event_id, + sourceEventType: event, + failureReason: result.failureReason ?? failureReason, + rawPayload: req.body, + }) + if (auditResult instanceof Error) { + baseLogger.error( + { transfer_id, error: auditResult }, + "Failed to persist Bridge transfer failure ERPNext audit row", + ) + alertBridge({ + dedupKey: generateDedupKey.erpnextTransferFailedAudit(transfer_id), + source: "erpnext-audit", + severity: "critical", + title: "Bridge transfer-failure ERPNext audit write failed", + detail: auditResult.message, + context: { transfer_id, event }, + }) + return res.status(500).json({ error: "Failed to persist ERPNext audit row" }) + } + + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "failed", + failureReason: result.failureReason ?? failureReason, + }) + } + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error({ error, transfer_id }, "Error processing Bridge transfer webhook") + alertBridge({ + dedupKey: generateDedupKey.bridgeWebhookTransfer(transfer_id, event), + source: "bridge-webhook", + severity: "critical", + title: "Bridge transfer webhook processing error", + detail: error instanceof Error ? error.message : String(error), + context: { transfer_id, event }, + }) + return res.status(500).json({ error: "Internal server error" }) + } +} diff --git a/src/services/bridge/webhook-server/transfer-direction.ts b/src/services/bridge/webhook-server/transfer-direction.ts new file mode 100644 index 000000000..6e785d0ea --- /dev/null +++ b/src/services/bridge/webhook-server/transfer-direction.ts @@ -0,0 +1,29 @@ +/** + * Distinguishes Bridge outbound withdrawals (USDT → ACH) from inbound deposits + * when replaying status_transitioned events that share the same state names. + */ +export const isOutboundBridgeWithdrawal = ( + eventObject: Record | undefined, +): boolean => { + if (!eventObject) return false + const source = eventObject.source as Record | undefined + const destination = eventObject.destination as Record | undefined + return source?.payment_rail === "ethereum" && destination?.payment_rail === "ach" +} + +const OUTBOUND_WITHDRAWAL_REPLAY_STATUSES = new Set([ + "payment_processed", + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +export const transferReplayEventTypeForStatus = (status: string): string | null => { + if (!OUTBOUND_WITHDRAWAL_REPLAY_STATUSES.has(status)) return null + if (status === "payment_processed") return "transfer.payment_processed" + return "transfer.failed" +} diff --git a/src/services/bridge/withdrawal-fees.ts b/src/services/bridge/withdrawal-fees.ts new file mode 100644 index 000000000..69598080e --- /dev/null +++ b/src/services/bridge/withdrawal-fees.ts @@ -0,0 +1,287 @@ +/** + * Bridge withdrawal customer fee estimates. + * + * estimatedCustomerFee = flashFee + estimatedBridgeFee + estimatedGasBuffer + * flashFee = amount * developerFeePercent (Flash fee passed to Bridge) + * estimatedBridgeFee = amount * bridgeFixedFeePercent (0.60% for USDT ETH -> USD ACH) + * estimatedGasBuffer = gasLimit * gasPriceGwei * ethUsd / 1e9 * bufferMultiplier + */ + +import { BridgeConfig } from "@config" + +import { + computeEstimatedGasBufferUsd, + fetchEthereumGasMarketSnapshot, + type EthereumGasMarketSnapshot, +} from "./ethereum-gas-estimate" + +export type BridgeWithdrawalFeeEstimateConfig = { + bridgeFixedFeePercent: number + usdtTransferGasLimit: number + gasPriceBufferMultiplier: number + ethereumGasRpcUrls: string[] + ethUsdPriceUrl: string + timeoutMs: number + cacheTtlMs: number + fallbackGasPriceGwei: number + ethUsdFallback: number +} + +export type CustomerFeeEstimate = { + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + flashFeePercent: string + flashFee: string +} + +export const defaultWithdrawalFeeEstimateConfig = + (): BridgeWithdrawalFeeEstimateConfig => ({ + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrls: [ + "https://ethereum-rpc.publicnode.com", + "https://eth.llamarpc.com", + "https://cloudflare-eth.com", + ], + ethUsdPriceUrl: + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd", + timeoutMs: 3_000, + cacheTtlMs: 60_000, + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, + }) + +export const getWithdrawalFeeEstimateConfig = (): BridgeWithdrawalFeeEstimateConfig => ({ + ...defaultWithdrawalFeeEstimateConfig(), + ...BridgeConfig.withdrawalFeeEstimate, +}) + +export const computeCustomerFeeEstimateFromGasMarket = ({ + amount, + gasMarket, + config = getWithdrawalFeeEstimateConfig(), + developerFeePercent = BridgeConfig.developerFeePercent, +}: { + amount: string + gasMarket: EthereumGasMarketSnapshot + config?: BridgeWithdrawalFeeEstimateConfig + developerFeePercent?: number +}): CustomerFeeEstimate => { + const flashFee = ((parseFloat(amount) * developerFeePercent) / 100).toFixed(2) + const estimatedBridgeFee = ( + (parseFloat(amount) * config.bridgeFixedFeePercent) / + 100 + ).toFixed(2) + const estimatedGasBuffer = computeEstimatedGasBufferUsd({ + gasLimit: config.usdtTransferGasLimit, + gasPriceGwei: gasMarket.gasPriceGwei, + ethUsd: gasMarket.ethUsd, + bufferMultiplier: config.gasPriceBufferMultiplier, + }) + const estimatedCustomerFee = ( + parseFloat(flashFee) + + parseFloat(estimatedBridgeFee) + + parseFloat(estimatedGasBuffer) + ).toFixed(2) + + return { + estimatedBridgeFeePercent: String(config.bridgeFixedFeePercent), + estimatedBridgeFee, + estimatedGasBuffer, + estimatedCustomerFee, + flashFeePercent: String(developerFeePercent), + flashFee, + } +} + +export const resolveWithdrawalCustomerFeeEstimate = async ( + amount: string, +): Promise => { + const config = getWithdrawalFeeEstimateConfig() + const gasMarket = await fetchEthereumGasMarketSnapshot({ + rpcUrls: config.ethereumGasRpcUrls, + timeoutMs: config.timeoutMs, + fallbackGasPriceGwei: config.fallbackGasPriceGwei, + ethUsdFallback: config.ethUsdFallback, + ethUsdPriceUrl: config.ethUsdPriceUrl, + cacheTtlMs: config.cacheTtlMs, + }) + + return computeCustomerFeeEstimateFromGasMarket({ amount, gasMarket, config }) +} + +export const computePendingAmountEstimates = ( + amount: string, + estimatedCustomerFee: string, +): { subtotalAmount: string; finalAmount: string } => { + const subtotal = Math.max(0, parseFloat(amount) - parseFloat(estimatedCustomerFee)) + const formatted = subtotal.toFixed(2) + return { subtotalAmount: formatted, finalAmount: formatted } +} + +export type BridgeWithdrawalReceiptFees = { + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string +} + +export type BridgeWithdrawalLike = { + id?: string + amount: string + currency: string + externalAccountId: string + status: string + flashFeePercent?: string + flashFee?: string + estimatedBridgeFeePercent?: string + estimatedBridgeFee?: string + estimatedGasBuffer?: string + estimatedCustomerFee?: string + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string + bridgeTransferId?: string | null + failureReason?: string + createdAt: Date | string +} + +export const isFlashFeeEstimate = ( + withdrawal: Pick, +): boolean => !withdrawal.bridgeDeveloperFee + +export const receiptFeesFromTransfer = (receipt?: { + developer_fee?: string + exchange_fee?: string + subtotal_amount?: string + final_amount?: string +}): BridgeWithdrawalReceiptFees => ({ + bridgeDeveloperFee: + receipt?.developer_fee != null ? String(receipt.developer_fee) : undefined, + bridgeExchangeFee: + receipt?.exchange_fee != null ? String(receipt.exchange_fee) : undefined, + subtotalAmount: + receipt?.subtotal_amount != null ? String(receipt.subtotal_amount) : undefined, + finalAmount: receipt?.final_amount != null ? String(receipt.final_amount) : undefined, +}) + +const withdrawalId = (withdrawal: BridgeWithdrawalLike): string => { + if (withdrawal.id) return withdrawal.id + const mongoId = (withdrawal as { _id?: { toString(): string } })._id + return mongoId ? mongoId.toString() : "" +} + +type MaybeMongooseWithdrawal = BridgeWithdrawalLike & { + toObject?: (options?: { virtuals?: boolean }) => Record +} + +/** Mongoose documents do not spread their schema fields; normalize before merging. */ +export const toBridgeWithdrawalLike = ( + withdrawal: MaybeMongooseWithdrawal, +): BridgeWithdrawalLike => { + if (typeof withdrawal.toObject !== "function") return withdrawal + + const plain = withdrawal.toObject({ virtuals: true }) as BridgeWithdrawalLike + return { + ...plain, + id: withdrawalId(plain), + } +} + +const estimatedCustomerFeeFor = ( + withdrawal: BridgeWithdrawalLike, +): string | undefined => { + if (withdrawal.estimatedCustomerFee) return withdrawal.estimatedCustomerFee + if ( + withdrawal.flashFee != null && + withdrawal.estimatedBridgeFee != null && + withdrawal.estimatedGasBuffer != null + ) { + return ( + parseFloat(withdrawal.flashFee) + + parseFloat(withdrawal.estimatedBridgeFee) + + parseFloat(withdrawal.estimatedGasBuffer) + ).toFixed(2) + } + return undefined +} + +const resolveAmounts = ( + withdrawal: BridgeWithdrawalLike, + flashFeeIsEstimate: boolean, +) => { + if (withdrawal.subtotalAmount && withdrawal.finalAmount) { + return { + subtotalAmount: withdrawal.subtotalAmount, + finalAmount: withdrawal.finalAmount, + } + } + + const estimatedCustomerFee = estimatedCustomerFeeFor(withdrawal) + if (flashFeeIsEstimate && estimatedCustomerFee) { + return computePendingAmountEstimates(withdrawal.amount, estimatedCustomerFee) + } + + return { + subtotalAmount: withdrawal.subtotalAmount, + finalAmount: withdrawal.finalAmount, + } +} + +const withFeeEstimate = ( + withdrawal: BridgeWithdrawalLike, + feeEstimate?: CustomerFeeEstimate, +): BridgeWithdrawalLike => { + const plain = toBridgeWithdrawalLike(withdrawal) + if (!feeEstimate) return plain + + return { + ...plain, + flashFeePercent: feeEstimate.flashFeePercent, + flashFee: feeEstimate.flashFee, + estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, + estimatedBridgeFee: feeEstimate.estimatedBridgeFee, + estimatedGasBuffer: feeEstimate.estimatedGasBuffer, + estimatedCustomerFee: feeEstimate.estimatedCustomerFee, + } +} + +export const presentBridgeWithdrawal = ( + withdrawal: BridgeWithdrawalLike, + feeEstimate?: CustomerFeeEstimate, +) => { + const source = withFeeEstimate(withdrawal, feeEstimate) + const createdAt = + source.createdAt instanceof Date ? source.createdAt.toISOString() : source.createdAt + const flashFeeIsEstimate = isFlashFeeEstimate(source) + const { subtotalAmount, finalAmount } = resolveAmounts(source, flashFeeIsEstimate) + const estimatedCustomerFee = estimatedCustomerFeeFor(source) + + return { + id: withdrawalId(source), + amount: source.amount, + currency: source.currency, + externalAccountId: source.externalAccountId, + status: source.status, + estimatedBridgeFeePercent: source.estimatedBridgeFeePercent, + estimatedBridgeFee: source.estimatedBridgeFee, + estimatedGasBuffer: source.estimatedGasBuffer, + estimatedCustomerFee, + flashFeePercent: source.flashFeePercent, + flashFee: source.flashFee, + flashFeeIsEstimate, + bridgeDeveloperFee: source.bridgeDeveloperFee, + bridgeExchangeFee: source.bridgeExchangeFee, + subtotalAmount, + finalAmount, + bridgeTransferId: source.bridgeTransferId ?? undefined, + failureReason: source.failureReason, + createdAt, + } +} + +export type PresentedBridgeWithdrawal = ReturnType diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts new file mode 100644 index 000000000..abe2e1dfe --- /dev/null +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -0,0 +1,244 @@ +import ErpNext from "@services/frappe/ErpNext" +import { baseLogger } from "@services/logger" +import { BridgeTransferRequestUpsertError } from "@services/frappe/errors" + +import { + BridgeTransferRequest, + BridgeTransferRequestStatus, + BridgeTransferRequestTransactionType, +} from "./models/BridgeTransferRequest" + +type BridgeDepositEventObject = { + id: string + state?: string + amount: string + currency: string + on_behalf_of: string + deposit_id?: string + virtual_account_id?: string + product_type?: string + // Virtual-account/bridge-wallet activity fields + type?: string + customer_id?: string + payment_route?: { + customer_id?: string + type?: string + deposit_id?: string + transfer_id?: string + } + destination_payment_rail?: string + receipt?: { + developer_fee?: unknown + initial_amount?: unknown + subtotal_amount?: unknown + final_amount?: unknown + destination_tx_hash?: string + } + developer_fee?: unknown +} + +const asOptionalString = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined + return String(value) +} + +const upsert = async ( + request: BridgeTransferRequest, +): Promise => { + if (!ErpNext?.upsertBridgeTransferRequest) { + return new BridgeTransferRequestUpsertError("ERPNext client is not configured") + } + return ErpNext.upsertBridgeTransferRequest(request) +} + +export const writeBridgeDepositRequest = async ({ + eventId, + eventObject, + rawPayload, +}: { + eventId: string + eventObject: BridgeDepositEventObject + rawPayload: unknown +}): Promise => { + const receipt = eventObject.receipt + + // Normalise: virtual_account / bridge_wallet events use different field names + const customerId = + eventObject.on_behalf_of ?? + eventObject.customer_id ?? + eventObject.payment_route?.customer_id + const state = eventObject.state ?? eventObject.type ?? "unknown" + const currency = eventObject.currency ?? "usd" + const isVirtualAccountActivity = + !!eventObject.type || + !!eventObject.virtual_account_id || + eventObject.product_type === "virtual_account" + const stableRequestId = + eventObject.deposit_id ?? + eventObject.payment_route?.deposit_id ?? + eventObject.payment_route?.transfer_id ?? + (isVirtualAccountActivity ? undefined : eventObject.id) + + if (!stableRequestId) { + baseLogger.warn( + { eventId, bridgeEventObjectId: eventObject.id, state }, + "Skipping Bridge deposit ERPNext audit row without stable request id", + ) + return true + } + + return upsert( + new BridgeTransferRequest({ + requestId: stableRequestId, + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.FiatReceived, + amount: String(eventObject.amount), + currency: String(currency), + developerFee: + asOptionalString(receipt?.developer_fee) ?? + asOptionalString(eventObject.developer_fee) ?? + "0", + initialAmount: asOptionalString(receipt?.initial_amount), + subtotalAmount: asOptionalString(receipt?.subtotal_amount), + finalAmount: asOptionalString(receipt?.final_amount), + bridgeCustomerId: customerId ?? "unknown", + bridgeTransferId: stableRequestId, + ibexTxHash: receipt?.destination_tx_hash, + sourceEventId: eventId, + sourceEventType: `deposit.${state}`, + sourceSystemsSeen: ["bridge_deposit"], + rawPayload, + }), + ) +} + +export const writeIbexCryptoReceiveRequest = async ({ + txHash, + address, + amount, + currency, + network, + accountId, + walletId, + rawPayload, +}: { + txHash: string + address: string + amount: string + currency: string + network: string + accountId: AccountId + walletId: WalletId + rawPayload: unknown +}): Promise => { + return upsert( + new BridgeTransferRequest({ + requestId: `ibex:${txHash}`, + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Settled, + amount: String(amount), + currency: String(currency), + network, + accountId, + walletId, + ibexTxHash: txHash, + address, + sourceEventId: txHash, + sourceEventType: "crypto.receive", + sourceSystemsSeen: ["ibex_crypto_receive"], + rawPayload, + }), + ) +} + +type BridgeCashoutWriteInput = { + transferId: string + amount: string + currency: string + accountId?: AccountId | string + sourceEventId?: string + sourceEventType: string + rawPayload: unknown +} + +export const writeBridgeCashoutPending = async ({ + transferId, + amount, + currency, + accountId, + sourceEventId, + sourceEventType, + rawPayload, +}: BridgeCashoutWriteInput): Promise => { + return upsert( + new BridgeTransferRequest({ + requestId: transferId, + transactionType: BridgeTransferRequestTransactionType.Cashout, + status: BridgeTransferRequestStatus.Pending, + amount: String(amount), + currency: String(currency), + accountId, + bridgeTransferId: transferId, + sourceEventId, + sourceEventType, + sourceSystemsSeen: ["bridge_transfer"], + rawPayload, + }), + ) +} + +export const writeBridgeCashoutCompleted = async ({ + transferId, + amount, + currency, + accountId, + sourceEventId, + sourceEventType, + rawPayload, +}: BridgeCashoutWriteInput): Promise => { + return upsert( + new BridgeTransferRequest({ + requestId: transferId, + transactionType: BridgeTransferRequestTransactionType.Cashout, + status: BridgeTransferRequestStatus.Completed, + amount: String(amount), + currency: String(currency), + accountId, + bridgeTransferId: transferId, + sourceEventId, + sourceEventType, + sourceSystemsSeen: ["bridge_transfer"], + rawPayload, + }), + ) +} + +export const writeBridgeCashoutFailed = async ({ + transferId, + amount, + currency, + accountId, + sourceEventId, + sourceEventType, + failureReason, + rawPayload, +}: BridgeCashoutWriteInput & { + failureReason?: string +}): Promise => { + return upsert( + new BridgeTransferRequest({ + requestId: transferId, + transactionType: BridgeTransferRequestTransactionType.Cashout, + status: BridgeTransferRequestStatus.Failed, + amount: String(amount), + currency: String(currency), + accountId, + bridgeTransferId: transferId, + sourceEventId, + sourceEventType, + sourceSystemsSeen: ["bridge_transfer"], + failureReason, + rawPayload, + }), + ) +} diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 8c72f16c8..33ef9e2a2 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -1,6 +1,6 @@ import ValidOffer from "@app/offers/ValidOffer" import { FrappeConfig } from "@config" -import { JMDAmount, USDAmount, Validated } from "@domain/shared" +import { JMDAmount, USDAmount, USDTAmount, Validated } from "@domain/shared" import { baseLogger } from "@services/logger" import { recordExceptionInCurrentSpan } from "@services/tracing" import axios, { isAxiosError } from "axios" @@ -14,6 +14,7 @@ import { BanksQueryError, BankAccountQueryError, SetDocTypeValueError, + BridgeTransferRequestUpsertError, } from "./errors" import { AccountUpgradeRequest, @@ -21,6 +22,7 @@ import { } from "./models/AccountUpgradeRequest" import { Bank } from "./models/Bank" import { BankAccount } from "./models/BankAccount" +import { BridgeTransferRequest } from "./models/BridgeTransferRequest" import { Filter } from "./SearchFilters" export type AccountUpgradeRequestFilters = { username?: Filter, status?: Filter } @@ -37,7 +39,7 @@ const erpUsd = (usd: USDAmount): number => Number(usd.asCents(2)) export type CashoutId = string & { readonly brand: unique symbol } -class ErpNext { +export class ErpNext { url: string headers: Record @@ -66,7 +68,12 @@ class ErpNext { wallet_id: payment.userAcct, flash_wallet: payment.flashAcct, user_receives: Number(payout.amount.asDollars()), - user_pays: Number(payment.amount.asDollars()), + // 1 USDT = 1 USD; USDTAmount exposes major units via asNumber (no asDollars). + user_pays: Number( + payment.amount instanceof USDTAmount + ? payment.amount.asNumber(2) + : payment.amount.asDollars(), + ), currency: payout.amount.currencyCode, exchange_rate: payout.exchangeRate ? Number(payout.exchangeRate.asDollars()) : undefined, flash_fee: Number(payout.serviceFee.asDollars()), @@ -240,6 +247,93 @@ class ErpNext { return new BanksQueryError(err) } } + + async upsertBridgeTransferRequest( + request: BridgeTransferRequest, + ): Promise { + const payload = request.toErpnext() + const requestId = payload.request_id + + try { + const existingName = await this.findBridgeTransferRequestName(requestId) + if (existingName instanceof BridgeTransferRequestUpsertError) return existingName + + if (existingName) { + await axios.put( + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(existingName)}`, + payload, + { headers: this.headers }, + ) + return true + } + + try { + await axios.post( + `${this.url}/api/resource/${BridgeTransferRequest.doctype}`, + payload, + { headers: this.headers }, + ) + return true + } catch (err) { + if (!this.isDuplicateRequestError(err)) throw err + + const racedName = await this.findBridgeTransferRequestName(requestId) + if (racedName instanceof BridgeTransferRequestUpsertError) return racedName + if (!racedName) throw err + + await axios.put( + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(racedName)}`, + payload, + { headers: this.headers }, + ) + return true + } + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, requestId }, + "Error upserting Bridge Transfer Request in ERPNext", + ) + recordExceptionInCurrentSpan({ error: err, attributes: { "erpnext.exception": responseData?.exception } }) + return new BridgeTransferRequestUpsertError(err) + } + } + + private async findBridgeTransferRequestName( + requestId: string, + ): Promise { + try { + const filters = JSON.stringify([ + [BridgeTransferRequest.doctype, "request_id", "=", requestId], + ]) + const fields = JSON.stringify(["name"]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`, + { + params: { filters, fields, limit_page_length: 1 }, + headers: this.headers, + }, + ) + + return resp.data?.data?.[0]?.name + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, requestId }, + "Error querying Bridge Transfer Request from ERPNext", + ) + recordExceptionInCurrentSpan({ error: err, attributes: { "erpnext.exception": responseData?.exception } }) + return new BridgeTransferRequestUpsertError(err) + } + } + + private isDuplicateRequestError(err: unknown): boolean { + if (!isAxiosError(err)) return false + const status = err.response?.status + const responseData = err.response?.data + const message = JSON.stringify(responseData ?? err.message).toLowerCase() + return status === 409 || message.includes("duplicate") || message.includes("unique") + } } // Only instantiate if config is available, otherwise export a null-safe placeholder diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index 0bdb3bb89..a64833018 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -9,3 +9,4 @@ export class UpgradeRequestQueryError extends ErpNextError {} export class SetDocTypeValueError extends ErpNextError {} export class BanksQueryError extends ErpNextError {} export class BankAccountQueryError extends ErpNextError {} +export class BridgeTransferRequestUpsertError extends ErpNextError {} diff --git a/src/services/frappe/models/BridgeTransferRequest.ts b/src/services/frappe/models/BridgeTransferRequest.ts new file mode 100644 index 000000000..1f33861d2 --- /dev/null +++ b/src/services/frappe/models/BridgeTransferRequest.ts @@ -0,0 +1,100 @@ +export enum BridgeTransferRequestTransactionType { + Topup = "Topup", + Cashout = "Cashout", +} + +export enum BridgeTransferRequestStatus { + Pending = "Pending", + FiatReceived = "Fiat Received", + Settled = "Settled", + Completed = "Completed", + Failed = "Failed", +} + +export type BridgeTransferRequestInput = { + requestId: string + transactionType: BridgeTransferRequestTransactionType + status: BridgeTransferRequestStatus + amount: string + currency: string + provider?: "Bridge" + asset?: string + network?: string + developerFee?: string + initialAmount?: string + subtotalAmount?: string + finalAmount?: string + accountId?: AccountId | string + walletId?: WalletId | string + bridgeCustomerId?: string + bridgeTransferId?: string + ibexTxHash?: string + address?: string + sourceEventId?: string + sourceEventType?: string + sourceSystemsSeen?: string[] + firstSeenAt?: string + lastSeenAt?: string + rawPayload?: unknown + failureReason?: string +} + +const toFrappeDatetime = (value?: string): string => { + const date = value ? new Date(value) : new Date() + + if (Number.isNaN(date.getTime())) return value ?? "" + + const pad = (part: number) => String(part).padStart(2, "0") + + return [ + `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`, + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`, + ].join(" ") +} + +export class BridgeTransferRequest { + static doctype = "Bridge Transfer Request" + readonly input: BridgeTransferRequestInput + + constructor(input: BridgeTransferRequestInput) { + this.input = input + } + + toErpnext() { + const sourceSystemsSeen = [...new Set(this.input.sourceSystemsSeen ?? [])].join(",") + + return { + doctype: BridgeTransferRequest.doctype, + request_id: this.input.requestId, + transaction_type: this.input.transactionType, + status: this.input.status, + provider: this.input.provider ?? "Bridge", + asset: this.input.asset ?? "USDT", + network: this.input.network ?? "Ethereum", + amount: this.input.amount, + currency: this.input.currency, + developer_fee: this.input.developerFee, + initial_amount: this.input.initialAmount, + subtotal_amount: this.input.subtotalAmount, + final_amount: this.input.finalAmount, + account_id: this.input.accountId, + wallet_id: this.input.walletId, + bridge_customer_id: this.input.bridgeCustomerId, + bridge_transfer_id: this.input.bridgeTransferId, + ibex_tx_hash: this.input.ibexTxHash, + address: this.input.address, + source_event_id: this.input.sourceEventId, + source_event_type: this.input.sourceEventType, + source_systems_seen: sourceSystemsSeen || undefined, + first_seen_at: this.input.firstSeenAt + ? toFrappeDatetime(this.input.firstSeenAt) + : undefined, + last_seen_at: toFrappeDatetime(this.input.lastSeenAt), + raw_payload_json: + this.input.rawPayload === undefined + ? undefined + : JSON.stringify(this.input.rawPayload), + failure_reason: this.input.failureReason, + } + } +} diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index af1867d31..9e6e32990 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -1,42 +1,135 @@ -import IbexClient, { GetFeeEstimateResponse200, IbexClientError } from "ibex-client" +import IbexClient, { + AddInvoiceBodyParam, + AddInvoiceResponse201, + CreateAccountResponse201, + CreateLnurlPayBodyParam, + CreateLnurlPayResponse201, + CreateCryptoSendInfoBodyParam, + CryptoSendInfo, + CryptoSendBodyParam, + CryptoSendRequirements, + CryptoSendResponse200, + DecodeLnurlMetadataParam, + DecodeLnurlResponse200, + EstimateFeeCopyResponse200, + GenerateBitcoinAddressResponse201, + GetTransactionDetails1Response200, + GMetadataParam, + GResponse200, + InvoiceFromHashResponse200, + PayInvoiceV2BodyParam, + PayInvoiceV2Response200, + PayToALnurlPayResponse201, + SendToAddressCopyBodyParam, + SendToAddressCopyResponse200, + IbexUrls, +} from "ibex-client" + +import { IbexConfig } from "@config" +import { + addAttributesToCurrentSpan, + wrapAsyncFunctionsToRunInSpan, +} from "@services/tracing" + +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { baseLogger } from "@services/logger" + +import { Redis } from "./cache" +import { + GetFeeEstimateArgs, + IbexAccountDetails, + IbexFeeEstimation, + IbexInvoiceArgs, + PayInvoiceArgs, + CryptoReceiveOption, + CryptoReceiveInfo, + CreateCryptoReceiveInfoRequest, + IbexCurrency, + UsdWalletAmount, +} from "./types" + import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors" -import { IbexConfig } from "@config"; -import { AddInvoiceBodyParam, AddInvoiceResponse201, CreateAccountResponse201, CreateLnurlPayBodyParam, CreateLnurlPayResponse201, DecodeLnurlMetadataParam, DecodeLnurlResponse200, EstimateFeeCopyMetadataParam, EstimateFeeCopyResponse200, GenerateBitcoinAddressBodyParam, GenerateBitcoinAddressResponse201, GetAccountDetailsMetadataParam, GetAccountDetailsResponse200, GetFeeEstimationMetadataParam, GetFeeEstimationResponse200, GetTransactionDetails1MetadataParam, GetTransactionDetails1Response200, GMetadataParam, GResponse200, InvoiceFromHashMetadataParam, InvoiceFromHashResponse200, PayInvoiceV2BodyParam, PayInvoiceV2Response200, PayToALnurlPayBodyParam, PayToALnurlPayResponse201, SendToAddressCopyBodyParam, SendToAddressCopyResponse200 } from "ibex-client"; -import { addAttributesToCurrentSpan, wrapAsyncFunctionsToRunInSpan } from "@services/tracing"; -import WebhookServer from "./webhook-server"; -import { Redis } from "./cache" -import { GetFeeEstimateArgs, IbexAccountDetails, IbexFeeEstimation, IbexInvoiceArgs, PayInvoiceArgs, SendOnchainArgs } from "./types"; -import { USDAmount } from "@domain/shared"; -import { cappedIbexReceiveExpiration } from "@domain/bitcoin/lightning"; +import WebhookServer from "./webhook-server" +import { cappedIbexReceiveExpiration } from "@domain/bitcoin/lightning" const Ibex = new IbexClient( - { clientId: IbexConfig.clientId, clientSecret: IbexConfig.clientSecret, environment: IbexConfig.environment }, - Redis + { + clientId: IbexConfig.clientId, + clientSecret: IbexConfig.clientSecret, + environment: IbexConfig.environment, + }, + Redis, ) -const createAccount = async (name: string, currencyId: IbexCurrencyId): Promise => { +const IbexUrlConfig = IbexUrls[IbexConfig.environment] + +const createAccount = async ( + name: string, + currencyId: IbexCurrencyId, +): Promise => { return Ibex.createAccount({ name, currencyId }).then(errorHandler) } -const getAccountDetails = async (accountId: IbexAccountId): Promise => { +const ibexCurrencyIdForUsdAmount = (amount: UsdWalletAmount): IbexCurrencyId => { + if (amount instanceof USDAmount) return USDAmount.currencyId + return USDTAmount.currencyId +} + +const ibexCurrencyIdForUsdWalletCurrency = ( + currency?: WalletCurrency, +): IbexCurrencyId => { + if (currency === WalletCurrency.Usdt) return USDTAmount.currencyId + return USDAmount.currencyId +} + +const parseIbexUsdAmount = ( + amount: number | string, + currencyId: IbexCurrencyId, +): UsdWalletAmount | ParseError => { + const parsed = + currencyId === USDTAmount.currencyId + ? USDTAmount.fromNumber(amount.toString()) + : USDAmount.dollars(amount.toString()) + + return parsed instanceof Error ? new ParseError(parsed) : parsed +} + +const parseAccountBalance = ( + balance: number | undefined, + currency: WalletCurrency, +): IbexAccountDetails["balance"] => { + if (balance === undefined) return undefined + + const amount = + currency === WalletCurrency.Usdt + ? USDTAmount.fromNumber(balance.toString()) + : USDAmount.dollars(balance.toString()) + + return amount instanceof Error ? undefined : amount +} + +const getAccountDetails = async ( + accountId: IbexAccountId, + currency: WalletCurrency = WalletCurrency.Usd, +): Promise => { return Ibex.getAccountDetails({ accountId }) - .then(r => { - if (r instanceof Error) return r - else { - let balance = r.balance !== undefined ? USDAmount.dollars(r.balance.toString()) : undefined - if (balance instanceof Error) balance = undefined - return { - id: r.id, - userId: r.userId, - name: r.name, - balance - } + .then((resp) => { + if (resp instanceof Error) return resp + + return { + id: resp.id, + userId: resp.userId, + name: resp.name, + balance: parseAccountBalance(resp.balance, currency), } }) .then(errorHandler) } -const getAccountTransactions = async (params: GMetadataParam): Promise => { +const getAccountTransactions = async ( + params: GMetadataParam, +): Promise => { addAttributesToCurrentSpan({ "request.params": JSON.stringify(params) }) return Ibex.getAccountTransactions(params).then(errorHandler) } @@ -55,54 +148,66 @@ const addInvoice = async (args: IbexInvoiceArgs): Promise => { +const getTransactionDetails = async ( + id: IbexTransactionId, +): Promise => { return Ibex.getTransactionDetails({ transaction_id: id }).then(errorHandler) } -const generateBitcoinAddress = async (accountId: IbexAccountId): Promise => { +const generateBitcoinAddress = async ( + accountId: IbexAccountId, +): Promise => { return Ibex.generateBitcoinAddress({ accountId, webhookUrl: WebhookServer.endpoints.onReceive.onchain, - webhookSecret: WebhookServer.secret, + webhookSecret: WebhookServer.secret, }).then(errorHandler) } -const invoiceFromHash = async (invoice_hash: PaymentHash): Promise => { +const invoiceFromHash = async ( + invoice_hash: PaymentHash, +): Promise => { return Ibex.invoiceFromHash({ invoice_hash }).then(errorHandler) } -// Only supports USD for now -const getLnFeeEstimation = async (args: GetFeeEstimateArgs): Promise => { - const currencyId = USDAmount.currencyId - // const amount = (args.send instanceof IbexCurrency) ? args.send.amount.toString() : undefined - +const getLnFeeEstimation = async ( + args: GetFeeEstimateArgs, +): Promise | IbexError> => { + const currencyId = args.send + ? ibexCurrencyIdForUsdAmount(args.send) + : ibexCurrencyIdForUsdWalletCurrency(args.currency) + const resp = await Ibex.getFeeEstimation({ bolt11: args.invoice as string, - amount: args.send?.asDollars(8), + amount: args.send?.toIbex().toString(), currencyId: currencyId.toString(), }) if (resp instanceof Error) return new IbexError(resp) - else if (resp.amount === null || resp.amount === undefined) return new UnexpectedIbexResponse("Fee not found.") - else if (resp.invoiceAmount === null || resp.invoiceAmount === undefined) return new UnexpectedIbexResponse("invoiceAmount not found.") + else if (resp.amount === null || resp.amount === undefined) + return new UnexpectedIbexResponse("Fee not found.") + else if (resp.invoiceAmount === null || resp.invoiceAmount === undefined) + return new UnexpectedIbexResponse("invoiceAmount not found.") else { - let fee = USDAmount.dollars(resp.amount) - if (fee instanceof Error) return new ParseError(fee) - let invoiceAmount = USDAmount.dollars(resp.invoiceAmount) - if (invoiceAmount instanceof Error) return new ParseError(invoiceAmount) - return { - fee, + const fee = parseIbexUsdAmount(resp.amount, currencyId) + if (fee instanceof Error) return fee + const invoiceAmount = parseIbexUsdAmount(resp.invoiceAmount, currencyId) + if (invoiceAmount instanceof Error) return invoiceAmount + return { + fee, invoice: invoiceAmount, } } } -const payInvoice = async (args: PayInvoiceArgs): Promise => { - const bodyWithHooks = { - accountId: args.accountId, - bolt11: args.invoice, - amount: args.send?.toIbex(), - webhookUrl: WebhookServer.endpoints.onPay.invoice, - webhookSecret: WebhookServer.secret, +const payInvoice = async ( + args: PayInvoiceArgs, +): Promise => { + const bodyWithHooks = { + accountId: args.accountId, + bolt11: args.invoice, + amount: args.send?.toIbex(), + webhookUrl: WebhookServer.endpoints.onPay.invoice, + webhookSecret: WebhookServer.secret, } as PayInvoiceV2BodyParam addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) return Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler) @@ -110,57 +215,275 @@ const payInvoice = async (args: PayInvoiceArgs): Promise => { - const body = { - accountId: args.accountId, - address: args.address, - amount: args.amount.toIbex(), - webhookUrl: WebhookServer.endpoints.onPay.onchain, - webhookSecret: WebhookServer.secret, - } as SendToAddressCopyBodyParam - addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) - return Ibex.sendToAddressV2(body).then(errorHandler) -} - -const estimateOnchainFee = async (send: USDAmount, address: OnChainAddress): Promise => { - return Ibex.estimateFeeV2({ - amount: send.toIbex(), - "currency-id": USDAmount.currencyId.toString(), - address +const sendOnchain = async ( + body: SendToAddressCopyBodyParam, +): Promise => { + const bodyWithHooks = { + ...body, + webhookUrl: WebhookServer.endpoints.onPay.onchain, + webhookSecret: WebhookServer.secret, + } as SendToAddressCopyBodyParam + addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) + return Ibex.sendToAddressV2(bodyWithHooks).then(errorHandler) +} + +const sendCrypto = async ( + body: CryptoSendBodyParam, +): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) + return Ibex.sendCrypto(body).then(errorHandler) +} + +const getCryptoSendRequirements = async (args: { + network: string + currencyId: IbexCurrencyId +}): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(args) }) + return Ibex.getCryptoSendRequirements(args).then(errorHandler) +} + +const createCryptoSendInfo = async ( + body: CreateCryptoSendInfoBodyParam, +): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) + return Ibex.createCryptoSendInfo(body).then(errorHandler) +} + +const estimateOnchainFee = async ( + send: UsdWalletAmount, + address: OnChainAddress, +): Promise => { + return Ibex.estimateFeeV2({ + "amount": send.toIbex(), + "currency-id": ibexCurrencyIdForUsdAmount(send).toString(), + address, }).then(errorHandler) } - -const createLnurlPay = async (body: CreateLnurlPayBodyParam): Promise => { - const bodyWithHooks = { - ...body, - webhookUrl: WebhookServer.endpoints.onReceive.lnurl, - webhookSecret: WebhookServer.secret, + +const createLnurlPay = async ( + body: CreateLnurlPayBodyParam, +): Promise => { + const bodyWithHooks = { + ...body, + webhookUrl: WebhookServer.endpoints.onReceive.lnurl, + webhookSecret: WebhookServer.secret, } as CreateLnurlPayBodyParam addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) }) return Ibex.createLnurlPay(bodyWithHooks).then(errorHandler) } -const decodeLnurl = async (lnurl: DecodeLnurlMetadataParam): Promise => { +const decodeLnurl = async ( + lnurl: DecodeLnurlMetadataParam, +): Promise => { return Ibex.decodeLnurl(lnurl).then(errorHandler) } - -const payToLnurl = async (args: PayLnurlArgs): Promise => { + +const payToLnurl = async ( + args: PayLnurlArgs, +): 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, + webhookSecret: WebhookServer.secret, }).then(errorHandler) } +const getIbexToken = async (): Promise => { + const cached = await Ibex.authentication.storage.getAccessToken() + if (typeof cached === "string") return `${cached}` + + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: IbexConfig.clientId, + client_secret: IbexConfig.clientSecret, + audience: IbexUrlConfig.audience, + }) + + const resp = await fetch(`${IbexUrlConfig.authDomain}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }).catch( + (err: unknown) => new IbexError(err instanceof Error ? err : new Error(String(err))), + ) + + if (resp instanceof IbexError) return resp + if (!resp.ok) { + const responseBody = await resp.text().catch(() => "") + return new IbexError( + new Error(`IBEX token request failed: ${resp.status} — ${responseBody}`), + ) + } + + const data = (await resp.json()) as { + access_token?: string + expires_in?: number + } + if (!data.access_token) + return new IbexError(new Error("IBEX token request: no access_token in response")) + + await Ibex.authentication.storage.setAccessToken(data.access_token, data.expires_in) + + return data.access_token +} + +const ibexFetch = async ( + token: string, + path: string, + init: RequestInit = {}, +): Promise => { + const url = `${IbexUrlConfig.hubUrl}${path}` + const resp = await fetch(url, { + ...init, + headers: { + "Authorization": token, + "Content-Type": "application/json", + ...init.headers, + }, + }) + if (!resp.ok) { + const body = await resp.text().catch(() => "") + baseLogger.error({ url, status: resp.status, body }, "IBEX request failed") + return new IbexError(new Error(`IBEX ${path} failed: ${resp.status} — ${body}`)) + } + return resp.json() as Promise +} + +const ibexGet = (token: string, path: string) => + ibexFetch(token, path, { method: "GET" }) + +const ibexPost = (token: string, path: string, body: unknown) => + ibexFetch(token, path, { method: "POST", body: JSON.stringify(body) }) + +const createIbexAccount = async ( + name: string, + currencyId: IbexCurrencyId, +): Promise => { + try { + const token = await getIbexToken() + if (token instanceof IbexError) return token + const data = await ibexPost(token, "/account/create", { + name, + currencyId, + }) + if (data instanceof IbexError) return data + return data + } catch (err) { + return new IbexError(err instanceof Error ? err : new Error(String(err))) + } +} +const getCryptoReceiveBalance = async ( + receiveInfoId: string, +): Promise => { + try { + const token = await getIbexToken() + if (token instanceof IbexError) return token + const data = await ibexGet<{ balance: number }>( + token, + `/crypto/receive-infos/${receiveInfoId}/balance`, + ) + if (data instanceof IbexError) return data + const balance = USDTAmount.smallestUnits(data.balance?.toString()) + if (balance instanceof Error) return new IbexError(balance) + return balance + } catch (err) { + return new IbexError(err instanceof Error ? err : new Error(String(err))) + } +} + +const getCryptoReceiveOptions = async (): Promise => { + try { + const token = await getIbexToken() + if (token instanceof IbexError) return token + const data = await ibexGet( + token, + "/crypto/receive-infos/options", + ) + + if (data instanceof IbexError) return data + return data + } catch (err) { + return new IbexError(err instanceof Error ? err : new Error(String(err))) + } +} + +const createCryptoReceiveInfo = async ( + accountId: IbexAccountId, + option: Pick, +): Promise => { + try { + const token = await getIbexToken() + if (token instanceof IbexError) return token + const data = await ibexPost( + token, + `/accounts/${accountId}/crypto/receive-infos`, + { name: option.name, network: option.network } as CreateCryptoReceiveInfoRequest, + ) + if (data instanceof IbexError) return data + if (!data.data.address) return new UnexpectedIbexResponse("Address not found") + return data + } catch (err) { + return new IbexError(err instanceof Error ? err : new Error(String(err))) + } +} + +const getTronUsdtOption = async (): Promise => { + const options = await getCryptoReceiveOptions() + if (options instanceof IbexError) return options + + const tronUsdt = options.find( + (opt) => + opt.currencyId === USDTAmount.currencyId && opt.network.toLowerCase() === "tron", + ) + + if (!tronUsdt) { + return new IbexError(new Error("Tron USDT option not found")) + } + + return tronUsdt +} + +const getEthereumUsdtOption = async (): Promise => { + const options = await getCryptoReceiveOptions() + if (options instanceof IbexError) return options + + const UsdtCurrencyId = await getIbexCurrencyId(WalletCurrency.Usdt) + if (UsdtCurrencyId instanceof IbexError) return UsdtCurrencyId as IbexError + + const ethereumUsdt = options.find( + (opt) => + opt.currencyId === UsdtCurrencyId && opt.network.toLowerCase() === "ethereum", + ) + + if (!ethereumUsdt) { + return new IbexError(new Error("Ethereum USDT option not found")) + } + + return ethereumUsdt +} + +const getIbexCurrencyId = async ( + currency: WalletCurrency, +): Promise => { + const token = await getIbexToken() + if (token instanceof IbexError) return token + + const data = await ibexGet<{ currencies: IbexCurrency[] }>(token, "/currency/all") + if (data instanceof IbexError) return data + const currencyId = data.currencies.find((c) => c.name === currency)?.id + if (!currencyId) return new IbexError(new Error(`Currency ${currency} not found`)) + return currencyId +} + // const sendBetweenAccounts = async ( -// sender: IbexAccount, -// receiver: IbexAccount, +// sender: IbexAccount, +// receiver: IbexAccount, // transfer: USDollars, // memo: string = "Flash-to-Flash" // ): Promise => { -// const invoiceResp = await addInvoice({ +// const invoiceResp = await addInvoice({ // accountId: receiver.id, // memo, // amount: transfer, // convert cents to dollars for Ibex api @@ -176,20 +499,30 @@ const payToLnurl = async (args: PayLnurlArgs): Promise = { + fee: T + invoice: T } export type IbexAccountDetails = { - id: string | undefined; - userId: string | undefined; - name: string | undefined; - balance: USDAmount | undefined; + id: string | undefined + userId: string | undefined + name: string | undefined + balance: USDAmount | USDTAmount | undefined } -export type IbexInvoiceArgs = { - accountId: IbexAccountId, - amount?: USDAmount +export type IbexInvoiceArgs = { + accountId: IbexAccountId + amount?: UsdWalletAmount memo: string - expiration?: Seconds -}; + expiration?: Seconds +} + +export interface CryptoReceiveOption { + id?: string + currencyId: number + network: string + name?: string +} + +export interface IbexCurrency { + id: IbexCurrencyId + name: string + isFiat: boolean + symbol: string + accountEnabled: boolean +} + +export interface CryptoReceiveInfo { + id: string + wallet_id: string + option_id: string + data: { + address: string + } + currency: string + network: string + created_at: string +} + +export interface CreateCryptoReceiveInfoRequest { + name: string + network: string +} diff --git a/src/services/ibex/webhook-server/index.ts b/src/services/ibex/webhook-server/index.ts index 1c94cf993..b190516c4 100644 --- a/src/services/ibex/webhook-server/index.ts +++ b/src/services/ibex/webhook-server/index.ts @@ -1,18 +1,18 @@ import express, { Request, Response } from "express" import { IbexConfig } from "@config" import { baseLogger as logger } from "@services/logger" -import { onPay, onReceive } from "./routes" + +import { onPay, onReceive, cryptoReceive } from "./routes" const start = () => { const app = express() - // Middleware to parse JSON requests app.use(express.json()) - // Routes app.get("/health", (_: Request, resp: Response) => resp.send("Ibex server is running")) app.use(onReceive.router) app.use(onPay.router) + app.use(cryptoReceive.router) app.listen(IbexConfig.webhook.port, () => logger.info( `Listening for ibex events on port ${IbexConfig.webhook.port}. Can be reached at ${IbexConfig.webhook.uri}`, @@ -35,6 +35,9 @@ export default { lnurl: IbexConfig.webhook.uri + onPay.paths.lnurl, onchain: IbexConfig.webhook.uri + onPay.paths.onchain, }, + cryptoReceive: { + cryptoReceive: IbexConfig.webhook.uri + cryptoReceive.paths.cryptoReceive, + }, }, secret: IbexConfig.webhook.secret, } diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts new file mode 100644 index 000000000..939ffd0dc --- /dev/null +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -0,0 +1,228 @@ +import express, { Request, Response } from "express" +import { AccountsRepository } from "@services/mongoose/accounts" +import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" +import { listWalletsByAccountId } from "@app/wallets" +import { sendBridgeDepositNotificationBestEffort } from "@app/bridge/send-deposit-notification" +import { WalletCurrency, USDTAmount } from "@domain/shared" +import { baseLogger } from "@services/logger" +import { LockService } from "@services/lock" +import { reconcileByTxHash } from "@services/bridge/reconciliation" +import { + alertIbexCryptoReceiveFailure, + alertIbexReconciliationFailed, +} from "@services/alerts/ibex-bridge-movement" +import { writeIbexCryptoReceiveRequest } from "@services/frappe/BridgeTransferRequestWriter" + +import { authenticate, logRequest } from "../middleware" + +const paths = { + cryptoReceive: "/crypto/receive", +} + +const router = express.Router() + +interface CryptoReceiveResult { + status: "success" | "error" + code?: string +} + +const cryptoReceiveHandler = async (req: Request, res: Response) => { + const { tx_hash, address, amount, currency, network } = req.body + const normalizedCurrency = String(currency || "").toUpperCase() + const normalizedNetwork = String(network || "").toLowerCase() + + if ( + !tx_hash || + !address || + !amount || + normalizedCurrency !== "USDT" || + normalizedNetwork !== "ethereum" + ) { + baseLogger.warn( + { tx_hash, address, amount, currency, network }, + "Invalid crypto receive payload", + ) + return res.status(400).json({ error: "Invalid payload" }) + } + + const lockResult = await LockService().lockOnChainTxHash( + tx_hash as OnChainTxHash, + async () => { + try { + const account = await AccountsRepository().findByBridgeEthereumAddress(address) + if (account instanceof Error) { + baseLogger.error({ address, tx_hash }, "Account not found for Ethereum address") + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "account_not_found", + title: "IBEX crypto receive: account not found for Bridge Ethereum address", + context: { address }, + }) + return { status: "error", code: "account_not_found" } as CryptoReceiveResult + } + + const ibexLog = await createIbexCryptoReceive({ + txHash: String(tx_hash), + address: String(address), + amount: String(amount), + currency: normalizedCurrency, + network: normalizedNetwork, + accountId: account.id, + }) + if (ibexLog instanceof Error) { + baseLogger.error( + { error: ibexLog, tx_hash }, + "Failed to persist IBEX crypto receive log", + ) + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "persist_failed", + title: "IBEX crypto receive log persistence failed", + detail: ibexLog.message, + context: { address }, + }) + return { status: "error", code: "internal_error" } as CryptoReceiveResult + } + + reconcileByTxHash({ txHash: String(tx_hash) }).catch((err) => { + baseLogger.error({ err, tx_hash }, "Real-time reconciliation failed") + alertIbexReconciliationFailed({ + txHash: String(tx_hash), + detail: err instanceof Error ? err.message : String(err), + }) + }) + + const wallets = await listWalletsByAccountId(account.id) + if (wallets instanceof Error) { + baseLogger.error( + { accountId: account.id, error: wallets }, + "Failed to list wallets", + ) + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "wallet_list_failed", + title: "IBEX crypto receive: wallet list failed", + detail: wallets.message, + context: { accountId: account.id, address }, + }) + return { status: "error", code: "wallet_list_failed" } as CryptoReceiveResult + } + + const usdtWallet = wallets.find((w) => w.currency === WalletCurrency.Usdt) + if (!usdtWallet) { + baseLogger.error({ accountId: account.id }, "USDT wallet not found") + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "usdt_wallet_not_found", + title: "IBEX crypto receive: USDT wallet not found", + context: { accountId: account.id, address }, + }) + return { status: "error", code: "usdt_wallet_not_found" } as CryptoReceiveResult + } + + const usdtAmount = USDTAmount.fromNumber(amount) + if (usdtAmount instanceof Error) { + baseLogger.error({ amount, error: usdtAmount }, "Invalid USDT amount") + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "invalid_amount", + title: "IBEX crypto receive: invalid USDT amount", + detail: usdtAmount.message, + context: { accountId: account.id, address, amount }, + }) + return { status: "error", code: "invalid_amount" } as CryptoReceiveResult + } + + baseLogger.info( + { + accountId: account.id, + walletId: usdtWallet.id, + amount: usdtAmount.asNumber(), + tx_hash, + address, + }, + "USDT deposit received", + ) + + const auditResult = await writeIbexCryptoReceiveRequest({ + txHash: String(tx_hash), + address: String(address), + amount: String(amount), + currency: normalizedCurrency, + network: normalizedNetwork.charAt(0).toUpperCase() + normalizedNetwork.slice(1), + accountId: account.id, + walletId: usdtWallet.id, + rawPayload: req.body, + }) + if (auditResult instanceof Error) { + baseLogger.error( + { + error: auditResult, + tx_hash, + accountId: account.id, + walletId: usdtWallet.id, + }, + "Failed to persist IBEX crypto receive ERPNext audit row", + ) + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "erpnext_audit_failed", + title: "IBEX crypto receive ERPNext audit write failed", + detail: auditResult.message, + context: { + accountId: account.id, + walletId: usdtWallet.id, + address, + }, + }) + return { status: "error", code: "erpnext_audit_failed" } as CryptoReceiveResult + } + + await sendBridgeDepositNotificationBestEffort({ + accountId: account.id, + amount: String(usdtAmount.asNumber()), + currency: normalizedCurrency, + }) + + return { status: "success" } as CryptoReceiveResult + } catch (error) { + baseLogger.error({ error, tx_hash }, "Error processing crypto receive webhook") + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "internal_error", + title: "IBEX crypto receive webhook processing error", + detail: error instanceof Error ? error.message : String(error), + }) + return { status: "error", code: "internal_error" } as CryptoReceiveResult + } + }, + ) + + if (lockResult instanceof Error) { + baseLogger.warn( + { tx_hash, error: lockResult }, + "Lock acquisition failed or duplicate webhook", + ) + return res.status(200).json({ status: "already_processed" }) + } + + if (lockResult.status === "success") { + return res.status(200).json({ status: "success" }) + } + + const statusMap: Record = { + account_not_found: 404, + wallet_list_failed: 500, + usdt_wallet_not_found: 404, + invalid_amount: 400, + erpnext_audit_failed: 500, + internal_error: 500, + } + + const statusCode = statusMap[lockResult.code || ""] || 500 + return res.status(statusCode).json({ error: lockResult.code }) +} + +router.post(paths.cryptoReceive, authenticate, logRequest, cryptoReceiveHandler) + +export { cryptoReceiveHandler, paths, router } diff --git a/src/services/ibex/webhook-server/routes/index.ts b/src/services/ibex/webhook-server/routes/index.ts index e41b6e40b..cb1759853 100644 --- a/src/services/ibex/webhook-server/routes/index.ts +++ b/src/services/ibex/webhook-server/routes/index.ts @@ -1,2 +1,3 @@ export * as onPay from "./on-pay" -export * as onReceive from "./on-receive" \ No newline at end of file +export * as onReceive from "./on-receive" +export * as cryptoReceive from "./crypto-receive" diff --git a/src/services/lock/index.ts b/src/services/lock/index.ts index 9d7acea34..9b72e9d41 100644 --- a/src/services/lock/index.ts +++ b/src/services/lock/index.ts @@ -155,7 +155,12 @@ export const LockService = (): ILockService => { ): Promise => { const path = getIdempotencyKeyLockResource(idempotencyKey) - await timelock({ resource: path, duration: durationLockIdempotencyKey }) + try { + await timelock({ resource: path, duration: durationLockIdempotencyKey }) + } catch (error) { + if (error instanceof ExecutionError) return error + throw error + } } return wrapAsyncFunctionsToRunInSpan({ diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 32d55ec4b..4cc8fb43f 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -82,7 +82,6 @@ export const AccountsRepository = (): IAccountsRepository => { if (!result) { return new CouldNotFindAccountFromUsernameError(npub) } - let account = translateToAccount(result) return translateToAccount(result) } catch (err) { return parseRepositoryError(err) @@ -173,6 +172,51 @@ export const AccountsRepository = (): IAccountsRepository => { } } + const updateBridgeFields = async ( + id: AccountId, + fields: { + bridgeCustomerId?: BridgeCustomerId + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" + bridgeEthereumAddress?: string + }, + ): Promise => { + try { + const result = await Account.findByIdAndUpdate( + toObjectId(id), + { $set: fields }, + { new: true }, + ) + if (!result) return new RepositoryError("Account not found") + return translateToAccount(result) + } catch (error) { + return parseRepositoryError(error) + } + } + + const findByBridgeEthereumAddress = async ( + address: string, + ): Promise => { + try { + const result = await Account.findOne({ bridgeEthereumAddress: address }) + if (!result) return new RepositoryError("Account not found for Ethereum address") + return translateToAccount(result) + } catch (error) { + return parseRepositoryError(error) + } + } + + const findByBridgeCustomerId = async ( + customerId: BridgeCustomerId, + ): Promise => { + try { + const result = await Account.findOne({ bridgeCustomerId: customerId }) + if (!result) return new RepositoryError("Account not found for Bridge customer ID") + return translateToAccount(result) + } catch (error) { + return parseRepositoryError(error) + } + } + return { persistNew, findByUserId, @@ -182,6 +226,9 @@ export const AccountsRepository = (): IAccountsRepository => { findByUsername, findByNpub, update, + updateBridgeFields, + findByBridgeEthereumAddress, + findByBridgeCustomerId, } } @@ -243,4 +290,7 @@ const translateToAccount = (result: AccountRecord): Account => ({ kratosUserId: result.kratosUserId as UserId, displayCurrency: (result.displayCurrency || UsdDisplayCurrency) as DisplayCurrency, + bridgeCustomerId: result.bridgeCustomerId as BridgeCustomerId | undefined, + bridgeKycStatus: (result.bridgeKycStatus === "pending" ? "open" : result.bridgeKycStatus) as Account["bridgeKycStatus"], + bridgeEthereumAddress: result.bridgeEthereumAddress, }) diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts new file mode 100644 index 000000000..c69e892fc --- /dev/null +++ b/src/services/mongoose/bridge-accounts.ts @@ -0,0 +1,396 @@ +import { + BridgeVirtualAccountId, + BridgeExternalAccountId, + BridgeTransferId, +} from "@domain/primitives/bridge" +import { RepositoryError } from "@domain/errors" + +import { BridgeVirtualAccount, BridgeExternalAccount, BridgeWithdrawal } from "./schema" + +// ============ Virtual Accounts ============ + +export const createVirtualAccount = async (data: { + accountId: string + bridgeVirtualAccountId: string + bankName: string + routingNumber: string + accountNumber: string + accountNumberLast4: string +}) => { + try { + // Atomic upsert: if a doc for this accountId already exists (concurrent call won the + // race), $setOnInsert is skipped and we get back the winner's record — no duplicate. + const record = await BridgeVirtualAccount.findOneAndUpdate( + { accountId: data.accountId }, + { $setOnInsert: data }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + return record + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findVirtualAccountByAccountId = async (accountId: string) => { + try { + const record = await BridgeVirtualAccount.findOne({ accountId }) + return record || new RepositoryError("Virtual account not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findVirtualAccountByBridgeId = async (bridgeId: BridgeVirtualAccountId) => { + try { + const record = await BridgeVirtualAccount.findOne({ + bridgeVirtualAccountId: bridgeId, + }) + return record || new RepositoryError("Virtual account not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +// ============ External Accounts ============ + +export const createExternalAccount = async (data: { + accountId: string + bridgeExternalAccountId: string + bankName: string + accountNumberLast4: string + status?: "pending" | "verified" | "failed" +}) => { + try { + const { bridgeExternalAccountId, accountId, status, ...metadata } = data + const record = await BridgeExternalAccount.findOneAndUpdate( + { bridgeExternalAccountId, accountId }, + { + $setOnInsert: { bridgeExternalAccountId, accountId }, + $set: { ...metadata, status: status ?? "pending", updatedAt: new Date() }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + return record + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findExternalAccountsByAccountId = async (accountId: string) => { + try { + const records = await BridgeExternalAccount.find({ accountId }) + return records + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const markExternalAccountsMissingFromBridge = async ( + accountId: string, + bridgeExternalAccountIds: string[], +) => { + try { + const filter: Record = { + accountId, + status: { $ne: "failed" }, + } + if (bridgeExternalAccountIds.length > 0) { + filter.bridgeExternalAccountId = { $nin: bridgeExternalAccountIds } + } + + return await BridgeExternalAccount.updateMany(filter, { + status: "failed", + updatedAt: new Date(), + }) + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateExternalAccountStatus = async ( + bridgeId: BridgeExternalAccountId, + status: "pending" | "verified" | "failed", +) => { + try { + const record = await BridgeExternalAccount.findOneAndUpdate( + { bridgeExternalAccountId: bridgeId }, + { status }, + { new: true }, + ) + return record || new RepositoryError("External account not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +// ============ Withdrawals ============ + +export const BRIDGE_WITHDRAWAL_NOT_FOUND = "Withdrawal not found" + +export const BRIDGE_FAILURE_REASON_MAX_LENGTH = 512 + +export const truncateBridgeFailureReason = ( + reason: string | undefined, +): string | undefined => { + if (reason === undefined) return undefined + if (reason.length <= BRIDGE_FAILURE_REASON_MAX_LENGTH) return reason + return `${reason.slice(0, BRIDGE_FAILURE_REASON_MAX_LENGTH - 3)}...` +} + +export const bridgeWithdrawalRecordId = (record: { + id?: string + _id?: { toString(): string } +}): string => { + if (record.id) return record.id + if (record._id) return record._id.toString() + return "" +} + +export const createWithdrawal = async (data: { + accountId: string + bridgeTransferId?: string + amount: string + currency: string + externalAccountId: string + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + status?: "pending" | "completed" | "failed" +}) => { + try { + const record = await BridgeWithdrawal.create(data) + return record + } catch (error: unknown) { + const mongoErr = error as { code?: number } + if (mongoErr.code === 11000) { + const record = await BridgeWithdrawal.findOne({ + accountId: data.accountId, + externalAccountId: data.externalAccountId, + amount: data.amount, + currency: data.currency, + status: "pending", + }) + if (record) return record + } + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalFeeEstimates = async ( + id: string, + fees: { + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + }, +) => { + try { + const record = await BridgeWithdrawal.findByIdAndUpdate( + id, + { + flashFeePercent: fees.flashFeePercent, + flashFee: fees.flashFee, + estimatedBridgeFeePercent: fees.estimatedBridgeFeePercent, + estimatedBridgeFee: fees.estimatedBridgeFee, + estimatedGasBuffer: fees.estimatedGasBuffer, + estimatedCustomerFee: fees.estimatedCustomerFee, + updatedAt: new Date(), + }, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findPendingWithdrawalWithoutTransfer = async ( + accountId: string, + externalAccountId: string, + amount: string, +) => { + try { + const record = await BridgeWithdrawal.findOne({ + accountId, + externalAccountId, + amount, + bridgeTransferId: { $exists: false }, + status: "pending", + }) + return record // null when no in-flight row exists + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalTransferId = async ( + id: string, + bridgeTransferId: string, + amount: string, + currency: string, + bridgeDepositAddress?: string, + receiptFees?: { + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string + }, +) => { + try { + const update: Record = { + bridgeTransferId, + amount, + currency, + bridgeDeveloperFee: receiptFees?.bridgeDeveloperFee, + bridgeExchangeFee: receiptFees?.bridgeExchangeFee, + subtotalAmount: receiptFees?.subtotalAmount, + finalAmount: receiptFees?.finalAmount, + status: "submitted", + updatedAt: new Date(), + } + if (bridgeDepositAddress) update.bridgeDepositAddress = bridgeDepositAddress + + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "pending", bridgeTransferId: { $exists: false } }, + update, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or already submitted") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalOnchainSend = async ( + id: string, + ibexPayoutId: string | undefined, + ibexTxHash?: string, +) => { + try { + const update: Record = { + status: "usdt_sent", + updatedAt: new Date(), + } + if (ibexPayoutId) update.ibexPayoutId = ibexPayoutId + if (ibexTxHash) update.ibexTxHash = ibexTxHash + + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "submitted", ibexPayoutId: { $exists: false } }, + update, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or already sent") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalSendFailed = async ( + id: string, + bridgeTransferId: string, + amount: string, + currency: string, + bridgeDepositAddress: string, + failureReason: string, +) => { + try { + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "submitted", ibexPayoutId: { $exists: false } }, + { + bridgeTransferId, + amount, + currency, + bridgeDepositAddress, + status: "send_failed", + failureReason: truncateBridgeFailureReason(failureReason), + updatedAt: new Date(), + }, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or already sent") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findWithdrawalsByAccountId = async (accountId: string) => { + try { + const records = await BridgeWithdrawal.find({ accountId }).sort({ createdAt: -1 }) + return records + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalStatus = async ( + bridgeTransferId: BridgeTransferId, + status: "pending" | "completed" | "failed", + failureReason?: string, +) => { + try { + const update: Record = { status, updatedAt: new Date() } + const truncatedReason = truncateBridgeFailureReason(failureReason) + if (truncatedReason !== undefined) update.failureReason = truncatedReason + + const record = await BridgeWithdrawal.findOneAndUpdate( + { bridgeTransferId, status: { $in: ["submitted", "usdt_sent"] } }, + update, + { new: true }, + ) + if (record) return record + + const existing = await BridgeWithdrawal.findOne({ bridgeTransferId }) + if (!existing) return new RepositoryError(BRIDGE_WITHDRAWAL_NOT_FOUND) + + // Idempotent: duplicate webhook after we already reached this terminal status. + if (existing.status === status) return existing + + return new RepositoryError( + `Withdrawal already ${existing.status}, cannot transition to ${status}`, + ) + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findWithdrawalByBridgeTransferId = async (transferId: BridgeTransferId) => { + try { + const record = await BridgeWithdrawal.findOne({ bridgeTransferId: transferId }) + return record || new RepositoryError("Withdrawal not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findWithdrawalById = async (id: string) => { + try { + const record = await BridgeWithdrawal.findById(id) + return record || new RepositoryError("Withdrawal not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const cancelWithdrawal = async (accountId: string, withdrawalId: string) => { + try { + const record = await BridgeWithdrawal.findOneAndUpdate( + { + _id: withdrawalId, + accountId, + status: "pending", + bridgeTransferId: { $exists: false }, + }, + { status: "cancelled", updatedAt: new Date() }, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or cannot be cancelled") + } catch (error) { + return new RepositoryError(String(error)) + } +} diff --git a/src/services/mongoose/bridge-deposit-log.ts b/src/services/mongoose/bridge-deposit-log.ts new file mode 100644 index 000000000..316fe2aac --- /dev/null +++ b/src/services/mongoose/bridge-deposit-log.ts @@ -0,0 +1,26 @@ +import { BridgeDeposits } from "./schema" + +export const createBridgeDeposit = async (data: { + eventId: string + transferId: string + customerId: string + state: string + amount: string + currency: string + developerFee: string + subtotalAmount?: string + initialAmount?: string + finalAmount?: string + destinationTxHash?: string +}): Promise<{ id: string } | Error> => { + try { + const log = await BridgeDeposits.findOneAndUpdate( + { eventId: data.eventId }, + { $setOnInsert: data }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + return { id: log._id.toString() } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/mongoose/bridge-reconciliation-orphan.ts b/src/services/mongoose/bridge-reconciliation-orphan.ts new file mode 100644 index 000000000..4c63a649f --- /dev/null +++ b/src/services/mongoose/bridge-reconciliation-orphan.ts @@ -0,0 +1,105 @@ +import { BridgeReconciliationOrphan } from "./schema" + +type OrphanType = + | "bridge_without_ibex" + | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" +type OrphanStatus = "unmatched" | "resolved" + +export const upsertBridgeReconciliationOrphan = async (data: { + orphanKey: string + orphanType: OrphanType + transferId?: string + txHash?: string + bridgeEventId?: string + customerId?: string + amount?: string + currency?: string + triageContext: Record +}): Promise<{ id: string } | Error> => { + try { + const orphan = await BridgeReconciliationOrphan.findOneAndUpdate( + { orphanKey: data.orphanKey }, + { ...data, status: "unmatched", detectedAt: new Date() }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + + return { id: orphan._id.toString() } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const resolveOrphansByTxHash = async ( + txHash: string, +): Promise<{ resolvedCount: number } | Error> => { + try { + const now = new Date() + const result = await BridgeReconciliationOrphan.updateMany( + { + txHash: txHash.toLowerCase(), + status: "unmatched", + }, + { $set: { status: "resolved", resolvedAt: now } }, + ) + return { resolvedCount: result.modifiedCount } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const findOrphans = async ({ + status, + orphanType, + limit = 50, +}: { + status?: OrphanStatus + orphanType?: OrphanType + limit?: number +} = {}): Promise< + | { + id: string + orphanKey: string + orphanType: OrphanType + status: OrphanStatus + transferId?: string + txHash?: string + customerId?: string + amount?: string + currency?: string + triageContext: Record + detectedAt: Date + resolvedAt?: Date + }[] + | Error +> => { + try { + const filter: Record = {} + if (status) filter.status = status + if (orphanType) filter.orphanType = orphanType + + const docs = await BridgeReconciliationOrphan.find(filter) + .sort({ detectedAt: -1 }) + .limit(limit) + .lean() + .exec() + + return docs.map((d) => ({ + id: (d._id as { toString(): string }).toString(), + orphanKey: d.orphanKey as string, + orphanType: d.orphanType as OrphanType, + status: (d.status ?? "unmatched") as OrphanStatus, + transferId: d.transferId as string | undefined, + txHash: d.txHash as string | undefined, + customerId: d.customerId as string | undefined, + amount: d.amount as string | undefined, + currency: d.currency as string | undefined, + triageContext: d.triageContext as Record, + detectedAt: d.detectedAt as Date, + resolvedAt: d.resolvedAt as Date | undefined, + })) + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/mongoose/bridge-replay-log.ts b/src/services/mongoose/bridge-replay-log.ts new file mode 100644 index 000000000..361994991 --- /dev/null +++ b/src/services/mongoose/bridge-replay-log.ts @@ -0,0 +1,42 @@ +import { BridgeReplay } from "./schema" + +export const createBridgeReplay = async (data: { + eventId: string + eventType: string + eventPayload: Record + bridgeEventCreatedAt: Date + replayedAt: Date + operator: string + timeWindowStart: Date + timeWindowEnd: Date + httpStatus: number + httpResponse: Record + dryRun?: boolean +}): Promise<{ id: string } | Error> => { + try { + const log = await BridgeReplay.create(data) + + return { id: log._id.toString() } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const findBridgeReplays = async (filter?: { + eventType?: string + dryRun?: boolean + limit?: number +}): Promise => { + try { + const queryFilter: Record = {} + if (filter?.eventType !== undefined) queryFilter.eventType = filter.eventType + if (filter?.dryRun !== undefined) queryFilter.dryRun = filter.dryRun + + return await BridgeReplay.find(queryFilter) + .sort({ replayedAt: -1 }) + .limit(filter?.limit ?? 100) + .lean() + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts new file mode 100644 index 000000000..f5c9e8da0 --- /dev/null +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -0,0 +1,343 @@ +import { randomUUID } from "crypto" + +import { CouldNotUpdateError } from "@domain/errors" + +import { parseRepositoryError } from "./utils" +import { CashWalletCutoverConfig, CashWalletMigration } from "./schema" + +const CONFIG_ID = "cash_wallet_cutover" + +const TERMINAL_STATUSES: CashWalletMigrationStatus[] = [ + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", +] + +type UpsertMigrationArgs = { + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId?: WalletId + cutoverVersion: number + runId: string + idempotencyKey: string +} + +type TransitionMigrationArgs = { + id: string + from: CashWalletMigrationStatus + to: CashWalletMigrationStatus + cutoverVersion: number + runId: string + patch?: Partial> +} + +type LockMigrationArgs = { + id: string + workerId: string + staleBefore: Date + cutoverVersion: number + runId: string +} + +type MarkMigrationFailedArgs = Omit & { + error: Error + status: "failed" | "requires_operator_review" +} + +const defaultConfig = (): CashWalletCutoverConfig => ({ + state: "pre", + cutoverVersion: 1, + updatedAt: new Date(0), +}) + +const resultToConfig = ( + record: CashWalletCutoverConfigRecord, +): CashWalletCutoverConfig => ({ + state: record.state, + scheduledAt: record.scheduledAt, + startedAt: record.startedAt, + completedAt: record.completedAt, + pausedAt: record.pausedAt, + pauseReason: record.pauseReason, + updatedBy: record.updatedBy, + cutoverVersion: record.cutoverVersion, + runId: record.runId, + updatedAt: record.updatedAt, +}) + +const resultToMigration = (record: CashWalletMigrationRecord): CashWalletMigration => ({ + id: record._id, + accountId: record.accountId as AccountId, + accountUuid: record.accountUuid as AccountUuid | undefined, + legacyUsdWalletId: record.legacyUsdWalletId as WalletId, + destinationUsdtWalletId: record.destinationUsdtWalletId as WalletId, + previousDefaultWalletId: record.previousDefaultWalletId as WalletId | undefined, + cutoverVersion: record.cutoverVersion, + runId: record.runId, + status: record.status, + sourceBalanceUsdCents: record.sourceBalanceUsdCents, + destinationAmountUsdtMicros: record.destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros: record.destinationStartingBalanceUsdtMicros, + feeAmountUsdCents: record.feeAmountUsdCents, + feeAmountUsdtMicros: record.feeAmountUsdtMicros, + balanceMoveInvoicePaymentRequest: record.balanceMoveInvoicePaymentRequest, + balanceMoveInvoicePaymentHash: record.balanceMoveInvoicePaymentHash, + balanceMovePaymentTransactionId: record.balanceMovePaymentTransactionId, + feeReimbursementInvoicePaymentRequest: record.feeReimbursementInvoicePaymentRequest, + feeReimbursementInvoicePaymentHash: record.feeReimbursementInvoicePaymentHash, + feeReimbursementPaymentTransactionId: record.feeReimbursementPaymentTransactionId, + estimatedFee: record.estimatedFee, + idempotencyKey: record.idempotencyKey, + attempts: record.attempts, + lastError: record.lastError, + lockedAt: record.lockedAt, + lockedBy: record.lockedBy, + startedAt: record.startedAt, + completedAt: record.completedAt, + updatedAt: record.updatedAt, +}) + +export const CashWalletCutoverRepository = () => { + const getConfig = async (): Promise => { + try { + const result = await CashWalletCutoverConfig.findById(CONFIG_ID) + if (!result) return defaultConfig() + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const updateConfig = async ( + patch: Partial, + actor?: string, + ): Promise => { + try { + const $set: Record = { updatedBy: actor, updatedAt: new Date() } + const $unset: Record = {} + + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + $unset[key] = 1 + } else { + $set[key] = value + } + } + + const update = Object.keys($unset).length > 0 ? { $set, $unset } : { $set } + + const result = await CashWalletCutoverConfig.findOneAndUpdate( + { _id: CONFIG_ID }, + update, + { upsert: true, new: true }, + ) + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const upsertMigration = async ( + args: UpsertMigrationArgs, + ): Promise => { + try { + const now = new Date() + const result = await CashWalletMigration.findOneAndUpdate( + { accountId: args.accountId, runId: args.runId }, + { + $setOnInsert: { + _id: randomUUID(), + ...args, + status: "not_started", + attempts: 0, + updatedAt: now, + }, + }, + { upsert: true, new: true }, + ) + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const findMigrationByAccountId = async ({ + accountId, + cutoverVersion, + runId, + }: { + accountId: AccountId + cutoverVersion: number + runId: string + }): Promise => { + try { + const result = await CashWalletMigration.findOne({ + accountId, + cutoverVersion, + runId, + }) + if (!result) return null + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const transitionMigration = async ({ + id, + from, + to, + cutoverVersion, + runId, + patch = {}, + }: TransitionMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, status: from, cutoverVersion, runId }, + { $set: { ...patch, status: to, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) + return new CouldNotUpdateError("Could not transition cash wallet migration") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const acquireMigrationLock = async ({ + id, + workerId, + staleBefore, + cutoverVersion, + runId, + }: LockMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { + _id: id, + cutoverVersion, + runId, + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], + }, + { $set: { lockedAt: new Date(), lockedBy: workerId, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) + return new CouldNotUpdateError("Could not acquire cash wallet migration lock") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const releaseMigrationLock = async ({ + id, + workerId, + cutoverVersion, + runId, + }: Omit): Promise< + CashWalletMigration | RepositoryError + > => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, lockedBy: workerId, cutoverVersion, runId }, + { $set: { lockedAt: null, lockedBy: null, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) + return new CouldNotUpdateError("Could not release cash wallet migration lock") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const markMigrationFailed = async ({ + id, + workerId, + cutoverVersion, + runId, + error, + status, + }: MarkMigrationFailedArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, lockedBy: workerId, cutoverVersion, runId }, + { + $set: { + status, + lastError: error.message, + lockedAt: null, + lockedBy: null, + updatedAt: new Date(), + }, + $inc: { attempts: 1 }, + }, + { new: true }, + ) + if (!result) + return new CouldNotUpdateError("Could not mark cash wallet migration failed") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const listRunnableMigrations = async ({ + cutoverVersion, + runId, + limit, + }: { + cutoverVersion: number + runId: string + limit?: number + }): Promise => { + try { + const results = await CashWalletMigration.find({ + cutoverVersion, + runId, + status: { $nin: TERMINAL_STATUSES }, + }) + .sort({ updatedAt: 1 }) + .limit(limit ?? 0) + return results.map(resultToMigration) + } catch (err) { + return parseRepositoryError(err) + } + } + + const countByStatus = async ({ + cutoverVersion, + runId, + status, + }: { + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + }): Promise => { + try { + return CashWalletMigration.countDocuments({ cutoverVersion, runId, status }) + } catch (err) { + return parseRepositoryError(err) + } + } + + return { + getConfig, + updateConfig, + upsertMigration, + findMigrationByAccountId, + transitionMigration, + acquireMigrationLock, + releaseMigrationLock, + markMigrationFailed, + listRunnableMigrations, + countByStatus, + } +} diff --git a/src/services/mongoose/ibex-crypto-receive-log.ts b/src/services/mongoose/ibex-crypto-receive-log.ts new file mode 100644 index 000000000..490330f09 --- /dev/null +++ b/src/services/mongoose/ibex-crypto-receive-log.ts @@ -0,0 +1,51 @@ +import { IbexCryptoReceive } from "./schema" + +export const createIbexCryptoReceive = async (data: { + txHash: string + address: string + amount: string + currency: string + network: string + accountId?: string +}): Promise<{ id: string } | Error> => { + try { + const log = await IbexCryptoReceive.findOneAndUpdate( + { txHash: data.txHash }, + { ...data, receivedAt: new Date() }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) + + return { id: log._id.toString() } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const findIbexCryptoReceivesSince = async ({ + since, + until = new Date(), +}: { + since: Date + until?: Date +}): Promise< + | Array<{ + txHash: string + address: string + amount: string + currency: string + network: string + accountId?: string + receivedAt: Date + }> + | Error +> => { + try { + return await IbexCryptoReceive.find({ + receivedAt: { $gte: since, $lte: until }, + }) + .sort({ receivedAt: -1 }) + .lean() + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/mongoose/index.ts b/src/services/mongoose/index.ts index 8e5be4f68..e0cb83509 100644 --- a/src/services/mongoose/index.ts +++ b/src/services/mongoose/index.ts @@ -8,3 +8,4 @@ export * from "./wallet-invoices" export * from "./wallet-on-chain-addresses" export * from "./wallet-onchain-pending-receive" export * from "./merchants" +export * from "./cash-wallet-cutover" diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 6f490709b..43d619335 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -1,6 +1,11 @@ import crypto from "crypto" -import { getDefaultAccountsConfig, getFeesConfig, getDefaultFCMTopics, Levels } from "@config" +import { + getDefaultAccountsConfig, + getFeesConfig, + getDefaultFCMTopics, + Levels, +} from "@config" import { AccountStatus, UsernameRegex } from "@domain/accounts" import { WalletIdRegex, WalletType } from "@domain/wallets" import { WalletCurrency } from "@domain/shared" @@ -17,6 +22,59 @@ import { WalletRecord } from "./wallets" const Schema = mongoose.Schema +// Bridge Record Interfaces +interface IBridgeVirtualAccountRecord { + accountId: string + bridgeVirtualAccountId: string + bankName: string + routingNumber: string + accountNumber: string + accountNumberLast4: string + createdAt: Date +} + +interface IBridgeExternalAccountRecord { + accountId: string + bridgeExternalAccountId: string + bankName: string + accountNumberLast4: string + status: "pending" | "verified" | "failed" + createdAt: Date + updatedAt: Date +} + +interface IBridgeWithdrawalRecord { + accountId: string + bridgeTransferId?: string + bridgeDepositAddress?: string + ibexPayoutId?: string + ibexTxHash?: string + amount: string + currency: string + status: + | "pending" + | "submitted" + | "usdt_sent" + | "send_failed" + | "completed" + | "failed" + | "cancelled" + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string + failureReason?: string + externalAccountId: string + createdAt: Date + updatedAt: Date +} + const dbMetadataSchema = new Schema({ routingFeeLastEntry: Date, // TODO: rename to routingRevenueLastEntry }) @@ -295,6 +353,31 @@ const AccountSchema = new Schema( }, displayCurrency: String, // FIXME: should be an enum + + bridgeCustomerId: { + type: String, + required: false, + }, + bridgeKycStatus: { + type: String, + enum: [ + "open", + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + "approved", + "rejected", + "offboarded", + ], + required: false, + }, + bridgeEthereumAddress: { + type: String, + required: false, + }, }, { id: false }, ) @@ -304,6 +387,8 @@ AccountSchema.index({ coordinates: 1, }) +AccountSchema.index({ bridgeEthereumAddress: 1 }, { sparse: true }) + export const Account = mongoose.model("Account", AccountSchema) const QuizSchema = new Schema({ @@ -566,4 +651,255 @@ export const WalletOnChainPendingReceive = "WalletOnChainPendingReceive", WalletOnChainPendingReceiveSchema, ) - \ No newline at end of file + +const CashWalletCutoverConfigSchema = new Schema({ + _id: { type: String, default: "cash_wallet_cutover" }, + state: { type: String, enum: ["pre", "in_progress", "complete"], required: true }, + scheduledAt: Date, + startedAt: Date, + completedAt: Date, + pausedAt: Date, + pauseReason: String, + updatedBy: String, + cutoverVersion: { type: Number, required: true, default: 1 }, + runId: String, + updatedAt: { type: Date, default: Date.now }, +}) + +export const CashWalletCutoverConfig = mongoose.model( + "CashWalletCutoverConfig", + CashWalletCutoverConfigSchema, +) + +const CashWalletMigrationSchema = new Schema({ + _id: { type: String, required: true }, + accountId: { type: String, required: true, index: true }, + accountUuid: String, + legacyUsdWalletId: { type: String, required: true }, + destinationUsdtWalletId: { type: String, required: true }, + previousDefaultWalletId: String, + cutoverVersion: { type: Number, required: true, index: true }, + runId: { type: String, required: true, index: true }, + status: { type: String, required: true, index: true }, + sourceBalanceUsdCents: String, + destinationAmountUsdtMicros: String, + destinationStartingBalanceUsdtMicros: String, + feeAmountUsdCents: String, + feeAmountUsdtMicros: String, + balanceMoveInvoicePaymentRequest: String, + balanceMoveInvoicePaymentHash: String, + balanceMovePaymentTransactionId: String, + feeReimbursementInvoicePaymentRequest: String, + feeReimbursementInvoicePaymentHash: String, + feeReimbursementPaymentTransactionId: String, + estimatedFee: Boolean, + idempotencyKey: { type: String, required: true }, + attempts: { type: Number, default: 0 }, + lastError: String, + lockedAt: Date, + lockedBy: String, + startedAt: Date, + completedAt: Date, + updatedAt: { type: Date, default: Date.now, index: true }, +}) + +CashWalletMigrationSchema.index({ accountId: 1, runId: 1 }, { unique: true }) +CashWalletMigrationSchema.index({ idempotencyKey: 1 }, { unique: true }) +CashWalletMigrationSchema.index({ cutoverVersion: 1, status: 1, updatedAt: 1 }) +CashWalletMigrationSchema.index({ runId: 1, status: 1 }) +CashWalletMigrationSchema.index({ lockedAt: 1 }) + +export const CashWalletMigration = mongoose.model( + "CashWalletMigration", + CashWalletMigrationSchema, +) + +const BridgeVirtualAccountSchema = new Schema({ + // unique: true enforces one VA per account at the DB layer — idempotency guard + accountId: { type: String, required: true, unique: true }, + bridgeVirtualAccountId: { type: String, required: true, unique: true }, + bankName: { type: String, required: true }, + routingNumber: { type: String, required: true }, + accountNumber: { type: String, required: true }, + accountNumberLast4: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, +}) + +const BridgeExternalAccountSchema = new Schema({ + accountId: { type: String, required: true, index: true }, + bridgeExternalAccountId: { type: String, required: true, unique: true }, + bankName: { type: String, required: true }, + accountNumberLast4: { type: String, required: true }, + status: { type: String, enum: ["pending", "verified", "failed"], default: "pending" }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, +}) + +// CRIT-2 (ENG-281): Compound index enforces that a given bridgeExternalAccountId +// can only be associated with one accountId at the DB layer, preventing cross-account +// withdrawal attacks even if application-layer ownership checks are bypassed. +BridgeExternalAccountSchema.index( + { accountId: 1, bridgeExternalAccountId: 1 }, + { unique: true }, +) + +const BridgeWithdrawalSchema = new Schema({ + accountId: { type: String, required: true, index: true }, + bridgeTransferId: { type: String, unique: true, sparse: true }, + bridgeDepositAddress: { type: String }, + ibexPayoutId: { type: String, unique: true, sparse: true }, + ibexTxHash: { type: String }, + amount: { type: String, required: true }, + currency: { type: String, required: true }, + status: { + type: String, + enum: [ + "pending", + "submitted", + "usdt_sent", + "send_failed", + "completed", + "failed", + "cancelled", + ], + default: "pending", + }, + flashFeePercent: { type: String, required: true }, + flashFee: { type: String, required: true }, + estimatedBridgeFeePercent: { type: String, required: true }, + estimatedBridgeFee: { type: String, required: true }, + estimatedGasBuffer: { type: String, required: true }, + estimatedCustomerFee: { type: String, required: true }, + bridgeDeveloperFee: { type: String }, + bridgeExchangeFee: { type: String }, + subtotalAmount: { type: String }, + finalAmount: { type: String }, + failureReason: { type: String, maxlength: 512 }, + externalAccountId: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, +}) + +// At most one pending row per (account, destination, amount, currency). Partial filter must +// not use $exists:false — MongoDB rejects it for partial indexes ("$not ... $exists"). +// "pending" alone is enough: completed/failed rows are excluded so the same tuple can repeat +// after a terminal status. +BridgeWithdrawalSchema.index( + { accountId: 1, externalAccountId: 1, amount: 1, currency: 1 }, + { + unique: true, + partialFilterExpression: { status: "pending" }, + }, +) + +const BridgeDepositsSchema = new Schema({ + eventId: { type: String, required: true, unique: true }, + transferId: { type: String, required: true }, + customerId: { type: String, required: true }, + state: { type: String, required: true }, + amount: { type: String, required: true }, + currency: { type: String, required: true }, + subtotalAmount: { type: String }, + developerFee: { type: String, required: true, default: "0" }, + initialAmount: { type: String }, + finalAmount: { type: String }, + destinationTxHash: { type: String }, + createdAt: { type: Date, default: Date.now }, +}) + +BridgeDepositsSchema.index({ transferId: 1 }) +BridgeDepositsSchema.index({ customerId: 1, createdAt: -1 }) + +export const BridgeDeposits = mongoose.model("BridgeDeposits", BridgeDepositsSchema) + +const IbexCryptoReceiveSchema = new Schema({ + txHash: { type: String, required: true, unique: true }, + address: { type: String, required: true }, + amount: { type: String, required: true }, + currency: { type: String, required: true }, + network: { type: String, required: true }, + accountId: { type: String }, + receivedAt: { type: Date, default: Date.now }, +}) + +IbexCryptoReceiveSchema.index({ receivedAt: -1 }) +IbexCryptoReceiveSchema.index({ address: 1, receivedAt: -1 }) + +export const IbexCryptoReceive = mongoose.model( + "IbexCryptoReceive", + IbexCryptoReceiveSchema, +) + +const BridgeReconciliationOrphanSchema = new Schema({ + orphanKey: { type: String, required: true, unique: true }, + orphanType: { + type: String, + enum: [ + "bridge_without_ibex", + "ibex_without_bridge", + "bridge_transfer_without_ibex_send", + "ibex_send_without_bridge_settlement", + ], + required: true, + }, + status: { + type: String, + enum: ["unmatched", "resolved"], + default: "unmatched", + required: true, + }, + transferId: { type: String }, + txHash: { type: String }, + bridgeEventId: { type: String }, + customerId: { type: String }, + amount: { type: String }, + currency: { type: String }, + triageContext: { type: Schema.Types.Mixed, required: true }, + detectedAt: { type: Date, default: Date.now }, + resolvedAt: { type: Date }, +}) + +BridgeReconciliationOrphanSchema.index({ orphanType: 1, detectedAt: -1 }) +BridgeReconciliationOrphanSchema.index({ detectedAt: -1 }) +BridgeReconciliationOrphanSchema.index({ status: 1, detectedAt: -1 }) +BridgeReconciliationOrphanSchema.index({ txHash: 1 }) + +export const BridgeReconciliationOrphan = mongoose.model( + "BridgeReconciliationOrphan", + BridgeReconciliationOrphanSchema, +) + +const BridgeReplaySchema = new Schema({ + eventId: { type: String, required: true }, + eventType: { type: String, required: true }, + eventPayload: { type: Schema.Types.Mixed, required: true }, + bridgeEventCreatedAt: { type: Date, required: true }, + replayedAt: { type: Date, required: true, default: Date.now }, + operator: { type: String, required: true }, + timeWindowStart: { type: Date, required: true }, + timeWindowEnd: { type: Date, required: true }, + httpStatus: { type: Number, required: true }, + httpResponse: { type: Schema.Types.Mixed, required: true }, + dryRun: { type: Boolean, required: true, default: false }, +}) + +BridgeReplaySchema.index({ eventId: 1 }) +BridgeReplaySchema.index({ replayedAt: -1 }) +BridgeReplaySchema.index({ eventType: 1, replayedAt: -1 }) + +export const BridgeReplay = mongoose.model("BridgeReplay", BridgeReplaySchema) + +export const BridgeVirtualAccount = mongoose.model( + "BridgeVirtualAccount", + BridgeVirtualAccountSchema, +) + +export const BridgeExternalAccount = mongoose.model( + "BridgeExternalAccount", + BridgeExternalAccountSchema, +) + +export const BridgeWithdrawal = mongoose.model( + "BridgeWithdrawal", + BridgeWithdrawalSchema, +) diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 1a9849006..332c918f5 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -94,10 +94,61 @@ interface AccountRecord { title?: string coordinates?: CoordinateObjectForUser + // Bridge integration: + bridgeCustomerId?: string + bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" + bridgeEthereumAddress?: string + // mongoose in-built functions save: () => Promise } +interface CashWalletCutoverConfigRecord { + _id: string + state: CashWalletCutoverState + scheduledAt?: Date + startedAt?: Date + completedAt?: Date + pausedAt?: Date + pauseReason?: string + updatedBy?: string + cutoverVersion: number + runId?: string + updatedAt: Date +} + +interface CashWalletMigrationRecord { + _id: string + accountId: string + accountUuid?: string + legacyUsdWalletId: string + destinationUsdtWalletId: string + previousDefaultWalletId?: string + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string + feeAmountUsdCents?: string + feeAmountUsdtMicros?: string + balanceMoveInvoicePaymentRequest?: string + balanceMoveInvoicePaymentHash?: string + balanceMovePaymentTransactionId?: string + feeReimbursementInvoicePaymentRequest?: string + feeReimbursementInvoicePaymentHash?: string + feeReimbursementPaymentTransactionId?: string + estimatedFee?: boolean + idempotencyKey: string + attempts: number + lastError?: string + lockedAt?: Date + lockedBy?: string + startedAt?: Date + completedAt?: Date + updatedAt: Date +} + interface LocationRecord { type: "Point" coordinates: CoordinateRecord diff --git a/src/services/mongoose/wallets.ts b/src/services/mongoose/wallets.ts index 30f5444f5..80da0dcce 100644 --- a/src/services/mongoose/wallets.ts +++ b/src/services/mongoose/wallets.ts @@ -1,27 +1,31 @@ +import { randomUUID } from "crypto" + import { CouldNotFindWalletFromIdError, CouldNotFindWalletFromOnChainAddressError, CouldNotFindWalletFromOnChainAddressesError, - CouldNotListWalletsFromAccountIdError, CouldNotListWalletsFromWalletCurrencyError, InvalidLnurlError, RepositoryError, UnsupportedCurrencyError, } from "@domain/errors" import { Types } from "mongoose" -import { randomUUID } from "crypto" // FLASH FORK: import IBEX routes and helper import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +import { ErrorLevel, USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { WalletType } from "@domain/wallets" + + import { toObjectId, fromObjectId, parseRepositoryError } from "./utils" import { Wallet } from "./schema" import { AccountsRepository } from "./accounts" -import { recordExceptionInCurrentSpan } from "@services/tracing" -import { ErrorLevel, USDAmount, WalletCurrency } from "@domain/shared" -import { WalletType } from "@domain/wallets" export interface WalletRecord { id: string @@ -32,6 +36,19 @@ export interface WalletRecord { lnurlp: string } +const getIbexCurrencyId = ( + currency: WalletCurrency, +): IbexCurrencyId | UnsupportedCurrencyError => { + switch (currency) { + case WalletCurrency.Usd: + return USDAmount.currencyId + case WalletCurrency.Usdt: + return USDTAmount.currencyId + default: + return new UnsupportedCurrencyError(`Unsupported IBEX wallet currency: ${currency}`) + } +} + export const WalletsRepository = (): IWalletsRepository => { const persistNew = async ({ accountId, @@ -42,12 +59,14 @@ export const WalletsRepository = (): IWalletsRepository => { if (account instanceof Error) return account try { - let currencyId = USDAmount.currencyId + const currencyId = getIbexCurrencyId(currency) + if (currencyId instanceof Error) return currencyId const resp = await Ibex.createAccount(accountId, currencyId) if (resp instanceof IbexError) return resp const ibexAccountId = resp.id + let lnurlp: string | undefined if (ibexAccountId !== undefined) { const lnurlResp = await Ibex.createLnurlPay({ @@ -63,8 +82,7 @@ export const WalletsRepository = (): IWalletsRepository => { ibexAccountId, }, }) - } - else lnurlp = lnurlResp.lnurl + } else lnurlp = lnurlResp.lnurl } const wallet = new Wallet({ @@ -72,7 +90,7 @@ export const WalletsRepository = (): IWalletsRepository => { id: ibexAccountId, type, currency, - lnurlp + lnurlp, }) await wallet.save() return resultToWallet(wallet) @@ -101,7 +119,7 @@ export const WalletsRepository = (): IWalletsRepository => { _accountId: toObjectId(accountId), }) if (!result || result.length === 0) { - return new CouldNotListWalletsFromAccountIdError(`accountId: ${accountId}}`) + return [] } return result.map(resultToWallet) } catch (err) { @@ -162,7 +180,7 @@ export const WalletsRepository = (): IWalletsRepository => { lnurlp, }: { accountId: AccountId - currency: WalletCurrency + currency?: WalletCurrency lnurlp: Lnurl }): Promise => { if (!lnurlp.toLowerCase().startsWith("lnurl1")) { @@ -173,7 +191,7 @@ export const WalletsRepository = (): IWalletsRepository => { { _accountId: toObjectId(accountId), type: WalletType.External }, { $set: { lnurlp }, - $setOnInsert: { id: randomUUID(), currency: currency }, + $setOnInsert: { id: randomUUID(), currency: currency ?? WalletCurrency.Btc }, }, { upsert: true, new: true }, ) diff --git a/test/flash/bridge-sandbox-e2e/README.md b/test/flash/bridge-sandbox-e2e/README.md new file mode 100644 index 000000000..f43f89b0c --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/README.md @@ -0,0 +1,208 @@ +# Bridge Sandbox E2E Tests + +This suite exercises Bridge sandbox flows through the public GraphQL schema and local webhook handlers. It is opt-in by design: normal unit and integration test runs do not execute these specs. + +## What It Covers + +- Bridge KYC initiation +- Optional Bridge virtual account creation +- Optional external account link URL generation +- External-account webhook handling +- Deposit webhook handling and idempotency +- Withdrawal validation error paths +- Optional cash wallet cutover smoke checks +- Optional ETH-USDT Lightning parity smoke checks + +The suite uses local MongoDB for test user setup, real Bridge/IBEX sandbox configuration for Bridge mutations, and direct Express handler injection for webhook tests. Webhook injection avoids requiring a public tunnel while still exercising the production route handlers. + +## Prerequisites + +Run from the repository root: + +```bash +cd /path/to/your/repo +``` + +### `.env` Setup (First Run) + +The package scripts source `.env` from the project root, then source `.env.local` when it exists. Create or update `.env` with at minimum: + +```bash +# Required +export IBEX_ENVIRONMENT=sandbox +export MONGODB_CON=mongodb://localhost:27017/flash + +# Bridge sandbox — fill from Bridge dashboard +# These are the API key and webhook secret, not stored in .env directly in production: +export BRIDGE_BASE_URL=https://api.sandbox.bridge.xyz/v0 +export BRIDGE_WEBHOOK_URL=http://localhost:4009 +``` + +### Bridge Webhook Setup + +For localhost testing, use ngrok and the setup helper: + +```bash +./dev/setup.sh --webhook +``` + +The helper: + +1. Starts or reuses `ngrok http 4009`. +2. Lists existing Bridge sandbox webhooks. +3. Deletes old active/disabled Bridge sandbox webhooks. +4. Creates fresh `kyc`, `deposit`, `transfer`, and `external_account` webhooks. +5. Copies the returned Bridge webhook public keys into `~/.config/flash/dev-overrides.yaml`. +6. Prints the command to start the local Bridge webhook server. + +The helper writes local secrets and public keys to `~/.config/flash/dev-overrides.yaml`; do not hard-code them in `dev/config/base-config.yaml`. + +### Required Setup + +- Node dependencies installed or available in the worktree (`yarn install`). +- `.env` present and sourceable by the package script. +- MongoDB available using the repo's normal test configuration. +- `IBEX_ENVIRONMENT=sandbox` in `.env`. +- Bridge sandbox webhook public keys populated in `~/.config/flash/dev-overrides.yaml`: + + ```yaml + bridge: + webhook: + publicKeys: + kyc: "" + deposit: "" + transfer: "" + external_account: "" + ``` + +- `src/services/bridge/index.ts` service guard allowing Level 1 accounts (✅ already applied in this PR). + +The setup file enforces the two safety gates: + +- `RUN_BRIDGE_SANDBOX_E2E=true` +- `IBEX_ENVIRONMENT=sandbox` + +The package scripts set `RUN_BRIDGE_SANDBOX_E2E=true` automatically, but `IBEX_ENVIRONMENT` must already be present in `.env` or exported in the shell. + +## First Run (Human Verification) + +Run from the worktree root: + +```bash +cd /path/to/your/repo +source .env +IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e +``` + +### What to check on first run + +| Layer | What to verify | If it fails | +|-------|---------------|------------| +| Preflight | Source-code check of `checkAccountLevel()` allows level ≥ 1 | `src/services/bridge/index.ts` guard must be `level < 1`, not `level < 2` | +| KYC spec | `bridgeInitiateKyc` returns `{kycLink, tosLink}` URLs | Ensure ENG-345 deployed, sandbox has Bridge customer API set up | +| Virtual account | Skipped by default; with `BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true`, `bridgeCreateVirtualAccount` returns account details | Requires a Bridge-side KYC-approved sandbox customer; local webhook injection alone does not approve the hosted Bridge customer | +| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkUrl, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for hosted bank-linking | +| Deposit webhook | Injected webhook processes and persists deposit | Verify webhook secret in config.yaml | +| Withdrawal error paths | Validation errors returned for invalid inputs | Check withdrawal schema deployed (ENG-348) | +| Withdrawal **success** path | ⚠️ **Not expected to pass first run** — requires real KYC-approved sandbox customer, funded wallet, and verified external account. | The full withdrawal flow only runs with `BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true`; error-path tests run without it | + +### If something fails + +1. Check `IBEX_ENVIRONMENT` is `sandbox` (not `production`) +2. Confirm MongoDB is running: `mongosh --eval "db.adminCommand('ping')"` +3. Run `./dev/setup.sh --webhook` again to refresh ngrok, Bridge webhook endpoints, and local public keys +4. Preflight failure → `src/services/bridge/index.ts` still has `level < 2` — apply the Task 0 fix +5. KYC/VA failures → confirm the corresponding ENG issue is deployed to sandbox + +## Commands + +Run the whole suite: + +```bash +export IBEX_ENVIRONMENT=sandbox +yarn test:bridge-sandbox-e2e +``` + +Run the CI-style variant without `pino-pretty`: + +```bash +export IBEX_ENVIRONMENT=sandbox +yarn test:bridge-sandbox-e2e:ci +``` + +Run one spec: + +```bash +export IBEX_ENVIRONMENT=sandbox +TEST=test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts yarn test:bridge-sandbox-e2e:ci +``` + +Increase timeout for slow sandbox calls: + +```bash +export IBEX_ENVIRONMENT=sandbox +JEST_TIMEOUT=240000 yarn test:bridge-sandbox-e2e:ci +``` + +## Optional Smoke Gates + +These specs are skipped unless explicitly enabled: + +```bash +export IBEX_ENVIRONMENT=sandbox +CUTOVER_TESTS=true yarn test:bridge-sandbox-e2e:ci +``` + +```bash +export IBEX_ENVIRONMENT=sandbox +LN_PARITY_TESTS=true yarn test:bridge-sandbox-e2e:ci +``` + +These Bridge-hosted success paths are also skipped unless explicitly enabled because +they require sandbox state/entitlements outside the local test harness: + +```bash +export IBEX_ENVIRONMENT=sandbox +BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true yarn test:bridge-sandbox-e2e:ci +``` + +```bash +export IBEX_ENVIRONMENT=sandbox +BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true yarn test:bridge-sandbox-e2e:ci +``` + +## Files + +- `jest.config.js` - Jest config scoped to this suite. +- `jest.setup.ts` - opt-in guards, yargs config-path mock, MongoDB setup, Redis/Mongo cleanup. +- `config-overrides.yaml` - sandbox-only non-secret overrides used by Jest after local dev overrides. +- `preflight.ts` - source check that verifies Bridge Level 1 access is not blocked by the service guard. +- `helpers.ts` - test user creation, GraphQL execution, Bridge mutation wrappers, webhook injection, ERPNext lookup, deposit lookup. +- `helpers/http-utils.ts` - mock Express request/response objects for route-handler injection. +- `kyc-virtual-account.spec.ts` - KYC link and virtual account flow. +- `external-account.spec.ts` - Plaid link URL and external-account webhook behavior. +- `deposit-withdrawal.spec.ts` - deposit webhook handling, deposit persistence, withdrawal validation paths. +- `cutover-state.spec.ts` - optional cash wallet cutover state smoke test. +- `ln-parity.spec.ts` - optional Lightning USD invoice smoke test. + +## Known Limitations + +- The external account spec verifies injected webhook behavior by default. Link URL generation is gated because some Bridge sandbox keys/customers are not authorized for hosted bank-linking. +- The deposit tests validate webhook handling and persistence. Full wallet-balance reconciliation depends on sandbox deposit state and is not asserted yet. +- Virtual account and withdrawal success are not covered by default because they require a real Bridge-side KYC-approved sandbox customer, funded wallet, and verified external account. +- Deposit webhook processing writes `BridgeTransferRequest` audit rows to the local ERPNext instance when `~/.config/flash/dev-overrides.yaml` points Frappe at the local Docker site. +- The suite uses Jest `forceExit` because importing the public GraphQL schema creates app-wide Redis clients; teardown calls `disconnectAll()`, but ioredis TCP handles can otherwise keep the opt-in E2E process alive after the tests finish. + +## Troubleshooting + +If the suite exits before running tests, check the setup guards first: + +```bash +echo "$IBEX_ENVIRONMENT" +``` + +It must print `sandbox`. + +If MongoDB setup fails, start the repo's normal local services before rerunning. The suite creates local test users and wallets before calling Bridge flows. + +If preflight fails, inspect the guard in `src/services/bridge/index.ts`. The suite expects `BridgeService.checkAccountLevel()` to block Level 0 only, so Level 1 accounts can run Bridge operations. diff --git a/test/flash/bridge-sandbox-e2e/config-overrides.yaml b/test/flash/bridge-sandbox-e2e/config-overrides.yaml new file mode 100644 index 000000000..f00b74ad0 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/config-overrides.yaml @@ -0,0 +1,2 @@ +sendgrid: + apiKey: "SG.sandbox-e2e-placeholder" diff --git a/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts b/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts new file mode 100644 index 000000000..4b695ebbb --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/cutover-state.spec.ts @@ -0,0 +1,108 @@ +/** + * Bridge Sandbox E2E — Post-Cutover State Assertions + * + * Verifies the system-wide cash wallet cutover state via the public + * `cashWalletCutover` query, and optionally validates that accounts + * see correct wallet routing after cutover. + * + * The cutover state is a system-wide singleton (not per-account) stored in + * the CashWalletCutoverConfig collection. + * + * This spec is guarded by SKIP_CUTOVER_TESTS — it only runs actively when + * CUTOVER_TESTS=true is set, since sandbox environments may not have + * cutover infrastructure seeded. + * + * Verified shapes (from source audit of `cashWalletCutover.ts`, `lifecycle.ts`): + * - cashWalletCutover query (public) returns CashWalletCutoverObject: + * { state: CashWalletCutoverState!, scheduledAt, startedAt, + * completedAt, pausedAt, pauseReason, cutoverVersion: Int!, + * runId, updatedBy, updatedAt: Timestamp! } + * - Valid states: "not_started", "started", "provisioned", "balance_read", + * "invoice_created", "balance_move_sending", "balance_move_sent", + * "balance_move_verified", "fee_reimbursement_invoice_created", + * "fee_reimbursement_sending", "fee_reimbursed", "pointer_flipped", + * "legacy_zero_verified", "complete", "failed", "requires_operator_review", + * "skipped_already_migrated", "rollback_started", "rolled_back" + */ + +const CUTOVER_TESTS = process.env.CUTOVER_TESTS === "true" + +;(CUTOVER_TESTS ? describe : describe.skip)("Post-Cutover State", () => { + describe("System-wide cutover config query", () => { + // The cutover query doesn't use auth context, but + // execQuery requires an accountId for context building. + const dummyAccountId = `acct_cutover_test_${Date.now()}` + + it("returns cashWalletCutover with expected shape", async () => { + const { execQuery } = await import("./helpers") + + const source = ` + query CashWalletCutover { + cashWalletCutover { + state + cutoverVersion + runId + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + updatedAt + updatedBy + } + } + ` + + const response = await execQuery<{ + cashWalletCutover: { state: string; cutoverVersion: number; updatedAt: string } + }>(source, dummyAccountId) + if ("errors" in response) throw new Error(JSON.stringify(response.errors)) + + expect(response.cashWalletCutover).toBeDefined() + expect(typeof response.cashWalletCutover.state).toBe("string") + expect(response.cashWalletCutover.cutoverVersion).toEqual(expect.any(Number)) + expect(typeof response.cashWalletCutover.updatedAt).toBe("string") + }) + + it("state is a valid cutover state enum value", async () => { + const { execQuery } = await import("./helpers") + + const VALID_STATES = new Set([ + "not_started", + "started", + "provisioned", + "balance_read", + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", + "legacy_zero_verified", + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", + ]) + + const source = ` + query CashWalletCutover { + cashWalletCutover { + state + } + } + ` + + const response = await execQuery<{ + cashWalletCutover: { state: string } + }>(source, dummyAccountId) + if ("errors" in response) throw new Error(JSON.stringify(response.errors)) + + expect(VALID_STATES.has(response.cashWalletCutover?.state)).toBe(true) + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts b/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts new file mode 100644 index 000000000..d7389032a --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/deposit-withdrawal.spec.ts @@ -0,0 +1,194 @@ +/** + * Bridge Sandbox E2E — Deposit → Withdrawal Lifecycle + * + * Tests the deposit webhook processing and withdrawal initiation flow. + * + * Verified shapes (from source audit): + * - depositHandler accepts + * { event_id, event_object: { id, state, amount, currency, on_behalf_of, receipt? } } + * returns { status: "success" } on 200 + * - bridgeInitiateWithdrawal(input: { amount: String!, externalAccountId: ID! }) returns + * { errors, withdrawal: { id, amount, currency, status, failureReason, createdAt } } + * + * The deposit handler is fully testable via webhook injection — it's self-contained + * and persists to BridgeDeposits + optional ERPNext. + * + * The withdrawal mutation calls Bridge API's createTransfer endpoint, which requires + * real sandbox state (KYC'd customer, funded wallet, verified external account). + * Full-flow tests are guarded by BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true. + * Error-path tests verify expected failures when prerequisites are missing. + * + * ⚠️ The deposit handler triggers reconciliation only when state === "payment_processed" + * WITH a destination_tx_hash. For sandbox testing, the reconciliation may or may not + * succeed — the test asserts the handler responds correctly regardless. + */ + +import { + createBridgeSandboxUser, + initiateWithdrawal, + injectDepositWebhook, + findDepositLogByEventId, + BridgeTestUser, +} from "./helpers" + +describe("Bridge Deposit → Withdrawal", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + }) + + // ============ Deposit Webhook ============ + + describe("Deposit Webhook Processing", () => { + it("processes a valid deposit webhook and returns success", async () => { + const eventId = `dep-test-${Date.now()}` + const response = await injectDepositWebhook({ + event_id: eventId, + event_object: { + id: `transfer_test_${Date.now()}`, + state: "payment_processed", + amount: "100.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + receipt: { + initial_amount: "100.00", + subtotal_amount: "100.00", + final_amount: "96.00", + developer_fee: "2.00", + destination_tx_hash: `0x${Date.now().toString(16)}dead`, + }, + }, + }) + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + expect(response.body.status).toBe("success") + }) + + it("persists a deposit log to the BridgeDeposits collection", async () => { + const eventId = `dep-log-${Date.now()}` + const transferId = `transfer_log_${Date.now()}` + const response = await injectDepositWebhook({ + event_id: eventId, + event_object: { + id: transferId, + state: "payment_processed", + amount: "50.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + }) + + expect(response.status).toBe(200) + + // Query the deposit log directly + const log = await findDepositLogByEventId(eventId) + expect(log).toBeTruthy() + expect(log!.eventId).toBe(eventId) + expect(log!.transferId).toBe(transferId) + expect(log!.amount).toBe("50.00") + expect(log!.currency).toBe("usdt") + expect(log!.state).toBe("payment_processed") + }) + + it("returns already_processed for duplicate deposit webhooks", async () => { + const eventId = `dep-dup-${Date.now()}` + const payload = { + event_id: eventId, + event_object: { + id: `transfer_dup_${Date.now()}`, + state: "payment_processed", + amount: "25.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + } + + // First call + const first = await injectDepositWebhook(payload) + expect(first.status).toBe(200) + expect(first.body.status).toBe("success") + + // Duplicate event_id — idempotency lock fires before the handler re-processes + const second = await injectDepositWebhook(payload) + expect(second.status).toBe(200) + expect(second.body.status).toBe("already_processed") + }) + + it("handles intermediate state transitions (not just payment_processed)", async () => { + const response = await injectDepositWebhook({ + event_id: `dep-pending-${Date.now()}`, + event_object: { + id: `transfer_pending_${Date.now()}`, + state: "pending_transfer", + amount: "75.00", + currency: "usdt", + on_behalf_of: `sandbox_cus_${user.accountId.slice(-8)}`, + }, + }) + + // Intermediate states are logged and return success but do NOT trigger + // reconciliation (only payment_processed with tx hash does) + expect(response.status).toBe(200) + expect(response.body.status).toBe("success") + }) + + it("rejects a deposit webhook with missing required fields", async () => { + const response = await injectDepositWebhook({ + event_id: "dep-invalid", + event_object: { + id: "", + state: "", + amount: "", + currency: "", + on_behalf_of: "", + }, + }) + + // Handler validates presence of event_object.id, event_id, amount, on_behalf_of + expect(response.status).toBe(400) + }) + }) + + // ============ Withdrawal ============ + + describe("Withdrawal Initiation", () => { + it("rejects withdrawal when amount is below minimum", async () => { + // minimum withdrawal is 2 (from config), so 0.50 should be rejected + const result = await initiateWithdrawal(user.accountId, { + amount: "0.50", + externalAccountId: "ext_acct_placeholder", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0].message).toMatch(/minimum/i) + expect(result.withdrawal).toBeNull() + }) + + it("rejects withdrawal when amount is invalid (non-numeric)", async () => { + const result = await initiateWithdrawal(user.accountId, { + amount: "abc", + externalAccountId: "ext_acct_placeholder", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.withdrawal).toBeNull() + }) + + it("rejects withdrawal when account has no Bridge customer ID", async () => { + // No KYC has been initiated for this account, so bridgeCustomerId is null + const result = await initiateWithdrawal(user.accountId, { + amount: "50.00", + externalAccountId: "ext_acct_no_customer", + }) + + expect(result.errors).toBeDefined() + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0].message).toMatch(/customer|KYC/i) + expect(result.withdrawal).toBeNull() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/external-account.spec.ts b/test/flash/bridge-sandbox-e2e/external-account.spec.ts new file mode 100644 index 000000000..412eefd03 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/external-account.spec.ts @@ -0,0 +1,156 @@ +/** + * Bridge Sandbox E2E — External Account (Plaid) Flow + * + * Tests the `bridgeAddExternalAccount` mutation and external-account + * webhook handling. + * + * Verified shapes (from source audit): + * - bridgeAddExternalAccount returns + * { errors, externalAccount: { linkUrl: string!, expiresAt: string! } } + * - externalAccountHandler accepts + * { event_id, event_object: { id, customer_id, bank_name, last_4, active } } + * and returns { status: "success" } or { status: "already_processed" } on 200 + * + * ⚠️ Plaid sandbox linking is a manual step — the test generates the link URL + * and verifies it's well-formed, then simulates the webhook that follows + * successful Plaid linking. The test does NOT automate the Plaid browser UI. + * + * ⚠️ Some sandboxes return "link_url" instead of "linkUrl" — check actual response + * against the configured return shape and update assertions if needed. + */ + +import { + createBridgeSandboxUser, + initiateKyc, + addExternalAccount, + injectKycWebhook, + injectExternalAccountWebhook, + getAccountById, + BridgeTestUser, +} from "./helpers" + +const EXTERNAL_ACCOUNT_LINK_TESTS = + process.env.BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED === "true" + +describe("Bridge External Account", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + + // === Prerequisites: KYC + Virtual Account === + const kycResult = await initiateKyc( + user.accountId, + `ext-acct-${user.accountId.slice(-8)}-${Date.now()}@test.flashapp.me`, + ) + if (kycResult.errors?.length) { + throw new Error(`KYC initiation failed: ${kycResult.errors[0].message}`) + } + + // Approve KYC via webhook injection using the customer ID persisted by KYC initiation. + const account = await getAccountById(user.accountId) + const webhookCustomerId = account.bridgeCustomerId + if (!webhookCustomerId) { + throw new Error("KYC initiation did not persist a Bridge customer ID") + } + user.customerId = webhookCustomerId + const webhookResult = await injectKycWebhook({ + event_id: `ext-acct-kyc-${Date.now()}`, + event_object: { customer_id: webhookCustomerId, kyc_status: "approved" }, + }) + if (webhookResult.status !== 200) { + throw new Error(`KYC webhook failed with status ${webhookResult.status}`) + } + }) + ;(EXTERNAL_ACCOUNT_LINK_TESTS ? describe : describe.skip)( + "Plaid Link URL Generation", + () => { + it("generates a Plaid link URL when called", async () => { + const result = await addExternalAccount(user.accountId) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.externalAccount).toBeDefined() + expect(result.externalAccount!.linkUrl).toBeTruthy() + expect(result.externalAccount!.linkUrl).toMatch(/^https:\/\//) + expect(result.externalAccount!.expiresAt).toBeTruthy() + }) + + it("link URL is different on each call (one-time use tokens)", async () => { + const result1 = await addExternalAccount(user.accountId) + const result2 = await addExternalAccount(user.accountId) + + expect(result1.errors).toHaveLength(0) + expect(result2.errors).toHaveLength(0) + + // Plaid link tokens are one-time use; consecutive calls should differ + expect(result1.externalAccount?.linkUrl).toBeTruthy() + expect(result2.externalAccount?.linkUrl).toBeTruthy() + expect(result1.externalAccount!.linkUrl).not.toBe( + result2.externalAccount!.linkUrl, + ) + }) + }, + ) + + describe("External Account Webhook Processing", () => { + it("processes a valid external-account webhook and returns success", async () => { + // This simulates what Bridge sends after a user completes the Plaid flow + const response = await injectExternalAccountWebhook({ + event_id: `ext-created-${Date.now()}`, + event_object: { + id: `ext_acct_test_${Date.now()}`, + customer_id: user.customerId!, + bank_name: "Test Bank", + last_4: "1234", + active: true, + }, + }) + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + expect(response.body.status).toBe("success") + }) + + it("returns already_processed for duplicate external-account webhooks", async () => { + const eventId = `ext-duplicate-${Date.now()}` + const payload = { + event_id: eventId, + event_object: { + id: `ext_acct_dup_${Date.now()}`, + customer_id: user.customerId!, + bank_name: "Test Bank", + last_4: "9999", + active: true, + }, + } + + // First call — should succeed + const first = await injectExternalAccountWebhook(payload) + expect(first.status).toBe(200) + expect(first.body.status).toBe("success") + + // Second call with same event_id — idempotency lock + const second = await injectExternalAccountWebhook(payload) + expect(second.status).toBe(200) + expect(second.body.status).toBe("already_processed") + }) + + it("rejects a webhook with missing customer_id", async () => { + const response = await injectExternalAccountWebhook({ + event_id: `ext-missing-${Date.now()}`, + event_object: { + id: "ext_acct_missing_cus", + customer_id: "", + bank_name: "No Customer", + last_4: "0000", + active: true, + }, + }) + + // Handler validates customer_id presence — returns 400 or 503 depending + // on whether it fails the initial guard (400) or the account lookup (503) + expect(response.status).toBeGreaterThanOrEqual(400) + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/helpers.ts b/test/flash/bridge-sandbox-e2e/helpers.ts new file mode 100644 index 000000000..06ea0c544 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/helpers.ts @@ -0,0 +1,374 @@ +/** + * Bridge Sandbox E2E — Helpers + * + * Shared utilities for sandbox end-to-end tests. + * Follows the test/galoy/helpers pattern: executes GraphQL operations + * via the schema (graphql() from the graphql library), not via + * direct resolver calls. + * + * All GraphQL return shapes verified against source code. + */ + +import { graphql, Source } from "graphql" + +import { createAccountWithPhoneIdentifier } from "@app/accounts" +import { addWalletIfNonexistent } from "@app/accounts/add-wallet" +import { DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES } from "@app/cash-wallet-cutover/client-capability" +import { getDefaultAccountsConfig } from "@config" +import { AccountLevel } from "@domain/accounts" +import { CouldNotFindAccountFromKratosIdError, RepositoryError } from "@domain/errors" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { gqlMainSchema } from "@graphql/public" +import { depositHandler } from "@services/bridge/webhook-server/routes/deposit" +import { externalAccountHandler } from "@services/bridge/webhook-server/routes/external-account" +import { kycHandler } from "@services/bridge/webhook-server/routes/kyc" +import { AuthWithPhonePasswordlessService } from "@services/kratos" +import { + AccountsRepository, + UsersRepository, + WalletsRepository, +} from "@services/mongoose" +import { AccountsRepository as AccountsRepo } from "@services/mongoose/accounts" +import { Account as AccountModel, BridgeDeposits } from "@services/mongoose/schema" + +import { createReqRes } from "./helpers/http-utils" + +import { randomPhone } from "test/galoy/helpers" + +// ============ Types ============ + +export interface BridgeTestUser { + accountId: string + walletId: string + customerId?: string + virtualAccountId?: string + level: AccountLevel +} + +type GraphQlErrorResponse = { + errors: Array<{ message: string }> +} + +type KycInitiationResult = GraphQlErrorResponse & { + kycLink?: { kycLink: string; tosLink: string } +} + +type VirtualAccountResult = GraphQlErrorResponse & { + virtualAccount?: Record +} + +type ExternalAccountResult = GraphQlErrorResponse & { + externalAccount?: { linkUrl: string; expiresAt: string } +} + +type WithdrawalResult = GraphQlErrorResponse & { + withdrawal?: Record | null +} + +type HandlerResponse = { + status: number + body: Record +} + +// ============ Schema Execution ============ + +function buildContext(accountId: string): GraphQLPublicContextAuth { + return { + domainAccount: { id: accountId, level: 1 }, + cashWalletClientCapabilities: DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES, + } as GraphQLPublicContextAuth +} + +export async function execQuery>( + source: string, + accountId: string, + variableValues?: Record, +): Promise { + const result = await graphql({ + schema: gqlMainSchema, + source: new Source(source), + contextValue: buildContext(accountId), + variableValues, + }) + if (result.errors) { + return { errors: result.errors.map((error) => ({ message: error.message })) } + } + return (result.data ?? {}) as T +} + +// ============ User Creation ============ + +/** + * Create a test user with the given account level and USDT wallet. + * Persists to local MongoDB (not Bridge sandbox). + */ +export async function createBridgeSandboxUser( + level: AccountLevel = AccountLevel.One, +): Promise { + const phone = randomPhone() + const kratosUserId = await AuthWithPhonePasswordlessService().createIdentityNoSession({ + phone, + }) + if (kratosUserId instanceof Error) throw kratosUserId + + // Create Kratos user + const user = await UsersRepository().update({ + id: kratosUserId, + deviceTokens: [`token-${kratosUserId}`] as DeviceToken[], + phone, + }) + if (user instanceof Error) throw user + + // Create account + let account = await AccountsRepository().findByUserId(kratosUserId) + + if (account instanceof CouldNotFindAccountFromKratosIdError) { + account = await createAccountWithPhoneIdentifier({ + newAccountInfo: { phone, kratosUserId }, + config: { + ...getDefaultAccountsConfig(), + initialLevel: level, + }, + }) + if (account instanceof Error) throw account + + // Add USDT wallet for Bridge flows + const usdtWallet = await addWalletIfNonexistent({ + currency: WalletCurrency.Usdt, + accountId: account.id, + type: WalletType.Checking, + }) + if (usdtWallet instanceof Error) throw usdtWallet + + // Set account level directly (createAccountWithPhoneIdentifier may not enforce initialLevel) + await AccountModel.updateOne({ _id: account.id }, { $set: { level } }) + } + + if (account instanceof Error) throw account + + // Get the USDT wallet + const walletsResult = await WalletsRepository().listByAccountId(account.id) + if (walletsResult instanceof RepositoryError) throw walletsResult + const usdtWallet = walletsResult.find( + (wallet) => + wallet.currency === WalletCurrency.Usdt && wallet.type === WalletType.Checking, + ) + if (!usdtWallet) throw new Error("No USDT wallet created for sandbox user") + + return { + accountId: account.id, + walletId: usdtWallet.id, + level, + } +} + +// ============ Bridge Mutation Wrappers ============ + +const BRIDGE_INITIATE_KYC = ` + mutation BridgeInitiateKyc($input: BridgeInitiateKycInput!) { + bridgeInitiateKyc(input: $input) { + errors { message } + kycLink { kycLink tosLink } + } + } +` + +const BRIDGE_CREATE_VIRTUAL_ACCOUNT = ` + mutation BridgeCreateVirtualAccount { + bridgeCreateVirtualAccount { + errors { message } + virtualAccount { id bankName routingNumber accountNumber accountNumberLast4 pending message kycLink tosLink } + } + } +` + +const BRIDGE_ADD_EXTERNAL_ACCOUNT = ` + mutation BridgeAddExternalAccount { + bridgeAddExternalAccount { + errors { message } + externalAccount { linkUrl expiresAt } + } + } +` + +const BRIDGE_REQUEST_WITHDRAWAL = ` + mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { message } + withdrawal { id amount currency status failureReason createdAt } + } + } +` + +/** + * Initiate Bridge KYC for a user. + * Returns { errors, kycLink: { kycLink, tosLink } | null } + */ +export async function initiateKyc( + accountId: string, + email: string, +): Promise { + const data = (await execQuery(BRIDGE_INITIATE_KYC, accountId, { + input: { email }, + })) as { bridgeInitiateKyc?: KycInitiationResult } + return data?.bridgeInitiateKyc ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Create a virtual account for a user. + * Requires KYC to be completed first. + */ +export async function createVirtualAccount( + accountId: string, +): Promise { + const data = (await execQuery(BRIDGE_CREATE_VIRTUAL_ACCOUNT, accountId)) as { + bridgeCreateVirtualAccount?: VirtualAccountResult + } + return data?.bridgeCreateVirtualAccount ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Add an external account (Plaid). + * Requires KYC + virtual account to be completed first. + */ +export async function addExternalAccount( + accountId: string, +): Promise { + const data = (await execQuery(BRIDGE_ADD_EXTERNAL_ACCOUNT, accountId)) as { + bridgeAddExternalAccount?: ExternalAccountResult + } + return data?.bridgeAddExternalAccount ?? { errors: [{ message: "No data returned" }] } +} + +/** + * Initiate a withdrawal. + */ +export async function initiateWithdrawal( + accountId: string, + input: { amount: string; externalAccountId: string }, +): Promise { + const data = (await execQuery(BRIDGE_REQUEST_WITHDRAWAL, accountId, { + input, + })) as { bridgeRequestWithdrawal?: WithdrawalResult } + return data?.bridgeRequestWithdrawal ?? { errors: [{ message: "No data returned" }] } +} + +// ============ Webhook Injection ============ + +/** + * Inject a KYC webhook payload directly into the Express route handler. + * Tests the same handler code that runs in production. + */ +export async function injectKycWebhook(payload: { + event_id: string + event_object: { customer_id: string; kyc_status: string } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await kycHandler( + req as unknown as Parameters[0], + res as unknown as Parameters[1], + ) + return { status: res.statusCode, body: (res._body ?? {}) as Record } +} + +/** + * Inject an external account webhook payload directly into the Express route handler. + * Simulates Bridge sending an external_account.created event after Plaid linking. + */ +export async function injectExternalAccountWebhook(payload: { + event_id: string + event_object: { + id: string + customer_id: string + bank_name?: string + last_4?: string + active?: boolean + } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await externalAccountHandler( + req as unknown as Parameters[0], + res as unknown as Parameters[1], + ) + return { status: res.statusCode, body: (res._body ?? {}) as Record } +} + +/** + * Inject a deposit webhook payload directly into the Express route handler. + * Simulates Bridge sending a transfer state-transition event. + */ +export async function injectDepositWebhook(payload: { + event_id: string + event_object: { + id: string + state: string + amount: string + currency: string + on_behalf_of: string + receipt?: { + initial_amount?: string + subtotal_amount?: string + final_amount?: string + developer_fee?: string + destination_tx_hash?: string + } + } +}): Promise { + const { req, res } = createReqRes({ body: payload }) + await depositHandler( + req as unknown as Parameters[0], + res as unknown as Parameters[1], + ) + return { status: res.statusCode, body: (res._body ?? {}) as Record } +} + +// ============ ERPNext Verification ============ + +/** + * Query ERPNext for a matching audit row. + * Silently returns null when ERPNEXT_URL is not set. + */ +export async function verifyErpnextAuditRow( + docType: string, + referenceId: string, +): Promise | null> { + if (!process.env.ERPNEXT_URL) { + return null + } + try { + const response = await fetch( + `${process.env.ERPNEXT_URL}/api/resource/${docType}?filters=${JSON.stringify([["reference_id", "=", referenceId]])}`, + { + headers: { + Authorization: `token ${process.env.ERPNEXT_API_KEY}:${process.env.ERPNEXT_API_SECRET}`, + }, + }, + ) + if (!response.ok) return null + const json = await response.json() + return json.data?.[0] || null + } catch { + return null + } +} + +// ============ Deposit Log Lookup ============ + +/** + * Find a deposit log by event ID directly from MongoDB. + */ +export async function findDepositLogByEventId( + eventId: string, +): Promise | null> { + const doc = await BridgeDeposits.findOne({ eventId }).lean().exec() + return (doc as Record) ?? null +} + +// ============ Account Lookup ============ + +export async function getAccountById(accountId: string) { + const account = await AccountsRepo().findById(accountId as AccountId) + if (account instanceof Error) throw account + return account +} diff --git a/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts b/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts new file mode 100644 index 000000000..a5d9942ef --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/helpers/http-utils.ts @@ -0,0 +1,68 @@ +/** + * Create mock Express req/res objects for webhook handler injection. + */ + +import { EventEmitter } from "events" + +interface MockRequestOptions { + body?: Record + headers?: Record + method?: string + path?: string +} + +class MockResponse extends EventEmitter { + public statusCode: number = 200 + public _body: unknown = null + public _headers: Record = {} + public _ended: boolean = false + + status(code: number): this { + this.statusCode = code + return this + } + + json(data: unknown): this { + this._body = data + this._ended = true + this.emit("finish") + return this + } + + send(data: unknown): this { + this._body = data + this._ended = true + this.emit("finish") + return this + } + + setHeader(name: string, value: string): this { + this._headers[name] = value + return this + } + + end(): this { + this._ended = true + this.emit("finish") + return this + } +} + +export function createReqRes(options: MockRequestOptions = {}): { + req: Record + res: MockResponse +} { + const req: Record = { + body: options.body || {}, + headers: options.headers || { "content-type": "application/json" }, + method: options.method || "POST", + path: options.path || "/", + ip: "127.0.0.1", + query: {}, + params: {}, + } + + const res = new MockResponse() + + return { req, res } +} diff --git a/test/flash/bridge-sandbox-e2e/jest.config.js b/test/flash/bridge-sandbox-e2e/jest.config.js new file mode 100644 index 000000000..021f2d501 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/jest.config.js @@ -0,0 +1,27 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const swcConfig = require("../../swc-config.json") + +module.exports = { + moduleFileExtensions: ["js", "json", "ts", "cjs", "mjs"], + rootDir: "../../../", + roots: ["/test/flash/bridge-sandbox-e2e"], + transform: { + "^.+\\.(t|j)sx?$": ["@swc/jest", swcConfig], + }, + testRegex: ".*\\.spec\\.ts$", + setupFilesAfterEnv: ["/test/flash/bridge-sandbox-e2e/jest.setup.ts"], + testEnvironment: "node", + forceExit: true, + moduleNameMapper: { + "^@config$": ["src/config/index"], + "^@app$": ["src/app/index"], + "^@utils$": ["src/utils/index"], + "^@core/(.*)$": ["src/core/$1"], + "^@app/(.*)$": ["src/app/$1"], + "^@domain/(.*)$": ["src/domain/$1"], + "^@services/(.*)$": ["src/services/$1"], + "^@servers/(.*)$": ["src/servers/$1"], + "^@graphql/(.*)$": ["src/graphql/$1"], + "^test/(.*)$": ["test/$1"], + }, +} diff --git a/test/flash/bridge-sandbox-e2e/jest.setup.ts b/test/flash/bridge-sandbox-e2e/jest.setup.ts new file mode 100644 index 000000000..c4bbfe2f2 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/jest.setup.ts @@ -0,0 +1,85 @@ +/** + * Bridge Sandbox E2E Setup + * + * This suite requires: + * - RUN_BRIDGE_SANDBOX_E2E=true in environment + * - A running backend connected to Bridge sandbox + * - BridgeService.checkAccountLevel() allowing level >= 1 + * - IBEX_ENVIRONMENT=sandbox (safety guard) + */ + +// Must mock yargs BEFORE any config imports so yaml.ts gets a valid --configPath +jest.mock("yargs", () => { + const defaultOverridesPath = `${process.env.CONFIG_PATH ?? `${process.env.HOME}/.config/flash`}/dev-overrides.yaml` + const overridePath = process.env.BRIDGE_SANDBOX_CONFIG_PATH ?? defaultOverridesPath + const yargsMock = { + option: jest.fn().mockReturnThis(), + argv: { + configPath: [ + "./dev/config/base-config.yaml", + overridePath, + "./test/flash/bridge-sandbox-e2e/config-overrides.yaml", + ], + }, + } + return jest.fn(() => yargsMock) +}) + +jest.mock("@services/notifications/firebase", () => ({ + __esModule: true, + default: { + isDeviceTokenValid: jest.fn(async () => true), + subscribeToTopics: jest.fn(async () => undefined), + }, + messaging: null, +})) + +import { setupMongoConnection } from "@services/mongodb" +import { disconnectAll } from "@services/redis" + +import { preflightServiceLevelGuard } from "./preflight" + +let mongoose: Awaited> | undefined + +beforeAll(async () => { + // === Guard: Must be explicitly opted in === + if (!process.env.RUN_BRIDGE_SANDBOX_E2E) { + throw new Error( + "Bridge sandbox E2E skipped. Set RUN_BRIDGE_SANDBOX_E2E=true in env to run.", + ) + } + + // === Guard: Must be pointed at sandbox, not production === + // Set via: export IBEX_ENVIRONMENT=sandbox (or add to .env) + if (process.env.IBEX_ENVIRONMENT !== "sandbox") { + throw new Error( + "IBEX_ENVIRONMENT must be 'sandbox' for Bridge sandbox E2E tests.\n" + + " Run: export IBEX_ENVIRONMENT=sandbox\n" + + " Or add to .env: export IBEX_ENVIRONMENT=sandbox", + ) + } + + // === Connect MongoDB for test user creation === + try { + mongoose = await setupMongoConnection(true) + } catch (err) { + throw new Error( + `MongoDB connection failed: ${err instanceof Error ? err.message : err}`, + ) + } + + // === Preflight: Verify service-level guard allows level >= 1 === + const preflightOk = preflightServiceLevelGuard() + if (!preflightOk) { + throw new Error("Preflight failed — aborting suite.") + } +}) + +afterAll(async () => { + disconnectAll() + if (mongoose) { + await mongoose.connection.close() + } +}) + +jest.setTimeout(Number(process.env.JEST_TIMEOUT) || 120000) diff --git a/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts b/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts new file mode 100644 index 000000000..1baffb0e5 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/kyc-virtual-account.spec.ts @@ -0,0 +1,103 @@ +/** + * Bridge Sandbox E2E — KYC + Virtual Account Flow + * + * Tests the complete KYC initiation → webhook processing → virtual account creation flow. + * + * Verified return shapes (from source audit): + * - bridgeInitiateKyc returns { errors, kycLink: { kycLink: string!, tosLink: string! } } + * - bridgeCreateVirtualAccount returns { errors, virtualAccount: { id, bankName, ... } } + * - No ERPNext writer exists for BridgeVirtualAccount (only BridgeTransferRequest exists) + */ + +import { + createBridgeSandboxUser, + initiateKyc, + createVirtualAccount, + injectKycWebhook, + getAccountById, + BridgeTestUser, +} from "./helpers" + +const VIRTUAL_ACCOUNT_TESTS = + process.env.BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED === "true" + +describe("Bridge KYC → Virtual Account", () => { + let user: BridgeTestUser + + beforeAll(async () => { + user = await createBridgeSandboxUser(1) + }) + + describe("KYC Initiation", () => { + it("initiates KYC and returns a KYC link URL and TOS link", async () => { + const result = await initiateKyc( + user.accountId, + `sandbox-${user.accountId.slice(-8)}@test.flashapp.me`, + ) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.kycLink).toBeDefined() + expect(result.kycLink!.kycLink).toBeTruthy() + expect(result.kycLink!.kycLink).toMatch(/^https:\/\//) + expect(result.kycLink!.tosLink).toBeTruthy() + expect(result.kycLink!.tosLink).toMatch(/^https:\/\//) + }) + }) + + describe("KYC Webhook Processing", () => { + beforeAll(async () => { + // Initiate KYC first to create the Bridge customer + const kycResult = await initiateKyc( + user.accountId, + `webhook-${user.accountId.slice(-8)}-${Date.now()}@test.flashapp.me`, + ) + if (kycResult.errors?.length) { + throw new Error(`KYC initiation failed: ${kycResult.errors[0].message}`) + } + }) + + it("processes a KYC-approved webhook and marks account as approved", async () => { + const account = await getAccountById(user.accountId) + const webhookCustomerId = account.bridgeCustomerId + if (!webhookCustomerId) { + throw new Error("KYC initiation did not persist a Bridge customer ID") + } + + const response = await injectKycWebhook({ + event_id: `test-kyc-approved-${Date.now()}`, + event_object: { + customer_id: webhookCustomerId, + kyc_status: "approved", + }, + }) + + // The handler returns 200 for any valid webhook payload structure + expect(response.status).toBe(200) + }) + }) + ;(VIRTUAL_ACCOUNT_TESTS ? describe : describe.skip)("Virtual Account Creation", () => { + it("creates a virtual account after KYC approval", async () => { + const result = await createVirtualAccount(user.accountId) + + expect(result.errors).toBeDefined() + expect(result.errors).toHaveLength(0) + expect(result.virtualAccount).toBeDefined() + expect(result.virtualAccount!.id).toBeTruthy() + // Virtual account should include bank details + expect(result.virtualAccount!.bankName).toBeDefined() + expect(result.virtualAccount!.routingNumber).toBeDefined() + expect(result.virtualAccount!.accountNumberLast4).toBeDefined() + }) + + it("virtual account is idempotent — calling twice returns same result", async () => { + const result1 = await createVirtualAccount(user.accountId) + const result2 = await createVirtualAccount(user.accountId) + + expect(result1.errors).toHaveLength(0) + expect(result2.errors).toHaveLength(0) + // Both calls should succeed (idempotent) — the second may return existing VA + expect(result1.virtualAccount?.id || result2.virtualAccount?.id).toBeTruthy() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts b/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts new file mode 100644 index 000000000..0e7a356f8 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/ln-parity.spec.ts @@ -0,0 +1,59 @@ +/** + * Bridge Sandbox E2E — ETH-USDT LN Parity Smoke + * + * Verifies that Lightning payments still route correctly after USDT + * on-chain deposits are handled by Bridge. + * + * Key assertions (when infrastructure is available): + * - LN USD invoices can be created on a Bridge-capable account + * - LN USD invoice amounts convert correctly from USD → USDT parity + * (addressing CurrencyPrecisionAnalysis bug #282 / flash-mobile #555) + * - On-chain USDT deposits through Bridge don't affect LN routing + * + * ⚠️ GUARDED: Requires LN payment infrastructure + funded wallet in + * the sandbox environment. Only runs when LN_PARITY_TESTS=true. + * + * This spec is a placeholder for validation that should be run manually + * after the sandbox environment is seeded with: + * 1. A user with completed KYC + virtual account (Bridge mode) + * 2. A funded USDT wallet (via sandbox deposit) + * 3. Working LNURL-USD invoice creation + */ + +const ACCOUNT_ID = `acct_lnparity_test_${Date.now()}` +const LN_PARITY_TESTS = process.env.LN_PARITY_TESTS === "true" + +;(LN_PARITY_TESTS ? describe : describe.skip)("ETH-USDT LN Parity", () => { + describe("Lightning invoice creation (USD)", () => { + it("creates a LN USD invoice for a Bridge-capable account", async () => { + const { execQuery } = await import("./helpers") + + const source = ` + mutation LnUsdInvoiceCreate($input: LnUsdInvoiceCreateInput!) { + lnUsdInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest paymentHash } + } + } + ` + + const response = await execQuery(source, ACCOUNT_ID, { + input: { amount: 1000 }, // 1000 millisatoshis = $0.10 USD-ish + }) + + expect(response.lnUsdInvoiceCreate).toBeDefined() + + const errors = response.lnUsdInvoiceCreate?.errors + if (errors?.length) { + // Log known missing-infrastructure errors without failing + console.warn("LN invoice creation returned errors:", errors) + return + } + + const invoice = response.lnUsdInvoiceCreate.invoice + expect(invoice.paymentRequest).toBeTruthy() + expect(invoice.paymentRequest).toMatch(/^lnb\d+/) + expect(invoice.paymentHash).toBeTruthy() + }) + }) +}) diff --git a/test/flash/bridge-sandbox-e2e/preflight.ts b/test/flash/bridge-sandbox-e2e/preflight.ts new file mode 100644 index 000000000..6810ed100 --- /dev/null +++ b/test/flash/bridge-sandbox-e2e/preflight.ts @@ -0,0 +1,64 @@ +/** + * Preflight Checks for Bridge Sandbox E2E Suite + * + * IMPORTANT: BridgeService.checkAccountLevel() is a private module-level + * function — it is NOT exported and cannot be imported by tests. + * + * This preflight uses source-code analysis to verify the guard condition. + * It checks the source file for `account.level < N` within the + * `checkAccountLevel` function and validates that N <= 1. + */ + +import fs from "fs" +import path from "path" + +/** + * Verify the service-level guard condition in BridgeService.checkAccountLevel(). + * + * Checks the source file for the account level comparison. + * The guard in src/services/bridge/index.ts should be `account.level < 1` + * so that Level 1 accounts can access Bridge operations. + * + * @returns true if the guard is correctly configured (level >= 1 allowed) + */ +export function preflightServiceLevelGuard(): boolean { + const servicePath = path.resolve(__dirname, "../../../src/services/bridge/index.ts") + + let content: string + try { + content = fs.readFileSync(servicePath, "utf-8") + } catch { + console.error("PREFLIGHT FAILED: Could not read BridgeService source at", servicePath) + return false + } + + // Extract the guard comparison value from checkAccountLevel. + // Matches: `account.level < N` anywhere inside the function body. + const funcMatch = content.match( + /const\s+checkAccountLevel[\s\S]*?account\.level\s*<(\s*\d+)/, + ) + + if (!funcMatch) { + console.error( + "PREFLIGHT FAILED: Could not detect service-level guard pattern in BridgeService.", + ) + return false + } + + const guardLevel = parseInt(funcMatch[1], 10) + + if (guardLevel <= 1) { + // Level 0 only is blocked — level 1+ is allowed. Correct configuration. + return true + } + + console.error( + `PREFLIGHT FAILED: BridgeService.checkAccountLevel() blocks level < ${guardLevel}, ` + + `but the e2e suite requires level >= 1 to pass through.\n` + + `Fix required in src/services/bridge/index.ts:\n` + + ` if (account.level < ${guardLevel}) -> if (account.level < 1)\n` + + `See test/flash/bridge-sandbox-e2e/README.md for setup details.`, + ) + + return false +} diff --git a/test/flash/unit/app/accounts/create-account.spec.ts b/test/flash/unit/app/accounts/create-account.spec.ts new file mode 100644 index 000000000..4c006e9d1 --- /dev/null +++ b/test/flash/unit/app/accounts/create-account.spec.ts @@ -0,0 +1,122 @@ +import { createAccountWithPhoneIdentifier } from "@app/accounts/create-account" +import { AccountLevel } from "@domain/accounts" +import { WalletCurrency } from "@domain/shared" +import { PersistError } from "@domain/errors" +import { WalletType } from "@domain/wallets" +import { + AccountsRepository, + UsersRepository, + WalletsRepository, +} from "@services/mongoose" + +jest.mock("@config", () => ({ + getAdminAccounts: jest.fn(() => []), +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + UsersRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +const mockedAccountsRepository = AccountsRepository as jest.MockedFunction< + typeof AccountsRepository +> +const mockedUsersRepository = UsersRepository as jest.MockedFunction< + typeof UsersRepository +> +const mockedWalletsRepository = WalletsRepository as jest.MockedFunction< + typeof WalletsRepository +> + +describe("createAccountWithPhoneIdentifier", () => { + let persistNew: jest.Mock + + const account = { + id: "account-id" as AccountId, + defaultWalletId: undefined, + } as unknown as Account + + const config = { + initialWallets: [WalletCurrency.Usd], + initialStatus: "active", + initialLevel: AccountLevel.One, + } as AccountsConfig + + beforeEach(() => { + jest.clearAllMocks() + + mockedUsersRepository.mockReturnValue({ + update: jest.fn().mockResolvedValue({ id: "user-id" }), + } as unknown as ReturnType) + + mockedAccountsRepository.mockReturnValue({ + persistNew: jest.fn().mockResolvedValue({ ...account }), + update: jest + .fn() + .mockImplementation(async (updatedAccount: Account) => updatedAccount), + } as unknown as ReturnType) + + persistNew = jest.fn().mockImplementation(async ({ accountId, type, currency }) => ({ + id: `${currency}-wallet-id`, + accountId, + type, + currency, + })) + + mockedWalletsRepository.mockReturnValue({ + persistNew, + } as unknown as ReturnType) + }) + + it("creates both USD and USDT cash wallets and defaults new accounts to USDT", async () => { + const result = await createAccountWithPhoneIdentifier({ + newAccountInfo: { + kratosUserId: "kratos-user-id" as UserId, + phone: "+15551234567" as PhoneNumber, + }, + config, + }) + + expect(result).not.toBeInstanceOf(Error) + + expect(persistNew).toHaveBeenCalledWith({ + accountId: account.id, + type: WalletType.Checking, + currency: WalletCurrency.Usd, + }) + expect(persistNew).toHaveBeenCalledWith({ + accountId: account.id, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + expect(persistNew).toHaveBeenCalledTimes(2) + expect((result as Account).defaultWalletId).toBe(`${WalletCurrency.Usdt}-wallet-id`) + }) + + it("does not create an account with a USD fallback default if the USDT wallet is missing", async () => { + persistNew.mockImplementation(async ({ accountId, type, currency }) => { + if (currency === WalletCurrency.Usdt) return new PersistError("USDT wallet failed") + return { + id: `${currency}-wallet-id`, + accountId, + type, + currency, + } + }) + + const result = await createAccountWithPhoneIdentifier({ + newAccountInfo: { + kratosUserId: "kratos-user-id" as UserId, + phone: "+15551234567" as PhoneNumber, + }, + config, + }) + + expect(result).toBeInstanceOf(Error) + }) +}) diff --git a/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts b/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts new file mode 100644 index 000000000..baa6f3ac4 --- /dev/null +++ b/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts @@ -0,0 +1,39 @@ +jest.mock("@config", () => { + const path = require("path") + const { I18n } = require("i18n") + const i18n = new I18n() + i18n.configure({ + objectNotation: true, + updateFiles: false, + locales: ["en", "es"], + defaultLocale: "en", + retryInDefaultLocale: true, + directory: path.resolve(__dirname, "../../../../../src/config/locales"), + }) + return { + getI18nInstance: () => i18n, + getLocale: () => "en", + } +}) + +import { + BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE, + getBridgeWithdrawalFlashFeeNotice, + getBridgeWithdrawalFlashFeeNoticeForUser, +} from "@app/bridge/get-withdrawal-flash-fee-notice" + +describe("getBridgeWithdrawalFlashFeeNotice", () => { + it("uses the configured i18n phrase for supported languages", () => { + expect(BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE).toBe( + "notification.bridgeWithdrawal.flashFeeNotice", + ) + expect(getBridgeWithdrawalFlashFeeNotice("en")).toContain("estimates") + expect(getBridgeWithdrawalFlashFeeNotice("es")).toContain("estimados") + }) + + it("falls back to the default locale when the user language is empty", () => { + expect(getBridgeWithdrawalFlashFeeNoticeForUser({ language: "" })).toBe( + getBridgeWithdrawalFlashFeeNotice("en"), + ) + }) +}) diff --git a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts new file mode 100644 index 000000000..30f3bb9c0 --- /dev/null +++ b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts @@ -0,0 +1,151 @@ +import { sendBridgeWithdrawalNotification } from "@app/bridge/send-withdrawal-notification" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" +import { getI18nInstance } from "@config" + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: jest.fn(), +})) + +jest.mock("@services/notifications/push-notifications", () => ({ + PushNotificationsService: jest.fn(), + SendFilteredPushNotificationStatus: { + Filtered: "filtered", + Sent: "sent", + }, +})) + +jest.mock("@app/users/remove-device-tokens", () => ({ + removeDeviceTokens: jest.fn(), +})) + +jest.mock("@config", () => { + const mockI18n = { + __: jest.fn().mockImplementation(({ phrase }, options) => `${phrase} ${JSON.stringify(options)}`), + } + return { + getI18nInstance: jest.fn(() => mockI18n), + } +}) + +describe("sendBridgeWithdrawalNotification", () => { + const accountId = "507f1f77bcf86cd799439011" + const mockAccount = { + id: accountId, + kratosUserId: "user-id", + notificationSettings: { push: { enabled: true, disabledCategories: [] } }, + } + const mockUser = { + deviceTokens: ["token-1"], + language: "en", + } + + const sendFilteredNotification = jest.fn().mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Sent, + }) + const mockI18n = getI18nInstance() + + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + ;(PushNotificationsService as jest.Mock).mockReturnValue({ + sendFilteredNotification, + }) + ;(getI18nInstance as jest.Mock).mockReturnValue(mockI18n) + }) + + it("sends a completed withdrawal notification with Cashout category", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "100.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Cashout", + data: expect.objectContaining({ type: "bridge_withdrawal_completed" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ phrase: "notification.bridgeWithdrawal.completed.title" }), + ) + }) + + it("uses bodyWithReason when a failure reason is provided", async () => { + await sendBridgeWithdrawalNotification({ + accountId, + amount: "50.00", + currency: "usdt", + outcome: "failed", + failureReason: "ACH return", + }) + + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.failed.bodyWithReason", + }), + expect.objectContaining({ reason: "ACH return" }), + ) + }) + + it("returns true when notification is filtered by user settings", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "10.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + }) + + it("sends a cancelled withdrawal notification with the correct phrase key and data type", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "25.00", + currency: "usdt", + outcome: "cancelled", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Cashout", + data: expect.objectContaining({ type: "bridge_withdrawal_cancelled" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.cancelled.title", + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.cancelled.body", + }), + expect.objectContaining({ amount: "25.00 USDT" }), + ) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/cashout-routing.spec.ts b/test/flash/unit/app/cash-wallet-cutover/cashout-routing.spec.ts new file mode 100644 index 000000000..0defc38ad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/cashout-routing.spec.ts @@ -0,0 +1,174 @@ +import { resolveCashoutWalletSelection } from "@app/cash-wallet-cutover/cashout-routing" +import { + CashWalletMigrationFailedError, + CashWalletMissingUsdtWalletError, +} from "@app/cash-wallet-cutover/errors" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const accountId = "user-account-id" as AccountId +const legacyUsdWalletId = "11111111-1111-4111-8111-111111111111" as WalletId +const userUsdtWalletId = "22222222-2222-4222-8222-222222222222" as WalletId + +const bankOwnerAccountId = "bank-owner-account-id" as AccountId +const bankOwnerUsdWalletId = "33333333-3333-4333-8333-333333333333" as WalletId +const bankOwnerUsdtWalletId = "44444444-4444-4444-8444-444444444444" as WalletId + +const asWallet = (id: WalletId, acctId: AccountId, currency: WalletCurrency): Wallet => + ({ + id, + accountId: acctId, + currency, + type: WalletType.Checking, + }) as Wallet + +const userUsdtWallet = asWallet(userUsdtWalletId, accountId, WalletCurrency.Usdt) +const bankOwnerUsdWallet = asWallet( + bankOwnerUsdWalletId, + bankOwnerAccountId, + WalletCurrency.Usd, +) +const bankOwnerUsdtWallet = asWallet( + bankOwnerUsdtWalletId, + bankOwnerAccountId, + WalletCurrency.Usdt, +) + +const config = (overrides: Record) => + ({ + cutoverVersion: 1, + updatedAt: new Date(), + ...overrides, + }) as unknown as CashWalletCutoverConfig + +// Resolves the bank-owner USDT wallet by account, and the user USDT wallet by account. +const usdtWalletsRepo = () => ({ + findById: jest.fn().mockResolvedValue(bankOwnerUsdWallet), + listByAccountId: jest + .fn() + .mockImplementation(async (id: AccountId) => + id === bankOwnerAccountId + ? [bankOwnerUsdWallet, bankOwnerUsdtWallet] + : [userUsdtWallet], + ), +}) + +describe("resolveCashoutWalletSelection", () => { + it("routes to the legacy USD wallets pre-cutover, trusting the client walletId", async () => { + const migrationsRepo = { + getConfig: jest.fn().mockResolvedValue(config({ state: "pre" })), + findMigrationByAccountId: jest.fn(), + } + const walletsRepo = { + findById: jest.fn(), + listByAccountId: jest.fn(), + } + + const result = await resolveCashoutWalletSelection({ + accountId, + requestedUserWalletId: legacyUsdWalletId, + bankOwnerUsdWalletId, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + route: "legacy_usd", + userWalletId: legacyUsdWalletId, + flashWalletId: bankOwnerUsdWalletId, + }) + expect(migrationsRepo.findMigrationByAccountId).not.toHaveBeenCalled() + expect(walletsRepo.listByAccountId).not.toHaveBeenCalled() + }) + + it("routes to USDT wallets once the cutover is complete", async () => { + const migrationsRepo = { + getConfig: jest.fn().mockResolvedValue(config({ state: "complete" })), + findMigrationByAccountId: jest.fn(), + } + const walletsRepo = usdtWalletsRepo() + + const result = await resolveCashoutWalletSelection({ + accountId, + requestedUserWalletId: legacyUsdWalletId, + bankOwnerUsdWalletId, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + route: "usdt", + userWalletId: userUsdtWalletId, + flashWalletId: bankOwnerUsdtWalletId, + }) + }) + + it("stays on legacy USD mid-cutover for an account that has not started migrating", async () => { + const migrationsRepo = { + getConfig: jest + .fn() + .mockResolvedValue(config({ state: "in_progress", runId: "run-1" })), + findMigrationByAccountId: jest.fn().mockResolvedValue(null), + } + const walletsRepo = { findById: jest.fn(), listByAccountId: jest.fn() } + + const result = await resolveCashoutWalletSelection({ + accountId, + requestedUserWalletId: legacyUsdWalletId, + bankOwnerUsdWalletId, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + route: "legacy_usd", + userWalletId: legacyUsdWalletId, + flashWalletId: bankOwnerUsdWalletId, + }) + }) + + it("blocks the cashout when the account migration has failed", async () => { + const migrationsRepo = { + getConfig: jest + .fn() + .mockResolvedValue(config({ state: "in_progress", runId: "run-1" })), + findMigrationByAccountId: jest + .fn() + .mockResolvedValue({ status: "failed" } as unknown as CashWalletMigration), + } + const walletsRepo = { findById: jest.fn(), listByAccountId: jest.fn() } + + const result = await resolveCashoutWalletSelection({ + accountId, + requestedUserWalletId: legacyUsdWalletId, + bankOwnerUsdWalletId, + migrationsRepo, + walletsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletMigrationFailedError) + }) + + it("errors when the USDT route is selected but the account has no USDT wallet", async () => { + const migrationsRepo = { + getConfig: jest.fn().mockResolvedValue(config({ state: "complete" })), + findMigrationByAccountId: jest.fn(), + } + const walletsRepo = { + findById: jest.fn().mockResolvedValue(bankOwnerUsdWallet), + listByAccountId: jest + .fn() + .mockResolvedValue([asWallet(legacyUsdWalletId, accountId, WalletCurrency.Usd)]), + } + + const result = await resolveCashoutWalletSelection({ + accountId, + requestedUserWalletId: legacyUsdWalletId, + bankOwnerUsdWalletId, + migrationsRepo, + walletsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletMissingUsdtWalletError) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts new file mode 100644 index 000000000..223313e45 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts @@ -0,0 +1,139 @@ +jest.mock("@services/mongoose", () => ({ + CashWalletCutoverRepository: jest.fn(), +})) + +import { startPrimaryCashWalletCutover } from "@app/cash-wallet-cutover/lifecycle" +import { CashWalletCutoverPreflightError } from "@app/cash-wallet-cutover/errors" +import { UnknownRepositoryError } from "@domain/errors" + +const cutoverVersion = 7 +const runId = "cash-wallet-cutover-2026-06-03" +const actor = "operator@example.com" +const now = new Date("2026-06-03T18:00:00.000Z") + +const preConfig = { + state: "pre", + cutoverVersion: 6, + updatedAt: new Date("2026-06-03T17:00:00.000Z"), +} as CashWalletCutoverConfig + +const startedConfig = { + ...preConfig, + state: "in_progress", + cutoverVersion, + runId, + startedAt: now, +} as CashWalletCutoverConfig + +const runnableMigration = { + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "11111111-1111-4111-8111-111111111111" as WalletId, + destinationUsdtWalletId: "22222222-2222-4222-8222-222222222222" as WalletId, + cutoverVersion, + runId, + status: "not_started", + idempotencyKey: `${runId}:account-id`, + attempts: 0, + updatedAt: now, +} as CashWalletMigration + +const makeRepo = ({ + config = preConfig, + runnable = [runnableMigration], + updateResult = startedConfig, +}: { + config?: CashWalletCutoverConfig | RepositoryError + runnable?: CashWalletMigration[] | RepositoryError + updateResult?: CashWalletCutoverConfig | RepositoryError +} = {}) => ({ + getConfig: jest.fn().mockResolvedValue(config), + updateConfig: jest.fn().mockResolvedValue(updateResult), + listRunnableMigrations: jest.fn().mockResolvedValue(runnable), + countByStatus: jest.fn(), +}) + +describe("startPrimaryCashWalletCutover", () => { + it("rejects start when the requested run has no prepared runnable migrations", async () => { + const repo = makeRepo({ runnable: [] }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor, + now, + migrationsRepo: repo, + }) + + expect(result).toBeInstanceOf(CashWalletCutoverPreflightError) + expect(repo.listRunnableMigrations).toHaveBeenCalledWith({ + cutoverVersion, + runId, + limit: 1, + }) + expect(repo.updateConfig).not.toHaveBeenCalled() + }) + + it("propagates prepared-run repository errors before updating config", async () => { + const repoError = new UnknownRepositoryError("list runnable migrations failed") + const repo = makeRepo({ runnable: repoError }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor, + now, + migrationsRepo: repo, + }) + + expect(result).toBe(repoError) + expect(repo.updateConfig).not.toHaveBeenCalled() + }) + + it("starts when the requested run has a prepared runnable migration", async () => { + const repo = makeRepo() + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor, + now, + migrationsRepo: repo, + }) + + expect(result).toBe(startedConfig) + expect(repo.listRunnableMigrations).toHaveBeenCalledWith({ + cutoverVersion, + runId, + limit: 1, + }) + expect(repo.updateConfig).toHaveBeenCalledWith( + { + state: "in_progress", + cutoverVersion, + runId, + startedAt: now, + scheduledAt: undefined, + pausedAt: undefined, + pauseReason: undefined, + }, + actor, + ) + }) + + it("keeps same-run in-progress start idempotent without rechecking migrations", async () => { + const repo = makeRepo({ config: startedConfig, runnable: [] }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor, + now, + migrationsRepo: repo, + }) + + expect(result).toBe(startedConfig) + expect(repo.listRunnableMigrations).not.toHaveBeenCalled() + expect(repo.updateConfig).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts new file mode 100644 index 000000000..992183907 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts @@ -0,0 +1,107 @@ +import { + cashWalletHistoryWalletIdsForPresentation, + cashWalletHistoryWalletsForPresentation, +} from "@app/cash-wallet-cutover/presentation" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const accountId = "cash-account-id" as AccountId + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id: id as WalletId, + accountId, + currency, + type: WalletType.Checking, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: `lnurlp-${id}` as Lnurl, + }) as Wallet + +const btcWallet = wallet({ + id: "11111111-1111-4111-8111-111111111111", + currency: WalletCurrency.Btc, +}) +const legacyUsdWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Usdt, +}) + +describe("cash wallet history expansion for presentation", () => { + const legacyCompatPresentation = { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + + const usdtPresentation = { + wallets: [btcWallet, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + + const preCutoverPresentation = { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: legacyUsdWallet, + } + + it("keeps pre-cutover Cash Wallet history on legacy USD only", () => { + expect( + cashWalletHistoryWalletIdsForPresentation({ + presentation: preCutoverPresentation, + }), + ).toEqual([btcWallet.id, legacyUsdWallet.id]) + }) + + it("appends the legacy-compatible USD archive after active USDT history", () => { + expect( + cashWalletHistoryWalletIdsForPresentation({ + presentation: legacyCompatPresentation, + }), + ).toEqual([btcWallet.id, usdtWallet.id, legacyUsdWallet.id]) + }) + + it("expands explicit legacy USD history to active USDT then legacy archive", () => { + expect( + cashWalletHistoryWalletIdsForPresentation({ + walletIds: [legacyUsdWallet.id], + presentation: legacyCompatPresentation, + }), + ).toEqual([usdtWallet.id, legacyUsdWallet.id]) + }) + + it("expands explicit USDT history to active USDT then legacy archive", () => { + expect( + cashWalletHistoryWalletIdsForPresentation({ + walletIds: [usdtWallet.id], + presentation: usdtPresentation, + }), + ).toEqual([usdtWallet.id, legacyUsdWallet.id]) + }) + + it("leaves non-cash wallet filters unchanged", () => { + expect( + cashWalletHistoryWalletIdsForPresentation({ + walletIds: [btcWallet.id], + presentation: legacyCompatPresentation, + }), + ).toEqual([btcWallet.id]) + }) + + it("returns wallet objects for expanded wallet object history calls", () => { + expect( + cashWalletHistoryWalletsForPresentation({ + wallets: [legacyUsdWallet], + presentation: legacyCompatPresentation, + }), + ).toEqual([usdtWallet, legacyUsdWallet]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts b/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts new file mode 100644 index 000000000..c2338da0c --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/recipient-routing.spec.ts @@ -0,0 +1,58 @@ +import { resolveCashWalletRecipientMutationWalletId } from "@app/cash-wallet-cutover/recipient-routing" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const recipientAccountId = "recipient-account-id" as AccountId +const recipientWalletId = "11111111-1111-4111-8111-111111111111" as WalletId +const routedWalletId = "22222222-2222-4222-8222-222222222222" as WalletId + +const recipientWallet = { + id: recipientWalletId, + accountId: recipientAccountId, + currency: WalletCurrency.Usd, + type: WalletType.Checking, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurlp-recipient" as Lnurl, +} as Wallet + +const recipientAccount = { + id: recipientAccountId, + uuid: "recipient-account-uuid" as AccountUuid, +} as Account + +const client = { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, +} as const + +describe("resolveCashWalletRecipientMutationWalletId", () => { + it("routes recipient legacy USD wallet ids through the recipient account presentation", async () => { + const walletsRepo = { + findById: jest.fn().mockResolvedValue(recipientWallet), + listByAccountId: jest.fn(), + } + const accountsRepo = { + findById: jest.fn().mockResolvedValue(recipientAccount), + } + const resolveMutationWalletIdForAccount = jest.fn().mockResolvedValue(routedWalletId) + + const result = await resolveCashWalletRecipientMutationWalletId({ + recipientWalletId, + client, + walletsRepo, + accountsRepo, + resolveMutationWalletIdForAccount, + }) + + expect(result).toBe(routedWalletId) + expect(walletsRepo.findById).toHaveBeenCalledWith(recipientWalletId) + expect(accountsRepo.findById).toHaveBeenCalledWith(recipientAccountId) + expect(resolveMutationWalletIdForAccount).toHaveBeenCalledWith({ + account: recipientAccount, + walletId: recipientWalletId, + client, + walletsRepo, + }) + }) +}) diff --git a/test/flash/unit/app/offers/storage/redis-serde.spec.ts b/test/flash/unit/app/offers/storage/redis-serde.spec.ts new file mode 100644 index 000000000..3394755f4 --- /dev/null +++ b/test/flash/unit/app/offers/storage/redis-serde.spec.ts @@ -0,0 +1,73 @@ +import { OffersSerde } from "@app/offers/storage/OffersSerde" +import { CashoutDetails } from "@app/offers/types" +import { JMDAmount, USDAmount, USDTAmount } from "@domain/shared" + +const usd = (cents: string) => { + const amount = USDAmount.cents(cents) + if (amount instanceof Error) throw amount + return amount +} + +const jmd = (cents: string) => { + const amount = JMDAmount.cents(cents) + if (amount instanceof Error) throw amount + return amount +} + +const usdt = (cents: string) => { + const amount = USDTAmount.usdCents(cents) + if (amount instanceof Error) throw amount + return amount +} + +describe("OffersSerde", () => { + it("round-trips USDT cashout payment amounts as domain amount instances", () => { + const details: CashoutDetails = { + payment: { + userAcct: "22222222-2222-4222-8222-222222222222" as WalletId, + flashAcct: "44444444-4444-4444-8444-444444444444" as WalletId, + invoice: { + paymentRequest: "lnbc1test" as Bolt11, + expiresAt: new Date(Date.now() + 60_000), + } as LnInvoice, + amount: usdt("100"), + }, + payout: { + bankAccountId: "12345 - First Global", + amount: jmd("15500"), + serviceFee: usd("0"), + exchangeRate: jmd("15500"), + }, + } + + const parsed = OffersSerde.deserialize(OffersSerde.serialize(details)) + + expect(parsed.payment.amount).toBeInstanceOf(USDTAmount) + expect(parsed.payment.amount.isLesserThan(usdt("101"))).toBe(true) + expect(parsed.payment.invoice.expiresAt).toBeInstanceOf(Date) + expect(parsed.payout.amount).toBeInstanceOf(JMDAmount) + expect(parsed.payout.serviceFee).toBeInstanceOf(USDAmount) + }) + + it("throws instead of hydrating invalid amount tuples into Error objects", () => { + const invalidSerializedOffer = JSON.stringify({ + payment: { + userAcct: "22222222-2222-4222-8222-222222222222", + flashAcct: "44444444-4444-4444-8444-444444444444", + invoice: { + paymentRequest: "lnbc1test", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + amount: ["not-a-number", "USDT"], + }, + payout: { + bankAccountId: "12345 - First Global", + amount: ["15500", "JMD"], + serviceFee: ["0", "USD"], + exchangeRate: ["15500", "JMD"], + }, + }) + + expect(() => OffersSerde.deserialize(invalidSerializedOffer)).toThrow() + }) +}) 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/app/payments/send-intraledger.spec.ts b/test/flash/unit/app/payments/send-intraledger.spec.ts new file mode 100644 index 000000000..f92548a46 --- /dev/null +++ b/test/flash/unit/app/payments/send-intraledger.spec.ts @@ -0,0 +1,262 @@ +const mockAddInvoice = jest.fn() +const mockPayInvoice = jest.fn() +const mockFindWalletById = jest.fn() +const mockFindAccountById = jest.fn() + +jest.mock("@config", () => ({ + getCallbackServiceConfig: jest.fn(() => ({})), + getValuesToSkipProbe: jest.fn(() => []), +})) + +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@app/prices", () => ({ + btcFromUsdMidPriceFn: jest.fn(), + getCurrentPriceAsDisplayPriceRatio: jest.fn(), + usdFromBtcMidPriceFn: jest.fn(), +})) + +jest.mock("@app/wallets", () => { + const { MismatchedCurrencyForWalletError } = jest.requireActual("@domain/errors") + const { WalletCurrency } = jest.requireActual("@domain/shared") + + const validateIsBtcWallet = jest.fn(async () => true) + const validateIsUsdWallet = jest.fn(async (walletId, args) => { + const wallet = await mockFindWalletById(walletId) + if (wallet instanceof Error) return wallet + + if ( + wallet.currency === WalletCurrency.Usd || + (args?.includeUsdt === true && wallet.currency === WalletCurrency.Usdt) + ) { + return true + } + + return new MismatchedCurrencyForWalletError() + }) + + return { validateIsBtcWallet, validateIsUsdWallet } +}) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + addInvoice: (...args: unknown[]) => mockAddInvoice(...args), + payInvoice: (...args: unknown[]) => mockPayInvoice(...args), + }, +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindAccountById(...args), + })), + WalletsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + })), + UsersRepository: jest.fn(), +})) + +jest.mock("@services/dealer-price", () => ({ + DealerPriceService: jest.fn(() => ({})), +})) + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(() => ({})), +})) + +jest.mock("@services/ledger", () => ({ + LedgerService: jest.fn(() => ({})), +})) + +jest.mock("@services/ledger/facade", () => ({})) + +jest.mock("@services/notifications", () => ({ + NotificationsService: jest.fn(() => ({})), +})) + +jest.mock("@services/svix", () => ({ + CallbackService: jest.fn(() => ({})), +})) + +jest.mock("@app/payments/helpers", () => ({ + addContactsAfterSend: jest.fn(), + checkIntraledgerLimits: jest.fn(async () => true), + checkTradeIntraAccountLimits: jest.fn(async () => true), + getPriceRatioForLimits: jest.fn(async () => ({})), +})) + +import { intraledgerPaymentSendWalletIdForUsdWallet } from "@app/payments/send-intraledger" +import { MismatchedCurrencyForWalletError } from "@domain/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +const senderUsdWalletId = "11111111-1111-4111-8111-111111111111" as WalletId +const senderUsdtWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const recipientUsdWalletId = "33333333-3333-4333-8333-333333333333" as WalletId +const recipientUsdtWalletId = "44444444-4444-4444-8444-444444444444" as WalletId + +const activeAccount = (id: string) => + ({ + id, + status: "active", + level: 1, + }) as unknown as Account + +const wallet = ({ + id, + accountId, + currency, +}: { + id: string + accountId: string + currency: string +}) => + ({ + id, + accountId, + currency, + }) as unknown as Wallet + +describe("intraledgerPaymentSendWalletIdForUsdWallet", () => { + beforeEach(() => { + jest.clearAllMocks() + + mockFindAccountById.mockImplementation(async (accountId: AccountId) => + activeAccount(accountId as string), + ) + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1recipient" } }) + mockPayInvoice.mockResolvedValue({ status: 2 }) + }) + + it("keeps USD to USD using cent amount semantics", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdWalletId) { + return wallet({ + id: senderUsdWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usd, + }) + } + return wallet({ + id: recipientUsdWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usd, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 19446, + memo: "USD intraledger", + }) + + expect(result).toEqual({ value: "success" }) + expect(mockAddInvoice).toHaveBeenCalledWith({ + accountId: recipientUsdWalletId, + amount: expect.any(USDAmount), + memo: "USD intraledger", + }) + expect(mockAddInvoice.mock.calls[0][0].amount.asCents()).toBe("19446") + expect(mockAddInvoice.mock.calls[0][0].amount.toIbex()).toBe(194.46) + expect(mockPayInvoice).toHaveBeenCalledWith({ + accountId: senderUsdWalletId, + invoice: "lnbc1recipient", + }) + }) + + it("sends USDT to USDT using USD-cent amount semantics", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdtWalletId) { + return wallet({ + id: senderUsdtWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usdt, + }) + } + return wallet({ + id: recipientUsdtWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usdt, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdtWalletId, + recipientWalletId: recipientUsdtWalletId, + amount: 19446, + memo: "USDT intraledger", + }) + + expect(result).toEqual({ value: "success" }) + expect(mockAddInvoice).toHaveBeenCalledWith({ + accountId: recipientUsdtWalletId, + amount: expect.any(USDTAmount), + memo: "USDT intraledger", + }) + expect(mockAddInvoice.mock.calls[0][0].amount.asSmallestUnits()).toBe("194460000") + expect(mockAddInvoice.mock.calls[0][0].amount.toIbex()).toBe(194.46) + expect(mockPayInvoice).toHaveBeenCalledWith({ + accountId: senderUsdtWalletId, + invoice: "lnbc1recipient", + }) + }) + + it("rejects USD to USDT as a mixed-currency intraledger payment", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdWalletId) { + return wallet({ + id: senderUsdWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usd, + }) + } + return wallet({ + id: recipientUsdtWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usdt, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdtWalletId, + amount: 100, + memo: "mixed currency", + }) + + expect(result).toBeInstanceOf(MismatchedCurrencyForWalletError) + expect(mockAddInvoice).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) + + it("rejects USDT to USD as a mixed-currency intraledger payment", async () => { + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + if (walletId === senderUsdtWalletId) { + return wallet({ + id: senderUsdtWalletId, + accountId: "sender-account", + currency: WalletCurrency.Usdt, + }) + } + return wallet({ + id: recipientUsdWalletId, + accountId: "recipient-account", + currency: WalletCurrency.Usd, + }) + }) + + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdtWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 100, + memo: "mixed currency", + }) + + expect(result).toBeInstanceOf(MismatchedCurrencyForWalletError) + expect(mockAddInvoice).not.toHaveBeenCalled() + expect(mockPayInvoice).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts new file mode 100644 index 000000000..4c7fe2680 --- /dev/null +++ b/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts @@ -0,0 +1,38 @@ +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import { USDTAmount, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getAccountDetails: jest.fn(), + getCryptoReceiveBalance: jest.fn(), + }, +})) + +describe("getBalanceForWallet", () => { + beforeEach(() => { + jest.mocked(Ibex.getAccountDetails).mockReset() + jest.mocked(Ibex.getCryptoReceiveBalance).mockReset() + }) + + it("loads USDT balances from the IBEX account id, not a crypto receive-info id", async () => { + const balance = USDTAmount.ZERO + jest.mocked(Ibex.getAccountDetails).mockResolvedValue({ + id: "ibex-account-id", + balance, + } as never) + + const result = await getBalanceForWallet({ + walletId: "ibex-account-id" as WalletId, + currency: WalletCurrency.Usdt, + }) + + expect(Ibex.getAccountDetails).toHaveBeenCalledWith( + "ibex-account-id", + WalletCurrency.Usdt, + ) + expect(Ibex.getCryptoReceiveBalance).not.toHaveBeenCalled() + expect(result).toBe(balance) + }) +}) diff --git a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts new file mode 100644 index 000000000..d8503e30e --- /dev/null +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -0,0 +1,242 @@ +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getAccountTransactions: jest.fn(), + }, +})) + +import { + getTransactionsForWallets, + toWalletTransactions, +} from "@app/wallets/get-transactions-for-wallet" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import Ibex from "@services/ibex/client" +import { baseLogger } from "@services/logger" +import { GResponse200 } from "ibex-client" + +const accountId = "account-id" as AccountId + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id: id as WalletId, + accountId, + currency, + type: WalletType.Checking, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: `lnurlp-${id}` as Lnurl, + }) as Wallet + +describe("toWalletTransactions", () => { + it("maps IBEX USDT currency id to USDT wallet currency with integer micros", () => { + const transactions = toWalletTransactions([ + { + id: "trx-id-1", + accountId: "wallet-id", + amount: 0.17531, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + { + id: "trx-id-2", + accountId: "wallet-id", + amount: 9.824690376349, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transactions).toHaveLength(2) + expect(transactions[0].settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transactions[0].settlementAmount).toBe(175_310) + expect(transactions[1].settlementAmount).toBe(9_824_690) + expect( + transactions.reduce((sum, transaction) => sum + transaction.settlementAmount, 0), + ).toBe(10_000_000) + }) + + it("maps IBEX USDT send amounts to negative integer micros", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 0.5, + networkFee: 0.000001, + currencyId: 29, + transactionTypeId: 2, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(-500_000) + expect(transaction.settlementDisplayAmount).toBe("-0.5") + expect(transaction.settlementFee).toBe(1) + }) + + it("maps IBEX crypto send transaction type to outgoing on-chain USDT", () => { + const [transaction] = toWalletTransactions([ + { + id: "crypto-send-trx-id", + accountId: "wallet-id", + amount: 2.5, + networkFee: 0.179554, + currencyId: 29, + transactionTypeId: 10, + createdAt: "2026-06-17T05:42:53.512218Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(-2_500_000) + expect(transaction.settlementDisplayAmount).toBe("-2.5") + expect(transaction.settlementFee).toBe(179_554) + expect(transaction.settlementDisplayFee).toBe("0.179554") + expect(transaction.initiationVia.type).toBe("onchain") + expect(transaction.settlementVia.type).toBe("onchain") + }) + + it("defaults omitted IBEX USDT amount and network fee to zero micros", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usdt) + expect(transaction.settlementAmount).toBe(0) + expect(transaction.settlementFee).toBe(0) + }) + + it("logs USDT conversion errors with error details", () => { + const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation() + + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: Number.NaN, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementAmount).toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error), amount: expect.any(Number) }), + "Failed to parse IBEX USDT amount", + ) + + errorSpy.mockRestore() + }) + + it("keeps IBEX USD amounts in integer cents", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 500, + networkFee: 12, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd) + expect(transaction.settlementAmount).toBe(500) + expect(transaction.settlementFee).toBe(12) + }) + + it("does not silently classify unknown IBEX currency ids as BTC", () => { + const [transaction] = toWalletTransactions([ + { + id: "trx-id", + accountId: "wallet-id", + amount: 100, + currencyId: 999, + transactionTypeId: 1, + createdAt: "2026-05-13T00:00:00.000Z", + }, + ] as GResponse200) + + expect(transaction.settlementCurrency).not.toBe(WalletCurrency.Btc) + expect(transaction.initiationVia.type).toBe("unknown") + expect(transaction.settlementVia.type).toBe("unknown") + }) +}) + +describe("getTransactionsForWallets", () => { + beforeEach(() => { + jest.mocked(Ibex.getAccountTransactions).mockReset() + }) + + it("concatenates active wallet history before the legacy archive wallet history", async () => { + const activeSettlementWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Usdt, + }) + const legacyUsdWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, + }) + + jest + .mocked(Ibex.getAccountTransactions) + .mockImplementation(async ({ account_id }) => { + if (account_id === activeSettlementWallet.id) { + return [ + { + id: "active-newer", + accountId: activeSettlementWallet.id, + amount: 100, + currencyId: 29, + transactionTypeId: 1, + createdAt: "2026-06-01T00:00:00.000Z", + }, + ] as GResponse200 + } + + return [ + { + id: "legacy-archive-older", + accountId: legacyUsdWallet.id, + amount: 200, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-05-01T00:00:00.000Z", + }, + ] as GResponse200 + }) + + const result = await getTransactionsForWallets({ + wallets: [activeSettlementWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + }) + + expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(1, { + account_id: activeSettlementWallet.id, + limit: 20, + page: 0, + sort: "settledAt", + }) + expect(Ibex.getAccountTransactions).toHaveBeenNthCalledWith(2, { + account_id: legacyUsdWallet.id, + limit: 20, + page: 0, + sort: "settledAt", + }) + expect(result.result?.slice.map((transaction) => transaction.id)).toEqual([ + "active-newer", + "legacy-archive-older", + ]) + }) +}) diff --git a/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts new file mode 100644 index 000000000..905c76477 --- /dev/null +++ b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts @@ -0,0 +1,27 @@ +import { usdWalletAmountFromInput } from "@app/wallets/usd-wallet-amount" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +describe("usdWalletAmountFromInput", () => { + it("treats USD wallet input as cents", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Usd) + + expect(amount).toBeInstanceOf(USDAmount) + expect((amount as USDAmount).asCents()).toBe("19446") + expect((amount as USDAmount).toIbex()).toBe(194.46) + }) + + it("treats USDT wallet input as USD cents", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Usdt) + + expect(amount).toBeInstanceOf(USDTAmount) + expect((amount as USDTAmount).asSmallestUnits()).toBe("194460000") + expect((amount as USDTAmount).asNumber()).toBe("194.460000") + expect((amount as USDTAmount).toIbex()).toBe(194.46) + }) + + it("rejects BTC", () => { + const amount = usdWalletAmountFromInput("19446", WalletCurrency.Btc) + + expect(amount).toBeInstanceOf(Error) + }) +}) diff --git a/test/flash/unit/config/schema.spec.ts b/test/flash/unit/config/schema.spec.ts new file mode 100644 index 000000000..2da3f71ae --- /dev/null +++ b/test/flash/unit/config/schema.spec.ts @@ -0,0 +1,10 @@ +import { configSchema } from "../../../../src/config/schema" + +describe("config schema", () => { + it("requires bridge developerFeePercent without a hardcoded default", () => { + const bridgeSchema = configSchema.properties.bridge + + expect(bridgeSchema.properties.developerFeePercent).toEqual({ type: "number" }) + expect(bridgeSchema.required).toContain("developerFeePercent") + }) +}) diff --git a/test/flash/unit/dev/setup-bridge-webhooks.spec.ts b/test/flash/unit/dev/setup-bridge-webhooks.spec.ts new file mode 100644 index 000000000..4668525a3 --- /dev/null +++ b/test/flash/unit/dev/setup-bridge-webhooks.spec.ts @@ -0,0 +1,126 @@ +import { + buildWebhookDefinitions, + extractNgrokHttpsUrl, + mergeDevOverrides, + reconcileBridgeWebhooks, +} from "../../../../dev/setup-bridge-webhooks" + +describe("setup-bridge-webhooks", () => { + it("builds one Bridge webhook definition per local route", () => { + const definitions = buildWebhookDefinitions("https://flash-dev.ngrok-free.app") + + expect(definitions).toEqual({ + kyc: { + url: "https://flash-dev.ngrok-free.app/kyc", + eventCategories: ["customer", "kyc_link"], + }, + deposit: { + url: "https://flash-dev.ngrok-free.app/deposit", + eventCategories: ["virtual_account.activity", "bridge_wallet.activity"], + }, + transfer: { + url: "https://flash-dev.ngrok-free.app/transfer", + eventCategories: ["transfer"], + }, + external_account: { + url: "https://flash-dev.ngrok-free.app/external-account", + eventCategories: ["external_account"], + }, + }) + }) + + it("extracts the HTTPS ngrok public URL", () => { + const url = extractNgrokHttpsUrl({ + tunnels: [ + { proto: "http", public_url: "http://example.ngrok-free.app" }, + { proto: "https", public_url: "https://example.ngrok-free.app" }, + ], + }) + + expect(url).toBe("https://example.ngrok-free.app") + }) + + it("merges Bridge secrets and webhook public keys into existing dev overrides", () => { + const merged = mergeDevOverrides( + { + ibex: { environment: "sandbox" }, + bridge: { webhook: { replaySecret: "keep-me" } }, + }, + { + apiKey: "sk-test-123", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + webhookBaseUrl: "https://example.ngrok-free.app", + publicKeys: { + kyc: "kyc-pem", + deposit: "deposit-pem", + transfer: "transfer-pem", + external_account: "external-account-pem", + }, + }, + ) + + expect(merged).toEqual({ + ibex: { environment: "sandbox" }, + bridge: { + apiKey: "sk-test-123", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + webhook: { + replaySecret: "keep-me", + uri: "https://example.ngrok-free.app", + publicKeys: { + kyc: "kyc-pem", + deposit: "deposit-pem", + transfer: "transfer-pem", + external_account: "external-account-pem", + }, + }, + }, + }) + }) + + it("deletes old webhooks, creates new disabled webhooks, then enables them", async () => { + const calls: string[] = [] + const definitions = buildWebhookDefinitions("https://fresh.ngrok-free.app") + const api = { + listWebhooks: jest.fn().mockResolvedValue([ + { id: "wep_old_1", status: "active", url: "https://old.example/kyc" }, + { id: "wep_old_2", status: "disabled", url: "https://old.example/deposit" }, + { id: "wep_deleted", status: "deleted", url: "https://old.example/deleted" }, + ]), + deleteWebhook: jest.fn(async (id: string) => { + calls.push(`delete:${id}`) + }), + createWebhook: jest.fn(async ({ key }: { key: string }) => { + calls.push(`create:${key}`) + return { + id: `wep_${key}`, + public_key: `${key}-public-key`, + } + }), + enableWebhook: jest.fn(async (id: string) => { + calls.push(`enable:${id}`) + }), + } + + const result = await reconcileBridgeWebhooks(api, definitions) + + expect(calls).toEqual([ + "delete:wep_old_1", + "delete:wep_old_2", + "create:kyc", + "enable:wep_kyc", + "create:deposit", + "enable:wep_deposit", + "create:transfer", + "enable:wep_transfer", + "create:external_account", + "enable:wep_external_account", + ]) + expect(result.publicKeys).toEqual({ + kyc: "kyc-public-key", + deposit: "deposit-public-key", + transfer: "transfer-public-key", + external_account: "external_account-public-key", + }) + }) +}) diff --git a/test/flash/unit/domain/payments/index.spec.ts b/test/flash/unit/domain/payments/index.spec.ts index 2aecb5119..95f28c06a 100644 --- a/test/flash/unit/domain/payments/index.spec.ts +++ b/test/flash/unit/domain/payments/index.spec.ts @@ -37,6 +37,15 @@ describe("checkedToBtcPaymentAmount", () => { }) describe("checkedToUsdPaymentAmount", () => { + it("returns USDT payment amounts when called with a USDT wallet currency", () => { + expect(checkedToUsdPaymentAmount(19446, WalletCurrency.Usdt)).toEqual( + expect.objectContaining({ + currency: WalletCurrency.Usdt, + amount: 19446n, + }), + ) + }) + it("errors on null", () => { expect(checkedToUsdPaymentAmount(null)).toBeInstanceOf(InvalidUsdPaymentAmountError) }) diff --git a/test/flash/unit/domain/shared/Money.spec.ts b/test/flash/unit/domain/shared/Money.spec.ts index bd545f212..ef6065309 100644 --- a/test/flash/unit/domain/shared/Money.spec.ts +++ b/test/flash/unit/domain/shared/Money.spec.ts @@ -1,4 +1,5 @@ -import { JMDAmount, USDAmount, WalletCurrency } from "@domain/shared" +import { JMDAmount, USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import { MoneyAmount } from "@domain/shared/MoneyAmount" import JmdAmount from "@graphql/shared/types/scalar/jmd-amount" describe("Money Amount", () => { @@ -135,4 +136,51 @@ describe("Money Amount", () => { }) }) }) -}) \ No newline at end of file + + describe("USDT Amount", () => { + it("converts USD cents into USDT micro-units", () => { + const amount = USDTAmount.usdCents("100") + if (amount instanceof Error) throw amount + + expect(amount.asSmallestUnits()).toBe("1000000") + expect(amount.asNumber()).toBe("1.000000") + expect(amount.asUsdCents()).toBe("100") + expect(amount.toIbex()).toBe(1) + }) + + it("converts one USD cent into ten thousand USDT micro-units", () => { + const amount = USDTAmount.usdCents("1") + if (amount instanceof Error) throw amount + + expect(amount.asSmallestUnits()).toBe("10000") + expect(amount.asUsdCents()).toBe("1") + expect(amount.toIbex()).toBe(0.01) + }) + + it("rounds provider-originated sub-cent USDT to integer USD cents", () => { + const amount = USDTAmount.smallestUnits("19446") + if (amount instanceof Error) throw amount + + expect(amount.asUsdCents()).toBe("2") + }) + + it("keeps generic MoneyAmount USDT construction on internal micro-units", () => { + const amount = MoneyAmount.from("100", WalletCurrency.Usdt) + if (amount instanceof Error) throw amount + + expect(amount).toBeInstanceOf(USDTAmount) + expect((amount as USDTAmount).asSmallestUnits()).toBe("100") + }) + + it("round-trips USDT JSON without reinterpreting micros as app cents", () => { + const amount = USDTAmount.smallestUnits("19446") + if (amount instanceof Error) throw amount + + const restored = MoneyAmount.fromJSON(amount.toJson()) + if (restored instanceof Error) throw restored + + expect(restored).toBeInstanceOf(USDTAmount) + expect((restored as USDTAmount).asSmallestUnits()).toBe("19446") + }) + }) +}) diff --git a/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts b/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts new file mode 100644 index 000000000..241dae0f7 --- /dev/null +++ b/test/flash/unit/domain/wallets/onchain-usd-wallet-validator.spec.ts @@ -0,0 +1,30 @@ +import { AccountStatus } from "@domain/accounts" +import { OnchainUsdPaymentValidator } from "@domain/wallets" +import { USDTAmount, WalletCurrency, isValidated } from "@domain/shared" + +const account = { + id: "account-id" as AccountId, + status: AccountStatus.Active, +} as Account + +const usdtWallet = { + id: "wallet-id" as WalletId, + accountId: account.id, + currency: WalletCurrency.Usdt, +} as Wallet + +describe("Onchain USD wallet payment validation", () => { + it("accepts USDT USD wallets", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + + const result = await OnchainUsdPaymentValidator({ + account, + wallet: usdtWallet, + accountId: usdtWallet.id as IbexAccountId, + address: "0xabc" as OnChainAddress, + amount, + }) + + expect(isValidated(result)).toBe(true) + }) +}) diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts new file mode 100644 index 000000000..de19ea6b3 --- /dev/null +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -0,0 +1,100 @@ +import { mapAndParseErrorForGqlResponse, mapError } from "@graphql/error-map" +import { + BridgeAccountLevelError, + BridgeApiError, + BridgeBelowMinimumWithdrawalError, + BridgeCustomerNotFoundError, + BridgeDisabledError, + BridgeError, + BridgeInsufficientFundsError, + BridgeInvalidAmountError, + BridgeKycOffboardedError, + BridgeKycPendingError, + BridgeKycRejectedError, + BridgeKycTierCeilingExceededError, + BridgeRateLimitError, + BridgeTimeoutError, + BridgeTransferFailedError, + BridgeWebhookValidationError, + BridgeWithdrawalNetAmountTooLowError, + mapBridgeHttpError, +} from "@services/bridge/errors" + +describe("error-map: Bridge errors", () => { + const cases: Array<[Error, string]> = [ + [new BridgeInvalidAmountError(), "BRIDGE_INVALID_AMOUNT"], + [new BridgeBelowMinimumWithdrawalError(10), "BRIDGE_BELOW_MINIMUM_WITHDRAWAL"], + [new BridgeDisabledError(), "BRIDGE_DISABLED"], + [new BridgeAccountLevelError(), "BRIDGE_ACCOUNT_LEVEL_ERROR"], + [new BridgeKycPendingError(), "BRIDGE_KYC_PENDING"], + [new BridgeKycRejectedError(), "BRIDGE_KYC_REJECTED"], + [new BridgeKycOffboardedError(), "BRIDGE_KYC_OFFBOARDED"], + [new BridgeCustomerNotFoundError(), "BRIDGE_CUSTOMER_NOT_FOUND"], + [new BridgeInsufficientFundsError(), "BRIDGE_INSUFFICIENT_FUNDS"], + [ + new BridgeWithdrawalNetAmountTooLowError(), + "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", + ], + [new BridgeRateLimitError(), "BRIDGE_RATE_LIMIT"], + [new BridgeTimeoutError(), "BRIDGE_TIMEOUT"], + [new BridgeTransferFailedError(), "BRIDGE_TRANSFER_FAILED"], + [new BridgeWebhookValidationError(), "BRIDGE_WEBHOOK_VALIDATION"], + [new BridgeKycTierCeilingExceededError(), "BRIDGE_KYC_TIER_CEILING_EXCEEDED"], + [new BridgeApiError("Bridge API error", 500), "BRIDGE_API_ERROR"], + [new BridgeError("Bridge unavailable"), "BRIDGE_ERROR"], + ] + + it.each(cases)("maps %p to %s", (input, expectedCode) => { + const result = mapError(input as ApplicationError) + + expect(result.extensions.code).toBe(expectedCode) + expect(result.extensions.code).not.toBe("INVALID_INPUT") + expect(result.message).toBeTruthy() + }) + + it.each(cases)("parses %p into payload error code %s", (input, expectedCode) => { + const result = mapAndParseErrorForGqlResponse(input as ApplicationError) + + expect(result.code).toBe(expectedCode) + expect(result.code).not.toBe("INVALID_INPUT") + expect(result.message).toBeTruthy() + }) +}) + +describe("error-map: mapBridgeHttpError KYC tier ceiling detection", () => { + it("detects KYC tier ceiling via error.type", () => { + const result = mapBridgeHttpError(422, { + error: { type: "kyc_tier_limit_exceeded", message: "KYC tier limit reached" }, + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + expect(result.message).toBe("KYC tier limit reached") + }) + + it("detects KYC tier ceiling via error.type kyc_limit", () => { + const result = mapBridgeHttpError(400, { + error: { type: "kyc_limit_exceeded", message: "KYC limit exceeded" }, + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("detects KYC tier ceiling via response.message", () => { + const result = mapBridgeHttpError(422, { + message: "exceeds kyc ceiling", + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("does not detect on unrelated 422 errors", () => { + const result = mapBridgeHttpError(422, { + error: { type: "validation_error", message: "Invalid amount" }, + }) + expect(result).not.toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("does not detect on non-422/400 errors", () => { + const result = mapBridgeHttpError(500, { + error: { type: "kyc_tier_limit_exceeded", message: "KYC tier limit" }, + }) + expect(result).not.toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) +}) diff --git a/test/flash/unit/graphql/error-map.spec.ts b/test/flash/unit/graphql/error-map.spec.ts index 9be4c30e9..4cdb7c3ff 100644 --- a/test/flash/unit/graphql/error-map.spec.ts +++ b/test/flash/unit/graphql/error-map.spec.ts @@ -1,7 +1,33 @@ import { mapError } from "@graphql/error-map" import { PhoneAccountAlreadyExistsCannotUpgradeError } from "@services/kratos" +import { + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, + BridgeDepositInstructionsMissingError, +} from "@services/bridge/errors" describe("error-map", () => { + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND", () => { + const result = mapError(new BridgeWithdrawalNotFoundError()) + + expect(result.extensions.code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result.message).toContain("Withdrawal request not found") + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED", () => { + const result = mapError(new BridgeWithdrawalAlreadyInitiatedError()) + + expect(result.extensions.code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result.message).toContain("already been submitted") + }) + + it("maps BridgeDepositInstructionsMissingError to BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", () => { + const result = mapError(new BridgeDepositInstructionsMissingError()) + + expect(result.extensions.code).toBe("BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING") + expect(result.message).toContain("deposit instructions") + }) + it("maps PhoneAccountAlreadyExistsCannotUpgradeError to correct GQL error", () => { const input = new PhoneAccountAlreadyExistsCannotUpgradeError() const result = mapError(input) diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts new file mode 100644 index 000000000..9d9223261 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts @@ -0,0 +1,214 @@ +// jest.mock calls are hoisted before imports + +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + requestWithdrawal: jest.fn(), + initiateWithdrawal: jest.fn(), + cancelWithdrawalRequest: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import BridgeService from "@services/bridge" +import BridgeRequestWithdrawalMutation from "@graphql/public/root/mutation/bridge-request-withdrawal" +import BridgeInitiateWithdrawalMutation from "@graphql/public/root/mutation/bridge-initiate-withdrawal" +import BridgeCancelWithdrawalRequestMutation from "@graphql/public/root/mutation/bridge-cancel-withdrawal-request" +import { + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, +} from "@services/bridge/errors" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-001" +const AMOUNT = "50" +const WITHDRAWAL_ID = "withdrawal-001" +const TRANSFER_ID = "transfer-001" +const CREATED_AT = new Date("2026-01-01T00:00:00Z") + +const ctx = { + domainAccount: { id: ACCOUNT_ID, level: 2 }, +} as unknown as GraphQLPublicContextAuth + +const makePendingRow = (overrides: Record = {}) => ({ + id: WITHDRAWAL_ID, + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending" as const, + createdAt: CREATED_AT, + ...overrides, +}) + +// ── bridgeRequestWithdrawal ─────────────────────────────────────────────────── + +describe("bridgeRequestWithdrawal resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("creates a pending withdrawal and returns it", async () => { + const pendingRow = makePendingRow() + ;(BridgeService.requestWithdrawal as jest.Mock).mockResolvedValue(pendingRow) + + const result = await BridgeRequestWithdrawalMutation.resolve?.( + null, + { input: { amount: AMOUNT, externalAccountId: EXTERNAL_ACCOUNT_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.requestWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal).toEqual(pendingRow) + expect(result?.withdrawal?.status).toBe("pending") + expect(result?.withdrawal?.externalAccountId).toBe(EXTERNAL_ACCOUNT_ID) + }) + + it("returns an existing pending row when the service deduplicates the request", async () => { + const existingRow = makePendingRow({ id: "existing-withdrawal-001" }) + ;(BridgeService.requestWithdrawal as jest.Mock).mockResolvedValue(existingRow) + + const result = await BridgeRequestWithdrawalMutation.resolve?.( + null, + { input: { amount: AMOUNT, externalAccountId: EXTERNAL_ACCOUNT_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.id).toBe("existing-withdrawal-001") + expect(result?.withdrawal?.status).toBe("pending") + }) +}) + +// ── bridgeInitiateWithdrawal ────────────────────────────────────────────────── + +describe("bridgeInitiateWithdrawal resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("submits the pending row and returns the withdrawal with bridgeTransferId recorded", async () => { + const initiatedRow = makePendingRow({ bridgeTransferId: TRANSFER_ID, status: "submitted" }) + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue(initiatedRow) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.initiateWithdrawal).toHaveBeenCalledWith(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.status).toBe("submitted") + expect(result?.withdrawal?.bridgeTransferId).toBe(TRANSFER_ID) + }) + + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND when ID is missing or wrong-owner", async () => { + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( + new BridgeWithdrawalNotFoundError(), + ) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result?.withdrawal).toBeUndefined() + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED", async () => { + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( + new BridgeWithdrawalAlreadyInitiatedError(), + ) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result?.withdrawal).toBeUndefined() + }) +}) + +// ── bridgeCancelWithdrawalRequest ───────────────────────────────────────────── + +describe("bridgeCancelWithdrawalRequest resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("delegates to cancelWithdrawalRequest and returns the cancelled withdrawal", async () => { + const cancelledRow = makePendingRow({ status: "cancelled" }) + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue(cancelledRow) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.cancelWithdrawalRequest).toHaveBeenCalledWith( + ACCOUNT_ID, + WITHDRAWAL_ID, + ) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.status).toBe("cancelled") + expect(result?.withdrawal?.id).toBe(WITHDRAWAL_ID) + expect(result?.withdrawal?.amount).toBe(AMOUNT) + }) + + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND when ID is missing or wrong-owner", async () => { + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue( + new BridgeWithdrawalNotFoundError(), + ) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result?.withdrawal).toBeUndefined() + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED when already submitted", async () => { + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue( + new BridgeWithdrawalAlreadyInitiatedError(), + ) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result?.withdrawal).toBeUndefined() + }) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.spec.ts b/test/flash/unit/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.spec.ts new file mode 100644 index 000000000..f7b2834d8 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient.spec.ts @@ -0,0 +1,69 @@ +const mockAddInvoiceForRecipientForUsdWallet = jest.fn() +const mockResolveCashWalletRecipientMutationWalletId = jest.fn() + +jest.mock("@app", () => ({ + Wallets: { + addInvoiceForRecipientForUsdWallet: ( + ...args: Parameters + ) => mockAddInvoiceForRecipientForUsdWallet(...args), + }, +})) + +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletRecipientMutationWalletId: ( + ...args: Parameters + ) => mockResolveCashWalletRecipientMutationWalletId(...args), +})) + +import LnUsdInvoiceCreateOnBehalfOfRecipientMutation from "@graphql/public/root/mutation/ln-usd-invoice-create-on-behalf-of-recipient" + +const recipientWalletId = "11111111-1111-4111-8111-111111111111" as WalletId +const routedWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const amount = 1234 as UsdCents +const invoice = { paymentRequest: "lnbc1-routed" } as LnInvoice + +const client = { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, +} as const + +describe("LnUsdInvoiceCreateOnBehalfOfRecipientMutation", () => { + beforeEach(() => { + jest.clearAllMocks() + mockResolveCashWalletRecipientMutationWalletId.mockResolvedValue(routedWalletId) + mockAddInvoiceForRecipientForUsdWallet.mockResolvedValue(invoice) + }) + + it("routes fixed-amount recipient invoices through the recipient account active settlement wallet", async () => { + const result = await LnUsdInvoiceCreateOnBehalfOfRecipientMutation.resolve?.( + null, + { + input: { + recipientWalletId, + amount, + memo: "recipient memo" as Memo, + descriptionHash: undefined, + expiresIn: 5 as Minutes, + }, + }, + { cashWalletClientCapabilities: client } as GraphQLPublicContext, + {} as never, + ) + + expect(mockResolveCashWalletRecipientMutationWalletId).toHaveBeenCalledWith({ + recipientWalletId, + client, + }) + expect(mockAddInvoiceForRecipientForUsdWallet).toHaveBeenCalledWith({ + recipientWalletId: routedWalletId, + amount, + memo: "recipient memo", + descriptionHash: undefined, + expiresIn: 5, + }) + expect(result).toEqual({ + errors: [], + invoice, + }) + }) +}) 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() + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts b/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts new file mode 100644 index 000000000..2f3023543 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts @@ -0,0 +1,62 @@ +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + getWithdrawals: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } + logger.child.mockReturnValue(logger) + return { baseLogger: logger } +}) + +import BridgeService from "@services/bridge" +import bridgeWithdrawals from "@graphql/public/root/query/bridge-withdrawals" + +const ACCOUNT_ID = "account-001" as AccountId +const WITHDRAWAL_ID = "withdrawal-001" +const TRANSFER_ID = "transfer-001" +const CREATED_AT = "2026-01-01T00:00:00.000Z" + +const ctx = { + domainAccount: { id: ACCOUNT_ID, level: 2 }, +} as unknown as GraphQLPublicContextAuth + +describe("bridgeWithdrawals resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns service rows with id/status for the BridgeWithdrawal GraphQL type", async () => { + const serviceRow = { + id: WITHDRAWAL_ID, + amount: "50", + currency: "usdt", + externalAccountId: "ext-001", + status: "submitted", + bridgeTransferId: TRANSFER_ID, + failureReason: undefined, + createdAt: CREATED_AT, + } + ;(BridgeService.getWithdrawals as jest.Mock).mockResolvedValue([serviceRow]) + + const result = await bridgeWithdrawals.resolve?.(null, {}, ctx, {} as never) + + expect(BridgeService.getWithdrawals).toHaveBeenCalledWith(ACCOUNT_ID) + expect(result).toEqual([serviceRow]) + expect(result?.[0].id).toBe(WITHDRAWAL_ID) + expect(result?.[0].status).toBe("submitted") + expect((result?.[0] as Record).transferId).toBeUndefined() + expect((result?.[0] as Record).state).toBeUndefined() + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts new file mode 100644 index 000000000..254bcca73 --- /dev/null +++ b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts @@ -0,0 +1,116 @@ +jest.mock("@config", () => { + const path = require("path") + const { I18n } = require("i18n") + const i18n = new I18n() + i18n.configure({ + objectNotation: true, + updateFiles: false, + locales: ["en", "es"], + defaultLocale: "en", + retryInDefaultLocale: true, + directory: path.resolve(__dirname, "../../../../../../src/config/locales"), + }) + return { + getI18nInstance: () => i18n, + getLocale: () => "en", + } +}) + +import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { defaultFieldResolver } from "graphql" +import { getBridgeWithdrawalFlashFeeNotice } from "@app/bridge/get-withdrawal-flash-fee-notice" + +describe("Bridge public GraphQL object contract", () => { + it("exposes withdrawal fields returned by BridgeService", () => { + const fields = BridgeWithdrawal.getFields() + + expect(fields).toHaveProperty("id") + expect(fields).toHaveProperty("amount") + expect(fields).toHaveProperty("currency") + expect(fields).toHaveProperty("externalAccountId") + expect(fields).toHaveProperty("status") + expect(fields).toHaveProperty("estimatedBridgeFeePercent") + expect(fields).toHaveProperty("estimatedBridgeFee") + expect(fields).toHaveProperty("estimatedGasBuffer") + expect(fields).toHaveProperty("estimatedCustomerFee") + expect(fields).toHaveProperty("flashFeePercent") + expect(fields).toHaveProperty("flashFee") + expect(fields).toHaveProperty("flashFeeIsEstimate") + expect(fields).toHaveProperty("flashFeeNotice") + expect(fields).toHaveProperty("bridgeDeveloperFee") + expect(fields).toHaveProperty("bridgeExchangeFee") + expect(fields).toHaveProperty("subtotalAmount") + expect(fields).toHaveProperty("finalAmount") + expect(fields).toHaveProperty("bridgeTransferId") + expect(fields).toHaveProperty("failureReason") + expect(fields).toHaveProperty("createdAt") + expect(fields).not.toHaveProperty("transferId") + expect(fields).not.toHaveProperty("state") + }) + + it("resolves withdrawal id and status from service-shaped results", () => { + const fields = BridgeWithdrawal.getFields() + const withdrawal = { + id: "withdrawal-001", + amount: "25.00", + currency: "usdt", + externalAccountId: "ext-001", + status: "pending", + flashFeePercent: "2", + flashFee: "0.50", + flashFeeIsEstimate: true, + bridgeTransferId: undefined, + createdAt: "2026-06-05T00:00:00.000Z", + } + + expect(fields.id).toBeDefined() + expect(fields.status).toBeDefined() + expect(fields.bridgeTransferId).toBeDefined() + + expect( + defaultFieldResolver(withdrawal, {}, {}, { fieldName: "id", field: fields.id } as never), + ).toBe("withdrawal-001") + expect( + defaultFieldResolver( + withdrawal, + {}, + {}, + { fieldName: "status", field: fields.status } as never, + ), + ).toBe("pending") + expect( + defaultFieldResolver( + withdrawal, + {}, + {}, + { fieldName: "bridgeTransferId", field: fields.bridgeTransferId } as never, + ), + ).toBeUndefined() + }) + + it("resolves flashFeeNotice from the user locale when amounts are estimated", () => { + const fields = BridgeWithdrawal.getFields() + const withdrawal = { + flashFeeIsEstimate: true, + } + const ctx = { user: { language: "es" } } as GraphQLPublicContextAuth + + expect(fields.flashFeeNotice.resolve?.(withdrawal, {}, ctx, {})).toBe( + getBridgeWithdrawalFlashFeeNotice("es"), + ) + }) + + it("uses bridgeVirtualAccountId as the virtual account id returned by read queries", () => { + const idField = BridgeVirtualAccount.getFields().id + const virtualAccount = { + bridgeVirtualAccountId: "bridge-va-001", + bankName: "Test Bank", + routingNumber: "123456789", + accountNumber: "123456789012", + accountNumberLast4: "9012", + } + + expect(idField.resolve?.(virtualAccount, {}, {}, {})).toBe("bridge-va-001") + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts b/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts new file mode 100644 index 000000000..7a6af825c --- /dev/null +++ b/test/flash/unit/graphql/public/types/object/cash-wallet-history-resolvers.spec.ts @@ -0,0 +1,235 @@ +const mockGetTransactionsForAccountByWalletIds = jest.fn() +const mockGetTransactionsForWallets = jest.fn() +const mockGetTransactionsForWalletsByAddresses = jest.fn() +const mockResolveCashWalletPresentationForAccount = jest.fn() + +jest.mock("@app", () => ({ + Accounts: { + getTransactionsForAccountByWalletIds: ( + ...args: Parameters + ) => mockGetTransactionsForAccountByWalletIds(...args), + }, + Prices: {}, + Wallets: { + getTransactionsForWallets: ( + ...args: Parameters + ) => mockGetTransactionsForWallets(...args), + getTransactionsForWalletsByAddresses: ( + ...args: Parameters + ) => mockGetTransactionsForWalletsByAddresses(...args), + }, +})) + +jest.mock("@app/cash-wallet-cutover", () => { + const presentation = jest.requireActual("@app/cash-wallet-cutover/presentation") + + return { + cashWalletHistoryWalletIdsForPresentation: + presentation.cashWalletHistoryWalletIdsForPresentation, + cashWalletHistoryWalletsForPresentation: + presentation.cashWalletHistoryWalletsForPresentation, + resolveCashWalletPresentationForAccount: ( + ...args: Parameters + ) => mockResolveCashWalletPresentationForAccount(...args), + } +}) + +import ConsumerAccount from "@graphql/public/types/object/consumer-account" +import BusinessAccount from "@graphql/public/types/object/business-account" +import UsdWallet from "@graphql/shared/types/object/usd-wallet" +import UsdtWallet from "@graphql/shared/types/object/usdt-wallet" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const accountId = "cash-account-id" as AccountId +const account = { + id: accountId, + uuid: "cash-account-uuid" as AccountUuid, + displayCurrency: "USD" as DisplayCurrency, +} as Account + +const client = { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, +} as const + +const context = { + domainAccount: account, + cashWalletClientCapabilities: client, +} as GraphQLPublicContextAuth + +const wallet = ({ + id, + currency, + type = WalletType.Checking, +}: { + id: string + currency: WalletCurrency + type?: WalletType +}): Wallet => + ({ + id: id as WalletId, + accountId, + currency, + type, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: `lnurlp-${id}` as Lnurl, + }) as Wallet + +const btcWallet = wallet({ + id: "11111111-1111-4111-8111-111111111111", + currency: WalletCurrency.Btc, +}) +const legacyUsdWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Usdt, +}) + +const presentation = { + wallets: [btcWallet, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, +} + +const emptyConnectionResult = { + result: { slice: [], total: 0 }, + partialResult: true, +} + +beforeEach(() => { + jest.clearAllMocks() + mockResolveCashWalletPresentationForAccount.mockResolvedValue(presentation) + mockGetTransactionsForAccountByWalletIds.mockResolvedValue(emptyConnectionResult) + mockGetTransactionsForWallets.mockResolvedValue(emptyConnectionResult) + mockGetTransactionsForWalletsByAddresses.mockResolvedValue(emptyConnectionResult) +}) + +const resolveField = async ({ + objectType, + field, + source, + args, +}: { + objectType: typeof ConsumerAccount + field: string + source: unknown + args: Record +}) => { + const resolver = objectType.getFields()[field].resolve + if (!resolver) throw new Error(`Missing resolver for ${field}`) + + return resolver(source, args, context, {} as never, {} as never) +} + +describe("account Cash Wallet transaction resolvers", () => { + it("expands ConsumerAccount legacy USD filters to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: ConsumerAccount, + field: "transactions", + source: account, + args: { first: 20, walletIds: [legacyUsdWallet.id] }, + }) + + expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ + account, + walletIds: [usdtWallet.id, legacyUsdWallet.id], + paginationArgs: { first: 20, walletIds: [legacyUsdWallet.id] }, + }) + }) + + it("expands BusinessAccount default Cash Wallet history to active USDT then legacy USD archive", async () => { + await resolveField({ + objectType: BusinessAccount, + field: "transactions", + source: account, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForAccountByWalletIds).toHaveBeenCalledWith({ + account, + walletIds: [btcWallet.id, usdtWallet.id, legacyUsdWallet.id], + paginationArgs: { first: 20 }, + }) + }) +}) + +describe("wallet object Cash Wallet transaction resolvers", () => { + it("expands legacy USD wallet object transactions to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: UsdWallet, + field: "transactions", + source: legacyUsdWallet, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + }) + }) + + it("expands USDT wallet object transactions to active USDT history then legacy USD archive", async () => { + await resolveField({ + objectType: UsdtWallet, + field: "transactions", + source: usdtWallet, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + paginationArgs: { first: 20 }, + }) + }) + + it("expands wallet object transactionsByAddress across active USDT and legacy USD backing wallets", async () => { + const address = "bc1-cash-wallet-address" as OnChainAddress + + await resolveField({ + objectType: UsdWallet, + field: "transactionsByAddress", + source: legacyUsdWallet, + args: { first: 20, address }, + }) + + expect(mockGetTransactionsForWalletsByAddresses).toHaveBeenCalledWith({ + wallets: [usdtWallet, legacyUsdWallet], + addresses: [address], + paginationArgs: { first: 20, address }, + }) + }) + + it("keeps wrong-account wallet object history scoped to the source wallet only", async () => { + const otherAccountUsdWallet = wallet({ + id: "44444444-4444-4444-8444-444444444444", + currency: WalletCurrency.Usd, + }) + + await resolveField({ + objectType: UsdWallet, + field: "transactions", + source: { + ...otherAccountUsdWallet, + accountId: "other-account-id" as AccountId, + }, + args: { first: 20 }, + }) + + expect(mockGetTransactionsForWallets).toHaveBeenCalledWith({ + wallets: [ + { + ...otherAccountUsdWallet, + accountId: "other-account-id" as AccountId, + }, + ], + paginationArgs: { first: 20 }, + }) + }) +}) diff --git a/test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts b/test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts new file mode 100644 index 000000000..acc1a0121 --- /dev/null +++ b/test/flash/unit/graphql/shared/types/scalar/usd-cents.spec.ts @@ -0,0 +1,25 @@ +import { USDAmount, USDTAmount } from "@domain/shared" +import USDCentsScalar from "@graphql/shared/types/scalar/usd-cents" + +describe("USDCentsScalar", () => { + it("serializes USD amounts as cents", () => { + const amount = USDAmount.cents("123") + if (amount instanceof Error) throw amount + + expect(USDCentsScalar.serialize(amount)).toBe(123) + }) + + it("serializes USDT amounts as USD cents", () => { + const amount = USDTAmount.smallestUnits("1230000") + if (amount instanceof Error) throw amount + + expect(USDCentsScalar.serialize(amount)).toBe(123) + }) + + it("serializes USDT sub-cent amounts as rounded integer USD cents", () => { + const amount = USDTAmount.smallestUnits("9147993") + if (amount instanceof Error) throw amount + + expect(USDCentsScalar.serialize(amount)).toBe(915) + }) +}) diff --git a/test/flash/unit/services/alerts/dedup-key.spec.ts b/test/flash/unit/services/alerts/dedup-key.spec.ts new file mode 100644 index 000000000..f06f18c1d --- /dev/null +++ b/test/flash/unit/services/alerts/dedup-key.spec.ts @@ -0,0 +1,54 @@ +import { + generateDedupKey, + informDedupTtlMs, + normalizeDedupKey, +} from "@services/alerts/dedup-key" + +describe("generateDedupKey", () => { + it("uses coarse keys for Bridge API outage classes", () => { + expect(generateDedupKey.bridgeApi5xx()).toBe("bridge-api:5xx") + expect(generateDedupKey.bridgeApiTimeout()).toBe("bridge-api:timeout") + expect(generateDedupKey.bridgeApiNetwork()).toBe("bridge-api:network") + }) + + it("scopes ERPNext and webhook keys per resource", () => { + expect(generateDedupKey.erpnextDepositAudit("tr_1")).toBe( + "erpnext-audit:deposit:tr_1", + ) + expect(generateDedupKey.erpnextTransferCompletedAudit("tr_2")).toBe( + "erpnext-audit:transfer-complete:tr_2", + ) + expect(generateDedupKey.bridgeWebhookDeposit("wh_1")).toBe( + "bridge-webhook:deposit:wh_1", + ) + expect(generateDedupKey.bridgeWebhookTransfer("tr_3", "transfer.completed")).toBe( + "bridge-webhook:transfer:tr_3:transfer.completed", + ) + }) + + it("scopes IBEX Bridge movement keys per tx hash or transfer", () => { + expect(generateDedupKey.ibexCryptoReceive("0XABC")).toBe("ibex:crypto-receive:0xabc") + expect(generateDedupKey.ibexReconcileBridgeWithoutIbex("0xabc")).toBe( + "ibex:reconcile:bridge-without-ibex:0xabc", + ) + expect(generateDedupKey.ibexReconcileIbexWithoutBridge("0xabc")).toBe( + "ibex:reconcile:ibex-without-bridge:0xabc", + ) + expect(generateDedupKey.ibexReconcileBridgeWithoutIbexTransfer("tr_1")).toBe( + "ibex:reconcile:bridge-without-ibex:transfer:tr_1", + ) + }) +}) + +describe("informDedupTtlMs", () => { + it("uses a shorter TTL for Bridge API outage keys", () => { + expect(informDedupTtlMs("bridge-api:5xx")).toBe(30 * 60 * 1000) + expect(informDedupTtlMs("erpnext-audit:deposit:tr_1")).toBe(60 * 60 * 1000) + }) +}) + +describe("normalizeDedupKey", () => { + it("truncates keys to PagerDuty's maximum dedup_key length", () => { + expect(normalizeDedupKey("a".repeat(300))).toHaveLength(255) + }) +}) diff --git a/test/flash/unit/services/alerts/ibex-bridge-movement.spec.ts b/test/flash/unit/services/alerts/ibex-bridge-movement.spec.ts new file mode 100644 index 000000000..aaa1ff20d --- /dev/null +++ b/test/flash/unit/services/alerts/ibex-bridge-movement.spec.ts @@ -0,0 +1,59 @@ +jest.mock("@services/alerts", () => ({ + alertBridge: jest.fn(), +})) + +import { alertBridge } from "@services/alerts" +import { + alertIbexCryptoReceiveFailure, + alertIbexReconciliationOrphan, +} from "@services/alerts/ibex-bridge-movement" + +describe("ibex bridge movement alerts", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("routes crypto receive failures as IBEX warnings", () => { + alertIbexCryptoReceiveFailure({ + txHash: "0xabc", + code: "erpnext_audit_failed", + title: "IBEX crypto receive ERPNext audit write failed", + detail: "timeout", + context: { accountId: "acc_1" }, + }) + + expect(alertBridge).toHaveBeenCalledWith({ + dedupKey: "ibex:crypto-receive:0xabc", + source: "ibex", + severity: "warning", + title: "IBEX crypto receive ERPNext audit write failed", + detail: "timeout", + context: { + tx_hash: "0xabc", + code: "erpnext_audit_failed", + accountId: "acc_1", + }, + }) + }) + + it("routes reconciliation orphans as IBEX warnings", () => { + alertIbexReconciliationOrphan({ + orphanType: "ibex_without_bridge", + txHash: "0xdef", + reason: "No Bridge deposit payment_processed found for IBEX tx hash within window", + }) + + expect(alertBridge).toHaveBeenCalledWith({ + dedupKey: "ibex:reconcile:ibex-without-bridge:0xdef", + source: "ibex", + severity: "warning", + title: "IBEX crypto receive without matching Bridge deposit", + detail: "No Bridge deposit payment_processed found for IBEX tx hash within window", + context: { + orphan_type: "ibex_without_bridge", + tx_hash: "0xdef", + transfer_id: undefined, + }, + }) + }) +}) diff --git a/test/flash/unit/services/alerts/index.spec.ts b/test/flash/unit/services/alerts/index.spec.ts new file mode 100644 index 000000000..675185119 --- /dev/null +++ b/test/flash/unit/services/alerts/index.spec.ts @@ -0,0 +1,76 @@ +jest.mock("@services/alerts/slack", () => ({ + sendSlack: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/alerts/discord", () => ({ + sendDiscord: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/alerts/pagerduty", () => ({ + sendPagerDuty: jest.fn().mockResolvedValue(undefined), +})) + +import { alertBridge } from "@services/alerts" +import { resetInformDedup } from "@services/alerts/inform-dedup" +import { sendDiscord } from "@services/alerts/discord" +import { sendPagerDuty } from "@services/alerts/pagerduty" +import { sendSlack } from "@services/alerts/slack" + +describe("alertBridge", () => { + beforeEach(() => { + jest.clearAllMocks() + resetInformDedup() + }) + + it("fans out critical alerts to inform channels and PagerDuty", async () => { + alertBridge({ + dedupKey: "bridge-api:5xx", + source: "bridge-api", + severity: "critical", + title: "Bridge API 502 on GET /transfers", + }) + + await Promise.resolve() + + expect(sendSlack).toHaveBeenCalledTimes(1) + expect(sendDiscord).toHaveBeenCalledTimes(1) + expect(sendPagerDuty).toHaveBeenCalledTimes(1) + expect(sendPagerDuty).toHaveBeenCalledWith( + expect.objectContaining({ dedupKey: "bridge-api:5xx" }), + ) + }) + + it("does not page PagerDuty for warning alerts", async () => { + alertBridge({ + dedupKey: "ibex:warning:tx_1", + source: "ibex", + severity: "warning", + title: "IBEX movement failed", + }) + + await Promise.resolve() + + expect(sendSlack).toHaveBeenCalledTimes(1) + expect(sendDiscord).toHaveBeenCalledTimes(1) + expect(sendPagerDuty).not.toHaveBeenCalled() + }) + + it("suppresses duplicate Slack and Discord alerts for the same dedup key", async () => { + const alert = { + dedupKey: "bridge-api:5xx", + source: "bridge-api" as const, + severity: "critical" as const, + title: "Bridge API 502 on GET /transfers", + } + + alertBridge(alert) + alertBridge(alert) + alertBridge(alert) + + await Promise.resolve() + + expect(sendSlack).toHaveBeenCalledTimes(1) + expect(sendDiscord).toHaveBeenCalledTimes(1) + expect(sendPagerDuty).toHaveBeenCalledTimes(3) + }) +}) diff --git a/test/flash/unit/services/alerts/inform-dedup.spec.ts b/test/flash/unit/services/alerts/inform-dedup.spec.ts new file mode 100644 index 000000000..c1c0fcc16 --- /dev/null +++ b/test/flash/unit/services/alerts/inform-dedup.spec.ts @@ -0,0 +1,34 @@ +import { claimInformSlot, resetInformDedup } from "@services/alerts/inform-dedup" + +describe("claimInformSlot", () => { + beforeEach(() => { + resetInformDedup() + }) + + it("allows the first inform for a dedup key", () => { + expect(claimInformSlot("erpnext-audit:deposit:tr_1", 1_000)).toBe(true) + }) + + it("suppresses duplicate informs within the TTL", () => { + const key = "bridge-api:5xx" + const start = 10_000 + + expect(claimInformSlot(key, start)).toBe(true) + expect(claimInformSlot(key, start + 1_000)).toBe(false) + expect(claimInformSlot(key, start + 29 * 60 * 1000)).toBe(false) + }) + + it("allows a new inform after the TTL expires", () => { + const key = "bridge-api:5xx" + const start = 10_000 + + expect(claimInformSlot(key, start)).toBe(true) + expect(claimInformSlot(key, start + 30 * 60 * 1000)).toBe(true) + }) + + it("tracks different dedup keys independently", () => { + expect(claimInformSlot("erpnext-audit:deposit:tr_a", 1_000)).toBe(true) + expect(claimInformSlot("erpnext-audit:deposit:tr_b", 1_000)).toBe(true) + expect(claimInformSlot("erpnext-audit:deposit:tr_a", 2_000)).toBe(false) + }) +}) diff --git a/test/flash/unit/services/alerts/pagerduty.spec.ts b/test/flash/unit/services/alerts/pagerduty.spec.ts new file mode 100644 index 000000000..d95b45199 --- /dev/null +++ b/test/flash/unit/services/alerts/pagerduty.spec.ts @@ -0,0 +1,44 @@ +jest.mock("@config", () => ({ + ALERT_PAGERDUTY_ROUTING_KEY: "test-routing-key", +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("axios", () => ({ + post: jest.fn().mockResolvedValue({ status: 202 }), +})) + +import axios from "axios" +import { sendPagerDuty } from "@services/alerts/pagerduty" + +describe("sendPagerDuty", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("includes dedup_key in the Events API v2 payload", async () => { + await sendPagerDuty({ + dedupKey: "bridge-api:5xx", + source: "bridge-api", + severity: "critical", + title: "Bridge API 502 on GET /transfers", + context: { method: "GET", path: "/transfers" }, + }) + + expect(axios.post).toHaveBeenCalledWith( + "https://events.pagerduty.com/v2/enqueue", + expect.objectContaining({ + routing_key: "test-routing-key", + event_action: "trigger", + dedup_key: "bridge-api:5xx", + payload: expect.objectContaining({ + summary: "[bridge:bridge-api] Bridge API 502 on GET /transfers", + severity: "critical", + }), + }), + expect.any(Object), + ) + }) +}) diff --git a/test/flash/unit/services/bridge/client.spec.ts b/test/flash/unit/services/bridge/client.spec.ts new file mode 100644 index 000000000..3809ec41e --- /dev/null +++ b/test/flash/unit/services/bridge/client.spec.ts @@ -0,0 +1,214 @@ +// AC1: listAllEvents surfaces orphan events for ops triage tooling + +jest.mock("@config", () => ({ + BridgeConfig: { + apiKey: "test-api-key", + baseUrl: "https://api.sandbox.bridge.xyz/v0", + }, +})) + +import { BridgeClient, listAllEvents, BridgeWebhookEvent } from "@services/bridge/client" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeEvent = (id: string): BridgeWebhookEvent => ({ + id, + event_type: "transfer.completed", + payload: { transfer_id: id }, + created_at: "2026-05-01T10:00:00Z", +}) + +// ── listAllEvents ───────────────────────────────────────────────────────────── + +describe("listAllEvents", () => { + let listEventsSpy: jest.SpyInstance + + beforeEach(() => { + listEventsSpy = jest.spyOn(BridgeClient.prototype, "listEvents") + }) + + afterEach(() => { + listEventsSpy.mockRestore() + }) + + it("yields all events from a single page", async () => { + listEventsSpy.mockResolvedValue({ + data: [makeEvent("e1"), makeEvent("e2")], + has_more: false, + cursor: undefined, + }) + + const events: BridgeWebhookEvent[] = [] + for await (const e of listAllEvents()) { + events.push(e) + } + + expect(events).toHaveLength(2) + expect(events.map((e) => e.id)).toEqual(["e1", "e2"]) + expect(listEventsSpy).toHaveBeenCalledTimes(1) + }) + + it("paginates across multiple pages until has_more is false", async () => { + listEventsSpy + .mockResolvedValueOnce({ + data: [makeEvent("e1"), makeEvent("e2")], + has_more: true, + cursor: "cursor-page-2", + }) + .mockResolvedValueOnce({ + data: [makeEvent("e3"), makeEvent("e4")], + has_more: true, + cursor: "cursor-page-3", + }) + .mockResolvedValueOnce({ + data: [makeEvent("e5")], + has_more: false, + cursor: undefined, + }) + + const events: BridgeWebhookEvent[] = [] + for await (const e of listAllEvents()) { + events.push(e) + } + + expect(events).toHaveLength(5) + expect(events.map((e) => e.id)).toEqual(["e1", "e2", "e3", "e4", "e5"]) + expect(listEventsSpy).toHaveBeenCalledTimes(3) + }) + + it("passes the cursor from page N as 'after' on page N+1", async () => { + listEventsSpy + .mockResolvedValueOnce({ + data: [makeEvent("e1")], + has_more: true, + cursor: "cur-abc", + }) + .mockResolvedValueOnce({ + data: [makeEvent("e2")], + has_more: false, + cursor: undefined, + }) + + const drained: BridgeWebhookEvent[] = [] + for await (const event of listAllEvents()) { + drained.push(event) + } + + expect(drained).toHaveLength(2) + expect(listEventsSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ after: undefined }), + ) + expect(listEventsSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ after: "cur-abc" }), + ) + }) + + it("always requests page_size 100", async () => { + listEventsSpy.mockResolvedValue({ data: [], has_more: false, cursor: undefined }) + + const drained: BridgeWebhookEvent[] = [] + for await (const event of listAllEvents()) { + drained.push(event) + } + + expect(drained).toHaveLength(0) + expect(listEventsSpy).toHaveBeenCalledWith( + expect.objectContaining({ page_size: 100 }), + ) + }) + + it("filters events locally by start/end window and does not forward start/end to Bridge API", async () => { + const inWindow = makeEvent("e1") // created_at: "2026-05-01T10:00:00Z" — inside window + const tooEarly = { ...makeEvent("e2"), created_at: "2026-04-30T23:59:59Z" } + + listEventsSpy + .mockResolvedValueOnce({ data: [inWindow, tooEarly], has_more: true, cursor: "c1" }) + .mockResolvedValueOnce({ data: [makeEvent("e3")], has_more: false, cursor: undefined }) + + const drained: BridgeWebhookEvent[] = [] + for await (const event of listAllEvents({ + start: "2026-05-01T00:00:00Z", + end: "2026-05-02T00:00:00Z", + event_type: "transfer.completed", + })) { + drained.push(event) + } + + // e2 is before the window start — only e1 and e3 pass through + expect(drained.map((e) => e.id)).toEqual(["e1", "e3"]) + expect(listEventsSpy).toHaveBeenCalledTimes(2) + for (const call of listEventsSpy.mock.calls) { + // start/end must NOT be sent to Bridge — it only understands cursor params + expect(call[0]).not.toHaveProperty("start") + expect(call[0]).not.toHaveProperty("end") + expect(call[0]).not.toHaveProperty("start_date") + expect(call[0]).not.toHaveProperty("end_date") + // event_type is still forwarded (mapped to category inside listEvents) + expect(call[0]).toMatchObject({ event_type: "transfer.completed" }) + } + }) + + it("yields nothing and makes one call when the first page is empty", async () => { + listEventsSpy.mockResolvedValue({ data: [], has_more: false, cursor: undefined }) + + const events: BridgeWebhookEvent[] = [] + for await (const e of listAllEvents()) { + events.push(e) + } + + expect(events).toHaveLength(0) + expect(listEventsSpy).toHaveBeenCalledTimes(1) + }) + + it("stops immediately if has_more is false even when cursor is present", async () => { + listEventsSpy.mockResolvedValue({ + data: [makeEvent("e1")], + has_more: false, + cursor: "stale-cursor", + }) + + const events: BridgeWebhookEvent[] = [] + for await (const e of listAllEvents()) { + events.push(e) + } + + expect(events).toHaveLength(1) + expect(listEventsSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("BridgeClient transfer deletion", () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "tr_123", + amount: "2.5", + currency: "usd", + state: "canceled", + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { payment_rail: "ach", currency: "usd" }, + created_at: "2026-06-17T00:00:00Z", + updated_at: "2026-06-17T00:00:00Z", + }), + } as Response) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it("does not send an Idempotency-Key on DELETE transfer requests", async () => { + const client = new BridgeClient() + + await client.deleteTransfer("tr_123" as never) + + const [, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(init.method).toBe("DELETE") + expect(init.headers["Idempotency-Key"]).toBeUndefined() + }) +}) diff --git a/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts b/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts new file mode 100644 index 000000000..1cf7468fa --- /dev/null +++ b/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts @@ -0,0 +1,126 @@ +import { + computeEstimatedGasBufferUsd, + fetchEthereumGasPriceGwei, + fetchEthereumGasPriceGweiAverage, + fetchEthereumGasMarketSnapshot, + fetchEthUsdPrice, +} from "@services/bridge/ethereum-gas-estimate" + +describe("ethereum gas estimate", () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it("computes buffered ERC-20 gas cost in USD", () => { + expect( + computeEstimatedGasBufferUsd({ + gasLimit: 65_000, + gasPriceGwei: 20, + ethUsd: 3000, + bufferMultiplier: 1.5, + }), + ).toBe("5.85") + }) + + it("parses eth_gasPrice hex wei into gwei", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ result: "0x4a817c800" }), + } as Response) + + const gasPriceGwei = await fetchEthereumGasPriceGwei({ + rpcUrl: "https://example.invalid", + timeoutMs: 1000, + }) + + expect(gasPriceGwei).toBe(20) + }) + + it("averages gas price from multiple successful Ethereum RPC sources", async () => { + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: "0x4a817c800" }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: "0x6fc23ac00" }), + } as Response) + + const gasPriceGwei = await fetchEthereumGasPriceGweiAverage({ + rpcUrls: ["https://example-one.invalid", "https://example-two.invalid"], + timeoutMs: 1000, + }) + + expect(gasPriceGwei).toBe(25) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it("falls back only when all Ethereum RPC gas sources fail", async () => { + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 502, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ethereum: { usd: 2500 } }), + } as Response) + + const snapshot = await fetchEthereumGasMarketSnapshot({ + rpcUrls: ["https://example-one.invalid", "https://example-two.invalid"], + timeoutMs: 1000, + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, + }) + + expect(snapshot).toEqual({ gasPriceGwei: 30, ethUsd: 2500 }) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it("caches gas market snapshots for the configured TTL and uses the configured ETH/USD URL", async () => { + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: "0x4a817c800" }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ethereum: { usd: 2500 } }), + } as Response) + + const args = { + rpcUrls: ["https://cache-rpc.example.invalid"], + timeoutMs: 1000, + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, + ethUsdPriceUrl: "https://prices.example.invalid/eth-usd", + cacheTtlMs: 60_000, + } + + const first = await fetchEthereumGasMarketSnapshot(args) + const second = await fetchEthereumGasMarketSnapshot(args) + + expect(first).toEqual({ gasPriceGwei: 20, ethUsd: 2500 }) + expect(second).toBe(first) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1][0]).toBe("https://prices.example.invalid/eth-usd") + }) + + it("reads ETH/USD from CoinGecko", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2500.5 } }), + } as Response) + + const ethUsd = await fetchEthUsdPrice({ timeoutMs: 1000 }) + expect(ethUsd).toBe(2500.5) + }) +}) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts new file mode 100644 index 000000000..754917ca3 --- /dev/null +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -0,0 +1,1618 @@ +import crypto from "crypto" + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ + fns, + }: { + namespace: string + fns: Record unknown> + }) => fns, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true, minWithdrawalAmount: 10, developerFeePercent: 2 }, + // Minimal stubs so schema.ts can run its module-level initialisation + getFeesConfig: jest.fn().mockReturnValue({ + depositFeeVariable: 0, + depositFeeFixed: 0, + withdrawFeeVariable: 0, + withdrawFeeFixed: 0, + }), + getDefaultAccountsConfig: jest + .fn() + .mockReturnValue({ initialStatus: "active", initialLevel: 0, maxCurrencies: 5 }), + getDefaultFCMTopics: jest.fn().mockReturnValue([]), + Levels: [0, 1, 2, 3], + getI18nInstance: jest.fn().mockReturnValue({ __: jest.fn() }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const MOCK_WITHDRAWAL_FEE_ESTIMATE = { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", +} + +jest.mock("@services/bridge/withdrawal-fees", () => { + const actual = jest.requireActual("@services/bridge/withdrawal-fees") + return { + ...actual, + resolveWithdrawalCustomerFeeEstimate: jest.fn(), + } +}) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + createVirtualAccount: jest.fn(), + findVirtualAccountByAccountId: jest.fn(), + createExternalAccount: jest.fn(), + createWithdrawal: jest.fn(), + findPendingWithdrawalWithoutTransfer: jest.fn(), + findExternalAccountsByAccountId: jest.fn(), + markExternalAccountsMissingFromBridge: jest.fn(), + updateWithdrawalFeeEstimates: jest.fn(), + bridgeWithdrawalRecordId: jest.requireActual("@services/mongoose/bridge-accounts") + .bridgeWithdrawalRecordId, + updateWithdrawalTransferId: jest.fn(), + findWithdrawalById: jest.fn(), + findWithdrawalsByAccountId: jest.fn(), + cancelWithdrawal: jest.fn(), + updateWithdrawalOnchainSend: jest.fn(), + updateWithdrawalSendFailed: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + createVirtualAccount: jest.fn(), + createExternalAccount: jest.fn(), + createTransfer: jest.fn(), + listExternalAccounts: jest.fn(), + getCustomer: jest.fn().mockResolvedValue({ status: "active" }), + }, +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getEthereumUsdtOption: jest.fn(), + createCryptoReceiveInfo: jest.fn(), + getCryptoSendRequirements: jest.fn(), + createCryptoSendInfo: jest.fn(), + sendOnchain: jest.fn(), + sendCrypto: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@domain/primitives/bridge", () => ({ + toBridgeCustomerId: (id: string) => id, + toBridgeExternalAccountId: (id: string) => id, +})) + +// USDTAmount stand-in: the real type is not exported from @domain/shared. +// We spread the real module and inject a minimal class so `instanceof USDTAmount` +// guards in the service are satisfied during tests. +jest.mock("@domain/shared", () => { + class USDTAmount { + static currencyId = 29 + + ibexValue: number + + constructor(ibexValue: number) { + this.ibexValue = ibexValue + } + + toIbex() { + return this.ibexValue + } + + static fromNumber(value: number | string) { + return new USDTAmount(Number(value)) + } + } + return { ...jest.requireActual("@domain/shared"), USDTAmount } +}) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +import BridgeService, { deriveWithdrawalIdempotencyKey } from "@services/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import BridgeClient from "@services/bridge/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { WalletsRepository } from "@services/mongoose/wallets" +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import IbexClient from "@services/ibex/client" +import { RepositoryError } from "@domain/errors" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-account-001" +const AMOUNT = "50" +const CUSTOMER_ID = "cust-001" +const ETHEREUM_ADDRESS = "ETH_ADDR_001" +const TRANSFER_ID = "transfer-bridge-001" +const WITHDRAWAL_ID = "withdrawal-mongo-001" +const USDT_WALLET_ID = "ibex-eth-usdt-wallet-001" +const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const IBEX_PAYOUT_ID = "ibex-payout-001" +const IBEX_CRYPTO_SEND_REQUIREMENTS_ID = "send-requirements-001" +const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" +const RECEIVE_INFO_ID = "receive-info-001" +const VIRTUAL_ACCOUNT_ID = "virtual-account-001" +const CREATED_AT = new Date("2026-01-01T00:00:00Z") +const STALE_EXTERNAL_ACCOUNT_ID = "stale-ext-account-001" + +const mockAccount = { + id: ACCOUNT_ID, + level: 2, + bridgeCustomerId: CUSTOMER_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + bridgeKycStatus: "approved", + kratosUserId: "kratos-001", +} + +const makeRow = (id: string, overrides: Record = {}) => ({ + id, + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending" as const, + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + bridgeTransferId: undefined, + failureReason: undefined, + createdAt: CREATED_AT, + ...overrides, +}) + +const mockTransfer = { + id: TRANSFER_ID, + amount: AMOUNT, + currency: "usd", + state: "pending", + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdt", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, + receipt: { + initial_amount: AMOUNT, + developer_fee: "1.00", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }, +} + +const mockVirtualAccount = { + id: VIRTUAL_ACCOUNT_ID, + source_deposit_instructions: { + bank_name: "Test Bank", + bank_routing_number: "123456789", + bank_account_number: "123456789012", + }, +} + +const mockBridgeExternalAccount = { + id: EXTERNAL_ACCOUNT_ID, + customer_id: CUSTOMER_ID, + account_owner_name: "Dread", + account_type: "us", + currency: "usd", + bank_name: "Test Bank", + account_number_last_4: "1111", + active: true, + created_at: "2026-01-01T00:00:00Z", +} + +const makeWallet = (id: string, currency: string) => ({ + id, + accountId: ACCOUNT_ID, + type: "checking", + currency, +}) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const getUSDTAmount = (ibex: number) => { + const { USDTAmount } = jest.requireMock("@domain/shared") as { + USDTAmount: new (ibexValue: number) => { toIbex: () => number } + } + return new USDTAmount(ibex) +} + +const expectSuccess = (result: T | Error): T => { + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) throw result + return result +} + +/** Sets up the guards common to requestWithdrawal and initiateWithdrawal. */ +const setupGuards = () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + update: jest.fn(), + updateBridgeFields: jest.fn(), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest + .fn() + .mockResolvedValue([{ id: USDT_WALLET_ID, currency: "USDT", type: "checking" }]), + persistNew: jest.fn(), + }) + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(1000)) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + ;( + BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock + ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [mockBridgeExternalAccount], + has_more: false, + }) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + ibexPayoutId: IBEX_PAYOUT_ID, + status: "usdt_sent" as const, + }) + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) + ;(IbexClient.sendOnchain as jest.Mock).mockResolvedValue({ + status: "PENDING", + transactionHub: { id: IBEX_PAYOUT_ID }, + }) + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue({ + requirementsId: IBEX_CRYPTO_SEND_REQUIREMENTS_ID, + data: { address: { required: true } }, + }) + ;(IbexClient.createCryptoSendInfo as jest.Mock).mockResolvedValue({ + id: IBEX_CRYPTO_SEND_INFO_ID, + name: `bridge-withdrawal-${WITHDRAWAL_ID}`, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + transaction: { id: IBEX_PAYOUT_ID, status: "PENDING" }, + }) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("deriveWithdrawalIdempotencyKey", () => { + it('returns sha256("withdrawal:") as a hex string', () => { + const rowId = "507f1f77bcf86cd799439011" + const expected = crypto + .createHash("sha256") + .update(`withdrawal:${rowId}`) + .digest("hex") + + expect(deriveWithdrawalIdempotencyKey(rowId)).toBe(expected) + }) + + it("produces distinct keys for distinct row IDs", () => { + expect(deriveWithdrawalIdempotencyKey("id-alpha")).not.toBe( + deriveWithdrawalIdempotencyKey("id-beta"), + ) + }) + + it("is deterministic — same input always returns same output", () => { + const rowId = "507f1f77bcf86cd799439011" + expect(deriveWithdrawalIdempotencyKey(rowId)).toBe( + deriveWithdrawalIdempotencyKey(rowId), + ) + }) + + it("output is a 64-character lowercase hex string (sha256)", () => { + const key = deriveWithdrawalIdempotencyKey("any-id") + expect(key).toMatch(/^[0-9a-f]{64}$/) + }) +}) + +/** + * Linear ENG-296 — ETH-USDT Cash Wallet + Bridge virtual account + */ +describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("ENG-296 AC1+AC2+AC3: provisions USDT cash wallet, flips default off USD, persists Ibex ETH receive address, creates Bridge VA", async () => { + const usdtWallet = makeWallet(USDT_WALLET_ID, "USDT") + const accountWithoutUsdt = { + ...mockAccount, + defaultWalletId: "legacy-usd-wallet-id", + bridgeEthereumAddress: undefined, + } + + const accountsRepo = { + findById: jest.fn().mockResolvedValue(accountWithoutUsdt), + update: jest.fn().mockResolvedValue({ + ...accountWithoutUsdt, + defaultWalletId: USDT_WALLET_ID, + }), + updateBridgeFields: jest.fn().mockResolvedValue({ + ...accountWithoutUsdt, + defaultWalletId: USDT_WALLET_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + }), + } + ;(AccountsRepository as jest.Mock).mockReturnValue(accountsRepo) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest + .fn() + .mockResolvedValue([makeWallet("legacy-usd-wallet-id", "USD")]), + persistNew: jest.fn().mockResolvedValue(usdtWallet), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + new RepositoryError("not found"), + ) + ;(IbexClient.getEthereumUsdtOption as jest.Mock).mockResolvedValue({ + id: "eth-usdt-option", + currency: "USDT", + network: "ethereum", + name: "Ethereum USDT", + }) + ;(IbexClient.createCryptoReceiveInfo as jest.Mock).mockResolvedValue({ + id: RECEIVE_INFO_ID, + wallet_id: USDT_WALLET_ID, + option_id: "eth-usdt-option", + data: { address: ETHEREUM_ADDRESS }, + currency: "USDT", + network: "ethereum", + created_at: "2026-05-09T00:00:00Z", + }) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue( + mockVirtualAccount, + ) + ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + }) + + await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(WalletsRepository().persistNew).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + type: "checking", + currency: "USDT", + }) + expect(AccountsRepository().update).toHaveBeenCalledWith( + expect.objectContaining({ defaultWalletId: USDT_WALLET_ID }), + ) + expect(IbexClient.createCryptoReceiveInfo).toHaveBeenCalledWith( + USDT_WALLET_ID, + expect.objectContaining({ network: "ethereum", currency: "USDT" }), + ) + expect(accountsRepo.updateBridgeFields).toHaveBeenCalledWith( + ACCOUNT_ID, + expect.objectContaining({ bridgeEthereumAddress: ETHEREUM_ADDRESS }), + ) + expect(BridgeClient.createVirtualAccount).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + destination: expect.objectContaining({ + currency: "usdt", + payment_rail: "ethereum", + address: ETHEREUM_ADDRESS, + }), + }), + expect.any(String), + ) + }) + + it("ENG-296 AC1+AC3: reuses existing USDT cash wallet and stored Ethereum address (no extra Ibex receive-info call)", async () => { + const usdtWallet = makeWallet(USDT_WALLET_ID, "USDT") + const accountWithUsdtDefault = { + ...mockAccount, + defaultWalletId: USDT_WALLET_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(accountWithUsdtDefault), + update: jest.fn(), + updateBridgeFields: jest.fn(), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest.fn().mockResolvedValue([usdtWallet]), + persistNew: jest.fn(), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + new RepositoryError("not found"), + ) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue( + mockVirtualAccount, + ) + ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + }) + + await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(WalletsRepository().persistNew).not.toHaveBeenCalled() + expect(AccountsRepository().update).not.toHaveBeenCalled() + expect(IbexClient.createCryptoReceiveInfo).not.toHaveBeenCalled() + expect(BridgeClient.createVirtualAccount).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + destination: expect.objectContaining({ address: ETHEREUM_ADDRESS }), + }), + expect.any(String), + ) + }) + + it("ENG-296 AC3 (idempotent): existing VA returns stored bank details without wallet or Ibex side effects", async () => { + const existingVaRecord = { + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + bankName: "Existing Bank", + routingNumber: "021000021", + accountNumber: "000111222", + accountNumberLast4: "0222", + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + update: jest.fn(), + updateBridgeFields: jest.fn(), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest.fn(), + persistNew: jest.fn(), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + existingVaRecord, + ) + + const result = await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(result).toEqual( + expect.objectContaining({ + virtualAccountId: VIRTUAL_ACCOUNT_ID, + bankName: "Existing Bank", + routingNumber: "021000021", + accountNumber: "000111222", + accountNumberLast4: "0222", + }), + ) + expect(WalletsRepository().listByAccountId).not.toHaveBeenCalled() + expect(WalletsRepository().persistNew).not.toHaveBeenCalled() + expect(AccountsRepository().update).not.toHaveBeenCalled() + expect(IbexClient.getEthereumUsdtOption).not.toHaveBeenCalled() + expect(IbexClient.createCryptoReceiveInfo).not.toHaveBeenCalled() + expect(BridgeClient.createVirtualAccount).not.toHaveBeenCalled() + }) +}) + +describe("createExternalAccount", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeClient.createExternalAccount as jest.Mock).mockResolvedValue({ + ...mockBridgeExternalAccount, + account_number_last_4: "4321", + }) + }) + + const createExternalAccountInput = { + account_owner_name: "Dread", + address: { + street_line_1: "1 Test St", + city: "San Francisco", + country: "US", + state: "CA", + postal_code: "94105", + }, + account_type: "us", + currency: "usd", + account: { + account_number: "123456789012", + routing_number: "021000021", + checking_or_savings: "checking" as const, + }, + bank_name: "Test Bank", + } + + it("returns a local persistence error instead of reporting a linked account", async () => { + const persistError = new RepositoryError("mongo unavailable") + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue( + persistError, + ) + + const result = await BridgeService.createExternalAccount( + ACCOUNT_ID, + createExternalAccountInput, + ) + + expect(result).toBe(persistError) + expect(BridgeAccountsRepo.createExternalAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "4321", + status: "verified", + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// requestWithdrawal +// Step 1 of the split flow: validates everything and writes a pending MongoDB +// record — does NOT call the Bridge API. +// ───────────────────────────────────────────────────────────────────────────── + +describe("requestWithdrawal", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) + resolveWithdrawalCustomerFeeEstimate.mockResolvedValue(MOCK_WITHDRAWAL_FEE_ESTIMATE) + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + null, + ) + ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + }) + + it("creates a pending withdrawal record and returns the full result", async () => { + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + status: "pending", + }) + expect(expectSuccess(result)).toMatchObject({ + id: WITHDRAWAL_ID, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + flashFeeIsEstimate: true, + subtotalAmount: "47.70", + finalAmount: "47.70", + createdAt: expect.any(String), + }) + }) + + it("syncs Bridge external accounts before accepting a withdrawal request", async () => { + await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeAccountsRepo.createExternalAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + expect(BridgeAccountsRepo.markExternalAccountsMissingFromBridge).toHaveBeenCalledWith( + ACCOUNT_ID as string, + [EXTERNAL_ACCOUNT_ID], + ) + }) + + it("rejects a withdrawal when Bridge no longer lists the selected external account", async () => { + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [{ ...mockBridgeExternalAccount, id: "bridge-current-account-002" }], + has_more: false, + }) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "1111", + status: "verified", + }, + { + bridgeExternalAccountId: "bridge-current-account-002", + bankName: "Current Bank", + accountNumberLast4: "2222", + status: "verified", + }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("External account not found for this account") + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("never creates a Bridge transfer", async () => { + await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("reuses an existing pending withdrawal for the same account, amount, and external account", async () => { + const existingRow = makeRow("withdrawal-existing-001", { + estimatedBridgeFeePercent: undefined, + estimatedBridgeFee: undefined, + estimatedGasBuffer: undefined, + estimatedCustomerFee: undefined, + }) + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + existingRow, + ) + ;(BridgeAccountsRepo.updateWithdrawalFeeEstimates as jest.Mock).mockResolvedValue( + makeRow("withdrawal-existing-001"), + ) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).toHaveBeenCalledWith( + "withdrawal-existing-001", + MOCK_WITHDRAWAL_FEE_ESTIMATE, + ) + expect(expectSuccess(result)).toMatchObject({ + id: "withdrawal-existing-001", + status: "pending", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + subtotalAmount: "47.70", + finalAmount: "47.70", + }) + }) + + it("returns an error when the external account does not belong to the caller (CRIT-2)", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { bridgeExternalAccountId: "somebody-elses-account", status: "verified" }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns an error when the external account is not yet verified", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending" }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns BridgeInsufficientFundsError when USDT balance is below the requested amount", async () => { + ;(getBalanceForWallet as jest.Mock).mockImplementation(() => + Promise.resolve(getUSDTAmount(5)), + ) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toMatchObject({ + name: "BridgeInsufficientFundsError", + message: expect.stringContaining("Insufficient USDT balance"), + }) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("rejects withdrawals whose estimated fees consume the full amount", async () => { + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) + resolveWithdrawalCustomerFeeEstimate.mockResolvedValue({ + ...MOCK_WITHDRAWAL_FEE_ESTIMATE, + estimatedCustomerFee: AMOUNT, + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeWithdrawalNetAmountTooLowError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNetAmountTooLowError) + expect(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns BridgeCustomerNotFoundError when account has no Bridge customer ID", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest + .fn() + .mockResolvedValue({ ...mockAccount, bridgeCustomerId: undefined }), + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeCustomerNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeCustomerNotFoundError) + }) + + it("returns an error when account has no Ethereum address", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ + ...mockAccount, + bridgeEthereumAddress: undefined, + }), + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getExternalAccounts +// Bridge is the source of truth so Dashboard-deleted banks cannot remain selectable. +// ───────────────────────────────────────────────────────────────────────────── + +describe("getExternalAccounts", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + }) + + it("syncs from Bridge and returns only external accounts that Bridge still lists", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: STALE_EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "9999", + status: "verified", + }, + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + + const result = await BridgeService.getExternalAccounts(ACCOUNT_ID) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeAccountsRepo.markExternalAccountsMissingFromBridge).toHaveBeenCalledWith( + ACCOUNT_ID as string, + [EXTERNAL_ACCOUNT_ID], + ) + expect(expectSuccess(result)).toEqual([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// initiateWithdrawal (refactored) +// Step 2A: fetches the pending record by ID, re-checks balance, calls Bridge. +// ───────────────────────────────────────────────────────────────────────────── + +describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + }) + + it("fetches the pending withdrawal from MongoDB before calling Bridge", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.findWithdrawalById).toHaveBeenCalledWith(WITHDRAWAL_ID) + }) + + it("never calls createWithdrawal — the row already exists from requestWithdrawal", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("uses the idempotency key derived from the withdrawalId", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const expectedKey = deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID) + expect(BridgeClient.createTransfer).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.any(Object), + expectedKey, + ) + }) + + it("does not send crypto again when the withdrawal already has an IBEX payout", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + ibexPayoutId: IBEX_PAYOUT_ID, + }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("creates a Bridge transfer that asks Bridge for source deposit instructions", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeClient.createTransfer).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + amount: AMOUNT, + source: { + payment_rail: "ethereum", + currency: "usdt", + }, + features: expect.objectContaining({ + allow_any_from_address: true, + }), + }), + deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID), + ) + }) + + it("revalidates the pending withdrawal external account against Bridge before creating a transfer", async () => { + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [{ ...mockBridgeExternalAccount, id: "bridge-current-account-002" }], + has_more: false, + }) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "1111", + status: "verified", + }, + { + bridgeExternalAccountId: "bridge-current-account-002", + bankName: "Current Bank", + accountNumberLast4: "2222", + status: "verified", + }, + ]) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("External account not found") + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("does not send USDT when Bridge omits source deposit instructions", async () => { + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue({ + ...mockTransfer, + source_deposit_instructions: undefined, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeDepositInstructionsMissingError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeDepositInstructionsMissingError) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("creates an IBEX crypto send info for Bridge's deposit address before sending", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(IbexClient.getCryptoSendRequirements).toHaveBeenCalledWith({ + network: "ethereum", + currencyId: 29, + }) + expect(IbexClient.createCryptoSendInfo).toHaveBeenCalledWith({ + name: `bridge-withdrawal-${WITHDRAWAL_ID}`, + requirementsId: IBEX_CRYPTO_SEND_REQUIREMENTS_ID, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) + }) + + it("sends the user's USDT wallet through IBEX crypto send info", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(IbexClient.sendCrypto).toHaveBeenCalledWith({ + accountId: USDT_WALLET_ID, + cryptoSendInfosId: IBEX_CRYPTO_SEND_INFO_ID, + amount: Number(AMOUNT), + }) + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("updates the withdrawal record with Bridge and IBEX identifiers after the send", async () => { + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, + ) + expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( + WITHDRAWAL_ID, + IBEX_PAYOUT_ID, + undefined, + ) + expect(expectSuccess(result)).toMatchObject({ + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + flashFeeIsEstimate: false, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + }) + }) + + it("preserves successful IBEX sends that do not expose a transaction id", async () => { + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + status: "PENDING", + accepted: true, + }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, + ibexPayoutId: undefined, + status: "usdt_sent" as const, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( + WITHDRAWAL_ID, + undefined, + undefined, + ) + expect(expectSuccess(result)).toMatchObject({ + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + }) + }) + + it("marks the withdrawal send_failed when IBEX send fails after Bridge transfer creation", async () => { + const ibexError = new Error("ibex unavailable") + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue(ibexError) + ;(BridgeAccountsRepo.updateWithdrawalSendFailed as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "send_failed" as const, + failureReason: ibexError.message, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBe(ibexError) + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + ibexError.message, + ) + }) + + it("marks the withdrawal send_failed when IBEX send info creation fails", async () => { + const ibexError = new Error("requirements unavailable") + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue(ibexError) + ;(BridgeAccountsRepo.updateWithdrawalSendFailed as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "send_failed" as const, + failureReason: ibexError.message, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBe(ibexError) + expect(IbexClient.createCryptoSendInfo).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + ibexError.message, + ) + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal belongs to a different account", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "different-account" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when bridgeTransferId is already set", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: "already-submitted" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when status is not pending", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeInsufficientFundsError when balance dropped between request and initiate", async () => { + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(5)) // < AMOUNT=50 + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeInsufficientFundsError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeInsufficientFundsError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// cancelWithdrawalRequest +// Step 2B: marks the pending record "cancelled" and sends a push notification. +// Only allowed before the Bridge API has been called (no bridgeTransferId). +// ───────────────────────────────────────────────────────────────────────────── + +describe("cancelWithdrawalRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + }) + + it("cancels the pending withdrawal and returns status cancelled", async () => { + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(expectSuccess(result)).toMatchObject({ + status: "cancelled", + id: WITHDRAWAL_ID, + amount: AMOUNT, + }) + }) + + it("calls cancelWithdrawal with the correct accountId and withdrawalId", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.cancelWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID as string, + WITHDRAWAL_ID, + ) + }) + + it("never calls the Bridge API", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("sends a cancelled push notification after a successful cancel", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + outcome: "cancelled", + }) + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal belongs to a different account", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "different-account" }), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when the transfer was already submitted to Bridge", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: "already-submitted-id" }), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeAccountsRepo.cancelWithdrawal).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("does not send a notification when the repo cancelWithdrawal fails (e.g. race condition)", async () => { + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found or cannot be cancelled"), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// withdrawal request → confirm/cancel flow +// Chains the three service steps to pin the contract promised by the PR. +// ───────────────────────────────────────────────────────────────────────────── + +describe("withdrawal request → confirm/cancel flow", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) + resolveWithdrawalCustomerFeeEstimate.mockResolvedValue(MOCK_WITHDRAWAL_FEE_ESTIMATE) + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + null, + ) + ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + }) + + it("request creates a pending row, then initiate submits it and records bridgeTransferId", async () => { + const requested = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const pending = expectSuccess(requested) + expect(pending).toMatchObject({ status: "pending", id: WITHDRAWAL_ID }) + + const initiated = expectSuccess( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, pending.id), + ) + expect(initiated).toMatchObject({ + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + }) + expect(BridgeClient.createTransfer).toHaveBeenCalledTimes(1) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, + ) + }) + + it("duplicate request reuses the pending row, then initiate still submits that row", async () => { + const existingRow = makeRow("deduped-withdrawal-001") + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + existingRow, + ) + ;(BridgeAccountsRepo.updateWithdrawalFeeEstimates as jest.Mock).mockResolvedValue(existingRow) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(existingRow) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...existingRow, + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + + const first = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const second = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + const firstPending = expectSuccess(first) + const secondPending = expectSuccess(second) + expect(firstPending).toMatchObject({ id: "deduped-withdrawal-001" }) + expect(secondPending).toMatchObject({ id: "deduped-withdrawal-001" }) + + const initiated = expectSuccess( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, firstPending.id), + ) + expect(initiated.bridgeTransferId).toBe(TRANSFER_ID) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + "deduped-withdrawal-001", + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, + ) + }) + + it("request then cancel marks the row cancelled and sends the cancelled notification", async () => { + const requested = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const pending = expectSuccess(requested) + const cancelled = expectSuccess( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, pending.id), + ) + + expect(cancelled.status).toBe("cancelled") + expect(BridgeAccountsRepo.cancelWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID as string, + WITHDRAWAL_ID, + ) + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + outcome: "cancelled", + }) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("initiate returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + expect( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, "missing-withdrawal"), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), + ) + expect( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("initiate returns BridgeWithdrawalAlreadyInitiatedError when the row was already submitted", async () => { + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("cancel returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + expect( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, "missing-withdrawal"), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), + ) + expect( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("cancel returns BridgeWithdrawalAlreadyInitiatedError when bridgeTransferId is already set", async () => { + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeAccountsRepo.cancelWithdrawal).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getWithdrawals +// Returns the account's withdrawal history mapped to the GQL-facing shape +// (id/status/bridgeTransferId — NOT the old transferId/state fields). +// ───────────────────────────────────────────────────────────────────────────── + +describe("getWithdrawals", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + }) + + it("maps submitted rows to id/status — not transferId or state", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ id: WITHDRAWAL_ID, status: "submitted" }) + expect((result[0] as Record).transferId).toBeUndefined() + expect((result[0] as Record).state).toBeUndefined() + }) + + it("includes bridgeTransferId for submitted/completed rows", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "completed" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result[0]).toMatchObject({ + bridgeTransferId: TRANSFER_ID, + status: "completed", + }) + }) + + it("excludes pending rows that have no bridgeTransferId (pre-initiation)", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID), // bridgeTransferId: undefined — pre-approval + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result).toHaveLength(0) + }) + + it("excludes cancelled rows without a bridgeTransferId, includes submitted/completed/failed", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow("w-1", { status: "pending" }), // excluded + makeRow("w-2", { status: "cancelled" }), // excluded (no transferId) + makeRow("w-3", { status: "submitted", bridgeTransferId: TRANSFER_ID }), + makeRow("w-4", { status: "completed", bridgeTransferId: "t-completed" }), + makeRow("w-5", { status: "failed", bridgeTransferId: "t-failed" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result).toHaveLength(3) + expect(result.map((r) => r.status)).toEqual(["submitted", "completed", "failed"]) + }) + + it("formats createdAt as an ISO string", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result[0].createdAt).toBe(CREATED_AT.toISOString()) + }) +}) diff --git a/test/flash/unit/services/bridge/reconciliation.spec.ts b/test/flash/unit/services/bridge/reconciliation.spec.ts new file mode 100644 index 000000000..530c96a38 --- /dev/null +++ b/test/flash/unit/services/bridge/reconciliation.spec.ts @@ -0,0 +1,548 @@ +/** + * Unit tests for Bridge↔IBEX reconciliation + * Covers reconcileByTxHash (real-time) and reconcileBridgeAndIbexDeposits (batch) + */ + +// ── Mocks (must be before imports) ─────────────────────────────────────────── + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/schema", () => ({ + BridgeDeposits: { findOne: jest.fn(), find: jest.fn() }, + BridgeWithdrawal: { find: jest.fn() }, + IbexCryptoReceive: { findOne: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + updateWithdrawalStatus: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + deleteTransfer: jest.fn(), + getTransfer: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/ibex-crypto-receive-log", () => ({ + findIbexCryptoReceivesSince: jest.fn(), +})) + +jest.mock("@services/mongoose/bridge-reconciliation-orphan", () => ({ + upsertBridgeReconciliationOrphan: jest.fn(), + resolveOrphansByTxHash: jest.fn(), +})) + +jest.mock("@services/pubsub", () => ({ + PubSubService: jest.fn(), +})) + +jest.mock("@domain/pubsub", () => ({ + PubSubDefaultTriggers: { + BridgeReconciliationUpdate: "BRIDGE_RECONCILIATION_UPDATE", + }, +})) + +jest.mock("@services/alerts/ibex-bridge-movement", () => ({ + alertIbexReconciliationOrphan: jest.fn(), + alertIbexReconciliationFailed: jest.fn(), +})) + +import { + BridgeDeposits, + BridgeWithdrawal, + IbexCryptoReceive, +} from "@services/mongoose/schema" +import { findIbexCryptoReceivesSince } from "@services/mongoose/ibex-crypto-receive-log" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import BridgeApiClient from "@services/bridge/client" +import { + upsertBridgeReconciliationOrphan, + resolveOrphansByTxHash, +} from "@services/mongoose/bridge-reconciliation-orphan" +import { PubSubService } from "@services/pubsub" +import { alertIbexReconciliationOrphan } from "@services/alerts/ibex-bridge-movement" +import { + reconcileByTxHash, + reconcileBridgeAndIbexDeposits, + reconcileBridgeAndIbexWithdrawals, +} from "@services/bridge/reconciliation" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const TX_HASH = "0xABC123def456" +const NORM_HASH = TX_HASH.toLowerCase() + +const BRIDGE_DEPOSIT = { + eventId: "evt_001", + transferId: "tr_001", + customerId: "cust_001", + amount: "100", + currency: "usdt", + destinationTxHash: NORM_HASH, + state: "payment_processed", + createdAt: new Date("2026-01-01T12:00:00Z"), +} + +const IBEX_RECEIVE = { + txHash: NORM_HASH, + address: "0xdeadbeef", + amount: "100", + currency: "USDT", + network: "tron", + accountId: "acc_001", + receivedAt: new Date("2026-01-01T12:00:02Z"), +} + +const BRIDGE_WITHDRAWAL_USDT_SENT = { + _id: "withdrawal_001", + accountId: "acct_001", + bridgeTransferId: "tr_withdrawal_001", + bridgeDepositAddress: "0xbridge", + ibexPayoutId: "ibex_payout_001", + amount: "25.00", + currency: "usdt", + status: "usdt_sent", + createdAt: new Date("2026-01-01T12:00:00Z"), + updatedAt: new Date("2026-01-01T12:00:01Z"), +} + +const BRIDGE_WITHDRAWAL_SEND_FAILED = { + ...BRIDGE_WITHDRAWAL_USDT_SENT, + _id: "withdrawal_002", + bridgeTransferId: "tr_withdrawal_002", + ibexPayoutId: undefined, + status: "send_failed", + failureReason: "ibex unavailable", +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const mockPublish = jest.fn() + +const makeLeanQuery = (result: unknown) => ({ + lean: () => ({ exec: () => Promise.resolve(result) }), +}) + +beforeEach(() => { + jest.clearAllMocks() + ;(PubSubService as jest.Mock).mockReturnValue({ publish: mockPublish }) + ;(resolveOrphansByTxHash as jest.Mock).mockResolvedValue({ resolvedCount: 0 }) + ;(upsertBridgeReconciliationOrphan as jest.Mock).mockResolvedValue({ id: "orphan_001" }) + ;(BridgeApiClient.deleteTransfer as jest.Mock).mockResolvedValue({ id: "tr_deleted" }) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "awaiting_funds", + }) + ;(BridgeAccountsRepo.updateWithdrawalStatus as jest.Mock).mockResolvedValue({ + ...BRIDGE_WITHDRAWAL_USDT_SENT, + status: "completed", + }) +}) + +// ── reconcileByTxHash ───────────────────────────────────────────────────────── + +describe("reconcileByTxHash", () => { + describe("both sides found → matched", () => { + beforeEach(() => { + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(BRIDGE_DEPOSIT), + ) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(IBEX_RECEIVE), + ) + }) + + it("returns status matched", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("matched") + expect(result.txHash).toBe(NORM_HASH) + }) + + it("calls resolveOrphansByTxHash with normalized hash", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(resolveOrphansByTxHash).toHaveBeenCalledWith(NORM_HASH) + }) + + it("does NOT call upsertBridgeReconciliationOrphan", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + + it("publishes a matched event to PubSub", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(mockPublish).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: "BRIDGE_RECONCILIATION_UPDATE", + payload: expect.objectContaining({ + status: "matched", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + customerId: BRIDGE_DEPOSIT.customerId, + amount: BRIDGE_DEPOSIT.amount, + }), + }), + ) + }) + + it("normalizes txHash to lowercase before querying and returning", async () => { + const result = await reconcileByTxHash({ txHash: "0XABC123DEF456" }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.txHash).toBe(NORM_HASH) + const [bridgeCall] = (BridgeDeposits.findOne as jest.Mock).mock.calls + expect(bridgeCall[0].destinationTxHash.$regex.flags).toContain("i") + }) + }) + + describe("only Bridge found → bridge_without_ibex", () => { + beforeEach(() => { + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(BRIDGE_DEPOSIT), + ) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + }) + + it("returns status unmatched with correct orphanType", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("unmatched") + expect(result.orphanType).toBe("bridge_without_ibex") + }) + + it("does NOT call resolveOrphansByTxHash", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(resolveOrphansByTxHash).not.toHaveBeenCalled() + }) + + it("upserts orphan with key bridge:{hash}", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge:${NORM_HASH}`, + orphanType: "bridge_without_ibex", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + customerId: BRIDGE_DEPOSIT.customerId, + }), + ) + }) + + it("publishes an unmatched event to PubSub", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(mockPublish).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + status: "unmatched", + orphanType: "bridge_without_ibex", + }), + }), + ) + }) + + it("alerts ops when Bridge has no matching IBEX receive", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(alertIbexReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanType: "bridge_without_ibex", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + }), + ) + }) + }) + + describe("only IBEX found → ibex_without_bridge", () => { + beforeEach(() => { + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(IBEX_RECEIVE), + ) + }) + + it("returns status unmatched with correct orphanType", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("unmatched") + expect(result.orphanType).toBe("ibex_without_bridge") + }) + + it("upserts orphan with key ibex:{hash}", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `ibex:${NORM_HASH}`, + orphanType: "ibex_without_bridge", + txHash: NORM_HASH, + }), + ) + }) + + it("alerts ops when IBEX has no matching Bridge deposit", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(alertIbexReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanType: "ibex_without_bridge", + txHash: NORM_HASH, + }), + ) + }) + }) + + describe("self-healing: second call with both sides resolves orphan", () => { + it("resolves orphan when called again after missing side arrives", async () => { + // First call: only Bridge + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(BRIDGE_DEPOSIT), + ) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledTimes(1) + + jest.clearAllMocks() + ;(PubSubService as jest.Mock).mockReturnValue({ publish: mockPublish }) + ;(resolveOrphansByTxHash as jest.Mock).mockResolvedValue({ resolvedCount: 1 }) + + // Second call: both sides present (IBEX webhook arrived) + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(BRIDGE_DEPOSIT), + ) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue( + makeLeanQuery(IBEX_RECEIVE), + ) + const result = await reconcileByTxHash({ txHash: TX_HASH }) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("matched") + expect(resolveOrphansByTxHash).toHaveBeenCalledWith(NORM_HASH) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + }) + + describe("Bridge query uses payment_processed state filter", () => { + it("passes state: payment_processed to BridgeDeposits.findOne", async () => { + ;(BridgeDeposits.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + ;(IbexCryptoReceive.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + await reconcileByTxHash({ txHash: TX_HASH }) + expect(BridgeDeposits.findOne).toHaveBeenCalledWith( + expect.objectContaining({ state: "payment_processed" }), + ) + }) + }) +}) + +// ── reconcileBridgeAndIbexDeposits (batch) ──────────────────────────────────── + +describe("reconcileBridgeAndIbexDeposits", () => { + const makeBridgeFind = (deposits: unknown[]) => ({ + lean: () => ({ exec: () => Promise.resolve(deposits) }), + }) + + describe("all deposits matched", () => { + it("returns zero orphans when every Bridge deposit has a matching IBEX receive", async () => { + ;(BridgeDeposits.find as jest.Mock).mockReturnValue( + makeBridgeFind([BRIDGE_DEPOSIT]), + ) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.scannedBridge).toBe(1) + expect(result.scannedIbex).toBe(1) + expect(result.bridgeWithoutIbex).toBe(0) + expect(result.ibexWithoutBridge).toBe(0) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + }) + + describe("Bridge deposit with no matching IBEX receive", () => { + it("flags as bridge_without_ibex orphan", async () => { + ;(BridgeDeposits.find as jest.Mock).mockReturnValue( + makeBridgeFind([BRIDGE_DEPOSIT]), + ) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.bridgeWithoutIbex).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge:${NORM_HASH}`, + orphanType: "bridge_without_ibex", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + }), + ) + }) + }) + + describe("Bridge deposit with no destinationTxHash", () => { + it("flags as bridge-no-tx:{transferId} orphan", async () => { + const depositNoHash = { ...BRIDGE_DEPOSIT, destinationTxHash: undefined } + ;(BridgeDeposits.find as jest.Mock).mockReturnValue(makeBridgeFind([depositNoHash])) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.bridgeWithoutIbex).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge-no-tx:${BRIDGE_DEPOSIT.transferId}`, + orphanType: "bridge_without_ibex", + }), + ) + }) + }) + + describe("IBEX receive with no matching Bridge deposit", () => { + it("flags as ibex_without_bridge orphan", async () => { + ;(BridgeDeposits.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.ibexWithoutBridge).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `ibex:${NORM_HASH}`, + orphanType: "ibex_without_bridge", + txHash: IBEX_RECEIVE.txHash, + }), + ) + }) + }) + + describe("batch uses payment_processed state filter", () => { + it("passes state: payment_processed to BridgeDeposits.find", async () => { + ;(BridgeDeposits.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([]) + + await reconcileBridgeAndIbexDeposits() + expect(BridgeDeposits.find).toHaveBeenCalledWith( + expect.objectContaining({ state: "payment_processed" }), + ) + }) + }) + + describe("mixed scenario", () => { + it("counts matched and unmatched independently", async () => { + const deposit2 = { + ...BRIDGE_DEPOSIT, + transferId: "tr_002", + destinationTxHash: "0xother", + } + const ibex2 = { ...IBEX_RECEIVE, txHash: "0xorphan_ibex" } + + ;(BridgeDeposits.find as jest.Mock).mockReturnValue( + makeBridgeFind([BRIDGE_DEPOSIT, deposit2]), + ) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE, ibex2]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + // BRIDGE_DEPOSIT ↔ IBEX_RECEIVE match (same hash) + // deposit2 has no ibex → bridge_without_ibex + // ibex2 has no bridge → ibex_without_bridge + expect(result.scannedBridge).toBe(2) + expect(result.scannedIbex).toBe(2) + expect(result.bridgeWithoutIbex).toBe(1) + expect(result.ibexWithoutBridge).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledTimes(2) + }) + }) + + describe("error handling", () => { + it("returns an Error when findIbexCryptoReceivesSince fails", async () => { + ;(BridgeDeposits.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceivesSince as jest.Mock).mockResolvedValue( + new Error("mongo connection lost"), + ) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).toBeInstanceOf(Error) + }) + }) +}) + +describe("reconcileBridgeAndIbexWithdrawals", () => { + const makeWithdrawalFind = (withdrawals: unknown[]) => ({ + lean: () => ({ exec: () => Promise.resolve(withdrawals) }), + }) + + it("cancels Bridge transfers when IBEX crypto send failed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_SEND_FAILED]), + ) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(BridgeApiClient.deleteTransfer).toHaveBeenCalledWith("tr_withdrawal_002") + expect(result.cancelledSendFailedTransfers).toBe(1) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + + it("self-heals a sent withdrawal when Bridge already reports payment_processed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_USDT_SENT]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "payment_processed", + }) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(BridgeAccountsRepo.updateWithdrawalStatus).toHaveBeenCalledWith( + "tr_withdrawal_001", + "completed", + ) + expect(result.finalizedCompletedTransfers).toBe(1) + }) + + it("alerts when IBEX sent funds but Bridge is terminally failed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_USDT_SENT]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "error", + on_behalf_of: "cust_001", + }) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.ibexSendWithoutBridgeSettlement).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: "withdrawal-ibex-sent:tr_withdrawal_001", + orphanType: "ibex_send_without_bridge_settlement", + transferId: "tr_withdrawal_001", + }), + ) + expect(alertIbexReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanType: "ibex_send_without_bridge_settlement", + transferId: "tr_withdrawal_001", + }), + ) + }) +}) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts new file mode 100644 index 000000000..372f6840a --- /dev/null +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -0,0 +1,338 @@ +/** + * Bridge service return shapes must match the public BridgeWithdrawal GraphQL type. + * + * Withdrawal mutation/query resolvers return BridgeService results directly with no + * resolver-level field mapping, so the service is the source of truth for the GQL + * contract: NonNull `id`, `amount`, `currency`, `status`, `createdAt`; optional + * `externalAccountId`, `bridgeTransferId`, `failureReason`. + */ +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ + fns, + }: { + namespace: string + fns: Record unknown> + }) => fns, +})) + +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + BridgeConfig: { enabled: true, minWithdrawalAmount: 10, developerFeePercent: 2 }, +})) + +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } + logger.child.mockReturnValue(logger) + return { baseLogger: logger } +}) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutPending: jest.fn().mockResolvedValue(true), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getEthereumUsdtOption: jest.fn(), + createCryptoReceiveInfo: jest.fn(), + getCryptoSendRequirements: jest.fn(), + createCryptoSendInfo: jest.fn(), + sendOnchain: jest.fn(), + sendCrypto: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + createExternalAccount: jest.fn(), + createWithdrawal: jest.fn(), + findPendingWithdrawalWithoutTransfer: jest.fn(), + findExternalAccountsByAccountId: jest.fn(), + markExternalAccountsMissingFromBridge: jest.fn(), + findWithdrawalsByAccountId: jest.fn(), + findWithdrawalById: jest.fn(), + updateWithdrawalTransferId: jest.fn(), + updateWithdrawalOnchainSend: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { createTransfer: jest.fn(), listExternalAccounts: jest.fn() }, +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@domain/primitives/bridge", () => ({ + toBridgeCustomerId: (id: string) => id, + toBridgeExternalAccountId: (id: string) => id, +})) + +jest.mock("@domain/shared", () => { + class USDTAmount { + static currencyId = 29 + + private readonly ibexValue: number + + constructor(ibexValue: number) { + this.ibexValue = ibexValue + } + + toIbex() { + return this.ibexValue + } + + static fromNumber(value: number | string) { + return new USDTAmount(Number(value)) + } + } + return { ...jest.requireActual("@domain/shared"), USDTAmount } +}) + +import BridgeService from "@services/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import BridgeClient from "@services/bridge/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { WalletsRepository } from "@services/mongoose/wallets" +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-account-001" +const AMOUNT = "50" +const CUSTOMER_ID = "cust-001" +const ETHEREUM_ADDRESS = "ETH_ADDR_001" +const TRANSFER_ID = "transfer-bridge-001" +const WITHDRAWAL_ID = "withdrawal-mongo-001" +const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const IBEX_PAYOUT_ID = "ibex-payout-001" +const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" +const CREATED_AT = new Date("2026-06-05T00:00:00.000Z") + +const mockAccount = { + id: ACCOUNT_ID, + level: 2, + bridgeCustomerId: CUSTOMER_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + bridgeKycStatus: "approved", + kratosUserId: "kratos-001", +} + +const mockTransfer = { + id: TRANSFER_ID, + amount: AMOUNT, + currency: "usd", + state: "pending", + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdt", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, + receipt: { + initial_amount: AMOUNT, + developer_fee: "1.00", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }, +} + +const makeRow = (overrides: Record = {}) => ({ + id: WITHDRAWAL_ID, + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending" as const, + bridgeTransferId: undefined, + failureReason: undefined, + createdAt: CREATED_AT, + ...overrides, +}) + +const setupGuards = () => { + const { USDTAmount } = jest.requireMock("@domain/shared") as { + USDTAmount: new (ibexValue: number) => { toIbex: () => number } + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest + .fn() + .mockResolvedValue([{ id: "wallet-001", currency: "USDT", type: "checking" }]), + }) + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(new USDTAmount(1000)) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + ;( + BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock + ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [ + { + id: EXTERNAL_ACCOUNT_ID, + customer_id: CUSTOMER_ID, + account_owner_name: "Dread", + account_type: "us", + currency: "usd", + bank_name: "Test Bank", + account_number_last_4: "1111", + active: true, + created_at: "2026-06-05T00:00:00.000Z", + }, + ], + has_more: false, + }) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(makeRow()) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...makeRow(), + bridgeTransferId: TRANSFER_ID, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + status: "submitted" as const, + }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(), + bridgeTransferId: TRANSFER_ID, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + ibexPayoutId: IBEX_PAYOUT_ID, + status: "usdt_sent" as const, + }) + const IbexClient = jest.requireMock("@services/ibex/client").default + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue({ + requirementsId: "send-requirements-001", + data: { address: { required: true } }, + }) + ;(IbexClient.createCryptoSendInfo as jest.Mock).mockResolvedValue({ + id: IBEX_CRYPTO_SEND_INFO_ID, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + transaction: { id: IBEX_PAYOUT_ID, status: "PENDING" }, + }) + ;(IbexClient.sendOnchain as jest.Mock).mockResolvedValue({ + status: "PENDING", + transactionHub: { id: IBEX_PAYOUT_ID }, + }) + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) +} + +describe("initiateWithdrawal — BridgeWithdrawal GraphQL contract shape", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + }) + + it("returns every NonNull field required by the BridgeWithdrawal GraphQL type", async () => { + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + + expect(result.id).toBe(WITHDRAWAL_ID) + expect(result.amount).toBe(AMOUNT) + expect(result.currency).toBe("usdt") + expect(result.status).toBe("usdt_sent") + expect(result.createdAt).toBe(CREATED_AT.toISOString()) + expect(result.bridgeTransferId).toBe(TRANSFER_ID) + expect((result as Record).transferId).toBeUndefined() + expect((result as Record).state).toBeUndefined() + }) +}) + +describe("getWithdrawals — BridgeWithdrawal GraphQL contract shape", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + }) + + it("maps Mongo rows to id/status (not legacy transferId/state)", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow({ + bridgeTransferId: TRANSFER_ID, + status: "submitted", + failureReason: "ACH return", + }), + ]) + + const result = await BridgeService.getWithdrawals(ACCOUNT_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: WITHDRAWAL_ID, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "submitted", + bridgeTransferId: TRANSFER_ID, + failureReason: "ACH return", + createdAt: CREATED_AT.toISOString(), + }) + expect((result[0] as Record).transferId).toBeUndefined() + expect((result[0] as Record).state).toBeUndefined() + }) + + it("excludes rows without bridgeTransferId so NonNull id/status never resolve undefined", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow({ status: "pending" }), + makeRow({ id: "w-cancelled", status: "cancelled" }), + makeRow({ + id: "w-submitted", + bridgeTransferId: TRANSFER_ID, + status: "submitted", + }), + ]) + + const result = await BridgeService.getWithdrawals(ACCOUNT_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result).toHaveLength(1) + expect(result[0].id).toBe("w-submitted") + expect(result[0].status).toBe("submitted") + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts b/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts new file mode 100644 index 000000000..8f3ed3afd --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts @@ -0,0 +1,311 @@ +// AC3: Bridge fee value persisted on every deposit row + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-deposit-log", () => ({ + createBridgeDeposit: jest.fn(), +})) + +jest.mock("@services/bridge/reconciliation", () => ({ + reconcileByTxHash: jest.fn().mockResolvedValue({ status: "matched" }), +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeDepositRequest: jest.fn(), +})) + +import { Request, Response } from "express" +import { LockService } from "@services/lock" +import * as DepositLog from "@services/mongoose/bridge-deposit-log" +import { writeBridgeDepositRequest } from "@services/frappe/BridgeTransferRequestWriter" +import { depositHandler } from "@services/bridge/webhook-server/routes/deposit" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = (body: Record) => ({ body }) as unknown as Request + +// Real Bridge deposit event shape +const VALID_EVENT_OBJECT = { + id: "tr_xyz789abc123", + state: "funds_received", + amount: "1.00", + currency: "usd", + developer_fee: "0.0", + on_behalf_of: "cust_bob", + source: { + currency: "usdb", + payment_rail: "bridge_wallet", + bridge_wallet_id: "wallet_bob_usdb", + }, + receipt: { + developer_fee: "0.0", + initial_amount: "1.00", + subtotal_amount: "1.00", + final_amount: "1.00", + destination_tx_hash: "4gJH6oXpZUNgC1QLh8mXNPF92LtLKzHZj5eHuQrdQAgB", + }, + created_at: "2025-06-11T21:27:00.000Z", + updated_at: "2025-06-11T21:27:01.000Z", +} + +const VALID_BODY = { + api_version: "v0", + event_id: "wh_789xyz654mno", + event_category: "transfer", + event_type: "updated.status_transitioned", + event_object_id: "tr_xyz789abc123", + event_object_status: "funds_received", + event_object: VALID_EVENT_OBJECT, + event_object_changes: { state: ["payment_submitted", "funds_received"] }, + event_created_at: "2025-06-11T21:27:00.000Z", +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +const mockLockService = (acquired = true) => { + ;(LockService as jest.Mock).mockReturnValue({ + lockIdempotencyKey: jest + .fn() + .mockResolvedValue(acquired ? {} : new Error("already locked")), + }) +} + +beforeEach(() => { + jest.clearAllMocks() + mockLockService(true) + ;(writeBridgeDepositRequest as jest.Mock).mockResolvedValue(true) +}) + +// ── Invalid payload ─────────────────────────────────────────────────────────── + +describe("depositHandler — invalid payload", () => { + it("returns 400 when event_id is missing", async () => { + const res = makeRes() + const body = { ...VALID_BODY } + delete body.event_id + await depositHandler(makeReq(body), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + expect(DepositLog.createBridgeDeposit).not.toHaveBeenCalled() + }) + + it("returns 400 when event_object.id is missing", async () => { + const res = makeRes() + const objWithoutId = { ...VALID_EVENT_OBJECT } + delete objWithoutId.id + await depositHandler(makeReq({ ...VALID_BODY, event_object: objWithoutId }), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + expect(DepositLog.createBridgeDeposit).not.toHaveBeenCalled() + }) + + it("acknowledges Bridge wallet activity without amount or customer identifiers", async () => { + const res = makeRes() + await depositHandler( + makeReq({ + ...VALID_BODY, + event_category: "bridge_wallet.activity", + event_object: { + id: "activity_wallet_balance", + type: "balance_changed", + bridge_wallet_id: "wallet_123", + available_balance: "100.00", + currency: "usdb", + }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ + status: "skipped", + reason: "missing_crediting_fields", + }) + expect(DepositLog.createBridgeDeposit).not.toHaveBeenCalled() + expect(writeBridgeDepositRequest).not.toHaveBeenCalled() + }) +}) + +// ── Idempotency ─────────────────────────────────────────────────────────────── + +describe("depositHandler — idempotency", () => { + it("returns already_processed after idempotent local and audit writes on duplicate delivery", async () => { + const lockFn = jest + .fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce(new Error("already locked")) + ;(LockService as jest.Mock).mockReturnValue({ lockIdempotencyKey: lockFn }) + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ id: "log-dup" }) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(res.json as jest.Mock).toHaveBeenCalledWith({ status: "already_processed" }) + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledTimes(1) + expect(writeBridgeDepositRequest).toHaveBeenCalledTimes(1) + expect(lockFn).toHaveBeenCalledTimes(2) + }) + + it("locks on the event id after writing the audit row", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ id: "log-1" }) + + const lockFn = jest.fn().mockResolvedValue({}) + ;(LockService as jest.Mock).mockReturnValue({ lockIdempotencyKey: lockFn }) + + await depositHandler(makeReq(VALID_BODY), makeRes()) + expect(lockFn).toHaveBeenCalledWith("bridge-deposit:wh_789xyz654mno") + }) +}) + +// ── AC3: Fee persistence ────────────────────────────────────────────────────── + +describe("depositHandler — fee persistence (AC3)", () => { + it("persists developer_fee from the receipt on every deposit event", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-fee-001", + }) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledTimes(1) + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledWith( + expect.objectContaining({ + developerFee: "0.0", + }), + ) + }) + + it("persists the full deposit record with transfer id, customer, state and receipt breakdown", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-fee-002", + }) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "wh_789xyz654mno", + transferId: "tr_xyz789abc123", + customerId: "cust_bob", + state: "funds_received", + amount: "1.00", + currency: "usd", + subtotalAmount: "1.00", + initialAmount: "1.00", + finalAmount: "1.00", + destinationTxHash: "4gJH6oXpZUNgC1QLh8mXNPF92LtLKzHZj5eHuQrdQAgB", + }), + ) + }) + + it("does not use destination payment rail as a currency fallback", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-currency-001", + }) + + const eventObject = { + ...VALID_EVENT_OBJECT, + currency: undefined, + destination_payment_rail: "ach", + } + + await depositHandler(makeReq({ ...VALID_BODY, event_object: eventObject }), makeRes()) + + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledWith( + expect.objectContaining({ + currency: "usd", + }), + ) + }) + + it("returns 200 success after persisting the deposit log", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-fee-003", + }) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ status: "success" }) + }) + + it("returns 500 when log persistence fails", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue( + new Error("mongo timeout"), + ) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + }) + + it("handles null developer_fee gracefully", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-fee-004", + }) + + const body = { + ...VALID_BODY, + event_object: { + ...VALID_EVENT_OBJECT, + receipt: { ...VALID_EVENT_OBJECT.receipt, developer_fee: null }, + }, + } + + const res = makeRes() + await depositHandler(makeReq(body), res) + + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledWith( + expect.objectContaining({ developerFee: "0.0" }), + ) + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("writes an ERPNext audit row after the deposit log is persisted", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-audit-001", + }) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(writeBridgeDepositRequest).toHaveBeenCalledWith({ + eventId: "wh_789xyz654mno", + eventObject: VALID_EVENT_OBJECT, + rawPayload: VALID_BODY, + }) + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("returns 500 when the ERPNext audit write fails", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-audit-002", + }) + ;(writeBridgeDepositRequest as jest.Mock).mockResolvedValue( + new Error("erpnext timeout"), + ) + + const res = makeRes() + await depositHandler(makeReq(VALID_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ + error: "Failed to persist ERPNext audit row", + }) + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts new file mode 100644 index 000000000..dea9360af --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts @@ -0,0 +1,352 @@ +// AC1: Orphan event surfaces in ops tooling with triage context +// AC2: Replay CLI re-runs a stuck handler against a chosen transfer-id + +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + BridgeConfig: { + webhook: { replaySecret: "super-secret-replay-token-xyz" }, + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-replay-log", () => ({ + createBridgeReplay: jest.fn(), +})) + +jest.mock("@services/bridge/webhook-server/routes/deposit", () => ({ + depositHandler: jest.fn(), +})) +jest.mock("@services/bridge/webhook-server/routes/kyc", () => ({ + kycHandler: jest.fn(), +})) +jest.mock("@services/bridge/webhook-server/routes/transfer", () => ({ + transferHandler: jest.fn(), +})) + +import { Request, Response } from "express" +import { + replayAuthMiddleware, + replayHandler, +} from "@services/bridge/webhook-server/routes/replay" +import * as ReplayLog from "@services/mongoose/bridge-replay-log" +import { depositHandler } from "@services/bridge/webhook-server/routes/deposit" +import { kycHandler } from "@services/bridge/webhook-server/routes/kyc" +import { transferHandler } from "@services/bridge/webhook-server/routes/transfer" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = ( + body: Record = {}, + headers: Record = {}, +) => ({ body, headers }) as unknown as Request + +const BASE_BODY = { + event_type: "funds_received", + event_object: { id: "evt-001", transfer_id: "xfer-001" }, + event_created_at: "2026-05-01T12:00:00Z", + operator: "ops@example.com", + time_window_start: "2026-05-01T00:00:00Z", + time_window_end: "2026-05-01T23:59:59Z", +} + +// ── replayAuthMiddleware ────────────────────────────────────────────────────── + +describe("replayAuthMiddleware", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns 503 when replaySecret is not configured", () => { + const { BridgeConfig } = jest.requireMock("@config") + const saved = BridgeConfig.webhook.replaySecret + BridgeConfig.webhook.replaySecret = undefined + + const res = makeRes() + const next = jest.fn() + replayAuthMiddleware(makeReq({}, {}), res, next) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(503) + expect(next).not.toHaveBeenCalled() + + BridgeConfig.webhook.replaySecret = saved + }) + + it("returns 401 for a wrong token", () => { + const res = makeRes() + const next = jest.fn() + replayAuthMiddleware(makeReq({}, { authorization: "Bearer wrong-token" }), res, next) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it("returns 401 when Authorization header is absent", () => { + const res = makeRes() + const next = jest.fn() + replayAuthMiddleware(makeReq({}, {}), res, next) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it("calls next() for the correct Bearer token", () => { + const res = makeRes() + const next = jest.fn() + replayAuthMiddleware( + makeReq({}, { authorization: "Bearer super-secret-replay-token-xyz" }), + res, + next, + ) + + expect(next).toHaveBeenCalledTimes(1) + expect(res.status as jest.Mock).not.toHaveBeenCalled() + }) + + it("uses timing-safe comparison (different-length token is rejected)", () => { + const res = makeRes() + const next = jest.fn() + // A prefix of the real secret — same content up to length, but different length + replayAuthMiddleware( + makeReq({}, { authorization: "Bearer super-secret-replay-token-xy" }), + res, + next, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) +}) + +// ── replayHandler ───────────────────────────────────────────────────────────── + +describe("replayHandler", () => { + beforeEach(() => jest.clearAllMocks()) + + describe("input validation", () => { + it("returns 400 when event_type is missing", async () => { + const res = makeRes() + const body = { ...BASE_BODY } + delete body.event_type + await replayHandler(makeReq(body), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + + it("returns 400 when event_object is missing", async () => { + const res = makeRes() + const body = { ...BASE_BODY } + delete body.event_object + await replayHandler(makeReq(body), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + + it("returns 400 when event_created_at is missing", async () => { + const res = makeRes() + const body = { ...BASE_BODY } + delete body.event_created_at + await replayHandler(makeReq(body), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + + it("returns 400 for an unrecognised event_type", async () => { + const res = makeRes() + await replayHandler(makeReq({ ...BASE_BODY, event_type: "unknown_event" }), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + }) + + describe("event_type → handler routing", () => { + const cases: Array<[string, jest.Mock]> = [ + ["funds_received", depositHandler as jest.Mock], + ["funds_scheduled", depositHandler as jest.Mock], + ["payment_processed", depositHandler as jest.Mock], + ["kyc.approved", kycHandler as jest.Mock], + ["kyc.rejected", kycHandler as jest.Mock], + ["transfer.completed", transferHandler as jest.Mock], + ["transfer.failed", transferHandler as jest.Mock], + ] + + beforeEach(() => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ id: "log-001" }) + }) + + it("routes outbound withdrawal payment_processed replay to transfer handler", async () => { + ;(transferHandler as jest.Mock).mockImplementation((_req: Request, res: Response) => { + ;(res.status as jest.Mock)(200) + ;(res.json as jest.Mock)({ status: "success" }) + return Promise.resolve(res) + }) + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ id: "log-wd-001" }) + + const res = makeRes() + await replayHandler( + makeReq({ + ...BASE_BODY, + event_type: "updated.status_transitioned", + event_object_status: "payment_processed", + event_object: { + id: "tr-withdraw-001", + state: "payment_processed", + amount: "100.00", + currency: "usd", + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { payment_rail: "ach", currency: "usd" }, + }, + }), + res, + ) + + expect(transferHandler).toHaveBeenCalledTimes(1) + expect(depositHandler).not.toHaveBeenCalled() + const handlerReq = (transferHandler as jest.Mock).mock.calls[0][0] + expect(handlerReq.body.event).toBe("transfer.payment_processed") + }) + + test.each(cases)( + "%s is routed to the correct handler", + async (eventType, handler) => { + handler.mockImplementation((_req: Request, res: Response) => { + ;(res.status as jest.Mock)(200) + ;(res.json as jest.Mock)({ status: "success" }) + return Promise.resolve(res) + }) + + const res = makeRes() + await replayHandler(makeReq({ ...BASE_BODY, event_type: eventType }), res) + + expect(handler).toHaveBeenCalledTimes(1) + }, + ) + }) + + describe("dry_run mode", () => { + it("returns 200 without calling any handler", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ + id: "log-dry-001", + }) + + const res = makeRes() + await replayHandler(makeReq({ ...BASE_BODY, dry_run: true }), res) + + expect(depositHandler).not.toHaveBeenCalled() + expect(kycHandler).not.toHaveBeenCalled() + expect(transferHandler).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("persists a dry-run log entry with httpStatus 0", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ + id: "log-dry-002", + }) + + const res = makeRes() + await replayHandler(makeReq({ ...BASE_BODY, dry_run: true }), res) + + expect(ReplayLog.createBridgeReplay).toHaveBeenCalledWith( + expect.objectContaining({ httpStatus: 0, dryRun: true }), + ) + }) + + it("returns 500 when dry-run log creation fails", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue( + new Error("db error"), + ) + + const res = makeRes() + await replayHandler(makeReq({ ...BASE_BODY, dry_run: true }), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + }) + }) + + describe("live replay", () => { + beforeEach(() => { + ;(depositHandler as jest.Mock).mockImplementation( + (_req: Request, res: Response) => { + ;(res.status as jest.Mock)(200) + ;(res.json as jest.Mock)({ status: "success" }) + return Promise.resolve(res) + }, + ) + }) + + it("returns the handler's status code and response body", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ + id: "log-live-001", + }) + + const res = makeRes() + await replayHandler(makeReq(BASE_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + const jsonArg = (res.json as jest.Mock).mock.calls[0][0] + expect(jsonArg).toMatchObject({ status: "replayed", handler_status: 200 }) + }) + + it("persists a replay log with triage context (operator + time window)", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ + id: "log-live-002", + }) + + const res = makeRes() + await replayHandler(makeReq(BASE_BODY), res) + + expect(ReplayLog.createBridgeReplay).toHaveBeenCalledWith( + expect.objectContaining({ + operator: "ops@example.com", + timeWindowStart: new Date("2026-05-01T00:00:00Z"), + timeWindowEnd: new Date("2026-05-01T23:59:59Z"), + eventId: "evt-001", + httpStatus: 200, + dryRun: false, + }), + ) + }) + + it("includes log_id in the response so ops can trace the replay", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ + id: "log-trace-007", + }) + + const res = makeRes() + await replayHandler(makeReq(BASE_BODY), res) + + const jsonArg = (res.json as jest.Mock).mock.calls[0][0] + expect(jsonArg.log_id).toBe("log-trace-007") + }) + + it("returns 500 when log creation fails after a successful handler run", async () => { + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue( + new Error("mongo down"), + ) + + const res = makeRes() + await replayHandler(makeReq(BASE_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + }) + + it("propagates a handler 4xx back to the caller", async () => { + ;(depositHandler as jest.Mock).mockImplementation( + (_req: Request, res: Response) => { + ;(res.status as jest.Mock)(422) + ;(res.json as jest.Mock)({ error: "Unprocessable" }) + return Promise.resolve(res) + }, + ) + ;(ReplayLog.createBridgeReplay as jest.Mock).mockResolvedValue({ id: "log-4xx" }) + + const res = makeRes() + await replayHandler(makeReq(BASE_BODY), res) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(422) + }) + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts new file mode 100644 index 000000000..0c6aff7c2 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts @@ -0,0 +1,296 @@ +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + BRIDGE_WITHDRAWAL_NOT_FOUND: "Withdrawal not found", + updateWithdrawalStatus: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutCompleted: jest.fn(), + writeBridgeCashoutFailed: jest.fn(), +})) + +import { Request, Response } from "express" +import { LockService } from "@services/lock" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" +import { + writeBridgeCashoutCompleted, + writeBridgeCashoutFailed, +} from "@services/frappe/BridgeTransferRequestWriter" +import { transferHandler } from "@services/bridge/webhook-server/routes/transfer" + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = (body: Record) => ({ body }) as unknown as Request + +const WITHDRAWAL_RECORD = { id: "wd-1", status: "pending", bridgeTransferId: "tr-abc" } + +const updateFn = BridgeAccountsRepo.updateWithdrawalStatus as jest.Mock +let lockFn: jest.Mock + +beforeEach(() => { + jest.clearAllMocks() + lockFn = jest.fn().mockResolvedValue({}) + updateFn.mockResolvedValue({ ...WITHDRAWAL_RECORD, status: "completed" }) + ;(LockService as jest.Mock).mockReturnValue({ lockIdempotencyKey: lockFn }) + ;(writeBridgeCashoutCompleted as jest.Mock).mockResolvedValue(true) + ;(writeBridgeCashoutFailed as jest.Mock).mockResolvedValue(true) +}) + +describe("transferHandler", () => { + it("returns 503 when withdrawal row is not found yet (retryable)", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("Withdrawal not found")) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.failed", + event_object: { + id: "tr-early", + state: "canceled", + source: { failure_reason: "rejected" }, + }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(503) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("acquires idempotency lock only after a successful status and audit update", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_id: "wh-transfer-1", + event_object: { + id: "tr-abc", + state: "payment_processed", + amount: "25.00", + currency: "usdt", + }, + }), + res, + ) + + expect(updateFn).toHaveBeenCalled() + expect(writeBridgeCashoutCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + transferId: "tr-abc", + amount: "25.00", + currency: "usdt", + sourceEventId: "wh-transfer-1", + sourceEventType: "transfer.completed", + }), + ) + expect(lockFn).toHaveBeenCalledWith( + "bridge-transfer:tr-abc:transfer.completed:payment_processed", + ) + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("does not acquire lock when status update fails", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("mongo error")) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.failed", + event_object: { id: "tr-abc", state: "canceled" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("ignores refund_in_flight without updating withdrawal status", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.failed", + event_object: { id: "tr-abc", state: "refund_in_flight" }, + }), + res, + ) + + expect(updateFn).not.toHaveBeenCalled() + expect(lockFn).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "ignored_transient_state", + }) + }) + + it("returns already_processed when lock is held after a prior successful run", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "already_processed", + }) + }) + + it("sends a push notification after a successful completion", async () => { + updateFn.mockResolvedValue({ + ...WITHDRAWAL_RECORD, + status: "completed", + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + }) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + outcome: "completed", + }) + }) + + it("does not send a push notification when the idempotency lock is already held", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns 500 and does not mark processed when the completed audit write fails", async () => { + ;(writeBridgeCashoutCompleted as jest.Mock).mockResolvedValue( + new Error("erpnext timeout"), + ) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.completed", + event_object: { + id: "tr-abc", + state: "payment_processed", + amount: "25.00", + currency: "usdt", + }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ + error: "Failed to persist ERPNext audit row", + }) + expect(lockFn).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("writes a failed cashout audit row before marking a failed transfer processed", async () => { + updateFn.mockResolvedValue({ + ...WITHDRAWAL_RECORD, + status: "failed", + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + failureReason: "ACH return", + }) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.failed", + event_id: "wh-transfer-2", + event_object: { + id: "tr-abc", + state: "returned", + amount: "25.00", + currency: "usdt", + source: { failure_reason: "ACH return" }, + }, + }), + res, + ) + + expect(writeBridgeCashoutFailed).toHaveBeenCalledWith( + expect.objectContaining({ + transferId: "tr-abc", + amount: "25.00", + currency: "usdt", + accountId: "acct-1", + failureReason: "ACH return", + sourceEventId: "wh-transfer-2", + }), + ) + expect(lockFn).toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("returns 200 already_terminal when failure arrives after completion", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue( + new RepositoryError("Withdrawal already completed, cannot transition to failed"), + ) + + const res = makeRes() + await transferHandler( + makeReq({ + event_type: "transfer.failed", + event_object: { + id: "tr-abc", + state: "returned", + source: { failure_reason: "ACH return" }, + }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "already_terminal", + }) + expect(lockFn).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/verify-signature.spec.ts b/test/flash/unit/services/bridge/webhook-server/verify-signature.spec.ts new file mode 100644 index 000000000..f51c748f4 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/verify-signature.spec.ts @@ -0,0 +1,126 @@ +jest.mock("@config", () => ({ + BridgeConfig: { + webhook: { + publicKeys: { + kyc: "", + deposit: "", + transfer: "", + external_account: "", + }, + timestampSkewMs: 5 * 60 * 1000, + }, + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})) + +import crypto from "crypto" + +import { NextFunction, Request, Response } from "express" + +import { verifyBridgeSignature } from "@services/bridge/webhook-server/middleware/verify-signature" + +type RawBodyRequest = Request & { rawBody?: string } + +const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, +}) + +const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString() + +const RAW_BODY = JSON.stringify({ + api_version: "v0", + event_id: "wh_signature_fixture", + event_category: "customer", + event_type: "kyc.approved", + event_object_id: "cust_signature_fixture", +}) + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = (signature: string, rawBody = RAW_BODY) => + ({ + headers: { "x-webhook-signature": signature }, + rawBody, + }) as RawBodyRequest + +const signBridgeDigestFixture = (timestamp: string, rawBody = RAW_BODY) => { + const signedPayload = `${timestamp}.${rawBody}` + const digest = crypto.createHash("sha256").update(signedPayload).digest() + const signer = crypto.createSign("RSA-SHA256") + signer.update(digest) + return signer.sign(privateKey, "base64") +} + +const signRawPayloadDirectly = (timestamp: string, rawBody = RAW_BODY) => { + const signer = crypto.createSign("RSA-SHA256") + signer.update(`${timestamp}.${rawBody}`) + return signer.sign(privateKey, "base64") +} + +const signatureHeader = (timestamp: string, signature: string) => + `t=${timestamp},v0=${signature}` + +describe("verifyBridgeSignature", () => { + beforeAll(() => { + const { BridgeConfig } = jest.requireMock("@config") + BridgeConfig.webhook.publicKeys.kyc = publicKeyPem + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("accepts the Bridge documented digest signature over timestamp and exact raw body", () => { + const timestamp = Date.now().toString() + const signature = signBridgeDigestFixture(timestamp) + const req = makeReq(signatureHeader(timestamp, signature)) + const res = makeRes() + const next = jest.fn() as NextFunction + + verifyBridgeSignature("kyc")(req, res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(res.status as jest.Mock).not.toHaveBeenCalled() + }) + + it("rejects signatures created over the raw timestamp/body payload directly", () => { + const timestamp = Date.now().toString() + const signature = signRawPayloadDirectly(timestamp) + const req = makeReq(signatureHeader(timestamp, signature)) + const res = makeRes() + const next = jest.fn() as NextFunction + + verifyBridgeSignature("kyc")(req, res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(401) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ error: "Invalid signature" }) + }) + + it("rejects digest signatures generated from a reserialized body instead of the captured raw body", () => { + const timestamp = Date.now().toString() + const reserializedBody = JSON.stringify(JSON.parse(RAW_BODY), null, 2) + const signature = signBridgeDigestFixture(timestamp, reserializedBody) + const req = makeReq(signatureHeader(timestamp, signature), RAW_BODY) + const res = makeRes() + const next = jest.fn() as NextFunction + + verifyBridgeSignature("kyc")(req, res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(401) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ error: "Invalid signature" }) + }) +}) diff --git a/test/flash/unit/services/bridge/withdrawal-fees.spec.ts b/test/flash/unit/services/bridge/withdrawal-fees.spec.ts new file mode 100644 index 000000000..6d7dc93f1 --- /dev/null +++ b/test/flash/unit/services/bridge/withdrawal-fees.spec.ts @@ -0,0 +1,217 @@ +jest.mock("@config", () => ({ + BridgeConfig: { + developerFeePercent: 2, + timeoutMs: 10_000, + withdrawalFeeEstimate: { + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrls: [ + "https://ethereum-rpc.publicnode.com", + "https://eth.llamarpc.com", + "https://cloudflare-eth.com", + ], + ethUsdPriceUrl: "https://prices.example.invalid/eth-usd", + timeoutMs: 1_500, + cacheTtlMs: 45_000, + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, + }, + }, +})) + +import * as EthereumGasEstimate from "@services/bridge/ethereum-gas-estimate" +import { + computeCustomerFeeEstimateFromGasMarket, + computePendingAmountEstimates, + resolveWithdrawalCustomerFeeEstimate, + presentBridgeWithdrawal, + receiptFeesFromTransfer, +} from "@services/bridge/withdrawal-fees" + +const feeConfig = { + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrls: [ + "https://ethereum-rpc.publicnode.com", + "https://eth.llamarpc.com", + "https://cloudflare-eth.com", + ], + ethUsdPriceUrl: "https://prices.example.invalid/eth-usd", + timeoutMs: 1_500, + cacheTtlMs: 45_000, + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, +} + +describe("bridge withdrawal fees", () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it("computes customer fee as flash fee + bridge fee + buffered gas", () => { + const estimate = computeCustomerFeeEstimateFromGasMarket({ + amount: "50.00", + gasMarket: { gasPriceGwei: 20, ethUsd: 3000 }, + config: feeConfig, + developerFeePercent: 2, + }) + + expect(estimate.flashFeePercent).toBe("2") + expect(estimate.flashFee).toBe("1.00") + expect(estimate.estimatedBridgeFeePercent).toBe("0.6") + expect(estimate.estimatedBridgeFee).toBe("0.30") + expect(estimate.estimatedGasBuffer).toBe("5.85") + expect(estimate.estimatedCustomerFee).toBe("7.15") + }) + + it("resolves estimates using configured gas-market sources and cache settings", async () => { + const gasMarketSpy = jest + .spyOn(EthereumGasEstimate, "fetchEthereumGasMarketSnapshot") + .mockResolvedValue({ gasPriceGwei: 20, ethUsd: 3000 }) + + const estimate = await resolveWithdrawalCustomerFeeEstimate("50.00") + + expect(gasMarketSpy).toHaveBeenCalledWith({ + rpcUrls: feeConfig.ethereumGasRpcUrls, + timeoutMs: feeConfig.timeoutMs, + fallbackGasPriceGwei: feeConfig.fallbackGasPriceGwei, + ethUsdFallback: feeConfig.ethUsdFallback, + ethUsdPriceUrl: feeConfig.ethUsdPriceUrl, + cacheTtlMs: feeConfig.cacheTtlMs, + }) + expect(estimate.estimatedCustomerFee).toBe("7.15") + }) + + it("computes pending subtotal and final amount from estimated customer fee", () => { + expect(computePendingAmountEstimates("50.00", "7.15")).toEqual({ + subtotalAmount: "42.85", + finalAmount: "42.85", + }) + }) + + it("merges fee estimates on mongoose documents without losing core fields", () => { + const mongooseDoc = { + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: new Date("2026-06-11T00:00:00.000Z"), + toObject: () => ({ + _id: { toString: () => "w-mongo" }, + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: new Date("2026-06-11T00:00:00.000Z"), + }), + } + + const pending = presentBridgeWithdrawal(mongooseDoc, { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + }) + + expect(pending.id).toBe("w-mongo") + expect(pending.amount).toBe("50.00") + expect(pending.currency).toBe("usdt") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + }) + + it("fills missing legacy fee fields when a fresh estimate is provided", () => { + const pending = presentBridgeWithdrawal( + { + id: "w-legacy", + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: "2026-06-11T00:00:00.000Z", + }, + { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + }, + ) + + expect(pending.estimatedBridgeFeePercent).toBe("0.6") + expect(pending.estimatedGasBuffer).toBe("5.85") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + }) + + it("exposes pending amount estimates until Bridge receipt fees are stored", () => { + const pending = presentBridgeWithdrawal({ + id: "w-1", + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + createdAt: "2026-06-11T00:00:00.000Z", + }) + + expect(pending.flashFeeIsEstimate).toBe(true) + expect(pending.flashFee).toBe("1.00") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + expect(pending.finalAmount).toBe("42.85") + }) + + it("uses receipt amounts once Bridge fees are available", () => { + const submitted = presentBridgeWithdrawal({ + id: "w-1", + amount: "49.00", + currency: "usd", + externalAccountId: "ext-1", + status: "submitted", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + bridgeDeveloperFee: "0.30", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + bridgeTransferId: "tr-1", + createdAt: "2026-06-11T00:00:00.000Z", + }) + + expect(submitted.flashFeeIsEstimate).toBe(false) + expect(submitted.subtotalAmount).toBe("48.90") + expect(submitted.finalAmount).toBe("48.90") + }) + + it("maps Bridge transfer receipt fields", () => { + expect( + receiptFeesFromTransfer({ + developer_fee: "0.30", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }), + ).toEqual({ + bridgeDeveloperFee: "0.30", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + }) +}) diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts new file mode 100644 index 000000000..71fd7d1de --- /dev/null +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -0,0 +1,217 @@ +jest.mock("@services/frappe/ErpNext", () => ({ + upsertBridgeTransferRequest: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +import ErpNext from "@services/frappe/ErpNext" +import { baseLogger } from "@services/logger" +import { + writeBridgeCashoutCompleted, + writeBridgeCashoutFailed, + writeBridgeCashoutPending, + writeBridgeDepositRequest, + writeIbexCryptoReceiveRequest, +} from "@services/frappe/BridgeTransferRequestWriter" +import { BridgeTransferRequestStatus } from "@services/frappe/models/BridgeTransferRequest" + +const upsert = ErpNext.upsertBridgeTransferRequest as jest.Mock +const lastRequestInput = () => upsert.mock.calls[0][0].input + +describe("BridgeTransferRequestWriter", () => { + beforeEach(() => { + jest.clearAllMocks() + upsert.mockResolvedValue(true) + }) + + it("writes Bridge deposit events as topup audit requests", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + receipt: { + developer_fee: "0.05", + initial_amount: "10.00", + subtotal_amount: "9.95", + final_amount: "9.95", + destination_tx_hash: "tx_123", + }, + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_123", + status: BridgeTransferRequestStatus.FiatReceived, + bridgeCustomerId: "cust_123", + bridgeTransferId: "tr_123", + ibexTxHash: "tx_123", + }), + ) + }) + + it("skips virtual account activity until Bridge provides a stable deposit id", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_scheduled", + eventObject: { + id: "activity_123", + type: "funds_scheduled", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + customer_id: "cust_123", + virtual_account_id: "va_123", + product_type: "virtual_account", + }, + rawPayload: { event_id: "wh_scheduled" }, + }) + + expect(upsert).not.toHaveBeenCalled() + expect(baseLogger.warn).toHaveBeenCalledWith( + { + eventId: "wh_scheduled", + bridgeEventObjectId: "activity_123", + state: "funds_scheduled", + }, + "Skipping Bridge deposit ERPNext audit row without stable request id", + ) + }) + + it("does not use destination payment rail as a deposit currency fallback", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_rail", + eventObject: { + id: "tr_rail", + state: "funds_received", + amount: "10.00", + currency: undefined as unknown as string, + destination_payment_rail: "wire", + on_behalf_of: "cust_123", + }, + rawPayload: { event_id: "wh_rail" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + currency: "usd", + }), + ) + }) + + it("keys virtual account deposits by Bridge deposit id", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_received", + eventObject: { + id: "activity_456", + type: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + customer_id: "cust_123", + deposit_id: "deposit_123", + virtual_account_id: "va_123", + product_type: "virtual_account", + }, + rawPayload: { event_id: "wh_received" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "deposit_123", + bridgeTransferId: "deposit_123", + sourceEventId: "wh_received", + sourceEventType: "deposit.funds_received", + }), + ) + }) + + it("writes IBEX crypto receives as settled topup audit requests", async () => { + await writeIbexCryptoReceiveRequest({ + txHash: "tx_123", + address: "0xabc", + amount: "4.250000", + currency: "USDT", + network: "ethereum", + accountId: "acct_123" as AccountId, + walletId: "wallet_123" as WalletId, + rawPayload: { tx_hash: "tx_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "ibex:tx_123", + status: BridgeTransferRequestStatus.Settled, + accountId: "acct_123", + walletId: "wallet_123", + }), + ) + }) + + it("writes completed Bridge transfers as completed cashout audit requests", async () => { + await writeBridgeCashoutCompleted({ + transferId: "tr_cashout", + amount: "5.00", + currency: "usdt", + accountId: "acct_123" as AccountId, + sourceEventType: "transfer.completed", + rawPayload: { event: "transfer.completed" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_cashout", + status: BridgeTransferRequestStatus.Completed, + accountId: "acct_123", + }), + ) + }) + + it("writes pending Bridge transfers as pending cashout audit requests", async () => { + await writeBridgeCashoutPending({ + transferId: "tr_cashout", + amount: "5.00", + currency: "usdt", + accountId: "acct_123" as AccountId, + sourceEventId: "withdrawal_123", + sourceEventType: "bridge.withdrawal.usdt_sent", + rawPayload: { bridgeTransferId: "tr_cashout" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_cashout", + status: BridgeTransferRequestStatus.Pending, + accountId: "acct_123", + sourceEventId: "withdrawal_123", + sourceEventType: "bridge.withdrawal.usdt_sent", + }), + ) + }) + + it("writes failed Bridge transfers as failed cashout audit requests", async () => { + await writeBridgeCashoutFailed({ + transferId: "tr_cashout", + amount: "5.00", + currency: "usdt", + accountId: "acct_123" as AccountId, + failureReason: "ACH returned", + sourceEventType: "transfer.failed", + rawPayload: { event: "transfer.failed" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_cashout", + status: BridgeTransferRequestStatus.Failed, + failureReason: "ACH returned", + }), + ) + }) +}) diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts new file mode 100644 index 000000000..d473bae9b --- /dev/null +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -0,0 +1,81 @@ +jest.mock("axios", () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@config", () => ({ + FrappeConfig: undefined, +})) + +import axios from "axios" +import { ErpNext } from "@services/frappe/ErpNext" +import { + BridgeTransferRequest, + BridgeTransferRequestStatus, + BridgeTransferRequestTransactionType, +} from "@services/frappe/models/BridgeTransferRequest" + +const mockedAxios = axios as unknown as { + get: jest.Mock + post: jest.Mock + put: jest.Mock +} + +const client = new ErpNext("https://erp.example", "erp.example", { + apiKey: "key", + apiSecret: "secret", +}) + +const request = new BridgeTransferRequest({ + requestId: "tr_123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.FiatReceived, + amount: "10.00", + currency: "usd", +}) + +describe("ErpNext.upsertBridgeTransferRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("creates a Bridge Transfer Request when request_id is absent", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + mockedAxios.post.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const result = await client.upsertBridgeTransferRequest(request) + + expect(result).toBe(true) + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bridge Transfer Request", + expect.objectContaining({ request_id: "tr_123" }), + expect.any(Object), + ) + expect(mockedAxios.put).not.toHaveBeenCalled() + }) + + it("updates a Bridge Transfer Request when request_id already exists", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ name: "BTR-1" }] } }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const result = await client.upsertBridgeTransferRequest(request) + + expect(result).toBe(true) + expect(mockedAxios.post).not.toHaveBeenCalled() + expect(mockedAxios.put).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bridge%20Transfer%20Request/BTR-1", + expect.objectContaining({ request_id: "tr_123" }), + expect.any(Object), + ) + }) +}) diff --git a/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts b/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts new file mode 100644 index 000000000..a27a5c5ff --- /dev/null +++ b/test/flash/unit/services/frappe/models/BridgeTransferRequest.spec.ts @@ -0,0 +1,84 @@ +import { + BridgeTransferRequest, + BridgeTransferRequestStatus, + BridgeTransferRequestTransactionType, +} from "@services/frappe/models/BridgeTransferRequest" + +describe("BridgeTransferRequest", () => { + it("serializes a topup audit request to ERPNext field names", () => { + const request = new BridgeTransferRequest({ + requestId: "tr_123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.FiatReceived, + amount: "10.25", + currency: "usd", + bridgeCustomerId: "cust_123", + bridgeTransferId: "tr_123", + sourceEventId: "wh_123", + sourceEventType: "deposit.funds_received", + sourceSystemsSeen: ["bridge_deposit"], + rawPayload: { event_id: "wh_123" }, + }) + + expect(request.toErpnext()).toEqual({ + doctype: "Bridge Transfer Request", + request_id: "tr_123", + transaction_type: "Topup", + status: "Fiat Received", + provider: "Bridge", + asset: "USDT", + network: "Ethereum", + amount: "10.25", + currency: "usd", + developer_fee: undefined, + initial_amount: undefined, + subtotal_amount: undefined, + final_amount: undefined, + account_id: undefined, + wallet_id: undefined, + bridge_customer_id: "cust_123", + bridge_transfer_id: "tr_123", + ibex_tx_hash: undefined, + address: undefined, + source_event_id: "wh_123", + source_event_type: "deposit.funds_received", + source_systems_seen: "bridge_deposit", + first_seen_at: undefined, + last_seen_at: expect.any(String), + raw_payload_json: JSON.stringify({ event_id: "wh_123" }), + failure_reason: undefined, + }) + }) + + it("serializes source systems without duplicates", () => { + const request = new BridgeTransferRequest({ + requestId: "ibex:tx-123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Settled, + amount: "2.500000", + currency: "USDT", + sourceSystemsSeen: ["ibex_crypto_receive", "ibex_crypto_receive"], + }) + + expect(request.toErpnext().source_systems_seen).toBe("ibex_crypto_receive") + }) + + it("serializes datetimes in the format accepted by Frappe", () => { + const request = new BridgeTransferRequest({ + requestId: "tr_123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Settled, + amount: "2.500000", + currency: "USDT", + firstSeenAt: "2026-06-08T20:30:01.373Z", + lastSeenAt: "2026-06-08T20:31:02.540Z", + }) + + expect(request.toErpnext()).toEqual( + expect.objectContaining({ + first_seen_at: "2026-06-08 20:30:01", + last_seen_at: "2026-06-08 20:31:02", + }), + ) + }) +}) diff --git a/test/flash/unit/services/ibex/client-usd-wallet.spec.ts b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts new file mode 100644 index 000000000..b3a844d0e --- /dev/null +++ b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts @@ -0,0 +1,225 @@ +const mockAddInvoice = jest.fn() +const mockGetFeeEstimation = jest.fn() +const mockEstimateFeeV2 = jest.fn() +const mockGetCryptoSendRequirements = jest.fn() +const mockCreateCryptoSendInfo = jest.fn() +const mockSendCrypto = jest.fn() + +jest.mock("@services/ibex/cache", () => ({ + Redis: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock("@services/ibex/webhook-server", () => ({ + __esModule: true, + default: { + endpoints: { + onReceive: { + invoice: "https://flash.test/ibex/receive/invoice", + lnurl: "https://flash.test/ibex/receive/lnurl", + onchain: "https://flash.test/ibex/receive/onchain", + }, + onPay: { + invoice: "https://flash.test/ibex/pay/invoice", + lnurl: "https://flash.test/ibex/pay/lnurl", + onchain: "https://flash.test/ibex/pay/onchain", + }, + }, + secret: "test-secret", + }, +})) + +jest.mock("ibex-client", () => { + class AuthenticationError extends Error {} + class ApiError extends Error {} + class UnexpectedResponseError extends Error {} + class IbexClientError extends Error {} + + return { + ...jest.requireActual("ibex-client"), + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + authentication: { + storage: { + getAccessToken: jest.fn(), + setAccessToken: jest.fn(), + setRefreshToken: jest.fn(), + }, + }, + addInvoice: (...args: unknown[]) => mockAddInvoice(...args), + getFeeEstimation: (...args: unknown[]) => mockGetFeeEstimation(...args), + estimateFeeV2: (...args: unknown[]) => mockEstimateFeeV2(...args), + getCryptoSendRequirements: (...args: unknown[]) => + mockGetCryptoSendRequirements(...args), + createCryptoSendInfo: (...args: unknown[]) => mockCreateCryptoSendInfo(...args), + sendCrypto: (...args: unknown[]) => mockSendCrypto(...args), + })), + AuthenticationError, + ApiError, + UnexpectedResponseError, + IbexClientError, + IbexUrls: { + sandbox: { + authDomain: "https://auth.sandbox.example", + audience: "https://api.sandbox.example", + hubUrl: "https://api.sandbox.example", + }, + }, + } +}) + +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" + +describe("IBEX USD wallet amount handling", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("creates USDT invoices using decimal USDT amount", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1" } }) + + await Ibex.addInvoice({ + accountId: "ibex-usdt-account" as IbexAccountId, + amount, + memo: "usdt invoice", + }) + + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ibex-usdt-account", + amount: 0.019446, + }), + ) + }) + + it("estimates LN fees with USDT currency id and parses USDT amounts", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockGetFeeEstimation.mockResolvedValue({ amount: 0.000123, invoiceAmount: 0.019446 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + send: amount, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: "0.019446", + currencyId: "29", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDTAmount }).fee).toBeInstanceOf(USDTAmount) + expect((result as { fee: USDTAmount }).fee.asSmallestUnits()).toBe("123") + expect((result as { invoice: USDTAmount }).invoice.asSmallestUnits()).toBe("19446") + }) + + it("estimates fixed-amount LN fees with USDT currency id when no send amount is provided", async () => { + mockGetFeeEstimation.mockResolvedValue({ amount: 0.000123, invoiceAmount: 0.019446 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + currency: WalletCurrency.Usdt, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: undefined, + currencyId: "29", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDTAmount }).fee).toBeInstanceOf(USDTAmount) + expect((result as { fee: USDTAmount }).fee.asSmallestUnits()).toBe("123") + }) + + it("keeps USD LN fee estimation behavior unchanged", async () => { + const amount = USDAmount.cents("19446") as USDAmount + mockGetFeeEstimation.mockResolvedValue({ amount: 0.12, invoiceAmount: 194.46 }) + + const result = await Ibex.getLnFeeEstimation({ + invoice: "lnbc1" as Bolt11, + send: amount, + }) + + expect(mockGetFeeEstimation).toHaveBeenCalledWith({ + bolt11: "lnbc1", + amount: "194.46", + currencyId: "3", + }) + expect(result).not.toBeInstanceOf(Error) + expect((result as { fee: USDAmount }).fee).toBeInstanceOf(USDAmount) + expect((result as { fee: USDAmount }).fee.asCents()).toBe("12") + }) + + it("estimates on-chain fees with USDT currency id", async () => { + const amount = USDTAmount.smallestUnits("19446") as USDTAmount + mockEstimateFeeV2.mockResolvedValue({ fee: 0.000123 }) + + await Ibex.estimateOnchainFee(amount, "0xabc" as OnChainAddress) + + expect(mockEstimateFeeV2).toHaveBeenCalledWith({ + "amount": 0.019446, + "currency-id": "29", + "address": "0xabc", + }) + }) + + it("forwards crypto sends to the IBEX crypto send endpoint", async () => { + mockSendCrypto.mockResolvedValue({ + transaction: { id: "ibex-payout-001", status: "PENDING" }, + cryptoTransaction: { networkTxId: "0xtx" }, + }) + + await Ibex.sendCrypto({ + accountId: "ibex-usdt-account", + cryptoSendInfosId: "send-info-001", + amount: 2.5, + }) + + expect(mockSendCrypto).toHaveBeenCalledWith({ + accountId: "ibex-usdt-account", + cryptoSendInfosId: "send-info-001", + amount: 2.5, + }) + }) + + it("fetches crypto send requirements", async () => { + mockGetCryptoSendRequirements.mockResolvedValue({ + requirementsId: "requirements-001", + data: { address: { required: true } }, + }) + + await Ibex.getCryptoSendRequirements({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + + expect(mockGetCryptoSendRequirements).toHaveBeenCalledWith({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + }) + + it("creates crypto send info", async () => { + mockCreateCryptoSendInfo.mockResolvedValue({ + id: "send-info-001", + name: "bridge-withdrawal-001", + data: { address: "0xbridge" }, + }) + + await Ibex.createCryptoSendInfo({ + name: "bridge-withdrawal-001", + requirementsId: "requirements-001", + data: { address: "0xbridge" }, + }) + + expect(mockCreateCryptoSendInfo).toHaveBeenCalledWith({ + name: "bridge-withdrawal-001", + requirementsId: "requirements-001", + data: { address: "0xbridge" }, + }) + }) +}) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts new file mode 100644 index 000000000..af7830a4c --- /dev/null +++ b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts @@ -0,0 +1,198 @@ +jest.mock("@services/ibex/webhook-server/middleware", () => ({ + authenticate: jest.fn((_req, _res, next) => next()), + logRequest: jest.fn((_req, _res, next) => next()), +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/ibex-crypto-receive-log", () => ({ + createIbexCryptoReceive: jest.fn(), +})) + +jest.mock("@app/wallets", () => ({ + listWalletsByAccountId: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/bridge/reconciliation", () => ({ + reconcileByTxHash: jest.fn().mockResolvedValue({ status: "matched" }), +})) + +jest.mock("@app/bridge/send-deposit-notification", () => ({ + sendBridgeDepositNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeIbexCryptoReceiveRequest: jest.fn(), +})) + +jest.mock("@services/alerts/ibex-bridge-movement", () => ({ + alertIbexCryptoReceiveFailure: jest.fn(), + alertIbexReconciliationFailed: jest.fn(), +})) + +import { cryptoReceiveHandler } from "@services/ibex/webhook-server/routes/crypto-receive" +import { AccountsRepository } from "@services/mongoose/accounts" +import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" +import { listWalletsByAccountId } from "@app/wallets" +import { LockService } from "@services/lock" +import { WalletCurrency } from "@domain/shared" +import { writeIbexCryptoReceiveRequest } from "@services/frappe/BridgeTransferRequestWriter" +import { alertIbexCryptoReceiveFailure } from "@services/alerts/ibex-bridge-movement" + +const ACCOUNT_ID = "account-001" as AccountId +const WALLET_ID = "wallet-usdt-001" as WalletId +const ADDRESS = "0xabc123" +const TX_HASH = "tx-001" + +const makeResponse = () => { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } + return res +} + +describe("cryptoReceiveHandler", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(LockService as jest.Mock).mockReturnValue({ + lockOnChainTxHash: jest.fn((_hash, fn) => fn()), + }) + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeEthereumAddress: jest.fn().mockResolvedValue({ id: ACCOUNT_ID }), + }) + ;(createIbexCryptoReceive as jest.Mock).mockResolvedValue({ id: "log-001" }) + ;(listWalletsByAccountId as jest.Mock).mockResolvedValue([ + { id: WALLET_ID, currency: WalletCurrency.Usdt }, + ]) + ;(writeIbexCryptoReceiveRequest as jest.Mock).mockResolvedValue(true) + }) + + it("accepts Ethereum USDT receive webhooks and normalizes persisted currency/network", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "usdt", + network: "Ethereum", + }, + } as never, + res as never, + ) + + expect(AccountsRepository().findByBridgeEthereumAddress).toHaveBeenCalledWith(ADDRESS) + expect(createIbexCryptoReceive).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + accountId: ACCOUNT_ID, + }), + ) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + }) + + it("writes an ERPNext audit row after resolving the account and USDT wallet", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(writeIbexCryptoReceiveRequest).toHaveBeenCalledWith({ + txHash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "Ethereum", + accountId: ACCOUNT_ID, + walletId: WALLET_ID, + rawPayload: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + }) + expect(res.status).toHaveBeenCalledWith(200) + }) + + it("returns 500 when the ERPNext audit write fails", async () => { + ;(writeIbexCryptoReceiveRequest as jest.Mock).mockResolvedValue( + new Error("erpnext timeout"), + ) + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ error: "erpnext_audit_failed" }) + expect(alertIbexCryptoReceiveFailure).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: TX_HASH, + code: "erpnext_audit_failed", + title: "IBEX crypto receive ERPNext audit write failed", + }), + ) + }) + + it("rejects legacy Tron USDT receive webhooks for the ETH-USDT Cash Wallet path", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "tron", + }, + } as never, + res as never, + ) + + expect(LockService().lockOnChainTxHash).not.toHaveBeenCalled() + expect(createIbexCryptoReceive).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ error: "Invalid payload" }) + }) +}) diff --git a/test/flash/unit/services/mongoose/wallets.spec.ts b/test/flash/unit/services/mongoose/wallets.spec.ts new file mode 100644 index 000000000..9707694cd --- /dev/null +++ b/test/flash/unit/services/mongoose/wallets.spec.ts @@ -0,0 +1,82 @@ +import { UnsupportedCurrencyError } from "@domain/errors" +import { WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { WalletsRepository } from "@services/mongoose/wallets" + +const save = jest.fn() +const walletConstructor = jest.fn().mockImplementation((record) => ({ + ...record, + save, +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + createAccount: jest.fn(), + createLnurlPay: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/schema", () => ({ + Wallet: jest.fn().mockImplementation((record) => walletConstructor(record)), +})) + +jest.mock("@services/mongoose/utils", () => ({ + toObjectId: jest.fn((id) => id), + fromObjectId: jest.fn((id) => id), + parseRepositoryError: jest.fn((err) => err), +})) + +describe("WalletsRepository.persistNew", () => { + beforeEach(() => { + save.mockReset().mockResolvedValue(undefined) + walletConstructor.mockClear() + jest.mocked(AccountsRepository).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ id: "account-id" }), + } as never) + jest + .mocked(Ibex.createAccount) + .mockReset() + .mockResolvedValue({ + id: "ibex-account-id", + } as never) + jest + .mocked(Ibex.createLnurlPay) + .mockReset() + .mockResolvedValue({ + lnurl: "lnurlp", + } as never) + }) + + it("rejects currencies without an IBEX account currency id", async () => { + const result = await WalletsRepository().persistNew({ + accountId: "account-id" as AccountId, + type: "checking" as WalletType, + currency: WalletCurrency.Btc, + }) + + expect(result).toBeInstanceOf(UnsupportedCurrencyError) + expect(Ibex.createAccount).not.toHaveBeenCalled() + expect(Ibex.createLnurlPay).not.toHaveBeenCalled() + }) + + it("creates USDT wallets as IBEX currency 29 accounts", async () => { + const result = await WalletsRepository().persistNew({ + accountId: "account-id" as AccountId, + type: "checking" as WalletType, + currency: WalletCurrency.Usdt, + }) + + expect(result).not.toBeInstanceOf(Error) + expect(Ibex.createAccount).toHaveBeenCalledWith("account-id", 29) + expect(Ibex.createLnurlPay).toHaveBeenCalledWith({ + accountId: "ibex-account-id", + currencyId: 29, + }) + }) +}) diff --git a/tsconfig-build.json b/tsconfig-build.json index aa3e79ccb..9ce1b13ac 100644 --- a/tsconfig-build.json +++ b/tsconfig-build.json @@ -1,7 +1,11 @@ { "extends": "./tsconfig.json", - "include": ["src/servers/**/*.ts", "src/**/*.d.ts"], + "include": [ + "src/servers/**/*.ts", + "src/scripts/**/*.ts", + "src/**/*.d.ts" + ], "compilerOptions": { "noImplicitAny": true } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 74b16e187..a03e83d1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2206,6 +2206,11 @@ protobufjs "^7.5.5" yargs "^17.7.2" +"@hono/node-server@^1.19.9": + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -2582,6 +2587,13 @@ dependencies: lodash "^4.17.21" +"@lnflash/bridge-mcp@github:lnflash/bridge-mcp": + version "0.1.0" + resolved "https://codeload.github.com/lnflash/bridge-mcp/tar.gz/b2168e2b80d8935daf444732529bc51e757e6786" + dependencies: + "@modelcontextprotocol/sdk" "^1.0.0" + zod "^3.22.0" + "@mapbox/node-pre-gyp@^1.0.5": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" @@ -2633,6 +2645,29 @@ dependencies: make-plural "^7.0.0" +"@modelcontextprotocol/sdk@^1.0.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz#79786d8b525e269de850ac82b1f1f757f3915f44" + integrity sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ== + dependencies: + "@hono/node-server" "^1.19.9" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + "@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.3.0": version "1.3.2" resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz#51e5cad2f24b8759702d9cc185da0a3ef3784bad" @@ -4807,6 +4842,14 @@ accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + acorn-import-assertions@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" @@ -4851,6 +4894,13 @@ ajv-draft-04@^1.0.0: resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv@^6.12.4: version "6.15.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" @@ -4861,20 +4911,20 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.12.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" - integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== +ajv@^8.0.0, ajv@^8.17.1, ajv@^8.18.0: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== dependencies: fast-deep-equal "^3.1.3" fast-uri "^3.0.1" json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -ajv@^8.18.0: - version "8.20.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" - integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== +ajv@^8.12.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== dependencies: fast-deep-equal "^3.1.3" fast-uri "^3.0.1" @@ -5571,7 +5621,7 @@ bn.js@^4.11.8, bn.js@^4.11.9: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.2.tgz#3d8fed6796c24e177737f7cc5172ee04ef39ec99" integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== -body-parser@1.20.1, body-parser@1.20.2, body-parser@1.20.3, body-parser@^1.19.0, body-parser@~1.20.3: +body-parser@1.20.1, body-parser@1.20.2, body-parser@1.20.3, body-parser@^1.19.0, body-parser@^2.2.1, body-parser@~1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== @@ -6173,11 +6223,21 @@ content-disposition@0.5.4, content-disposition@~0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +content-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" + integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== + continuable-cache@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" @@ -6201,6 +6261,11 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + cookie-signature@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" @@ -6216,7 +6281,7 @@ cookie@0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== -cookie@0.7.2, cookie@^0.7.0, cookie@~0.7.1: +cookie@0.7.2, cookie@^0.7.0, cookie@^0.7.1, cookie@~0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -6277,7 +6342,7 @@ cross-inspect@1.0.1: dependencies: tslib "^2.4.0" -cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -6399,7 +6464,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.1, debug@^4.4.3: +debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -6485,7 +6550,7 @@ denque@^2.1.0: resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== -depd@2.0.0, depd@~2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -6869,16 +6934,16 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encodeurl@^2.0.0, encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - encoding-sniffer@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz#396ec97ac22ce5a037ba44af1992ac9d46a7b819" @@ -7091,7 +7156,7 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== @@ -7328,7 +7393,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: +etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== @@ -7361,6 +7426,18 @@ events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz#4e198eb91cd333d0a8ddcc036502b3618a25f449" + integrity sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg== + +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + execa@^5.0.0, execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -7408,6 +7485,13 @@ express-jwt@^8.4.1: express-unless "^2.1.3" jsonwebtoken "^9.0.0" +express-rate-limit@^8.2.1: + version "8.5.2" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.5.2.tgz#5922dbf76df2124611cea955d93432b37514b2f3" + integrity sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A== + dependencies: + ip-address "^10.2.0" + express-unless@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/express-unless/-/express-unless-2.1.3.tgz#f951c6cca52a24da3de32d42cfd4db57bc0f9a2e" @@ -7524,6 +7608,40 @@ express@^4.21.2: utils-merge "1.0.1" vary "~1.1.2" +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + ext@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" @@ -7743,6 +7861,18 @@ finalhandler@1.3.1: statuses "2.0.1" unpipe "~1.0.0" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + finalhandler@~1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" @@ -7944,6 +8074,11 @@ fresh@0.5.2, fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + fs-extra@^10.0.1: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -8333,11 +8468,6 @@ graphql-middleware@^6.1.33: "@graphql-tools/delegate" "^8.8.1" "@graphql-tools/schema" "^8.5.1" -graphql-query-complexity-apollo-plugin@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/graphql-query-complexity-apollo-plugin/-/graphql-query-complexity-apollo-plugin-1.0.2.tgz#1eff2eb4ea455a3577c9a2f3580561a5e19b394a" - integrity sha512-TjDzIuAILNI0+wemtTpl47Vj/wCi8TGhq8tVcJQOsHUkiflrVhrHwKHiBX7V5KC5rgUAJtnsqSgVBDJqsAvwjg== - graphql-query-complexity@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/graphql-query-complexity/-/graphql-query-complexity-0.12.0.tgz#5f636ccc54da82225f31e898e7f27192fe074b4c" @@ -8721,6 +8851,11 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" +hono@^4.11.4: + version "4.12.26" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.26.tgz#450edfd64aad96cccc36829d63ec1430272e3ef8" + integrity sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw== + hooker@~0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/hooker/-/hooker-0.2.3.tgz#b834f723cc4a242aa65963459df6d984c5d3d959" @@ -8782,17 +8917,7 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" -http-errors@~1.6.2: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -http-errors@~2.0.0, http-errors@~2.0.1: +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== @@ -8803,6 +8928,16 @@ http-errors@~2.0.0, http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + http-parser-js@>=0.5.1: version "0.5.10" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" @@ -8867,10 +9002,9 @@ i18n@^0.15.1: math-interval-parser "^2.0.1" mustache "^4.2.0" -ibex-client@^3.0.0: +"ibex-client@github:lnflash/ibex-client#28f4a784cb59e033f49257f22a437b68c95fd94b": version "3.0.0" - resolved "https://registry.yarnpkg.com/ibex-client/-/ibex-client-3.0.0.tgz#963ced9561b1e2cea0cec876ebf282f62ef69540" - integrity sha512-ATfB1qptJLv2C7K0bzzZCjTSYgJO/ihx059FCT+DjngfSkijJLOdFdFepQTcy7Cadh1ij5TdFa5+jYbr3ZiViw== + resolved "git+ssh://git@github.com/lnflash/ibex-client.git#28f4a784cb59e033f49257f22a437b68c95fd94b" dependencies: api "^6.1.2" node-cache "^5.1.2" @@ -8889,6 +9023,13 @@ iconv-lite@0.6.3, iconv-lite@^0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -9030,6 +9171,11 @@ ip-address@^10.0.1: resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.0.1.tgz#a8180b783ce7788777d796286d61bce4276818ed" integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== +ip-address@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -9226,6 +9372,11 @@ is-promise@^2.2.2: resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + is-regex@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" @@ -9870,6 +10021,11 @@ jose@^4.15.4: resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== +jose@^6.1.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.3.tgz#0975197ad973251221c658a3cddc4b951a250c2d" + integrity sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw== + joycon@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" @@ -9997,6 +10153,11 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -10618,6 +10779,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + medici@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/medici/-/medici-7.2.0.tgz#9b48e0ae1059937cd9b71ee6437896ca52d4c5aa" @@ -10654,6 +10820,11 @@ merge-descriptors@1.0.3: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -10704,7 +10875,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -"mime-db@>= 1.43.0 < 2": +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: version "1.54.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== @@ -10716,6 +10887,13 @@ mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.17, m dependencies: mime-db "1.52.0" +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -10954,6 +11132,11 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -11343,7 +11526,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@2.4.1, on-finished@~2.4.1: +on-finished@2.4.1, on-finished@^2.4.1, on-finished@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -11648,7 +11831,7 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.12, path-to-regexp@0.1.13, path-to-regexp@0.1.7, path-to-regexp@^6.2.0, path-to-regexp@~0.1.12: +path-to-regexp@0.1.12, path-to-regexp@0.1.13, path-to-regexp@0.1.7, path-to-regexp@^6.2.0, path-to-regexp@^8.0.0, path-to-regexp@~0.1.12: version "0.1.13" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== @@ -11799,6 +11982,11 @@ pirates@^4.0.4, pirates@^4.0.7: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -12057,7 +12245,7 @@ protoc-gen-js@^3.21.2: dependencies: adm-zip "0.5.10" -proxy-addr@~2.0.7: +proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== @@ -12122,7 +12310,7 @@ pushdata-bitcoin@1.0.1: dependencies: bitcoin-ops "^1.3.0" -qs@6.11.0, qs@6.13.0, qs@6.15.1, qs@^6.10.5, qs@^6.12.3, qs@^6.4.0, qs@^6.9.4, qs@~6.14.0, qs@~6.15.1: +qs@6.11.0, qs@6.13.0, qs@6.15.1, qs@^6.10.5, qs@^6.12.3, qs@^6.14.0, qs@^6.4.0, qs@^6.9.4, qs@~6.14.0, qs@~6.15.1: version "6.15.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== @@ -12163,7 +12351,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -range-parser@~1.2.1: +range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== @@ -12183,6 +12371,16 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + raw-body@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" @@ -12563,6 +12761,17 @@ ripemd160@^2.0.1: hash-base "^3.1.2" inherits "^2.0.4" +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -12713,6 +12922,23 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + send@~0.19.0, send@~0.19.1: version "0.19.2" resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" @@ -12765,6 +12991,16 @@ serve-static@1.16.2, serve-static@^1.14.1: parseurl "~1.3.3" send "0.19.0" +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + serve-static@~1.16.2: version "1.16.3" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" @@ -13128,7 +13364,7 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@~2.0.1, statuses@~2.0.2: +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== @@ -13831,6 +14067,15 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-is@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + dependencies: + content-type "^2.0.0" + media-typer "^1.1.0" + mime-types "^3.0.0" + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -14171,7 +14416,7 @@ varuint-bitcoin@1.1.2, varuint-bitcoin@^1.1.2: dependencies: safe-buffer "^5.1.1" -vary@^1, vary@~1.1.2: +vary@^1, vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== @@ -14545,7 +14790,17 @@ zen-observable@0.8.15: resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== -zod@^3.22.2: +zod-to-json-schema@^3.25.1: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + +zod@^3.22.0, zod@^3.22.2: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + +"zod@^3.25 || ^4.0": + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==