diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index bb5202f04..09c7b3662 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -343,6 +343,7 @@ type BridgeWithdrawal amount: String! createdAt: String! currency: String! + failureReason: String id: ID! status: String! } @@ -501,6 +502,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) @@ -1651,6 +1675,7 @@ type Query 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 @@ -2039,7 +2064,7 @@ type UsdtWallet implements Wallet @join__type(graph: PUBLIC) { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl 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/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..67d632d84 --- /dev/null +++ b/src/app/cash-wallet-cutover/index.ts @@ -0,0 +1,24 @@ +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" 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..80e4fd848 --- /dev/null +++ b/src/app/cash-wallet-cutover/lifecycle.ts @@ -0,0 +1,174 @@ +import { CashWalletCutoverRepository } from "@services/mongoose" + +import { + CashWalletCutoverInProgressError, + 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", + ) + } + + 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..7c41478a8 --- /dev/null +++ b/src/app/cash-wallet-cutover/presentation.ts @@ -0,0 +1,82 @@ +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, + } +} + +export const cashWalletTransactionWalletIdsForPresentation = ({ + walletIds, + presentation, +}: { + walletIds?: WalletId[] + presentation: CashWalletPresentationResult +}): WalletId[] => { + const selectedWalletIds = walletIds ?? presentation.wallets.map((wallet) => wallet.id) + + if (!presentation.legacyUsdWallet) return selectedWalletIds + + return Array.from( + new Set( + selectedWalletIds.map((walletId) => + walletId === presentation.legacyUsdWallet?.id + ? presentation.activeSettlementWallet.id + : walletId, + ), + ), + ) +} 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/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 6e747daf6..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" @@ -51,6 +52,7 @@ export const ApplicationErrors = { ...CaptchaErrors, ...AuthenticationErrors, ...UserErrors, + ...CashWalletCutoverErrors, ...KratosErrors, ...LedgerFacadeErrors, 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/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 a069657df..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" @@ -36,6 +37,7 @@ export const queryFields = { 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/schema.graphql b/src/graphql/admin/schema.graphql index 1eb75c263..46da132ac 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -136,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! @@ -278,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! @@ -335,6 +368,7 @@ type Query { 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! @@ -572,7 +606,7 @@ A wallet belonging to an account which contains a USDT balance and a list of tra """ type UsdtWallet implements Wallet { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl 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 9f93e8875..480711d87 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -792,6 +792,42 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidLnurlError": 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 : "" diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index 8f8e7f46f..7a401d97e 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" @@ -44,6 +45,7 @@ export const queryFields = { npubByUsername: NpubByUserNameQuery, isFlashNpub: IsFlashNpubQuery, supportedBanks: SupportedBanksQuery, + cashWalletCutover: CashWalletCutoverQuery, }, authed: { atAccountLevel: { 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, }, @@ -30,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]) { @@ -39,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({ @@ -49,17 +59,19 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ // TODO: Move Ibex call to Payments interface const checkedAmount = await usdWalletAmountFromWalletId({ - walletId, + walletId: routedWalletId, amount: amount.toString(), }) 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)] } + 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 e8d8d6227..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 @@ -14,6 +14,7 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract 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" @@ -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,8 +91,20 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< if (!domainAccount) throw new Error("Authentication required") // eslint-disable-next-line @typescript-eslint/no-explicit-any - const usCents = await usdWalletAmountFromWalletId({ + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } + + const usCents = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, amount: amount.toString(), }) if (usCents instanceof Error) { @@ -102,7 +115,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< } const PayLightningInvoice = await Ibex.payInvoice({ invoice: paymentRequest as Bolt11, - accountId: walletId, + accountId: routedWalletId, send: usCents, }) 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 ccfb2c7bb..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 @@ -3,6 +3,7 @@ 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" @@ -53,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) { @@ -68,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({ @@ -75,7 +85,7 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< // uncheckedPaymentRequest: paymentRequest, // }) - const wallet = await WalletsRepository().findById(walletIdChecked) + const wallet = await WalletsRepository().findById(routedWalletId) if (wallet instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(wallet)] } } @@ -84,8 +94,9 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< invoice: paymentRequest as Bolt11, 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/onchain-usd-payment-send.ts b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index b45224a6c..f85b02c55 100644 --- a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -12,6 +12,7 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { Wallets } from "@app/index" 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,8 +68,20 @@ const OnChainUsdPaymentSendMutation = GT.Field< } if (!domainAccount) throw new Error("Authentication required") - const usdAmount = await usdWalletAmountFromWalletId({ + 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) { @@ -77,24 +90,24 @@ const OnChainUsdPaymentSendMutation = GT.Field< 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/schema.graphql b/src/graphql/public/schema.graphql index e2f607717..30c15eada 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -358,6 +358,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 @@ -1296,6 +1315,7 @@ type Query { 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 @@ -1654,7 +1674,7 @@ A wallet belonging to an account which contains a USDT balance and a list of tra """ type UsdtWallet implements Wallet { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl diff --git a/src/graphql/public/types/object/business-account.ts b/src/graphql/public/types/object/business-account.ts index 97daf3670..a9980a89e 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 { + cashWalletTransactionWalletIdsForPresentation, + 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 = cashWalletTransactionWalletIdsForPresentation({ + 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..660b3a999 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 { + cashWalletTransactionWalletIdsForPresentation, + 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 = cashWalletTransactionWalletIdsForPresentation({ + 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/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/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index 305a855bb..510351520 100644 --- a/src/graphql/shared/types/object/usd-wallet.ts +++ b/src/graphql/shared/types/object/usd-wallet.ts @@ -9,6 +9,7 @@ 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, USDTAmount } from "@domain/shared" import { WalletType } from "@domain/wallets" @@ -22,6 +23,18 @@ import Lnurl from "../scalar/lnurl" 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,17 +62,34 @@ const UsdWallet = GT.Object({ }, balance: { type: FractionalCentAmount, - resolve: async (source) => { + resolve: async (source, args, ctx) => { if (source.type === WalletType.External) return null + 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: source.id, - currency: source.currency, + walletId: balanceWallet.id, + currency: balanceWallet.currency, }) if (balance instanceof Error) { throw mapError(balance) } if (balance instanceof USDTAmount) { - return Number(balance.asUsdCents()) + return usdtMicrosToUsdCents(balance.asSmallestUnits()) } return Number(balance.asCents(8)) }, diff --git a/src/graphql/shared/types/object/usdt-wallet.ts b/src/graphql/shared/types/object/usdt-wallet.ts index 3dd36759a..40f064cc0 100644 --- a/src/graphql/shared/types/object/usdt-wallet.ts +++ b/src/graphql/shared/types/object/usdt-wallet.ts @@ -56,7 +56,7 @@ const UsdtWallet = GT.Object({ }, balance: { - type: GT.NonNull(FractionalCentAmount), + type: FractionalCentAmount, resolve: async (source) => { const balance = await Wallets.getBalanceForWallet({ walletId: source.id, 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/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/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/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/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/ibex/client.ts b/src/services/ibex/client.ts index 8464520e1..cf448778e 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -17,6 +17,7 @@ import IbexClient, { PayToALnurlPayResponse201, SendToAddressCopyBodyParam, SendToAddressCopyResponse200, + IbexUrls, } from "ibex-client" import { IbexConfig } from "@config" @@ -55,6 +56,8 @@ const Ibex = new IbexClient( Redis, ) +const IbexUrlConfig = IbexUrls[IbexConfig.environment] + const createAccount = async ( name: string, currencyId: IbexCurrencyId, @@ -262,42 +265,39 @@ const getIbexToken = async (): Promise => { const cached = await Ibex.authentication.storage.getAccessToken() if (typeof cached === "string") return `${cached}` - // The SDK uses a single base URL for all calls, but the sandbox auth domain is separate - const resp = await fetch(`${IbexConfig.url}/auth/signin`, { + 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/json" }, - body: JSON.stringify({ email: IbexConfig.email, password: IbexConfig.password }), + 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 body = await resp.text().catch(() => "") - return new IbexError(new Error(`IBEX sign-in failed: ${resp.status} — ${body}`)) + const responseBody = await resp.text().catch(() => "") + return new IbexError( + new Error(`IBEX token request failed: ${resp.status} — ${responseBody}`), + ) } const data = (await resp.json()) as { - accessToken?: string - accessTokenExpiresAt?: number - refreshToken?: string - refreshTokenExpiresAt?: number + access_token?: string + expires_in?: number } - if (!data.accessToken) - return new IbexError(new Error("IBEX sign-in: no access token in response")) + if (!data.access_token) + return new IbexError(new Error("IBEX token request: no access_token in response")) - await Ibex.authentication.storage.setAccessToken( - data.accessToken, - data.accessTokenExpiresAt, - ) - if (data.refreshToken) { - await Ibex.authentication.storage.setRefreshToken( - data.refreshToken, - data.refreshTokenExpiresAt, - ) - } + await Ibex.authentication.storage.setAccessToken(data.access_token, data.expires_in) - return data.accessToken as string + return data.access_token } const ibexFetch = async ( @@ -305,7 +305,7 @@ const ibexFetch = async ( path: string, init: RequestInit = {}, ): Promise => { - const url = `${IbexConfig.url}${path}` + const url = `${IbexUrlConfig.hubUrl}${path}` const resp = await fetch(url, { ...init, headers: { @@ -439,7 +439,10 @@ const getEthereumUsdtOption = async (): Promise const getIbexCurrencyId = async ( currency: WalletCurrency, ): Promise => { - const data = await ibexGet<{ currencies: IbexCurrency[] }>("", "/currency/all") + 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`)) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts new file mode 100644 index 000000000..4ce2f19d8 --- /dev/null +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -0,0 +1,348 @@ +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) + } + } + 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/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 ef7b0ebf9..a15023362 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -620,6 +620,68 @@ export const WalletOnChainPendingReceive = WalletOnChainPendingReceiveSchema, ) +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 }, diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 39d5a3da9..332c918f5 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -103,6 +103,52 @@ interface AccountRecord { 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