From 124e928298d92b7e4d90283740f9be8eb9357464 Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:14:50 -0400 Subject: [PATCH 01/33] feat(cutover): add cash wallet migration state primitives --- .../cash-wallet-cutover/amount-conversion.ts | 24 +++++++ src/app/cash-wallet-cutover/errors.ts | 8 +++ src/app/cash-wallet-cutover/index.ts | 3 + src/app/cash-wallet-cutover/index.types.d.ts | 66 +++++++++++++++++++ src/app/cash-wallet-cutover/state-machine.ts | 40 +++++++++++ src/app/errors.ts | 2 + src/app/index.ts | 3 + src/graphql/error-map.ts | 24 +++++++ .../amount-conversion.spec.ts | 23 +++++++ .../migration-state-machine.spec.ts | 45 +++++++++++++ 10 files changed, 238 insertions(+) create mode 100644 src/app/cash-wallet-cutover/amount-conversion.ts create mode 100644 src/app/cash-wallet-cutover/errors.ts create mode 100644 src/app/cash-wallet-cutover/index.ts create mode 100644 src/app/cash-wallet-cutover/index.types.d.ts create mode 100644 src/app/cash-wallet-cutover/state-machine.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts 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..f3610da41 --- /dev/null +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -0,0 +1,24 @@ +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 diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts new file mode 100644 index 000000000..1bbb68678 --- /dev/null +++ b/src/app/cash-wallet-cutover/errors.ts @@ -0,0 +1,8 @@ +import { DomainError, ValidationError } from "@domain/shared" + +export class InvalidCashWalletCutoverAmountError extends ValidationError {} +export class InvalidCashWalletMigrationTransitionError extends ValidationError {} +export class CashWalletCutoverInProgressError extends ValidationError {} +export class CashWalletMigrationFailedError extends DomainError {} +export class CashWalletCutoverPreflightError extends DomainError {} +export class CashWalletCutoverTreasuryInsufficientBalanceError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts new file mode 100644 index 000000000..e7f75e8c5 --- /dev/null +++ b/src/app/cash-wallet-cutover/index.ts @@ -0,0 +1,3 @@ +export * from "./amount-conversion" +export * from "./errors" +export * from "./state-machine" 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..fdce0cf9a --- /dev/null +++ b/src/app/cash-wallet-cutover/index.types.d.ts @@ -0,0 +1,66 @@ +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 + 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/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts new file mode 100644 index 000000000..66898b3f0 --- /dev/null +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -0,0 +1,40 @@ +import { InvalidCashWalletMigrationTransitionError } from "./errors" + +const transitions: Partial> = { + not_started: ["started"], + started: ["provisioned", "failed"], + provisioned: ["balance_read", "failed", "skipped_already_migrated"], + balance_read: ["invoice_created", "pointer_flipped", "failed"], + 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", + "failed", + "requires_operator_review", + ], + 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/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/error-map.ts b/src/graphql/error-map.ts index 9f93e8875..9c0d8100c 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -792,6 +792,30 @@ 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 "InvalidCashWalletCutoverAmountError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletMigrationTransitionError": + 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/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts new file mode 100644 index 000000000..3d77174a3 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -0,0 +1,23 @@ +import { + feeUsdCentsToUsdtMicros, + usdCentsToUsdtMicros, +} from "@app/cash-wallet-cutover/amount-conversion" + +describe("cash wallet cutover amount conversion", () => { + it("converts USD cents to USDT micros exactly", () => { + expect(usdCentsToUsdtMicros("0")).toBe("0") + expect(usdCentsToUsdtMicros("1")).toBe("10000") + expect(usdCentsToUsdtMicros("100")).toBe("1000000") + expect(usdCentsToUsdtMicros("123456789")).toBe("1234567890000") + }) + + it("converts fee USD cents to USDT micros exactly", () => { + expect(feeUsdCentsToUsdtMicros("7")).toBe("70000") + }) + + it("rejects invalid or fractional cent inputs", () => { + expect(usdCentsToUsdtMicros("1.5")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("abc")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("-1")).toBeInstanceOf(Error) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts new file mode 100644 index 000000000..bc199dadd --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts @@ -0,0 +1,45 @@ +import { + assertCanTransition, + nextResumeStatus, +} from "@app/cash-wallet-cutover/state-machine" + +describe("cash wallet cutover migration state machine", () => { + it("allows the happy-path checkpoint order", () => { + const statuses = [ + "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", + ] as const + + for (let i = 0; i < statuses.length - 1; i++) { + expect(assertCanTransition(statuses[i], statuses[i + 1])).toBe(true) + } + }) + + it("rejects pointer flip before fee reimbursement", () => { + expect(assertCanTransition("balance_move_verified", "pointer_flipped")).toBeInstanceOf(Error) + }) + + it("resumes from stored checkpoint without repeating completed side effects", () => { + expect(nextResumeStatus("invoice_created")).toBe("invoice_created") + expect(nextResumeStatus("balance_move_sent")).toBe("balance_move_sent") + expect(nextResumeStatus("fee_reimbursement_invoice_created")).toBe("fee_reimbursement_invoice_created") + }) + + it("does not progress terminal/manual-review states without override", () => { + expect(assertCanTransition("complete", "started")).toBeInstanceOf(Error) + expect(assertCanTransition("failed", "started")).toBeInstanceOf(Error) + expect(assertCanTransition("requires_operator_review", "started")).toBeInstanceOf(Error) + }) +}) From 233f49d19e997dd3faee45f80b6e6dc78fefc0e5 Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:17:33 -0400 Subject: [PATCH 02/33] feat(cutover): persist cash wallet cutover state --- src/services/mongoose/cash-wallet-cutover.ts | 278 ++++++++++++++++++ src/services/mongoose/index.ts | 1 + src/services/mongoose/schema.ts | 62 ++++ src/services/mongoose/schema.types.d.ts | 45 +++ .../mongoose/cash-wallet-cutover.spec.ts | 174 +++++++++++ 5 files changed, 560 insertions(+) create mode 100644 src/services/mongoose/cash-wallet-cutover.ts create mode 100644 test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts new file mode 100644 index 000000000..768e06e6a --- /dev/null +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -0,0 +1,278 @@ +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", + "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 +} + +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, + 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 result = await CashWalletCutoverConfig.findOneAndUpdate( + { _id: CONFIG_ID }, + { $set: { ...patch, updatedBy: actor, updatedAt: new Date() } }, + { upsert: true, new: true }, + ) + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const upsertMigration = async ( + args: UpsertMigrationArgs, + ): Promise => { + try { + const now = new Date() + const result = await CashWalletMigration.findOneAndUpdate( + { accountId: args.accountId, runId: args.runId }, + { + $setOnInsert: { + _id: randomUUID(), + ...args, + status: "not_started", + attempts: 0, + updatedAt: now, + }, + }, + { upsert: true, new: true }, + ) + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const findMigrationByAccountId = async ({ + accountId, + cutoverVersion, + runId, + }: { + accountId: AccountId + cutoverVersion: number + runId: string + }): Promise => { + try { + const result = await CashWalletMigration.findOne({ accountId, cutoverVersion, runId }) + if (!result) return null + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const transitionMigration = async ({ + id, + from, + to, + cutoverVersion, + runId, + patch = {}, + }: TransitionMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, status: from, cutoverVersion, runId }, + { $set: { ...patch, status: to, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) return new CouldNotUpdateError("Could not transition cash wallet migration") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const acquireMigrationLock = async ({ + id, + workerId, + staleBefore, + cutoverVersion, + runId, + }: LockMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { + _id: id, + cutoverVersion, + runId, + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], + }, + { $set: { lockedAt: new Date(), lockedBy: workerId, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) return new CouldNotUpdateError("Could not acquire cash wallet migration lock") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const releaseMigrationLock = async ({ + id, + workerId, + cutoverVersion, + runId, + }: Omit): Promise => { + 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 listRunnableMigrations = async ({ + cutoverVersion, + runId, + limit, + }: { + cutoverVersion: number + runId: string + limit?: number + }): Promise => { + try { + const results = await CashWalletMigration.find({ + cutoverVersion, + runId, + status: { $nin: TERMINAL_STATUSES }, + }) + return results.slice(0, limit).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, + 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..ab476041b 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, + 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, cutoverVersion: 1 }, { unique: 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..e3e25c34b 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -103,6 +103,51 @@ 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 + feeAmountUsdCents?: string + feeAmountUsdtMicros?: string + balanceMoveInvoicePaymentRequest?: string + balanceMoveInvoicePaymentHash?: string + balanceMovePaymentTransactionId?: string + feeReimbursementInvoicePaymentRequest?: string + feeReimbursementInvoicePaymentHash?: string + feeReimbursementPaymentTransactionId?: string + estimatedFee?: boolean + idempotencyKey: string + attempts: number + lastError?: string + lockedAt?: Date + lockedBy?: string + startedAt?: Date + completedAt?: Date + updatedAt: Date +} + interface LocationRecord { type: "Point" coordinates: CoordinateRecord diff --git a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts new file mode 100644 index 000000000..ad6e00d19 --- /dev/null +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -0,0 +1,174 @@ +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" +import { CashWalletCutoverConfig, CashWalletMigration } from "@services/mongoose/schema" + +jest.mock("@services/mongoose/schema", () => ({ + CashWalletCutoverConfig: { + findById: jest.fn(), + findOneAndUpdate: jest.fn(), + }, + CashWalletMigration: { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + }, +})) + +describe("CashWalletCutoverRepository", () => { + const repo = CashWalletCutoverRepository() + const updatedAt = new Date("2026-05-19T00:00:00Z") + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns default pre state when no config exists", async () => { + jest.mocked(CashWalletCutoverConfig.findById).mockResolvedValue(null as never) + + const result = await repo.getConfig() + + expect(result).toEqual({ + state: "pre", + cutoverVersion: 1, + updatedAt: new Date(0), + }) + }) + + it("upserts singleton config", async () => { + jest.mocked(CashWalletCutoverConfig.findOneAndUpdate).mockResolvedValue({ + _id: "cash_wallet_cutover", + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + updatedBy: "operator", + updatedAt, + } as never) + + const result = await repo.updateConfig( + { state: "in_progress", cutoverVersion: 2, runId: "run-2" }, + "operator", + ) + + expect(CashWalletCutoverConfig.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "cash_wallet_cutover" }, + expect.objectContaining({ + $set: expect.objectContaining({ + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + updatedBy: "operator", + }), + }), + { upsert: true, new: true }, + ) + expect(result).toMatchObject({ state: "in_progress", cutoverVersion: 2, runId: "run-2" }) + }) + + it("creates one migration record per account id and run", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "not_started", + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt, + } as never) + + const result = await repo.upsertMigration({ + accountId: "account-id" as AccountId, + legacyUsdWalletId: "usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 2, + runId: "run-2", + idempotencyKey: "run-2:account-id", + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { accountId: "account-id", runId: "run-2" }, + expect.objectContaining({ + $setOnInsert: expect.objectContaining({ + accountId: "account-id", + runId: "run-2", + status: "not_started", + }), + }), + { upsert: true, new: true }, + ) + expect(result).toMatchObject({ id: "migration-id", accountId: "account-id" }) + }) + + it("transitions migration status atomically", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "started", + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt, + } as never) + + const result = await repo.transitionMigration({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 2, + runId: "run-2", + patch: { startedAt: updatedAt }, + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "migration-id", status: "not_started", cutoverVersion: 2, runId: "run-2" }, + expect.objectContaining({ $set: expect.objectContaining({ status: "started" }) }), + { new: true }, + ) + expect(result).toMatchObject({ status: "started" }) + }) + + it("acquires and rejects active locks atomically", async () => { + const staleBefore = new Date("2026-05-19T00:00:00Z") + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue(null as never) + + const result = await repo.acquireMigrationLock({ + id: "migration-id", + workerId: "worker-1", + staleBefore, + cutoverVersion: 2, + runId: "run-2", + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { + _id: "migration-id", + cutoverVersion: 2, + runId: "run-2", + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], + }, + expect.objectContaining({ $set: expect.objectContaining({ lockedBy: "worker-1" }) }), + { new: true }, + ) + expect(result).toBeInstanceOf(Error) + }) + + it("finds resumable non-terminal migrations for the current run", async () => { + jest.mocked(CashWalletMigration.find).mockResolvedValue([] as never) + + const result = await repo.listRunnableMigrations({ cutoverVersion: 2, runId: "run-2", limit: 10 }) + + expect(CashWalletMigration.find).toHaveBeenCalledWith( + expect.objectContaining({ + cutoverVersion: 2, + runId: "run-2", + status: { $nin: expect.arrayContaining(["complete", "failed", "requires_operator_review"]) }, + }), + ) + expect(result).toEqual([]) + }) +}) From bc0e07953b72e3f4ccffda14275fb4c61c2f810d Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:18:35 -0400 Subject: [PATCH 03/33] feat(cutover): add cash wallet write guard --- src/app/cash-wallet-cutover/guard.ts | 45 ++++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/cutover-gate.spec.ts | 86 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 src/app/cash-wallet-cutover/guard.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts new file mode 100644 index 000000000..d3fb154b6 --- /dev/null +++ b/src/app/cash-wallet-cutover/guard.ts @@ -0,0 +1,45 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, +} from "./errors" + +export { CashWalletCutoverInProgressError, CashWalletMigrationFailedError } + +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: "legacy_usd" | "eth_usdt" } | ApplicationError => { + if (cutover.state === "pre") return { route: "legacy_usd" } + if (cutover.state === "complete") return { route: "eth_usdt" } + + if (!migration || migration.status === "not_started") return { route: "legacy_usd" } + if (migration.status === "complete" || migration.status === "skipped_already_migrated") { + return { route: "eth_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" } +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index e7f75e8c5..8384d0e68 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,3 +1,4 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" +export * from "./guard" diff --git a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts new file mode 100644 index 000000000..0d79195a4 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts @@ -0,0 +1,86 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + evaluateCashWalletCutoverGuard, +} from "@app/cash-wallet-cutover/guard" + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 2, + runId: "run-2", + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 2, + runId: "run-2", + status, + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +describe("cash wallet cutover guard", () => { + it("allows legacy route before cutover starts", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("pre") })).toEqual({ + route: "legacy_usd", + }) + }) + + it("allows legacy route during cutover before this account starts", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("in_progress") })).toEqual({ + route: "legacy_usd", + }) + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration("not_started"), + }), + ).toEqual({ route: "legacy_usd" }) + }) + + it("rejects writes while this account is actively migrating", () => { + for (const status of [ + "balance_read", + "balance_move_sending", + "fee_reimbursement_sending", + ] as const) { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration(status), + }), + ).toBeInstanceOf(CashWalletCutoverInProgressError) + } + }) + + it("routes completed accounts to ETH-USDT during cutover", () => { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration("complete"), + }), + ).toEqual({ route: "eth_usdt" }) + }) + + it("rejects failed and manual-review migrations", () => { + for (const status of ["failed", "requires_operator_review"] as const) { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration(status), + }), + ).toBeInstanceOf(CashWalletMigrationFailedError) + } + }) + + it("routes all accounts to ETH-USDT after global completion", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({ + route: "eth_usdt", + }) + }) +}) From a876407b3e28791fb9325ae1b527c88704aaff72 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 08:58:06 -0400 Subject: [PATCH 04/33] feat(cutover): classify cash wallet migration candidates --- src/app/cash-wallet-cutover/discovery.ts | 56 +++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/discovery.spec.ts | 111 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/app/cash-wallet-cutover/discovery.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts new file mode 100644 index 000000000..c6e73ce63 --- /dev/null +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -0,0 +1,56 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +type CashWalletCutoverDiscoveryStatus = + | "legacy_default" + | "already_usdt" + | "residual_legacy_usd" + | "missing_legacy_usd" + | "missing_destination_usdt" + +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" } +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 8384d0e68..128f1d5f8 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -2,3 +2,4 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" export * from "./guard" +export * from "./discovery" diff --git a/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts b/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts new file mode 100644 index 000000000..1606d9cad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts @@ -0,0 +1,111 @@ +import { classifyCashWalletsForCutover } from "@app/cash-wallet-cutover/discovery" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (defaultWalletId: WalletId): Account => ({ + id: "account-id" as AccountId, + uuid: "account-uuid" as AccountUuid, + createdAt: new Date("2026-05-20T00:00:00Z"), + defaultWalletId, + username: "username" as Username, + npub: "npub" as Npub, + level: 1 as AccountLevel, + status: "active" as AccountStatus, + statusHistory: [{ status: "active" as AccountStatus, timestamp: new Date() }], + title: "" as BusinessMapTitle, + coordinates: undefined as Coordinates, + contactEnabled: false, + contacts: [], + withdrawFee: 0 as Satoshis, + isEditor: false, + notificationSettings: { push: { enabled: true, disabledCategories: [] } }, + quizQuestions: [], + quiz: [], + kratosUserId: "user-id" as UserId, + displayCurrency: "USD" as DisplayCurrency, +}) + +const wallet = ({ + id, + currency, + type = WalletType.Checking, +}: { + id: WalletId + currency: WalletCurrency + type?: WalletType +}): Wallet => ({ + id, + accountId: "account-id" as AccountId, + type, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, +}) + +describe("cash wallet cutover discovery", () => { + const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id" as WalletId, + currency: WalletCurrency.Usd, + }) + const destinationUsdtWallet = wallet({ + id: "usdt-wallet-id" as WalletId, + currency: WalletCurrency.Usdt, + }) + + it("classifies accounts whose default still points to legacy USD", () => { + const result = classifyCashWalletsForCutover({ + account: account("legacy-usd-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ + status: "legacy_default", + accountId: "account-id", + accountUuid: "account-uuid", + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + }) + + it("classifies accounts already defaulting to ETH-USDT", () => { + const result = classifyCashWalletsForCutover({ + account: account("usdt-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ + status: "already_usdt", + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + previousDefaultWalletId: "usdt-wallet-id", + }) + }) + + it("classifies legacy USD wallets that are no longer the default as residual", () => { + const result = classifyCashWalletsForCutover({ + account: account("btc-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ status: "residual_legacy_usd" }) + }) + + it("surfaces accounts that cannot be planned because a required cash wallet is missing", () => { + expect( + classifyCashWalletsForCutover({ + account: account("legacy-usd-wallet-id" as WalletId), + wallets: [legacyUsdWallet], + }), + ).toMatchObject({ status: "missing_destination_usdt" }) + + expect( + classifyCashWalletsForCutover({ + account: account("usdt-wallet-id" as WalletId), + wallets: [destinationUsdtWallet], + }), + ).toMatchObject({ status: "missing_legacy_usd" }) + }) +}) From 852fe79c536d2fc21b7b92a372589164a5a6e751 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:00:39 -0400 Subject: [PATCH 05/33] feat(cutover): add cash wallet preflight summary --- src/app/cash-wallet-cutover/discovery.ts | 4 +- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/preflight.ts | 49 ++++++++++++++ .../app/cash-wallet-cutover/preflight.spec.ts | 65 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/app/cash-wallet-cutover/preflight.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts index c6e73ce63..2141219e0 100644 --- a/src/app/cash-wallet-cutover/discovery.ts +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -1,14 +1,14 @@ import { WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" -type CashWalletCutoverDiscoveryStatus = +export type CashWalletCutoverDiscoveryStatus = | "legacy_default" | "already_usdt" | "residual_legacy_usd" | "missing_legacy_usd" | "missing_destination_usdt" -type CashWalletCutoverDiscovery = { +export type CashWalletCutoverDiscovery = { status: CashWalletCutoverDiscoveryStatus accountId: AccountId accountUuid?: AccountUuid diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 128f1d5f8..47fde4e65 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -3,3 +3,4 @@ export * from "./errors" export * from "./state-machine" export * from "./guard" export * from "./discovery" +export * from "./preflight" diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts new file mode 100644 index 000000000..4c40fd71a --- /dev/null +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -0,0 +1,49 @@ +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 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/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts b/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts new file mode 100644 index 000000000..66a1a5aad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts @@ -0,0 +1,65 @@ +import { buildCashWalletCutoverPreflightReport } from "@app/cash-wallet-cutover/preflight" + +const discovery = ( + status: CashWalletCutoverDiscoveryStatus, + accountId = `${status}-account` as AccountId, +): CashWalletCutoverDiscovery => ({ + status, + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: + status === "missing_legacy_usd" ? undefined : (`${accountId}-usd` as WalletId), + destinationUsdtWalletId: + status === "missing_destination_usdt" ? undefined : (`${accountId}-usdt` as WalletId), + previousDefaultWalletId: `${accountId}-default` as WalletId, +}) + +describe("cash wallet cutover preflight report", () => { + it("counts migration candidates and non-migrating classifications", () => { + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion: 3, + runId: "run-3", + discoveries: [ + discovery("legacy_default", "legacy-1" as AccountId), + discovery("legacy_default", "legacy-2" as AccountId), + discovery("already_usdt"), + discovery("residual_legacy_usd"), + discovery("missing_legacy_usd"), + discovery("missing_destination_usdt"), + ], + }) + + expect(report).toMatchObject({ + cutoverVersion: 3, + runId: "run-3", + totalAccounts: 6, + migrationCandidates: 2, + alreadyUsdt: 1, + residualLegacyUsd: 1, + blockers: 2, + canStart: false, + }) + expect(report.blockerAccounts).toEqual([ + { accountId: "missing_legacy_usd-account", reason: "missing_legacy_usd" }, + { + accountId: "missing_destination_usdt-account", + reason: "missing_destination_usdt", + }, + ]) + }) + + it("allows start when every account is either migratable, already migrated, or residual", () => { + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion: 3, + runId: "run-3", + discoveries: [ + discovery("legacy_default"), + discovery("already_usdt"), + discovery("residual_legacy_usd"), + ], + }) + + expect(report.canStart).toBe(true) + expect(report.blockerAccounts).toEqual([]) + }) +}) From c79aff73fe3f98ddcb5f61d08b97ccb4c7ec635a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:04:11 -0400 Subject: [PATCH 06/33] feat(cutover): collect cash wallet discovery results --- src/app/cash-wallet-cutover/discovery.ts | 22 +++++ .../discovery-collector.spec.ts | 86 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts index 2141219e0..14304c10b 100644 --- a/src/app/cash-wallet-cutover/discovery.ts +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -54,3 +54,25 @@ export const classifyCashWalletsForCutover = ({ 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/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts b/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts new file mode 100644 index 000000000..201b382ba --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts @@ -0,0 +1,86 @@ +import { RepositoryError } from "@domain/errors" + +import { discoverCashWalletCutoverAccounts } from "@app/cash-wallet-cutover/discovery" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (id: AccountId, defaultWalletId: WalletId): Account => + ({ + id, + uuid: `${id}-uuid` as AccountUuid, + defaultWalletId, + }) as Account + +const wallet = ({ + id, + accountId, + currency, +}: { + id: WalletId + accountId: AccountId + currency: WalletCurrency +}): Wallet => + ({ + id, + accountId, + type: WalletType.Checking, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, + }) as Wallet + +async function* unlockedAccounts(accounts: Account[]): AsyncGenerator { + for (const account of accounts) yield account +} + +describe("cash wallet cutover account discovery collector", () => { + it("classifies every unlocked account with its wallets", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const accountTwo = account("account-2" as AccountId, "account-2-usdt" as WalletId) + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet({ + id: `${accountId}-usd` as WalletId, + accountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: `${accountId}-usdt` as WalletId, + accountId, + currency: WalletCurrency.Usdt, + }), + ]), + } + + const result = await discoverCashWalletCutoverAccounts({ + accountsRepo: { + listUnlockedAccounts: () => unlockedAccounts([accountOne, accountTwo]), + }, + walletsRepo, + }) + + expect(result).toEqual([ + expect.objectContaining({ accountId: "account-1", status: "legacy_default" }), + expect.objectContaining({ accountId: "account-2", status: "already_usdt" }), + ]) + expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-1") + expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-2") + }) + + it("returns repository errors without continuing discovery", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const error = new RepositoryError("wallet lookup failed") + const walletsRepo = { + listByAccountId: jest.fn(async () => error), + } + + const result = await discoverCashWalletCutoverAccounts({ + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([accountOne]) }, + walletsRepo, + }) + + expect(result).toBe(error) + }) +}) From 9f2a8e55db36fd49db1abb567bbcf2655e45a9cf Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:06:22 -0400 Subject: [PATCH 07/33] feat(cutover): plan primary cash wallet migrations --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/planner.ts | 37 ++++++++++++++ .../app/cash-wallet-cutover/planner.spec.ts | 51 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/app/cash-wallet-cutover/planner.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/planner.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 47fde4e65..06c73fed4 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -4,3 +4,4 @@ export * from "./state-machine" export * from "./guard" export * from "./discovery" export * from "./preflight" +export * from "./planner" diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts new file mode 100644 index 000000000..e263cbf5f --- /dev/null +++ b/src/app/cash-wallet-cutover/planner.ts @@ -0,0 +1,37 @@ +type PrimaryCashWalletMigrationPlan = { + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId: WalletId + cutoverVersion: number + runId: string + idempotencyKey: string +} + +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/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts new file mode 100644 index 000000000..61f492a1b --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts @@ -0,0 +1,51 @@ +import { buildPrimaryCashWalletMigrationPlan } from "@app/cash-wallet-cutover/planner" + +const discovery = ( + status: CashWalletCutoverDiscoveryStatus, + accountId: AccountId, +): CashWalletCutoverDiscovery => ({ + status, + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: `${accountId}-usd` as WalletId, + destinationUsdtWalletId: `${accountId}-usdt` as WalletId, + previousDefaultWalletId: `${accountId}-default` as WalletId, +}) + +describe("primary cash wallet migration planner", () => { + it("creates deterministic migration plans for legacy-default accounts only", () => { + const result = buildPrimaryCashWalletMigrationPlan({ + cutoverVersion: 4, + runId: "run-4", + discoveries: [ + discovery("legacy_default", "account-1" as AccountId), + discovery("already_usdt", "account-2" as AccountId), + discovery("residual_legacy_usd", "account-3" as AccountId), + discovery("legacy_default", "account-4" as AccountId), + ], + }) + + expect(result).toEqual([ + { + accountId: "account-1", + accountUuid: "account-1-uuid", + legacyUsdWalletId: "account-1-usd", + destinationUsdtWalletId: "account-1-usdt", + previousDefaultWalletId: "account-1-default", + cutoverVersion: 4, + runId: "run-4", + idempotencyKey: "cash-wallet-cutover:run-4:account-1", + }, + { + accountId: "account-4", + accountUuid: "account-4-uuid", + legacyUsdWalletId: "account-4-usd", + destinationUsdtWalletId: "account-4-usdt", + previousDefaultWalletId: "account-4-default", + cutoverVersion: 4, + runId: "run-4", + idempotencyKey: "cash-wallet-cutover:run-4:account-4", + }, + ]) + }) +}) From f5c29c9a75eefd52cd970e7f1cc65db05b742ff5 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:54:44 -0400 Subject: [PATCH 08/33] feat(cutover): upsert planned migration records --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/migration-records.ts | 24 +++++++ src/app/cash-wallet-cutover/planner.ts | 2 + .../migration-records.spec.ts | 70 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/app/cash-wallet-cutover/migration-records.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 06c73fed4..6dc804e00 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -5,3 +5,4 @@ export * from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" +export * from "./migration-records" 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..fa56d81c5 --- /dev/null +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -0,0 +1,24 @@ +type CashWalletMigrationRecordsRepository = { + upsertMigration( + args: PrimaryCashWalletMigrationPlan, + ): Promise +} + +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/planner.ts b/src/app/cash-wallet-cutover/planner.ts index e263cbf5f..772928061 100644 --- a/src/app/cash-wallet-cutover/planner.ts +++ b/src/app/cash-wallet-cutover/planner.ts @@ -9,6 +9,8 @@ type PrimaryCashWalletMigrationPlan = { idempotencyKey: string } +export type { PrimaryCashWalletMigrationPlan } + export const buildPrimaryCashWalletMigrationPlan = ({ cutoverVersion, runId, diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts new file mode 100644 index 000000000..331f0dbb4 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts @@ -0,0 +1,70 @@ +import { RepositoryError } from "@domain/errors" + +import { upsertPrimaryCashWalletMigrationRecords } from "@app/cash-wallet-cutover/migration-records" + +const plan = (accountId: AccountId): PrimaryCashWalletMigrationPlan => ({ + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: `${accountId}-usd` as WalletId, + destinationUsdtWalletId: `${accountId}-usdt` as WalletId, + previousDefaultWalletId: `${accountId}-default` as WalletId, + cutoverVersion: 5, + runId: "run-5", + idempotencyKey: `cash-wallet-cutover:run-5:${accountId}`, +}) + +describe("cash wallet migration record upsert", () => { + it("upserts one not-started migration record for each primary plan", async () => { + const migrationsRepo = { + upsertMigration: jest.fn(async (args) => ({ + id: `${args.accountId}-migration`, + ...args, + status: "not_started" as CashWalletMigrationStatus, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + })), + } + + const result = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: [plan("account-1" as AccountId), plan("account-2" as AccountId)], + }) + + expect(result).toEqual([ + expect.objectContaining({ id: "account-1-migration", accountId: "account-1" }), + expect.objectContaining({ id: "account-2-migration", accountId: "account-2" }), + ]) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(2) + expect(migrationsRepo.upsertMigration).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + accountId: "account-1", + accountUuid: "account-1-uuid", + legacyUsdWalletId: "account-1-usd", + destinationUsdtWalletId: "account-1-usdt", + previousDefaultWalletId: "account-1-default", + cutoverVersion: 5, + runId: "run-5", + idempotencyKey: "cash-wallet-cutover:run-5:account-1", + }), + ) + }) + + it("returns repository errors and stops creating more records", async () => { + const error = new RepositoryError("could not upsert migration") + const migrationsRepo = { + upsertMigration: jest + .fn() + .mockResolvedValueOnce(error) + .mockResolvedValueOnce({} as CashWalletMigration), + } + + const result = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: [plan("account-1" as AccountId), plan("account-2" as AccountId)], + }) + + expect(result).toBe(error) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(1) + }) +}) From 5e9523c556ea42b2e2d2d9eb6c1063a839a0cf1a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:58:23 -0400 Subject: [PATCH 09/33] feat(cutover): prepare primary migration batch --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/migration-records.ts | 2 + src/app/cash-wallet-cutover/preflight.ts | 2 + src/app/cash-wallet-cutover/prepare.ts | 57 ++++++++ .../app/cash-wallet-cutover/prepare.spec.ts | 123 ++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 src/app/cash-wallet-cutover/prepare.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 6dc804e00..5e49b7ca7 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -6,3 +6,4 @@ export * from "./discovery" export * from "./preflight" export * from "./planner" export * from "./migration-records" +export * from "./prepare" diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts index fa56d81c5..7e0dd8a56 100644 --- a/src/app/cash-wallet-cutover/migration-records.ts +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -4,6 +4,8 @@ type CashWalletMigrationRecordsRepository = { ): Promise } +export type { CashWalletMigrationRecordsRepository } + export const upsertPrimaryCashWalletMigrationRecords = async ({ migrationsRepo, plans, diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts index 4c40fd71a..058679f6b 100644 --- a/src/app/cash-wallet-cutover/preflight.ts +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -15,6 +15,8 @@ type CashWalletCutoverPreflightReport = { canStart: boolean } +export type { CashWalletCutoverPreflightReport } + export const buildCashWalletCutoverPreflightReport = ({ cutoverVersion, runId, diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts new file mode 100644 index 000000000..d32a62976 --- /dev/null +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -0,0 +1,57 @@ +import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { buildPrimaryCashWalletMigrationPlan } 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/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts b/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts new file mode 100644 index 000000000..137c373dc --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts @@ -0,0 +1,123 @@ +import { RepositoryError } from "@domain/errors" + +import { preparePrimaryCashWalletCutover } from "@app/cash-wallet-cutover/prepare" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (id: AccountId, defaultWalletId: WalletId): Account => + ({ + id, + uuid: `${id}-uuid` as AccountUuid, + defaultWalletId, + }) as Account + +const wallet = (accountId: AccountId, id: WalletId, currency: WalletCurrency): Wallet => + ({ + id, + accountId, + type: WalletType.Checking, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, + }) as Wallet + +async function* unlockedAccounts(accounts: Account[]): AsyncGenerator { + for (const account of accounts) yield account +} + +describe("prepare primary cash wallet cutover", () => { + it("discovers accounts, builds preflight, and upserts primary migration records", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const accountTwo = account("account-2" as AccountId, "account-2-usdt" as WalletId) + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet(accountId, `${accountId}-usd` as WalletId, WalletCurrency.Usd), + wallet(accountId, `${accountId}-usdt` as WalletId, WalletCurrency.Usdt), + ]), + } + const migrationsRepo = { + upsertMigration: jest.fn(async (plan: PrimaryCashWalletMigrationPlan) => ({ + id: `${plan.accountId}-migration`, + ...plan, + status: "not_started" as CashWalletMigrationStatus, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + })), + } + + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { + listUnlockedAccounts: () => unlockedAccounts([accountOne, accountTwo]), + }, + walletsRepo, + migrationsRepo, + }) + + expect(result).toMatchObject({ + report: { + totalAccounts: 2, + migrationCandidates: 1, + alreadyUsdt: 1, + blockers: 0, + canStart: true, + }, + plannedMigrations: [ + expect.objectContaining({ + accountId: "account-1", + idempotencyKey: "cash-wallet-cutover:run-6:account-1", + }), + ], + migrations: [expect.objectContaining({ id: "account-1-migration" })], + }) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(1) + }) + + it("does not create migration records when preflight has blockers", async () => { + const blockedAccount = account("account-1" as AccountId, "account-1-usd" as WalletId) + const migrationsRepo = { + upsertMigration: jest.fn(), + } + + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([blockedAccount]) }, + walletsRepo: { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet(accountId, `${accountId}-usd` as WalletId, WalletCurrency.Usd), + ]), + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + report: { + blockers: 1, + canStart: false, + }, + plannedMigrations: [], + migrations: [], + }) + expect(migrationsRepo.upsertMigration).not.toHaveBeenCalled() + }) + + it("returns repository errors from discovery", async () => { + const error = new RepositoryError("wallet lookup failed") + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { + listUnlockedAccounts: () => + unlockedAccounts([account("account-1" as AccountId, "wallet-id" as WalletId)]), + }, + walletsRepo: { listByAccountId: jest.fn(async () => error) }, + migrationsRepo: { upsertMigration: jest.fn() }, + }) + + expect(result).toBe(error) + }) +}) From b40d28e78da2df8bc830fd5c7ebff70278d07d09 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 10:00:12 -0400 Subject: [PATCH 10/33] feat(cutover): start migration worker checkpoint --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/worker.ts | 34 +++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 71 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/app/cash-wallet-cutover/worker.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/worker.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 5e49b7ca7..2d707e786 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -7,3 +7,4 @@ export * from "./preflight" export * from "./planner" export * from "./migration-records" export * from "./prepare" +export * from "./worker" diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts new file mode 100644 index 000000000..fa5fe53e4 --- /dev/null +++ b/src/app/cash-wallet-cutover/worker.ts @@ -0,0 +1,34 @@ +import { assertCanTransition } from "./state-machine" + +type CashWalletMigrationTransitionRepository = { + transitionMigration(args: { + id: string + from: CashWalletMigrationStatus + to: CashWalletMigrationStatus + cutoverVersion: number + runId: string + patch?: Partial + }): Promise +} + +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 }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts new file mode 100644 index 000000000..6ffd4ba33 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -0,0 +1,71 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { startCashWalletMigration } from "@app/cash-wallet-cutover/worker" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("cash wallet migration worker checkpoints", () => { + it("starts a not-started migration with an atomic repository transition", async () => { + const startedAt = new Date("2026-05-20T13:00:00Z") + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("started")), + } + + const result = await startCashWalletMigration({ + migration: migration("not_started"), + migrationsRepo, + startedAt, + }) + + expect(result).toMatchObject({ status: "started" }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 7, + runId: "run-7", + patch: { startedAt }, + }) + }) + + it("rejects invalid start transitions before touching the repository", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await startCashWalletMigration({ + migration: migration("balance_read"), + migrationsRepo, + startedAt: new Date("2026-05-20T13:00:00Z"), + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns repository transition failures", async () => { + const error = new CouldNotUpdateError("transition failed") + const migrationsRepo = { + transitionMigration: jest.fn(async () => error), + } + + const result = await startCashWalletMigration({ + migration: migration("not_started"), + migrationsRepo, + startedAt: new Date("2026-05-20T13:00:00Z"), + }) + + expect(result).toBe(error) + }) +}) From 5f4827d3a67ef7ba01b6cbfd35123e8775af001e Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 10:03:05 -0400 Subject: [PATCH 11/33] feat(cutover): record migration source balance --- src/app/cash-wallet-cutover/worker.ts | 29 ++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 53 ++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index fa5fe53e4..ab2baed7b 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,4 +1,5 @@ import { assertCanTransition } from "./state-machine" +import { usdCentsToUsdtMicros } from "./amount-conversion" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -32,3 +33,31 @@ export const startCashWalletMigration = async ({ patch: { startedAt }, }) } + +export const recordCashWalletMigrationBalance = async ({ + migration, + migrationsRepo, + sourceBalanceUsdCents, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + sourceBalanceUsdCents: 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, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 6ffd4ba33..cb0cfae57 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -1,6 +1,9 @@ import { CouldNotUpdateError } from "@domain/errors" -import { startCashWalletMigration } from "@app/cash-wallet-cutover/worker" +import { + recordCashWalletMigrationBalance, + startCashWalletMigration, +} from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ id: "migration-id", @@ -68,4 +71,52 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) }) + + it("records source balance and destination amount before creating invoices", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_read"), + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + })), + } + + const result = await recordCashWalletMigrationBalance({ + migration: migration("provisioned"), + migrationsRepo, + sourceBalanceUsdCents: "1234", + }) + + expect(result).toMatchObject({ + status: "balance_read", + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "provisioned", + to: "balance_read", + cutoverVersion: 7, + runId: "run-7", + patch: { + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + }, + }) + }) + + it("rejects invalid balance amounts before touching the repository", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await recordCashWalletMigrationBalance({ + migration: migration("provisioned"), + migrationsRepo, + sourceBalanceUsdCents: "12.34", + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From b2dd54787e5c40546dfbd5ab83bbb9bb8397622a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:02:35 -0400 Subject: [PATCH 12/33] feat(cutover): create balance move invoice checkpoint --- src/app/cash-wallet-cutover/worker.ts | 47 ++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 90 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index ab2baed7b..398d8d806 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,6 @@ import { assertCanTransition } from "./state-machine" import { usdCentsToUsdtMicros } from "./amount-conversion" +import { InvalidCashWalletCutoverAmountError } from "./errors" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -12,6 +13,14 @@ type CashWalletMigrationTransitionRepository = { }): Promise } +type CashWalletMigrationInvoiceService = { + createInvoice(args: { + recipientWalletId: WalletId + amount: string + memo: string + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -61,3 +70,41 @@ export const recordCashWalletMigrationBalance = async ({ }, }) } + +export const createCashWalletMigrationBalanceMoveInvoice = async ({ + migration, + invoiceService, + migrationsRepo, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationInvoiceService + 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.createInvoice({ + recipientWalletId: migration.destinationUsdtWalletId, + amount: migration.destinationAmountUsdtMicros, + 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, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index cb0cfae57..9c3f3d7b4 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -1,6 +1,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { + createCashWalletMigrationBalanceMoveInvoice, recordCashWalletMigrationBalance, startCashWalletMigration, } from "@app/cash-wallet-cutover/worker" @@ -119,4 +120,93 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBeInstanceOf(Error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("creates a balance move invoice on the destination wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + })), + } + const invoice = { + paymentRequest: "lnbc1balance-move" as EncodedPaymentRequest, + paymentHash: "paymentHash" as PaymentHash, + } as LnInvoice + const invoiceService = { + createInvoice: jest.fn(async () => invoice), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: { + ...migration("balance_read"), + destinationAmountUsdtMicros: "12340000", + }, + invoiceService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "invoice_created", + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + }) + expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + amount: "12340000", + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_read", + to: "invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + }, + }) + }) + + it("rejects balance move invoice creation when the destination amount is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: migration("balance_read"), + invoiceService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(invoiceService.createInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns balance move invoice creation failures without advancing the checkpoint", async () => { + const error = new CouldNotUpdateError("invoice creation failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(async () => error), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: { + ...migration("balance_read"), + destinationAmountUsdtMicros: "12340000", + }, + invoiceService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From 4d78c2cddd62fd41c9ddbebf1b6ac79b0f27ce20 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:05:28 -0400 Subject: [PATCH 13/33] feat(cutover): send balance move payment checkpoint --- src/app/cash-wallet-cutover/worker.ts | 48 +++++++++++++- .../app/cash-wallet-cutover/worker.spec.ts | 62 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 398d8d806..7b59c7bba 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,6 +1,9 @@ import { assertCanTransition } from "./state-machine" import { usdCentsToUsdtMicros } from "./amount-conversion" -import { InvalidCashWalletCutoverAmountError } from "./errors" +import { + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -21,6 +24,13 @@ type CashWalletMigrationInvoiceService = { }): Promise } +type CashWalletMigrationPaymentService = { + payInvoice(args: { + senderWalletId: WalletId + paymentRequest: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -71,6 +81,42 @@ export const recordCashWalletMigrationBalance = async ({ }) } +export const sendCashWalletMigrationBalanceMovePayment = async ({ + migration, + paymentService, + migrationsRepo, +}: { + migration: CashWalletMigration + paymentService: CashWalletMigrationPaymentService + migrationsRepo: CashWalletMigrationTransitionRepository +}): 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", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: migration.legacyUsdWalletId, + paymentRequest: migration.balanceMoveInvoicePaymentRequest, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_sending", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + balanceMovePaymentTransactionId: payment.transactionId, + }, + }) +} + export const createCashWalletMigrationBalanceMoveInvoice = async ({ migration, invoiceService, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 9c3f3d7b4..46a693c5b 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,6 +3,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, startCashWalletMigration, } from "@app/cash-wallet-cutover/worker" @@ -209,4 +210,65 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("sends the balance move payment from the legacy wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_move_sending"), + balanceMovePaymentTransactionId: "ibex-tx-id", + })), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "balance_move_sending", + balanceMovePaymentTransactionId: "ibex-tx-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "legacy-usd-wallet-id", + paymentRequest: "lnbc1balance-move", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "invoice_created", + to: "balance_move_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + }) + }) + + it("rejects balance move payment sending when the invoice payment request is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: migration("invoice_created"), + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From eaa7c552e9d00b214b61a385e244628c596b1112 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:11:56 -0400 Subject: [PATCH 14/33] feat(cutover): verify balance move checkpoint --- src/app/cash-wallet-cutover/worker.ts | 71 +++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 120 ++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 7b59c7bba..19c2439dd 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -31,6 +31,16 @@ type CashWalletMigrationPaymentService = { }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> } +type CashWalletMigrationBalanceVerifier = { + verifyBalanceMove(args: { + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + transactionId: IbexTransactionId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -117,6 +127,67 @@ export const sendCashWalletMigrationBalanceMovePayment = async ({ }) } +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, + }) + 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, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 46a693c5b..85b5c0504 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -2,9 +2,11 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, + markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, startCashWalletMigration, + verifyCashWalletMigrationBalanceMove, } from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ @@ -271,4 +273,122 @@ describe("cash wallet migration worker checkpoints", () => { expect(paymentService.payInvoice).not.toHaveBeenCalled() expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("marks the balance move payment as sent after a transaction id is recorded", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + })), + } + + const result = await markCashWalletMigrationBalanceMoveSent({ + migration: { + ...migration("balance_move_sending"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "balance_move_sent", + balanceMovePaymentTransactionId: "ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_sending", + to: "balance_move_sent", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("rejects marking the balance move payment as sent before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await markCashWalletMigrationBalanceMoveSent({ + migration: migration("balance_move_sending"), + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("verifies the balance move before fee reimbursement", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("balance_move_verified")), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(async () => true), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: { + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + balanceVerifier, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "balance_move_verified" }) + expect(balanceVerifier.verifyBalanceMove).toHaveBeenCalledWith({ + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + sourceBalanceUsdCents: undefined, + destinationAmountUsdtMicros: undefined, + transactionId: "ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_sent", + to: "balance_move_verified", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns balance move verification failures without advancing the checkpoint", async () => { + const error = new CouldNotUpdateError("balance move not settled") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(async () => error), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: { + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + balanceVerifier, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("rejects balance move verification before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: migration("balance_move_sent"), + balanceVerifier, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From 9a7a723ba18c6f35e2ae2b3467a625484b327a1c Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:14:16 -0400 Subject: [PATCH 15/33] feat(cutover): create fee reimbursement invoice checkpoint --- src/app/cash-wallet-cutover/worker.ts | 44 ++++++++- .../app/cash-wallet-cutover/worker.spec.ts | 93 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 19c2439dd..6fd996582 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,5 @@ import { assertCanTransition } from "./state-machine" -import { usdCentsToUsdtMicros } from "./amount-conversion" +import { feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros } from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -225,3 +225,45 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ }, }) } + +export const createCashWalletMigrationFeeReimbursementInvoice = async ({ + migration, + invoiceService, + migrationsRepo, + feeAmountUsdCents, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository + feeAmountUsdCents: string +}): Promise => { + const feeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) + if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + + const transition = assertCanTransition( + migration.status, + "fee_reimbursement_invoice_created", + ) + if (transition instanceof Error) return transition + + const invoice = await invoiceService.createInvoice({ + recipientWalletId: migration.legacyUsdWalletId, + amount: feeAmountUsdCents, + 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, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 85b5c0504..75601d5c2 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -2,6 +2,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, @@ -391,4 +392,96 @@ describe("cash wallet migration worker checkpoints", () => { expect(balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("creates a fee reimbursement invoice on the legacy wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursement_invoice_created"), + feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + })), + } + const invoice = { + paymentRequest: "lnbc1fee-reimbursement" as EncodedPaymentRequest, + paymentHash: "feePaymentHash" as PaymentHash, + } as LnInvoice + const invoiceService = { + createInvoice: jest.fn(async () => invoice), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdCents: "7", + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_invoice_created", + feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + }) + expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "legacy-usd-wallet-id", + amount: "7", + memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_verified", + to: "fee_reimbursement_invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + }, + }) + }) + + it("rejects invalid fee reimbursement amounts before creating an invoice", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdCents: "0.07", + }) + + expect(result).toBeInstanceOf(Error) + expect(invoiceService.createInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns fee reimbursement invoice creation failures without advancing", async () => { + const error = new CouldNotUpdateError("fee invoice failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(async () => error), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdCents: "7", + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From be7b6896755941a21fbe66c3867dba89a94e8199 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:18:06 -0400 Subject: [PATCH 16/33] feat(cutover): complete fee reimbursement checkpoint --- src/app/cash-wallet-cutover/worker.ts | 61 +++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 128 ++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 6fd996582..b6e30ff29 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -267,3 +267,64 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ }, }) } + +export const sendCashWalletMigrationFeeReimbursementPayment = async ({ + migration, + paymentService, + migrationsRepo, +}: { + migration: CashWalletMigration + paymentService: CashWalletMigrationPaymentService + migrationsRepo: CashWalletMigrationTransitionRepository +}): 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", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: migration.destinationUsdtWalletId, + paymentRequest: migration.feeReimbursementInvoicePaymentRequest, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursement_sending", + cutoverVersion: migration.cutoverVersion, + runId: migration.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, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 75601d5c2..e773d334d 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,9 +3,11 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, + markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, } from "@app/cash-wallet-cutover/worker" @@ -484,4 +486,130 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("sends the fee reimbursement payment from the destination wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursement_sending"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + })), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "fee-ibex-tx-id" as IbexTransactionId, + })), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: { + ...migration("fee_reimbursement_invoice_created"), + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "usdt-wallet-id", + paymentRequest: "lnbc1fee-reimbursement", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursement_invoice_created", + to: "fee_reimbursement_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }, + }) + }) + + it("rejects fee reimbursement sending when the invoice payment request is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: migration("fee_reimbursement_invoice_created"), + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns fee reimbursement payment failures without advancing", async () => { + const error = new CouldNotUpdateError("fee payment failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(async () => error), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: { + ...migration("fee_reimbursement_invoice_created"), + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("marks the fee reimbursement as complete after a transaction id is recorded", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursed"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + })), + } + + const result = await markCashWalletMigrationFeeReimbursed({ + migration: { + ...migration("fee_reimbursement_sending"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursement_sending", + to: "fee_reimbursed", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("rejects marking fee reimbursement complete before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await markCashWalletMigrationFeeReimbursed({ + migration: migration("fee_reimbursement_sending"), + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From cede280b3da7f52bb8aead138e462a0a67c62223 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:20:02 -0400 Subject: [PATCH 17/33] feat(cutover): flip default wallet checkpoint --- src/app/cash-wallet-cutover/worker.ts | 37 ++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 59 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index b6e30ff29..79c72f221 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -41,6 +41,13 @@ type CashWalletMigrationBalanceVerifier = { }): Promise } +type CashWalletMigrationPointerService = { + flipDefaultWallet(args: { + accountId: AccountId + destinationWalletId: WalletId + }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -328,3 +335,33 @@ export const markCashWalletMigrationFeeReimbursed = async ({ 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, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index e773d334d..4492b6d51 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,6 +3,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, @@ -612,4 +613,62 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBeInstanceOf(Error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("flips the account default pointer to the destination USDT wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("pointer_flipped"), + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + } + const pointerService = { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + } + + const result = await flipCashWalletMigrationDefaultPointer({ + migration: migration("fee_reimbursed"), + pointerService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "pointer_flipped", + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + expect(pointerService.flipDefaultWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationWalletId: "usdt-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursed", + to: "pointer_flipped", + cutoverVersion: 7, + runId: "run-7", + patch: { + previousDefaultWalletId: "legacy-usd-wallet-id", + }, + }) + }) + + it("returns pointer flip failures without advancing", async () => { + const error = new CouldNotUpdateError("default wallet update failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const pointerService = { + flipDefaultWallet: jest.fn(async () => error), + } + + const result = await flipCashWalletMigrationDefaultPointer({ + migration: migration("fee_reimbursed"), + pointerService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From 1f05ef064d4474d32d5bfcb2bb56b8ea754260bb Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:31:54 -0400 Subject: [PATCH 18/33] feat(cutover): complete migration worker checkpoints --- src/app/cash-wallet-cutover/worker.ts | 54 +++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 77 +++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 79c72f221..2f50c54f6 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -48,6 +48,12 @@ type CashWalletMigrationPointerService = { }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> } +type CashWalletMigrationLegacyWalletVerifier = { + verifyLegacyWalletZero(args: { + legacyUsdWalletId: WalletId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -365,3 +371,51 @@ export const flipCashWalletMigrationDefaultPointer = async ({ }, }) } + +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/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 4492b6d51..b6d5fa9f6 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -4,6 +4,7 @@ import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, flipCashWalletMigrationDefaultPointer, + completeCashWalletMigration, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, @@ -11,6 +12,7 @@ import { sendCashWalletMigrationFeeReimbursementPayment, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, + verifyCashWalletMigrationLegacyZero, } from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ @@ -671,4 +673,79 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("verifies the legacy USD wallet is zero after the pointer flip", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("legacy_zero_verified")), + } + const legacyWalletVerifier = { + verifyLegacyWalletZero: jest.fn(async () => true), + } + + const result = await verifyCashWalletMigrationLegacyZero({ + migration: migration("pointer_flipped"), + legacyWalletVerifier, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "legacy_zero_verified" }) + expect(legacyWalletVerifier.verifyLegacyWalletZero).toHaveBeenCalledWith({ + legacyUsdWalletId: "legacy-usd-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "pointer_flipped", + to: "legacy_zero_verified", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns legacy zero verification failures without advancing", async () => { + const error = new CouldNotUpdateError("legacy wallet still has a balance") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const legacyWalletVerifier = { + verifyLegacyWalletZero: jest.fn(async () => error), + } + + const result = await verifyCashWalletMigrationLegacyZero({ + migration: migration("pointer_flipped"), + legacyWalletVerifier, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("completes the migration after legacy zero verification", async () => { + const completedAt = new Date("2026-05-20T15:30:00Z") + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("complete"), + completedAt, + })), + } + + const result = await completeCashWalletMigration({ + migration: migration("legacy_zero_verified"), + migrationsRepo, + completedAt, + }) + + expect(result).toMatchObject({ + status: "complete", + completedAt, + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "legacy_zero_verified", + to: "complete", + cutoverVersion: 7, + runId: "run-7", + patch: { completedAt }, + }) + }) }) From 50cf19469e1e964af044f7aa99cf411c98123fb6 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:34:33 -0400 Subject: [PATCH 19/33] feat(cutover): provision destination checkpoint --- src/app/cash-wallet-cutover/worker.ts | 34 +++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 48 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 2f50c54f6..efccc719a 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -54,6 +54,13 @@ type CashWalletMigrationLegacyWalletVerifier = { }): Promise } +type CashWalletMigrationProvisioningService = { + ensureDestinationWallet(args: { + accountId: AccountId + destinationUsdtWalletId: WalletId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -76,6 +83,33 @@ export const startCashWalletMigration = async ({ }) } +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, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index b6d5fa9f6..6681a3e72 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -7,6 +7,7 @@ import { completeCashWalletMigration, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, + provisionCashWalletMigrationDestination, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, @@ -82,6 +83,53 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) }) + it("provisions the destination wallet before reading balances", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("provisioned")), + } + const provisioningService = { + ensureDestinationWallet: jest.fn(async () => true), + } + + const result = await provisionCashWalletMigrationDestination({ + migration: migration("started"), + provisioningService, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "provisioned" }) + expect(provisioningService.ensureDestinationWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationUsdtWalletId: "usdt-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "started", + to: "provisioned", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns destination wallet provisioning failures without advancing", async () => { + const error = new CouldNotUpdateError("destination wallet missing") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const provisioningService = { + ensureDestinationWallet: jest.fn(async () => error), + } + + const result = await provisionCashWalletMigrationDestination({ + migration: migration("started"), + provisioningService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + it("records source balance and destination amount before creating invoices", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ From f9014f5050eaf893b30adaab7a6cdb4f9bb897a2 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:37:18 -0400 Subject: [PATCH 20/33] feat(cutover): dispatch migration worker steps --- src/app/cash-wallet-cutover/executor.ts | 39 +++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/executor.spec.ts | 82 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 src/app/cash-wallet-cutover/executor.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/executor.spec.ts 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/index.ts b/src/app/cash-wallet-cutover/index.ts index 2d707e786..055c076f6 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -8,3 +8,4 @@ export * from "./planner" export * from "./migration-records" export * from "./prepare" export * from "./worker" +export * from "./executor" diff --git a/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts b/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts new file mode 100644 index 000000000..60d5fae41 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts @@ -0,0 +1,82 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { executeCashWalletMigrationStep } from "@app/cash-wallet-cutover/executor" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +const handlers = () => ({ + not_started: jest.fn(async () => migration("started")), + started: jest.fn(async () => migration("provisioned")), + provisioned: jest.fn(async () => migration("balance_read")), + balance_read: jest.fn(async () => migration("invoice_created")), + invoice_created: jest.fn(async () => migration("balance_move_sending")), + balance_move_sending: jest.fn(async () => migration("balance_move_sent")), + balance_move_sent: jest.fn(async () => migration("balance_move_verified")), + balance_move_verified: jest.fn(async () => + migration("fee_reimbursement_invoice_created"), + ), + fee_reimbursement_invoice_created: jest.fn(async () => + migration("fee_reimbursement_sending"), + ), + fee_reimbursement_sending: jest.fn(async () => migration("fee_reimbursed")), + fee_reimbursed: jest.fn(async () => migration("pointer_flipped")), + pointer_flipped: jest.fn(async () => migration("legacy_zero_verified")), + legacy_zero_verified: jest.fn(async () => migration("complete")), +}) + +describe("cash wallet migration executor", () => { + it("dispatches a runnable migration to the handler for its current status", async () => { + const stepHandlers = handlers() + + const result = await executeCashWalletMigrationStep({ + migration: migration("invoice_created"), + handlers: stepHandlers, + }) + + expect(result).toMatchObject({ status: "balance_move_sending" }) + expect(stepHandlers.invoice_created).toHaveBeenCalledWith( + migration("invoice_created"), + ) + }) + + it("returns terminal migrations without invoking handlers", async () => { + const stepHandlers = handlers() + + const result = await executeCashWalletMigrationStep({ + migration: migration("requires_operator_review"), + handlers: stepHandlers, + }) + + expect(result).toMatchObject({ status: "requires_operator_review" }) + expect(Object.values(stepHandlers).some((handler) => handler.mock.calls.length)).toBe( + false, + ) + }) + + it("returns handler failures without trying a second checkpoint", async () => { + const error = new CouldNotUpdateError("checkpoint failed") + const stepHandlers = { + ...handlers(), + balance_move_sent: jest.fn(async () => error), + } + + const result = await executeCashWalletMigrationStep({ + migration: migration("balance_move_sent"), + handlers: stepHandlers, + }) + + expect(result).toBe(error) + expect(stepHandlers.balance_move_verified).not.toHaveBeenCalled() + }) +}) From b8083225912678fb8ec8d140a6fccacb9776c410 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:51:25 -0400 Subject: [PATCH 21/33] feat(cutover): run locked migration batches --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/runner.ts | 95 ++++++++++++++ .../app/cash-wallet-cutover/runner.spec.ts | 117 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/app/cash-wallet-cutover/runner.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/runner.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 055c076f6..84ac1018e 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -9,3 +9,4 @@ export * from "./migration-records" export * from "./prepare" export * from "./worker" export * from "./executor" +export * from "./runner" diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts new file mode 100644 index 000000000..a416e09a7 --- /dev/null +++ b/src/app/cash-wallet-cutover/runner.ts @@ -0,0 +1,95 @@ +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 +} + +type CashWalletMigrationBatchExecutor = ( + migration: CashWalletMigration, +) => Promise + +type CashWalletMigrationBatchResult = { + attempted: number + advanced: number + failed: number + skipped: number +} + +export const runCashWalletMigrationBatch = async ({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo, + executor, +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + lockStaleBefore: Date + migrationsRepo: CashWalletMigrationBatchRepository + executor: CashWalletMigrationBatchExecutor +}): 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 migration of migrations) { + 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 + } else { + result.advanced += 1 + } + + await migrationsRepo.releaseMigrationLock({ + id: locked.id, + workerId, + cutoverVersion, + runId, + }) + } + + return result +} diff --git a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts new file mode 100644 index 000000000..ab33cc4a2 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -0,0 +1,117 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { runCashWalletMigrationBatch } from "@app/cash-wallet-cutover/runner" + +const migration = ( + status: CashWalletMigrationStatus, + id = `${status}-migration`, +): CashWalletMigration => ({ + id, + accountId: `${id}-account` as AccountId, + legacyUsdWalletId: `${id}-legacy-usd-wallet` as WalletId, + destinationUsdtWalletId: `${id}-usdt-wallet` as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: `cash-wallet-cutover:run-7:${id}-account`, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("cash wallet migration batch runner", () => { + it("locks each runnable migration, executes one step, and releases the lock", async () => { + const runnable = [migration("not_started", "migration-1")] + const locked = { ...runnable[0], lockedBy: "worker-1" } + const completedStep = migration("started", "migration-1") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => runnable), + acquireMigrationLock: jest.fn(async () => locked), + releaseMigrationLock: jest.fn(async () => locked), + } + const executor = jest.fn(async () => completedStep) + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 1, failed: 0, skipped: 0 }) + expect(migrationsRepo.listRunnableMigrations).toHaveBeenCalledWith({ + cutoverVersion: 7, + runId: "run-7", + limit: 10, + }) + expect(migrationsRepo.acquireMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + staleBefore: new Date("2026-05-20T15:00:00Z"), + cutoverVersion: 7, + runId: "run-7", + }) + expect(executor).toHaveBeenCalledWith(locked) + expect(migrationsRepo.releaseMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("skips migrations that cannot be locked", async () => { + const lockError = new CouldNotUpdateError("lock unavailable") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => [migration("started", "migration-1")]), + acquireMigrationLock: jest.fn(async () => lockError), + releaseMigrationLock: jest.fn(), + } + const executor = jest.fn() + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 0, failed: 0, skipped: 1 }) + expect(executor).not.toHaveBeenCalled() + expect(migrationsRepo.releaseMigrationLock).not.toHaveBeenCalled() + }) + + it("releases the lock when execution fails", async () => { + const locked = migration("balance_move_sent", "migration-1") + const executionError = new CouldNotUpdateError("execution failed") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => [locked]), + acquireMigrationLock: jest.fn(async () => locked), + releaseMigrationLock: jest.fn(async () => locked), + } + const executor = jest.fn(async () => executionError) + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 0, failed: 1, skipped: 0 }) + expect(migrationsRepo.releaseMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + cutoverVersion: 7, + runId: "run-7", + }) + }) +}) From 7534f780fba1ff413022215ae9cee31a4b659b68 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:57:04 -0400 Subject: [PATCH 22/33] feat(cutover): build migration step handlers --- src/app/cash-wallet-cutover/handlers.ts | 139 ++++++++++++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/handlers.spec.ts | 94 ++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/app/cash-wallet-cutover/handlers.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts diff --git a/src/app/cash-wallet-cutover/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts new file mode 100644 index 000000000..af563fdd1 --- /dev/null +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -0,0 +1,139 @@ +import { + completeCashWalletMigration, + createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, + markCashWalletMigrationBalanceMoveSent, + markCashWalletMigrationFeeReimbursed, + provisionCashWalletMigrationDestination, + recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, + 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 + } + invoiceService: Parameters< + typeof createCashWalletMigrationBalanceMoveInvoice + >[0]["invoiceService"] + paymentService: Parameters< + typeof sendCashWalletMigrationBalanceMovePayment + >[0]["paymentService"] + balanceVerifier: Parameters< + typeof verifyCashWalletMigrationBalanceMove + >[0]["balanceVerifier"] + feeService: { + readFeeAmountUsdCents( + migration: CashWalletMigration, + ): 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 + return recordCashWalletMigrationBalance({ + migration, + migrationsRepo, + sourceBalanceUsdCents, + }) + }, + balance_read: (migration) => + createCashWalletMigrationBalanceMoveInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + }), + invoice_created: (migration) => + sendCashWalletMigrationBalanceMovePayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + }), + balance_move_sending: (migration) => + markCashWalletMigrationBalanceMoveSent({ migration, migrationsRepo }), + balance_move_sent: (migration) => + verifyCashWalletMigrationBalanceMove({ + migration, + migrationsRepo, + balanceVerifier: services.balanceVerifier, + }), + balance_move_verified: async (migration) => { + const feeAmountUsdCents = await services.feeService.readFeeAmountUsdCents(migration) + if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents + return createCashWalletMigrationFeeReimbursementInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + feeAmountUsdCents, + }) + }, + fee_reimbursement_invoice_created: (migration) => + sendCashWalletMigrationFeeReimbursementPayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + }), + 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 index 84ac1018e..afd60836c 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -10,3 +10,4 @@ export * from "./prepare" export * from "./worker" export * from "./executor" export * from "./runner" +export * from "./handlers" diff --git a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts new file mode 100644 index 000000000..f36dd5102 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts @@ -0,0 +1,94 @@ +import { createCashWalletMigrationStepHandlers } from "@app/cash-wallet-cutover/handlers" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("cash wallet migration step handlers", () => { + it("builds handlers for every runnable status", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to }) => migration(to)), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "1234"), + }, + invoiceService: { + createInvoice: jest.fn( + async () => + ({ + paymentRequest: "lnbc1" as EncodedPaymentRequest, + paymentHash: "hash" as PaymentHash, + }) as LnInvoice, + ), + }, + paymentService: { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(async () => true), + }, + feeService: { + readFeeAmountUsdCents: jest.fn(async () => "7"), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + expect(Object.keys(handlers).sort()).toEqual([ + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "balance_read", + "fee_reimbursed", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "invoice_created", + "legacy_zero_verified", + "not_started", + "pointer_flipped", + "provisioned", + "started", + ]) + + await handlers.not_started(migration("not_started")) + await handlers.started(migration("started")) + await handlers.provisioned(migration("provisioned")) + await handlers.balance_move_verified(migration("balance_move_verified")) + + expect(services.now).toHaveBeenCalled() + expect(services.provisioningService.ensureDestinationWallet).toHaveBeenCalled() + expect(services.balanceReader.readSourceBalanceUsdCents).toHaveBeenCalledWith( + migration("provisioned"), + ) + expect(services.feeService.readFeeAmountUsdCents).toHaveBeenCalledWith( + migration("balance_move_verified"), + ) + }) +}) From af6c70137fa40b798baa646aa873d800e6caf346 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:04:02 -0400 Subject: [PATCH 23/33] feat(cutover): wire migration runtime services --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/runtime-services.ts | 195 ++++++++++++++++++ .../runtime-services.spec.ts | 168 +++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 src/app/cash-wallet-cutover/runtime-services.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index afd60836c..d33b9fd57 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -11,3 +11,4 @@ export * from "./worker" export * from "./executor" export * from "./runner" export * from "./handlers" +export * from "./runtime-services" 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..920a29476 --- /dev/null +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -0,0 +1,195 @@ +import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" +import { addInvoiceForRecipientForUsdWallet, getBalanceForWallet } from "@app/wallets" +import { InvalidWalletId } from "@domain/errors" +import { USDAmount, 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 { + CashWalletMigrationFailedError, + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" + +type RuntimeServiceDependencies = { + now?: () => Date + addWalletIfNonexistent?: typeof addWalletIfNonexistent + updateDefaultWalletId?: typeof updateDefaultWalletId + getBalanceForWallet?: typeof getBalanceForWallet + createInvoice?: typeof addInvoiceForRecipientForUsdWallet + payInvoice?: typeof Ibex.payInvoice + getTransactionDetails?: typeof Ibex.getTransactionDetails + accountsRepo?: Pick, "findById"> +} + +const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount + +const feeAmountUsdCentsFromNumber = ( + feeAmount: number | undefined, +): string | InvalidCashWalletCutoverAmountError => { + if (feeAmount === undefined || Number.isNaN(feeAmount) || feeAmount < 0) { + return new InvalidCashWalletCutoverAmountError("Invalid fee amount") + } + return Math.ceil(feeAmount * 100).toString() +} + +export const createCashWalletMigrationRuntimeServices = ( + deps: RuntimeServiceDependencies = {}, +) => { + const addWallet = deps.addWalletIfNonexistent ?? addWalletIfNonexistent + const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId + const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet + const invoiceForRecipient = deps.createInvoice ?? addInvoiceForRecipientForUsdWallet + const payInvoice = deps.payInvoice ?? Ibex.payInvoice + const getTransactionDetails = deps.getTransactionDetails ?? Ibex.getTransactionDetails + const accountsRepo = deps.accountsRepo ?? AccountsRepository() + + 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() + }, + }, + invoiceService: { + createInvoice: ({ + recipientWalletId, + amount, + memo, + }: { + recipientWalletId: WalletId + amount: string + memo: string + }) => + invoiceForRecipient({ + recipientWalletId, + amount: amount as FractionalCentAmount, + memo, + }), + }, + paymentService: { + payInvoice: async ({ + senderWalletId, + paymentRequest, + }: { + senderWalletId: WalletId + paymentRequest: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> => { + const payment = await payInvoice({ + accountId: senderWalletId as IbexAccountId, + invoice: paymentRequest as Bolt11, + }) + 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: { + readFeeAmountUsdCents: async ( + migration: CashWalletMigration, + ): Promise => { + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before reading fee amount", + ) + } + + const transaction = await getTransactionDetails( + migration.balanceMovePaymentTransactionId, + ) + if (transaction instanceof Error) return transaction + + return feeAmountUsdCentsFromNumber(transaction.networkFee ?? transaction.fee) + }, + }, + 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/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts new file mode 100644 index 000000000..b89a49e38 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -0,0 +1,168 @@ +import { CouldNotUpdateError } from "@domain/errors" +import { USDAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +jest.mock("@app/accounts", () => ({ + addWalletIfNonexistent: jest.fn(), + updateDefaultWalletId: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ + addInvoiceForRecipientForUsdWallet: jest.fn(), + getBalanceForWallet: jest.fn(), +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ findById: jest.fn() })), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + payInvoice: jest.fn(), + getTransactionDetails: jest.fn(), + }, +})) + +import { createCashWalletMigrationRuntimeServices } from "@app/cash-wallet-cutover/runtime-services" + +const migration = (patch: Partial = {}): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status: "balance_move_verified", + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + ...patch, +}) + +describe("cash wallet migration runtime services", () => { + it("reads source USD balances as cents", async () => { + const deps = { + getBalanceForWallet: jest.fn(async () => USDAmount.cents("1234")), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.balanceReader.readSourceBalanceUsdCents(migration()) + + expect(result).toBe("1234") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, + }) + }) + + it("ensures the expected destination USDT wallet exists", async () => { + const deps = { + addWalletIfNonexistent: jest.fn(async () => ({ + id: "usdt-wallet-id" as WalletId, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.provisioningService.ensureDestinationWallet({ + accountId: "account-id" as AccountId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + }) + + expect(result).toBe(true) + expect(deps.addWalletIfNonexistent).toHaveBeenCalledWith({ + accountId: "account-id", + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + }) + + it("extracts the IBEX transaction id after paying an invoice", async () => { + const deps = { + payInvoice: jest.fn(async () => ({ + transaction: { id: "ibex-tx-id" }, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.paymentService.payInvoice({ + senderWalletId: "legacy-usd-wallet-id" as WalletId, + paymentRequest: "lnbc1payment", + }) + + expect(result).toEqual({ transactionId: "ibex-tx-id" }) + expect(deps.payInvoice).toHaveBeenCalledWith({ + accountId: "legacy-usd-wallet-id", + invoice: "lnbc1payment", + }) + }) + + it("returns an error when IBEX payment response has no transaction id", async () => { + const services = createCashWalletMigrationRuntimeServices({ + payInvoice: jest.fn(async () => ({})), + }) + + const result = await services.paymentService.payInvoice({ + senderWalletId: "legacy-usd-wallet-id" as WalletId, + paymentRequest: "lnbc1payment", + }) + + expect(result).toBeInstanceOf(Error) + }) + + it("reads the balance move fee as rounded-up USD cents", async () => { + const deps = { + getTransactionDetails: jest.fn(async () => ({ + networkFee: 0.077, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.feeService.readFeeAmountUsdCents( + migration({ balanceMovePaymentTransactionId: "ibex-tx-id" }), + ) + + expect(result).toBe("8") + expect(deps.getTransactionDetails).toHaveBeenCalledWith("ibex-tx-id") + }) + + it("flips the default wallet and returns the previous default wallet id", async () => { + const deps = { + accountsRepo: { + findById: jest.fn(async () => ({ + defaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + updateDefaultWalletId: jest.fn(async () => ({})), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.pointerService.flipDefaultWallet({ + accountId: "account-id" as AccountId, + destinationWalletId: "usdt-wallet-id" as WalletId, + }) + + expect(result).toEqual({ + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + expect(deps.updateDefaultWalletId).toHaveBeenCalledWith({ + accountId: "account-id", + walletId: "usdt-wallet-id", + }) + }) + + it("propagates legacy zero verification errors", async () => { + const error = new CouldNotUpdateError("balance lookup failed") + const services = createCashWalletMigrationRuntimeServices({ + getBalanceForWallet: jest.fn(async () => error), + }) + + const result = await services.legacyWalletVerifier.verifyLegacyWalletZero({ + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + }) + + expect(result).toBe(error) + }) +}) From a1b50165e7ac52067e8f5695af835b79e5c03dfb Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:08:56 -0400 Subject: [PATCH 24/33] feat(cutover): orchestrate primary migration batches --- src/app/cash-wallet-cutover/index.ts | 3 +- src/app/cash-wallet-cutover/orchestrator.ts | 52 +++++++++++++ .../cash-wallet-cutover/orchestrator.spec.ts | 78 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/app/cash-wallet-cutover/orchestrator.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index d33b9fd57..8ccc61780 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,7 +1,7 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" -export * from "./guard" +export { evaluateCashWalletCutoverGuard } from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" @@ -12,3 +12,4 @@ export * from "./executor" export * from "./runner" export * from "./handlers" export * from "./runtime-services" +export * from "./orchestrator" diff --git a/src/app/cash-wallet-cutover/orchestrator.ts b/src/app/cash-wallet-cutover/orchestrator.ts new file mode 100644 index 000000000..89d8203c6 --- /dev/null +++ b/src/app/cash-wallet-cutover/orchestrator.ts @@ -0,0 +1,52 @@ +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, + lockStaleBefore, + migrationsRepo = CashWalletCutoverRepository(), + runtimeServices = createCashWalletMigrationRuntimeServices(), +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + lockStaleBefore: Date + migrationsRepo?: PrimaryCashWalletCutoverBatchRepository + runtimeServices?: PrimaryCashWalletCutoverRuntimeServices +}) => { + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services: runtimeServices, + }) + + return runCashWalletMigrationBatch({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo, + executor: (migration) => + executeCashWalletMigrationStep({ + migration, + handlers, + }), + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts new file mode 100644 index 000000000..4cfd5e1f6 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts @@ -0,0 +1,78 @@ +jest.mock("@app/accounts", () => ({ + addWalletIfNonexistent: jest.fn(), + updateDefaultWalletId: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ + addInvoiceForRecipientForUsdWallet: jest.fn(), + getBalanceForWallet: jest.fn(), +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ findById: jest.fn() })), + CashWalletCutoverRepository: jest.fn(), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + payInvoice: jest.fn(), + getTransactionDetails: jest.fn(), + }, +})) + +import { runPrimaryCashWalletCutoverBatch } from "@app/cash-wallet-cutover/orchestrator" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("primary cash wallet cutover orchestrator", () => { + it("runs a locked batch with default step handlers", async () => { + const started = migration("not_started") + const locked = migration("not_started") + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("started")), + listRunnableMigrations: jest.fn(async () => [started]), + acquireMigrationLock: jest.fn(async () => locked), + releaseMigrationLock: jest.fn(async () => locked), + } + const runtimeServices = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { ensureDestinationWallet: jest.fn() }, + balanceReader: { readSourceBalanceUsdCents: jest.fn() }, + invoiceService: { createInvoice: jest.fn() }, + paymentService: { payInvoice: jest.fn() }, + balanceVerifier: { verifyBalanceMove: jest.fn() }, + feeService: { readFeeAmountUsdCents: jest.fn() }, + pointerService: { flipDefaultWallet: jest.fn() }, + legacyWalletVerifier: { verifyLegacyWalletZero: jest.fn() }, + } + + const result = await runPrimaryCashWalletCutoverBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 5, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + runtimeServices, + }) + + expect(result).toEqual({ attempted: 1, advanced: 1, failed: 0, skipped: 0 }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 7, + runId: "run-7", + patch: { startedAt: new Date("2026-05-20T16:00:00Z") }, + }) + }) +}) From 075340e8cd70108b605ad0e3665e2ecef2aaad5a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:15:04 -0400 Subject: [PATCH 25/33] feat(cutover): add migration lifecycle controls --- src/app/cash-wallet-cutover/errors.ts | 1 + src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/lifecycle.ts | 174 ++++++++++++++++ src/graphql/error-map.ts | 4 + .../app/cash-wallet-cutover/lifecycle.spec.ts | 190 ++++++++++++++++++ 5 files changed, 370 insertions(+) create mode 100644 src/app/cash-wallet-cutover/lifecycle.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts index 1bbb68678..1fa62da12 100644 --- a/src/app/cash-wallet-cutover/errors.ts +++ b/src/app/cash-wallet-cutover/errors.ts @@ -2,6 +2,7 @@ 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 CashWalletCutoverPreflightError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 8ccc61780..e652966cf 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -13,3 +13,4 @@ export * from "./runner" export * from "./handlers" export * from "./runtime-services" export * from "./orchestrator" +export * from "./lifecycle" 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/graphql/error-map.ts b/src/graphql/error-map.ts index 9c0d8100c..4cf3864ff 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -816,6 +816,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { 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/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts new file mode 100644 index 000000000..ff578a24d --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts @@ -0,0 +1,190 @@ +jest.mock("@services/mongoose", () => ({ + CashWalletCutoverRepository: jest.fn(), +})) + +import { + completePrimaryCashWalletCutover, + getPrimaryCashWalletCutoverStatus, + startPrimaryCashWalletCutover, +} from "@app/cash-wallet-cutover/lifecycle" +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + InvalidCashWalletCutoverStateTransitionError, +} from "@app/cash-wallet-cutover/errors" + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +const repo = ({ + currentConfig = config("pre"), + runnable = [], + counts = {}, +}: { + currentConfig?: CashWalletCutoverConfig + runnable?: CashWalletMigration[] + counts?: Partial> +} = {}) => ({ + getConfig: jest.fn(async () => currentConfig), + updateConfig: jest.fn(async (patch: Partial) => ({ + ...currentConfig, + ...patch, + })), + listRunnableMigrations: jest.fn(async () => runnable), + countByStatus: jest.fn( + async ({ status }: { status: CashWalletMigrationStatus }) => counts[status] ?? 0, + ), +}) + +describe("cash wallet cutover lifecycle", () => { + const now = new Date("2026-05-20T12:00:00Z") + + it("starts a prepared cutover run", async () => { + const migrationsRepo = repo() + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + state: "in_progress", + cutoverVersion: 7, + runId: "run-7", + startedAt: now, + }), + "operator", + ) + expect(result).toMatchObject({ state: "in_progress", runId: "run-7" }) + }) + + it("is idempotent for the active cutover run", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).not.toHaveBeenCalled() + expect(result).toEqual(config("in_progress")) + }) + + it("rejects starting a different run while one is active", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 8, + runId: "run-8", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletCutoverInProgressError) + }) + + it("rejects restarting a completed cutover", async () => { + const migrationsRepo = repo({ currentConfig: config("complete") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(InvalidCashWalletCutoverStateTransitionError) + }) + + it("refuses completion while runnable migrations remain", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + runnable: [{ id: "migration-id", status: "started" } as CashWalletMigration], + }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletCutoverInProgressError) + expect(migrationsRepo.updateConfig).not.toHaveBeenCalled() + }) + + it("refuses completion when failed migrations exist", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + counts: { failed: 1 }, + }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletMigrationFailedError) + }) + + it("marks cutover complete after all migrations are terminal-success", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + state: "complete", + cutoverVersion: 7, + runId: "run-7", + completedAt: now, + }), + "operator", + ) + expect(result).toMatchObject({ state: "complete" }) + }) + + it("returns non-zero migration counts for status checks", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + counts: { complete: 10, failed: 1 }, + }) + + const result = await getPrimaryCashWalletCutoverStatus({ + cutoverVersion: 7, + runId: "run-7", + migrationsRepo, + }) + + expect(result).toEqual({ + config: config("in_progress"), + countsByStatus: { + complete: 10, + failed: 1, + }, + }) + }) +}) From 8f8b3ebcea2301070d5692cc184c5813d503659b Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:17:28 -0400 Subject: [PATCH 26/33] fix(cutover): align migration indexes with run ids --- src/services/mongoose/cash-wallet-cutover.ts | 27 ++++++++++++----- src/services/mongoose/schema.ts | 1 - .../mongoose/cash-wallet-cutover.spec.ts | 30 +++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index 768e06e6a..a2a6b8d1d 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -49,7 +49,9 @@ const defaultConfig = (): CashWalletCutoverConfig => ({ updatedAt: new Date(0), }) -const resultToConfig = (record: CashWalletCutoverConfigRecord): CashWalletCutoverConfig => ({ +const resultToConfig = ( + record: CashWalletCutoverConfigRecord, +): CashWalletCutoverConfig => ({ state: record.state, scheduledAt: record.scheduledAt, startedAt: record.startedAt, @@ -154,7 +156,11 @@ export const CashWalletCutoverRepository = () => { runId: string }): Promise => { try { - const result = await CashWalletMigration.findOne({ accountId, cutoverVersion, runId }) + const result = await CashWalletMigration.findOne({ + accountId, + cutoverVersion, + runId, + }) if (!result) return null return resultToMigration(result) } catch (err) { @@ -176,7 +182,8 @@ export const CashWalletCutoverRepository = () => { { $set: { ...patch, status: to, updatedAt: new Date() } }, { new: true }, ) - if (!result) return new CouldNotUpdateError("Could not transition cash wallet migration") + if (!result) + return new CouldNotUpdateError("Could not transition cash wallet migration") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -201,7 +208,8 @@ export const CashWalletCutoverRepository = () => { { $set: { lockedAt: new Date(), lockedBy: workerId, updatedAt: new Date() } }, { new: true }, ) - if (!result) return new CouldNotUpdateError("Could not acquire cash wallet migration lock") + if (!result) + return new CouldNotUpdateError("Could not acquire cash wallet migration lock") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -213,14 +221,17 @@ export const CashWalletCutoverRepository = () => { workerId, cutoverVersion, runId, - }: Omit): Promise => { + }: 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") + if (!result) + return new CouldNotUpdateError("Could not release cash wallet migration lock") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -242,7 +253,9 @@ export const CashWalletCutoverRepository = () => { runId, status: { $nin: TERMINAL_STATUSES }, }) - return results.slice(0, limit).map(resultToMigration) + .sort({ updatedAt: 1 }) + .limit(limit ?? 0) + return results.map(resultToMigration) } catch (err) { return parseRepositoryError(err) } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index ab476041b..18391f8e5 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -670,7 +670,6 @@ const CashWalletMigrationSchema = new Schema({ updatedAt: { type: Date, default: Date.now, index: true }, }) -CashWalletMigrationSchema.index({ accountId: 1, cutoverVersion: 1 }, { unique: true }) CashWalletMigrationSchema.index({ accountId: 1, runId: 1 }, { unique: true }) CashWalletMigrationSchema.index({ idempotencyKey: 1 }, { unique: true }) CashWalletMigrationSchema.index({ cutoverVersion: 1, status: 1, updatedAt: 1 }) diff --git a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts index ad6e00d19..e036d25bb 100644 --- a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -61,7 +61,11 @@ describe("CashWalletCutoverRepository", () => { }), { upsert: true, new: true }, ) - expect(result).toMatchObject({ state: "in_progress", cutoverVersion: 2, runId: "run-2" }) + expect(result).toMatchObject({ + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + }) }) it("creates one migration record per account id and run", async () => { @@ -151,24 +155,40 @@ describe("CashWalletCutoverRepository", () => { runId: "run-2", $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], }, - expect.objectContaining({ $set: expect.objectContaining({ lockedBy: "worker-1" }) }), + expect.objectContaining({ + $set: expect.objectContaining({ lockedBy: "worker-1" }), + }), { new: true }, ) expect(result).toBeInstanceOf(Error) }) it("finds resumable non-terminal migrations for the current run", async () => { - jest.mocked(CashWalletMigration.find).mockResolvedValue([] as never) + const limit = jest.fn().mockResolvedValue([]) + const sort = jest.fn(() => ({ limit })) + jest.mocked(CashWalletMigration.find).mockReturnValue({ sort } as never) - const result = await repo.listRunnableMigrations({ cutoverVersion: 2, runId: "run-2", limit: 10 }) + const result = await repo.listRunnableMigrations({ + cutoverVersion: 2, + runId: "run-2", + limit: 10, + }) expect(CashWalletMigration.find).toHaveBeenCalledWith( expect.objectContaining({ cutoverVersion: 2, runId: "run-2", - status: { $nin: expect.arrayContaining(["complete", "failed", "requires_operator_review"]) }, + status: { + $nin: expect.arrayContaining([ + "complete", + "failed", + "requires_operator_review", + ]), + }, }), ) + expect(sort).toHaveBeenCalledWith({ updatedAt: 1 }) + expect(limit).toHaveBeenCalledWith(10) expect(result).toEqual([]) }) }) From 50f7bb4d63c5ebbbbd5e82a268bd5612b0237eb9 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:20:27 -0400 Subject: [PATCH 27/33] feat(cutover): add operator command script --- src/scripts/cash-wallet-cutover.ts | 119 +++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/scripts/cash-wallet-cutover.ts diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts new file mode 100644 index 000000000..ee2c70681 --- /dev/null +++ b/src/scripts/cash-wallet-cutover.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +import { CashWalletCutover } from "@app" +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { baseLogger } from "@services/logger" + +const args = yargs(hideBin(process.argv)) + .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("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 "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, + 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) + }) From 98a7021ab310fc109abeb2c290121b3fdaac64ee Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:22:09 -0400 Subject: [PATCH 28/33] chore(cutover): format migration state helpers --- src/app/cash-wallet-cutover/guard.ts | 5 ++++- src/app/cash-wallet-cutover/state-machine.ts | 4 +++- .../migration-state-machine.spec.ts | 12 +++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts index d3fb154b6..e37f61369 100644 --- a/src/app/cash-wallet-cutover/guard.ts +++ b/src/app/cash-wallet-cutover/guard.ts @@ -31,7 +31,10 @@ export const evaluateCashWalletCutoverGuard = ({ if (cutover.state === "complete") return { route: "eth_usdt" } if (!migration || migration.status === "not_started") return { route: "legacy_usd" } - if (migration.status === "complete" || migration.status === "skipped_already_migrated") { + if ( + migration.status === "complete" || + migration.status === "skipped_already_migrated" + ) { return { route: "eth_usdt" } } if (migration.status === "failed" || migration.status === "requires_operator_review") { diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts index 66898b3f0..3206d4bb5 100644 --- a/src/app/cash-wallet-cutover/state-machine.ts +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -1,6 +1,8 @@ import { InvalidCashWalletMigrationTransitionError } from "./errors" -const transitions: Partial> = { +const transitions: Partial< + Record +> = { not_started: ["started"], started: ["provisioned", "failed"], provisioned: ["balance_read", "failed", "skipped_already_migrated"], diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts index bc199dadd..2a6e9d17c 100644 --- a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts @@ -28,18 +28,24 @@ describe("cash wallet cutover migration state machine", () => { }) it("rejects pointer flip before fee reimbursement", () => { - expect(assertCanTransition("balance_move_verified", "pointer_flipped")).toBeInstanceOf(Error) + expect( + assertCanTransition("balance_move_verified", "pointer_flipped"), + ).toBeInstanceOf(Error) }) it("resumes from stored checkpoint without repeating completed side effects", () => { expect(nextResumeStatus("invoice_created")).toBe("invoice_created") expect(nextResumeStatus("balance_move_sent")).toBe("balance_move_sent") - expect(nextResumeStatus("fee_reimbursement_invoice_created")).toBe("fee_reimbursement_invoice_created") + expect(nextResumeStatus("fee_reimbursement_invoice_created")).toBe( + "fee_reimbursement_invoice_created", + ) }) it("does not progress terminal/manual-review states without override", () => { expect(assertCanTransition("complete", "started")).toBeInstanceOf(Error) expect(assertCanTransition("failed", "started")).toBeInstanceOf(Error) - expect(assertCanTransition("requires_operator_review", "started")).toBeInstanceOf(Error) + expect(assertCanTransition("requires_operator_review", "started")).toBeInstanceOf( + Error, + ) }) }) From e7a5965441c09b0cc836aae2bba97825f26a5df6 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:50:32 -0400 Subject: [PATCH 29/33] feat(cutover): preview dry-run migration plan --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/preview.ts | 48 +++++++++++ src/scripts/cash-wallet-cutover.ts | 11 +++ .../app/cash-wallet-cutover/preview.spec.ts | 81 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/app/cash-wallet-cutover/preview.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/preview.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index e652966cf..357dabbd0 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -14,3 +14,4 @@ export * from "./handlers" export * from "./runtime-services" export * from "./orchestrator" export * from "./lifecycle" +export * from "./preview" diff --git a/src/app/cash-wallet-cutover/preview.ts b/src/app/cash-wallet-cutover/preview.ts new file mode 100644 index 000000000..42ac910e6 --- /dev/null +++ b/src/app/cash-wallet-cutover/preview.ts @@ -0,0 +1,48 @@ +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { buildPrimaryCashWalletMigrationPlan } 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/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts index ee2c70681..3bd9a173a 100644 --- a/src/scripts/cash-wallet-cutover.ts +++ b/src/scripts/cash-wallet-cutover.ts @@ -13,6 +13,7 @@ import { import { baseLogger } from "@services/logger" const args = yargs(hideBin(process.argv)) + .command("preview", "discover accounts and print the migration plan without writes") .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") @@ -40,6 +41,16 @@ const run = async () => { 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 "prepare": { const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ cutoverVersion, diff --git a/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts b/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts new file mode 100644 index 000000000..90bcc5045 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts @@ -0,0 +1,81 @@ +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +import { previewPrimaryCashWalletCutover } from "@app/cash-wallet-cutover/preview" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = ({ + id, + defaultWalletId, +}: { + id: string + defaultWalletId: string +}): Account => + ({ + id, + uuid: `${id}-uuid`, + defaultWalletId, + }) as Account + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + type: WalletType.Checking, + currency, + }) as Wallet + +describe("preview primary cash wallet cutover", () => { + it("builds the preflight report and plan without repository writes", async () => { + const accounts = [ + account({ id: "account-1", defaultWalletId: "usd-1" }), + account({ id: "account-2", defaultWalletId: "usdt-2" }), + ] + + const accountsRepo = { + listUnlockedAccounts: function* () { + yield* accounts + }, + } + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => { + if (accountId === "account-1") { + return [ + wallet({ id: "usd-1", currency: WalletCurrency.Usd }), + wallet({ id: "usdt-1", currency: WalletCurrency.Usdt }), + ] + } + + return [ + wallet({ id: "usd-2", currency: WalletCurrency.Usd }), + wallet({ id: "usdt-2", currency: WalletCurrency.Usdt }), + ] + }), + } + + const result = await previewPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + accountsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + report: expect.objectContaining({ + totalAccounts: 2, + migrationCandidates: 1, + alreadyUsdt: 1, + canStart: true, + }), + plannedMigrations: [ + expect.objectContaining({ + accountId: "account-1", + legacyUsdWalletId: "usd-1", + destinationUsdtWalletId: "usdt-1", + }), + ], + }) + }) +}) From 751067a25b69054b1abe3babd02d21b75ca63a44 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 14:47:59 -0400 Subject: [PATCH 30/33] fix(cutover): satisfy production build types --- src/app/cash-wallet-cutover/migration-records.ts | 2 ++ src/app/cash-wallet-cutover/planner.ts | 2 ++ src/app/cash-wallet-cutover/preflight.ts | 2 ++ src/app/cash-wallet-cutover/prepare.ts | 10 ++++++++-- src/app/cash-wallet-cutover/preview.ts | 10 ++++++++-- src/app/cash-wallet-cutover/runtime-services.ts | 9 +++++++-- src/app/cash-wallet-cutover/worker.ts | 2 +- 7 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts index 7e0dd8a56..9ac33b8d0 100644 --- a/src/app/cash-wallet-cutover/migration-records.ts +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -1,3 +1,5 @@ +import { PrimaryCashWalletMigrationPlan } from "./planner" + type CashWalletMigrationRecordsRepository = { upsertMigration( args: PrimaryCashWalletMigrationPlan, diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts index 772928061..0a1e2031c 100644 --- a/src/app/cash-wallet-cutover/planner.ts +++ b/src/app/cash-wallet-cutover/planner.ts @@ -1,3 +1,5 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + type PrimaryCashWalletMigrationPlan = { accountId: AccountId accountUuid?: AccountUuid diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts index 058679f6b..df05bfddf 100644 --- a/src/app/cash-wallet-cutover/preflight.ts +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -1,3 +1,5 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + type CashWalletCutoverPreflightBlocker = { accountId: AccountId reason: "missing_legacy_usd" | "missing_destination_usdt" diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts index d32a62976..35793b208 100644 --- a/src/app/cash-wallet-cutover/prepare.ts +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -1,6 +1,12 @@ -import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" import { discoverCashWalletCutoverAccounts } from "./discovery" -import { buildPrimaryCashWalletMigrationPlan } from "./planner" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" import { upsertPrimaryCashWalletMigrationRecords, CashWalletMigrationRecordsRepository, diff --git a/src/app/cash-wallet-cutover/preview.ts b/src/app/cash-wallet-cutover/preview.ts index 42ac910e6..2cca59bc9 100644 --- a/src/app/cash-wallet-cutover/preview.ts +++ b/src/app/cash-wallet-cutover/preview.ts @@ -1,8 +1,14 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" import { discoverCashWalletCutoverAccounts } from "./discovery" -import { buildCashWalletCutoverPreflightReport } from "./preflight" -import { buildPrimaryCashWalletMigrationPlan } from "./planner" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" export const previewPrimaryCashWalletCutover = async ({ cutoverVersion, diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index 920a29476..b9e1be5d2 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -35,6 +35,9 @@ const feeAmountUsdCentsFromNumber = ( return Math.ceil(feeAmount * 100).toString() } +const numericFee = (value: unknown): number | undefined => + typeof value === "number" ? value : undefined + export const createCashWalletMigrationRuntimeServices = ( deps: RuntimeServiceDependencies = {}, ) => { @@ -146,11 +149,13 @@ export const createCashWalletMigrationRuntimeServices = ( } const transaction = await getTransactionDetails( - migration.balanceMovePaymentTransactionId, + migration.balanceMovePaymentTransactionId as IbexTransactionId, ) if (transaction instanceof Error) return transaction - return feeAmountUsdCentsFromNumber(transaction.networkFee ?? transaction.fee) + return feeAmountUsdCentsFromNumber( + numericFee(transaction.networkFee) ?? numericFee(transaction.fee), + ) }, }, pointerService: { diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index efccc719a..8e3111ec5 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -222,7 +222,7 @@ export const verifyCashWalletMigrationBalanceMove = async ({ destinationUsdtWalletId: migration.destinationUsdtWalletId, sourceBalanceUsdCents: migration.sourceBalanceUsdCents, destinationAmountUsdtMicros: migration.destinationAmountUsdtMicros, - transactionId: migration.balanceMovePaymentTransactionId, + transactionId: migration.balanceMovePaymentTransactionId as IbexTransactionId, }) if (verified instanceof Error) return verified From 3fe880b8adc39d1cfdb5b9797a5500832ed9a340 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 25 May 2026 11:13:32 -0400 Subject: [PATCH 31/33] feat(cutover): add operator controls and verification --- dev/apollo-federation/supergraph.graphql | 24 + .../01-query-admin-state.bru | 49 + .../02-set-scheduled-pre.bru | 62 + .../03-set-in-progress.bru | 61 + .../cash-wallet-cutover/04-set-complete.bru | 61 + .../admin/cash-wallet-cutover/folder.bru | 5 + .../notoken/queries/cash-wallet-cutover.bru | 45 + operator-runs/eng-345-manual-347/findings.md | 6 + .../funding-retry-results.json | 97 + .../eng-345-manual-347/manifest.json | 78 + .../eng-345-manual-347/prep-348-results.json | 344 +++ operator-runs/eng-345-manual-347/progress.md | 45 + operator-runs/eng-345-manual-347/results.json | 95 + operator-runs/eng-345-manual-347/run.ts | 477 ++++ operator-runs/eng-345-manual-347/task_plan.md | 70 + .../verify-348-prep-results.json | 74 + .../cash-wallet-cutover/amount-conversion.ts | 30 + src/app/cash-wallet-cutover/handlers.ts | 56 +- src/app/cash-wallet-cutover/index.types.d.ts | 1 + src/app/cash-wallet-cutover/runner.ts | 34 + .../cash-wallet-cutover/runtime-services.ts | 110 +- src/app/cash-wallet-cutover/state-machine.ts | 1 + src/app/cash-wallet-cutover/worker.ts | 64 +- src/graphql/admin/mutations.ts | 5 +- src/graphql/admin/queries.ts | 2 + .../mutation/cash-wallet-cutover-update.ts | 61 + src/graphql/admin/schema.graphql | 34 + .../types/payload/cash-wallet-cutover.ts | 13 + src/graphql/public/queries.ts | 2 + src/graphql/public/schema.graphql | 20 + .../shared/root/query/cash-wallet-cutover.ts | 14 + .../types/object/cash-wallet-cutover.ts | 21 + .../types/scalar/cash-wallet-cutover-state.ts | 12 + src/services/mongoose/cash-wallet-cutover.ts | 38 + src/services/mongoose/schema.ts | 1 + src/services/mongoose/schema.types.d.ts | 1 + .../amount-conversion.spec.ts | 27 + .../app/cash-wallet-cutover/handlers.spec.ts | 141 +- .../migration-state-machine.spec.ts | 4 + .../cash-wallet-cutover/orchestrator.spec.ts | 12 +- .../app/cash-wallet-cutover/runner.spec.ts | 11 +- .../runtime-services.spec.ts | 82 +- .../app/cash-wallet-cutover/worker.spec.ts | 105 +- .../unit/graphql/cash-wallet-cutover.spec.ts | 81 + .../mongoose/cash-wallet-cutover.spec.ts | 48 + yarn.lock | 2088 ++++++++--------- 46 files changed, 3501 insertions(+), 1211 deletions(-) create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru create mode 100644 dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru create mode 100644 operator-runs/eng-345-manual-347/findings.md create mode 100644 operator-runs/eng-345-manual-347/funding-retry-results.json create mode 100644 operator-runs/eng-345-manual-347/manifest.json create mode 100644 operator-runs/eng-345-manual-347/prep-348-results.json create mode 100644 operator-runs/eng-345-manual-347/progress.md create mode 100644 operator-runs/eng-345-manual-347/results.json create mode 100644 operator-runs/eng-345-manual-347/run.ts create mode 100644 operator-runs/eng-345-manual-347/task_plan.md create mode 100644 operator-runs/eng-345-manual-347/verify-348-prep-results.json create mode 100644 src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts create mode 100644 src/graphql/admin/types/payload/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/root/query/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/types/object/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts create mode 100644 test/flash/unit/graphql/cash-wallet-cutover.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 865c5fd87..baeee5a52 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -501,6 +501,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 +1674,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 diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru new file mode 100644 index 000000000..dfdf9e130 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru @@ -0,0 +1,49 @@ +meta { + name: 01-query-admin-state + type: graphql + seq: 1 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + query CashWalletCutoverAdminState { + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } +} + +body:graphql:vars { + {} +} + +script:post-response { + test("admin cutover state returns without GraphQL errors", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ + "PRE", + "IN_PROGRESS", + "COMPLETE", + ]) + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru new file mode 100644 index 000000000..2240d8493 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru @@ -0,0 +1,62 @@ +meta { + name: 02-set-scheduled-pre + type: graphql + seq: 2 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetScheduledPre($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "PRE", + "scheduledAt": "2026-05-22T15:00:00.000Z", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 preflight" + } + } +} + +script:post-response { + test("sets cutover config to PRE with schedule metadata", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("PRE") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru new file mode 100644 index 000000000..222d93e80 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru @@ -0,0 +1,61 @@ +meta { + name: 03-set-in-progress + type: graphql + seq: 3 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetInProgress($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "IN_PROGRESS", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 in-progress test" + } + } +} + +script:post-response { + test("sets cutover config to IN_PROGRESS", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("IN_PROGRESS") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru new file mode 100644 index 000000000..cb9d8ee55 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru @@ -0,0 +1,61 @@ +meta { + name: 04-set-complete + type: graphql + seq: 4 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetComplete($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "COMPLETE", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 complete test" + } + } +} + +script:post-response { + test("sets cutover config to COMPLETE", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("COMPLETE") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru new file mode 100644 index 000000000..88076d4b9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru @@ -0,0 +1,5 @@ +meta { + name: cash-wallet-cutover + seq: 10 +} + diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru new file mode 100644 index 000000000..79a2c7427 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru @@ -0,0 +1,45 @@ +meta { + name: cash-wallet-cutover + type: graphql + seq: 11 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + query CashWalletCutoverPublicState { + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } +} + +body:graphql:vars { + {} +} + +script:post-response { + test("public cutover state returns without GraphQL errors", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ + "PRE", + "IN_PROGRESS", + "COMPLETE", + ]) + }) +} + diff --git a/operator-runs/eng-345-manual-347/findings.md b/operator-runs/eng-345-manual-347/findings.md new file mode 100644 index 000000000..79191ab38 --- /dev/null +++ b/operator-runs/eng-345-manual-347/findings.md @@ -0,0 +1,6 @@ +# Findings + +- `preparePrimaryCashWalletCutover` accepts injected repositories, so the test can scope discovery to the 10 created accounts by passing an `accountsRepo.listUnlockedAccounts()` generator for only those IDs. +- Account creation creates both USD and USDT checking wallets and defaults new accounts to USDT. For this test, each new account must be explicitly updated back to the USD wallet with `Accounts.updateDefaultWalletId`. +- Funding can use `Payments.intraledgerPaymentSendWalletIdForUsdWallet` from the funder account's USD wallet to each target legacy USD wallet. The amount argument is cents, so `$0.25` is `25` and `$0.01` is `1`. +- Cutover batch execution can use `CashWalletCutover.runPrimaryCashWalletCutoverBatch`; status and lifecycle can use the existing app lifecycle functions. diff --git a/operator-runs/eng-345-manual-347/funding-retry-results.json b/operator-runs/eng-345-manual-347/funding-retry-results.json new file mode 100644 index 000000000..0f9591d76 --- /dev/null +++ b/operator-runs/eng-345-manual-347/funding-retry-results.json @@ -0,0 +1,97 @@ +{ + "targetCents": 25, + "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", + "results": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + } + ] +} diff --git a/operator-runs/eng-345-manual-347/manifest.json b/operator-runs/eng-345-manual-347/manifest.json new file mode 100644 index 000000000..e1af02bce --- /dev/null +++ b/operator-runs/eng-345-manual-347/manifest.json @@ -0,0 +1,78 @@ +{ + "cutoverVersion": 347, + "runId": "manual-eng-347", + "createdAt": "2026-05-23T13:37:44.318Z", + "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", + "accounts": [ + { + "index": 1, + "phone": "+16509-recovered-01", + "kratosUserId": "03cc58c6-a325-4966-9cf0-a96a58a8b366", + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "startingFundingCents": 25 + }, + { + "index": 2, + "phone": "+16509-recovered-02", + "kratosUserId": "4b7a6cea-7024-47d2-a4ba-f9df7d97aa7b", + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "startingFundingCents": 25 + }, + { + "index": 3, + "phone": "+16509-recovered-03", + "kratosUserId": "cb7eee02-7cb5-41a3-a4a5-1210624cb309", + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "startingFundingCents": 25 + }, + { + "index": 4, + "phone": "+16509-recovered-04", + "kratosUserId": "76fe0bc7-1a9c-440b-9586-317781c153f4", + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "startingFundingCents": 25 + }, + { + "index": 5, + "phone": "+16509-recovered-05", + "kratosUserId": "4736f580-cd98-4750-ab16-1da95e986dc9", + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "startingFundingCents": 25 + }, + { + "index": 6, + "phone": "+16509-recovered-06", + "kratosUserId": "c5cab6ac-a56e-431b-a460-118d3627f7d3", + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "startingFundingCents": 25 + }, + { + "index": 7, + "phone": "+16509-recovered-07", + "kratosUserId": "c1df1aa1-18c6-4ec9-97a6-9dfc5736be7f", + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "startingFundingCents": 25 + } + ] +} diff --git a/operator-runs/eng-345-manual-347/prep-348-results.json b/operator-runs/eng-345-manual-347/prep-348-results.json new file mode 100644 index 000000000..951b9cb85 --- /dev/null +++ b/operator-runs/eng-345-manual-347/prep-348-results.json @@ -0,0 +1,344 @@ +{ + "cutoverVersion": 348, + "runId": "manual-eng-348", + "flips": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + } + ], + "configReset": { + "acknowledged": true, + "modifiedCount": 1, + "upsertedId": null, + "upsertedCount": 0, + "matchedCount": 1 + }, + "preview": { + "report": { + "cutoverVersion": 348, + "runId": "manual-eng-348", + "totalAccounts": 7, + "migrationCandidates": 7, + "alreadyUsdt": 0, + "residualLegacyUsd": 0, + "blockers": 0, + "blockerAccounts": [], + "canStart": true + }, + "plannedMigrations": [ + { + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" + }, + { + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" + }, + { + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" + }, + { + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" + }, + { + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" + }, + { + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" + }, + { + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" + } + ] + }, + "prepared": { + "report": { + "cutoverVersion": 348, + "runId": "manual-eng-348", + "totalAccounts": 7, + "migrationCandidates": 7, + "alreadyUsdt": 0, + "residualLegacyUsd": 0, + "blockers": 0, + "blockerAccounts": [], + "canStart": true + }, + "plannedMigrations": [ + { + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" + }, + { + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" + }, + { + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" + }, + { + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" + }, + { + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" + }, + { + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" + }, + { + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" + } + ], + "migrations": [ + { + "id": "24353ef3-66ba-4acc-9365-1ad6e83301f0", + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.370Z" + }, + { + "id": "d47e3b77-39d0-49ea-a5eb-e0dbbf5a306c", + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.382Z" + }, + { + "id": "b5b42c93-519a-4103-b7fb-a1ff5a3dd17d", + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.393Z" + }, + { + "id": "1eafc20f-3e53-4d72-ba25-48cd8d5c5c24", + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.401Z" + }, + { + "id": "78fdd4b6-9c81-4883-a0e0-0bab20491c17", + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.406Z" + }, + { + "id": "9cf2b55b-d118-4b2a-a322-9c857e2dee8d", + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.414Z" + }, + { + "id": "20e0769a-6c20-4475-bb0a-9eda26698668", + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.424Z" + } + ] + }, + "status": { + "config": { + "state": "pre", + "updatedBy": "manual-local", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "updatedAt": "2026-05-23T14:49:36.246Z" + }, + "countsByStatus": { + "not_started": 7 + } + } +} diff --git a/operator-runs/eng-345-manual-347/progress.md b/operator-runs/eng-345-manual-347/progress.md new file mode 100644 index 000000000..7e433b28a --- /dev/null +++ b/operator-runs/eng-345-manual-347/progress.md @@ -0,0 +1,45 @@ +- 2026-05-23T13:37:13.813Z ERROR Cannot read properties of undefined (reading 'findOne') +- 2026-05-23T13:37:44.318Z created account 1: 6a11ada7e55310755eeb0257 USD=0a4d1c55-d8ec-4685-8457-216d41569d61 USDT=8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6 funding=25 +- 2026-05-23T13:37:45.186Z created account 2: 6a11ada8e55310755eeb0271 USD=08ee5f13-b4c3-4ff5-835a-55dd786d3887 USDT=741a97b0-9612-4f34-af32-ed28c0a3b5fd funding=25 +- 2026-05-23T13:37:45.866Z created account 3: 6a11ada9e55310755eeb028b USD=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 USDT=144db453-fdb8-4180-b405-097104637644 funding=25 +- 2026-05-23T13:37:47.835Z created account 4: 6a11ada9e55310755eeb02a5 USD=dbdfba0c-ead0-4c94-96f8-e15130b7f796 USDT=ae3bddb2-1bb7-4019-8390-39694881c91b funding=25 +- 2026-05-23T13:37:48.713Z created account 5: 6a11adabe55310755eeb02bf USD=9756a443-d30d-4b1c-a797-933ec2ced1d0 USDT=fa7c7780-9246-4088-beb6-56fcd98e3209 funding=25 +- 2026-05-23T13:37:49.430Z created account 6: 6a11adace55310755eeb02d9 USD=ecaf103c-d024-419a-8bad-b0c61e8068be USDT=53884f7a-0a55-4406-8b60-3c6d68a6c180 funding=25 +- 2026-05-23T13:37:50.263Z created account 7: 6a11adade55310755eeb02f3 USD=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c USDT=80f0de1f-b4e6-4904-8c3f-d90bad763ce2 funding=25 +- 2026-05-23T13:37:50.789Z ERROR +- 2026-05-23T13:39:59.481Z ERROR +- 2026-05-23T13:41:36.710Z ERROR +- 2026-05-23T13:42:04.155Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T13:42:26.832Z ERROR +- 2026-05-23T13:44:28.884Z ERROR +- 2026-05-23T13:50:13.799Z preview planned=7 +- 2026-05-23T13:50:27.921Z prepared migrations=7 +- 2026-05-23T13:50:44.208Z reset singleton cutover config to pre for manual-eng-347 +- 2026-05-23T13:50:48.388Z started state=in_progress +- 2026-05-23T13:51:03.235Z batch 1: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:03.298Z batch 2: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:06.709Z batch 3: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:06.840Z batch 4: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.144Z batch 5: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.238Z batch 6: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.268Z all migrations complete after batch 6 +- 2026-05-23T13:51:25.286Z completed lifecycle state=complete +- 2026-05-23T13:51:31.895Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T14:05:39.454Z funding retry account 1: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:42.219Z funding retry account 2: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:44.498Z funding retry account 3: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:46.786Z funding retry account 4: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:49.479Z funding retry account 5: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:50.986Z funding retry account 6: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:52.277Z funding retry account 7: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:06:31.393Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T14:49:35.240Z reset account 1 defaultWalletId=0a4d1c55-d8ec-4685-8457-216d41569d61 fundedUsdCents=25 +- 2026-05-23T14:49:35.433Z reset account 2 defaultWalletId=08ee5f13-b4c3-4ff5-835a-55dd786d3887 fundedUsdCents=25 +- 2026-05-23T14:49:35.582Z reset account 3 defaultWalletId=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 fundedUsdCents=25 +- 2026-05-23T14:49:35.729Z reset account 4 defaultWalletId=dbdfba0c-ead0-4c94-96f8-e15130b7f796 fundedUsdCents=25 +- 2026-05-23T14:49:35.950Z reset account 5 defaultWalletId=9756a443-d30d-4b1c-a797-933ec2ced1d0 fundedUsdCents=25 +- 2026-05-23T14:49:36.092Z reset account 6 defaultWalletId=ecaf103c-d024-419a-8bad-b0c61e8068be fundedUsdCents=25 +- 2026-05-23T14:49:36.245Z reset account 7 defaultWalletId=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c fundedUsdCents=25 +- 2026-05-23T14:49:36.256Z reset singleton cutover config to pre for manual-eng-348 +- 2026-05-23T14:49:36.323Z preview manual-eng-348 planned=7 +- 2026-05-23T14:49:36.431Z prepared manual-eng-348 migrations=7 diff --git a/operator-runs/eng-345-manual-347/results.json b/operator-runs/eng-345-manual-347/results.json new file mode 100644 index 000000000..57d76e89c --- /dev/null +++ b/operator-runs/eng-345-manual-347/results.json @@ -0,0 +1,95 @@ +{ + "status": { + "config": { + "state": "complete", + "startedAt": "2026-05-23T13:50:48.355Z", + "completedAt": "2026-05-23T13:51:25.254Z", + "updatedBy": "manual-local", + "cutoverVersion": 347, + "runId": "manual-eng-347", + "updatedAt": "2026-05-23T13:51:25.281Z" + }, + "countsByStatus": { + "complete": 7 + } + }, + "accounts": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "expectedFundingCents": 25, + "defaultWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "expectedFundingCents": 25, + "defaultWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "expectedFundingCents": 25, + "defaultWalletId": "144db453-fdb8-4180-b405-097104637644", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "expectedFundingCents": 25, + "defaultWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "expectedFundingCents": 25, + "defaultWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "expectedFundingCents": 25, + "defaultWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "expectedFundingCents": 25, + "defaultWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + } + ] +} diff --git a/operator-runs/eng-345-manual-347/run.ts b/operator-runs/eng-345-manual-347/run.ts new file mode 100644 index 000000000..3438416d3 --- /dev/null +++ b/operator-runs/eng-345-manual-347/run.ts @@ -0,0 +1,477 @@ +import fs from "fs/promises" +import path from "path" +import { randomUUID } from "crypto" + +import { getDefaultAccountsConfig } from "@config" + +import { Accounts, CashWalletCutover, Payments, Wallets } from "@app" +import { getBalanceForWallet } from "@app/wallets" +import { WalletCurrency } from "@domain/shared" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { WalletType } from "@domain/wallets" + +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { Account, CashWalletCutoverConfig } from "@services/mongoose/schema" + +type TargetAccount = { + index: number + phone: string + kratosUserId: string + accountId: string + accountUuid?: string + legacyUsdWalletId: string + destinationUsdtWalletId: string + startingFundingCents: number +} + +type Manifest = { + cutoverVersion: number + runId: string + createdAt: string + funderUsdWalletId?: string + accounts: TargetAccount[] +} + +const CUTOVER_VERSION = 347 +const RUN_ID = "manual-eng-347" +const ACCOUNT_COUNT = 10 +const OUTPUT_DIR = path.resolve("operator-runs/eng-345-manual-347") +const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json") +const RESULTS_PATH = path.join(OUTPUT_DIR, "results.json") +const PROGRESS_PATH = path.join(OUTPUT_DIR, "progress.md") + +const logProgress = async (line: string) => { + await fs.appendFile(PROGRESS_PATH, `- ${new Date().toISOString()} ${line}\n`) +} + +const throwIfError = (result: T | Error): T => { + if (result instanceof Error) throw result + return result +} + +const loadManifest = async (): Promise => { + const raw = await fs.readFile(MANIFEST_PATH, "utf8") + return JSON.parse(raw) as Manifest +} + +const saveManifest = async (manifest: Manifest) => { + await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`) +} + +const listWalletsForAccount = async (accountId: string) => { + const wallets = throwIfError( + await WalletsRepository().listByAccountId(accountId as AccountId), + ) + const usd = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + const usdt = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) + + if (!usd) throw new Error(`Missing USD wallet for account ${accountId}`) + if (!usdt) throw new Error(`Missing USDT wallet for account ${accountId}`) + + return { usd, usdt } +} + +const funderUsdWalletId = async (): Promise => { + const funder = await Account.findOne({ role: "funder" }) + if (!funder) throw new Error("Missing funder account") + + const wallets = throwIfError( + await WalletsRepository().listByAccountId(funder._id.toString() as AccountId), + ) + const usdWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + if (!usdWallet) throw new Error("Missing funder USD wallet") + + return usdWallet.id +} + +const createAccounts = async () => { + await fs.mkdir(OUTPUT_DIR, { recursive: true }) + + const config = getDefaultAccountsConfig() + const existing = await fs + .readFile(MANIFEST_PATH, "utf8") + .then((raw) => JSON.parse(raw) as Manifest) + .catch(() => undefined) + const funderWalletId = existing?.funderUsdWalletId ?? (await funderUsdWalletId()) + const accounts: TargetAccount[] = existing?.accounts ?? [] + const stamp = Date.now().toString().slice(-7) + + for (let i = accounts.length + 1; i <= ACCOUNT_COUNT; i += 1) { + const suffix = `${stamp}${String(i).padStart(2, "0")}` + const phone = `+16509${suffix}` as PhoneNumber + const kratosUserId = randomUUID() as UserId + + const account = throwIfError( + await Accounts.createAccountWithPhoneIdentifier({ + newAccountInfo: { kratosUserId, phone }, + config, + }), + ) + + const { usd, usdt } = await listWalletsForAccount(account.id) + + throwIfError( + await Accounts.updateDefaultWalletId({ + accountId: account.id, + walletId: usd.id, + }), + ) + + const fundingCents = i <= 8 ? 25 : i === 9 ? 1 : 0 + + accounts.push({ + index: i, + phone, + kratosUserId, + accountId: account.id, + accountUuid: account.uuid, + legacyUsdWalletId: usd.id, + destinationUsdtWalletId: usdt.id, + startingFundingCents: fundingCents, + }) + + await logProgress( + `created account ${i}: ${account.id} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, + ) + + await saveManifest({ + cutoverVersion: CUTOVER_VERSION, + runId: RUN_ID, + createdAt: existing?.createdAt ?? new Date().toISOString(), + funderUsdWalletId: funderWalletId, + accounts, + }) + } + + const manifest: Manifest = { + cutoverVersion: CUTOVER_VERSION, + runId: RUN_ID, + createdAt: existing?.createdAt ?? new Date().toISOString(), + funderUsdWalletId: funderWalletId, + accounts, + } + await saveManifest(manifest) + console.log(JSON.stringify(manifest, null, 2)) +} + +const completePartialAccounts = async () => { + const manifest = await loadManifest() + const existingIds = new Set(manifest.accounts.map((account) => account.accountId)) + const partials = await Account.find({ + role: "user", + defaultWalletId: { $exists: false }, + created_at: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, + }).sort({ created_at: 1 }) + + for (const partial of partials) { + if (manifest.accounts.length >= ACCOUNT_COUNT) break + const accountId = partial._id.toString() + if (existingIds.has(accountId)) continue + + const usd = throwIfError( + await WalletsRepository().persistNew({ + accountId: accountId as AccountId, + type: WalletType.Checking, + currency: WalletCurrency.Usd, + }), + ) + const usdt = throwIfError( + await WalletsRepository().persistNew({ + accountId: accountId as AccountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }), + ) + throwIfError( + await Accounts.updateDefaultWalletId({ + accountId: accountId as AccountId, + walletId: usd.id, + }), + ) + + const index = manifest.accounts.length + 1 + const fundingCents = index <= 8 ? 25 : index === 9 ? 1 : 0 + manifest.accounts.push({ + index, + phone: `partial-${index}`, + kratosUserId: partial.kratosUserId, + accountId, + accountUuid: partial.id, + legacyUsdWalletId: usd.id, + destinationUsdtWalletId: usdt.id, + startingFundingCents: fundingCents, + }) + existingIds.add(accountId) + await saveManifest(manifest) + await logProgress( + `completed partial account ${index}: ${accountId} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, + ) + } + + console.log(JSON.stringify(manifest, null, 2)) +} + +const fundAccounts = async () => { + const manifest = await loadManifest() + if (!manifest.funderUsdWalletId) { + manifest.funderUsdWalletId = await funderUsdWalletId() + await saveManifest(manifest) + } + + for (const account of manifest.accounts) { + if (account.startingFundingCents === 0) { + await logProgress(`left account ${account.index} unfunded`) + continue + } + + const status = throwIfError( + await Payments.intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: manifest.funderUsdWalletId, + recipientWalletId: account.legacyUsdWalletId, + amount: account.startingFundingCents, + memo: `ENG-345 ${RUN_ID} seed ${account.index}`, + }), + ) + + if (status !== PaymentSendStatus.Success && status !== PaymentSendStatus.Pending) { + throw new Error(`Funding account ${account.index} returned ${status}`) + } + + await logProgress( + `funded account ${account.index} ${account.legacyUsdWalletId} with ${account.startingFundingCents} cents status=${status}`, + ) + } +} + +const scopedAccountsRepo = (targetIds: Set) => ({ + async *listUnlockedAccounts() { + for (const accountId of targetIds) { + yield throwIfError(await AccountsRepository().findById(accountId as AccountId)) + } + }, +}) + +const preview = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.previewPrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), + walletsRepo: WalletsRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`preview planned=${result.plannedMigrations.length}`) +} + +const prepare = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.preparePrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), + walletsRepo: WalletsRepository(), + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`prepared migrations=${result.migrations.length}`) +} + +const start = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.startPrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + actor: "manual-local", + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`started state=${result.state}`) +} + +const runBatches = async () => { + const manifest = await loadManifest() + const batches = [] + + for (let i = 1; i <= 40; i += 1) { + const result = throwIfError( + await CashWalletCutover.runPrimaryCashWalletCutoverBatch({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + workerId: "manual-local", + limit: ACCOUNT_COUNT, + lockStaleBefore: new Date(Date.now() - 300_000), + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + batches.push(result) + await logProgress(`batch ${i}: ${JSON.stringify(result)}`) + + const status = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + + if (result.failed > 0) { + console.log(JSON.stringify({ batches, status }, null, 2)) + throw new Error(`Batch ${i} failed`) + } + + if (Object.keys(status.countsByStatus).length === 1 && status.countsByStatus.complete) { + console.log(JSON.stringify({ batches, status }, null, 2)) + await logProgress(`all migrations complete after batch ${i}`) + return + } + + if (result.attempted === 0) { + console.log(JSON.stringify({ batches, status }, null, 2)) + throw new Error("No runnable migrations, but run is not complete") + } + } + + throw new Error("Exceeded maximum batch count") +} + +const complete = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.completePrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + actor: "manual-local", + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`completed lifecycle state=${result.state}`) +} + +const status = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) +} + +const resetConfig = async () => { + const manifest = await loadManifest() + const result = await CashWalletCutoverConfig.updateOne( + { _id: "cash_wallet_cutover" }, + { + $set: { + state: "pre", + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + updatedBy: "manual-local", + updatedAt: new Date(), + }, + $unset: { + scheduledAt: "", + startedAt: "", + completedAt: "", + pausedAt: "", + pauseReason: "", + }, + }, + { upsert: true }, + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`reset singleton cutover config to pre for ${manifest.runId}`) +} + +const verify = async () => { + const manifest = await loadManifest() + const rows = [] + + for (const target of manifest.accounts) { + const account = throwIfError( + await AccountsRepository().findById(target.accountId as AccountId), + ) + const usdBalance = throwIfError( + await getBalanceForWallet({ + walletId: target.legacyUsdWalletId as WalletId, + currency: WalletCurrency.Usd, + }), + ) + const usdtBalance = throwIfError( + await getBalanceForWallet({ + walletId: target.destinationUsdtWalletId as WalletId, + currency: WalletCurrency.Usdt, + }), + ) + + rows.push({ + index: target.index, + accountId: target.accountId, + expectedFundingCents: target.startingFundingCents, + defaultWalletId: account.defaultWalletId, + defaultIsDestinationUsdt: account.defaultWalletId === target.destinationUsdtWalletId, + legacyUsdWalletId: target.legacyUsdWalletId, + destinationUsdtWalletId: target.destinationUsdtWalletId, + legacyUsdBalanceCents: usdBalance.asCents(), + destinationUsdtBalanceMicros: usdtBalance.asSmallestUnits(), + }) + } + + const statusResult = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + + const result = { status: statusResult, accounts: rows } + await fs.writeFile(RESULTS_PATH, `${JSON.stringify(result, null, 2)}\n`) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`wrote verification results to ${RESULTS_PATH}`) +} + +const commands: Record Promise> = { + "create-accounts": createAccounts, + "complete-partials": completePartialAccounts, + fund: fundAccounts, + preview, + prepare, + start, + "run-batches": runBatches, + complete, + status, + "reset-config": resetConfig, + verify, +} + +setupMongoConnection() + .then(async (mongoose) => { + const command = process.argv.find((arg) => commands[arg]) + if (!command || !commands[command]) { + throw new Error(`Expected command: ${Object.keys(commands).join(", ")}`) + } + + await commands[command]() + await mongoose?.connection.close() + process.exit(0) + }) + .catch(async (error) => { + await logProgress(`ERROR ${error instanceof Error ? error.message : String(error)}`) + console.error(error) + process.exit(1) + }) diff --git a/operator-runs/eng-345-manual-347/task_plan.md b/operator-runs/eng-345-manual-347/task_plan.md new file mode 100644 index 000000000..a7240b9cf --- /dev/null +++ b/operator-runs/eng-345-manual-347/task_plan.md @@ -0,0 +1,70 @@ +# Task Plan: ENG-345 Fresh 7-Account Manual Cutover + +## Goal +Run the full cash-wallet cutover pipeline on the 7 fresh local accounts that were successfully created with legacy USD default wallets. + +## Current Phase +Phase 6 + +## Phases + +### Phase 1: Discovery +- [x] Find existing account creation/funding helpers +- [x] Confirm database and service config for the local run +- [x] Document reusable commands +- **Status:** complete + +### Phase 2: Test Data Setup +- [x] Create 7 new accounts +- [x] Capture account IDs and USD/USDT wallet IDs for 7 complete accounts +- [x] Set 7 complete accounts' `defaultWalletId` to legacy USD wallet ID +- [x] Leave all 7 legacy USD wallets at zero balance +- **Status:** complete + +### Phase 3: Cutover Pipeline +- [x] Preview run +- [x] Prepare run +- [x] Start run +- [x] Run batches until all migrations reach terminal state or failure +- [x] Complete lifecycle if all migrations complete +- **Status:** complete + +### Phase 4: Verification +- [x] Verify migration counts +- [x] Verify account default wallet pointers changed to USDT +- [x] Verify source/destination amounts for zero-balance accounts +- [x] Document any failures and recovery actions +- **Status:** complete + +### Phase 5: Report +- [x] Summarize setup, commands, and final status +- [x] Update session memory +- **Status:** complete + +### Phase 6: Reset and Prep Funded Rerun +- [x] Verify seven funded legacy USD balances +- [x] Reset seven account `defaultWalletId` values to legacy USD wallets +- [x] Create fresh cutover prep run for the same seven accounts +- [x] Verify migration prep count and account pointers +- **Status:** complete + +## Key Parameters +- Worktree: `/Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review` +- Planned cutoverVersion: `347` +- Planned runId: `manual-eng-347` +- Account count: `7` +- Funding: `7 x $0.00` + +## Decisions Made +| Decision | Rationale | +|----------|-----------| +| Use a fresh run/version instead of rewinding manual-eng-346 | manual-eng-346 already moved funds and completed; fresh run gives clean manual-test evidence | +| Convert test to 7 zero-balance accounts | Dread requested changing the plan to a 7-account cutover after IBEX write failures blocked creating/funding 10 accounts | +| Use `cutoverVersion=348`, `runId=manual-eng-348` for the funded rerun prep | Version 347 already completed as the zero-balance cutover, so a new run keeps evidence separated | + +## Errors Encountered +| Error | Attempt | Resolution | +|-------|---------|------------| +| Config loader tried to read `create-accounts` as a YAML file; `Account` model import was undefined | 1 | Patched operator script to locate command anywhere in argv and import `Account` from `@services/mongoose/schema`; rerun with command before `--configPath` | +| IBEX fetch error while creating the 8th account left 7 complete accounts and one partial account without wallets/default | 2 | Reconstructed a manifest for the 7 known-good accounts, excluded the partial account, and patched script to save/resume manifest incrementally | +| IBEX write path continued returning blank `FetchError` after cooldown for partial wallet creation and funding invoice creation | 3 | Stopped rather than running an incomplete/fabricated 10-account cutover; verified reads still work and documented resume state | diff --git a/operator-runs/eng-345-manual-347/verify-348-prep-results.json b/operator-runs/eng-345-manual-347/verify-348-prep-results.json new file mode 100644 index 000000000..cb35272d1 --- /dev/null +++ b/operator-runs/eng-345-manual-347/verify-348-prep-results.json @@ -0,0 +1,74 @@ +{ + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": { + "config": { + "state": "pre", + "updatedBy": "manual-local", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "updatedAt": "2026-05-23T14:49:36.246Z" + }, + "countsByStatus": { + "not_started": 7 + } + }, + "accounts": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + } + ] +} diff --git a/src/app/cash-wallet-cutover/amount-conversion.ts b/src/app/cash-wallet-cutover/amount-conversion.ts index f3610da41..fd6bd4e09 100644 --- a/src/app/cash-wallet-cutover/amount-conversion.ts +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -22,3 +22,33 @@ export const usdCentsToUsdtMicros = ( } 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/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts index af563fdd1..20d32ff3f 100644 --- a/src/app/cash-wallet-cutover/handlers.ts +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -9,6 +9,7 @@ import { recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, verifyCashWalletMigrationLegacyZero, @@ -28,10 +29,14 @@ type CashWalletMigrationHandlerServices = { readSourceBalanceUsdCents( migration: CashWalletMigration, ): Promise + readDestinationBalanceUsdtMicros( + migration: CashWalletMigration, + ): Promise } invoiceService: Parameters< typeof createCashWalletMigrationBalanceMoveInvoice - >[0]["invoiceService"] + >[0]["invoiceService"] & + Parameters[0]["invoiceService"] paymentService: Parameters< typeof sendCashWalletMigrationBalanceMovePayment >[0]["paymentService"] @@ -39,10 +44,13 @@ type CashWalletMigrationHandlerServices = { typeof verifyCashWalletMigrationBalanceMove >[0]["balanceVerifier"] feeService: { - readFeeAmountUsdCents( + readFeeAmountUsdtMicros( migration: CashWalletMigration, ): Promise } + treasuryService: { + getTreasuryWalletId(): Promise + } pointerService: Parameters< typeof flipCashWalletMigrationDefaultPointer >[0]["pointerService"] @@ -74,18 +82,33 @@ export const createCashWalletMigrationStepHandlers = ({ 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) => - createCashWalletMigrationBalanceMoveInvoice({ + 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, @@ -101,21 +124,32 @@ export const createCashWalletMigrationStepHandlers = ({ balanceVerifier: services.balanceVerifier, }), balance_move_verified: async (migration) => { - const feeAmountUsdCents = await services.feeService.readFeeAmountUsdCents(migration) - if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents + 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, - feeAmountUsdCents, + feeAmountUsdtMicros, }) }, - fee_reimbursement_invoice_created: (migration) => - sendCashWalletMigrationFeeReimbursementPayment({ + 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, - }), + treasuryWalletId, + }) + }, fee_reimbursement_sending: (migration) => markCashWalletMigrationFeeReimbursed({ migration, migrationsRepo }), fee_reimbursed: (migration) => diff --git a/src/app/cash-wallet-cutover/index.types.d.ts b/src/app/cash-wallet-cutover/index.types.d.ts index fdce0cf9a..3f16650b7 100644 --- a/src/app/cash-wallet-cutover/index.types.d.ts +++ b/src/app/cash-wallet-cutover/index.types.d.ts @@ -46,6 +46,7 @@ type CashWalletMigration = { status: CashWalletMigrationStatus sourceBalanceUsdCents?: string destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string feeAmountUsdCents?: string feeAmountUsdtMicros?: string balanceMoveInvoicePaymentRequest?: string diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts index a416e09a7..c937496f0 100644 --- a/src/app/cash-wallet-cutover/runner.ts +++ b/src/app/cash-wallet-cutover/runner.ts @@ -17,6 +17,14 @@ type CashWalletMigrationBatchRepository = { 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 = ( @@ -30,6 +38,22 @@ type CashWalletMigrationBatchResult = { skipped: number } +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, @@ -79,6 +103,16 @@ export const runCashWalletMigrationBatch = async ({ 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 } diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index b9e1be5d2..776149ba5 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -1,17 +1,25 @@ import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" -import { addInvoiceForRecipientForUsdWallet, getBalanceForWallet } from "@app/wallets" +import { + addInvoiceForRecipientForUsdWallet, + getBalanceForWallet, +} from "@app/wallets" +import { decodeInvoice } from "@domain/bitcoin/lightning" import { InvalidWalletId } from "@domain/errors" -import { USDAmount, WalletCurrency } from "@domain/shared" +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 type RuntimeServiceDependencies = { now?: () => Date @@ -19,24 +27,27 @@ type RuntimeServiceDependencies = { updateDefaultWalletId?: typeof updateDefaultWalletId getBalanceForWallet?: typeof getBalanceForWallet createInvoice?: typeof addInvoiceForRecipientForUsdWallet + createNoAmountInvoice?: typeof Ibex.addInvoice payInvoice?: typeof Ibex.payInvoice - getTransactionDetails?: typeof Ibex.getTransactionDetails accountsRepo?: Pick, "findById"> + getTreasuryWalletId?: () => Promise } const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount +const isUsdtAmount = (amount: unknown): amount is USDTAmount => + amount instanceof USDTAmount -const feeAmountUsdCentsFromNumber = ( - feeAmount: number | undefined, -): string | InvalidCashWalletCutoverAmountError => { - if (feeAmount === undefined || Number.isNaN(feeAmount) || feeAmount < 0) { - return new InvalidCashWalletCutoverAmountError("Invalid fee amount") - } - return Math.ceil(feeAmount * 100).toString() -} +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 numericFee = (value: unknown): number | undefined => - typeof value === "number" ? value : undefined + const decodedInvoice = decodeInvoice(invoiceString) + if (decodedInvoice instanceof Error) return decodedInvoice + + return decodedInvoice +} export const createCashWalletMigrationRuntimeServices = ( deps: RuntimeServiceDependencies = {}, @@ -45,8 +56,8 @@ export const createCashWalletMigrationRuntimeServices = ( const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet const invoiceForRecipient = deps.createInvoice ?? addInvoiceForRecipientForUsdWallet + const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice const payInvoice = deps.payInvoice ?? Ibex.payInvoice - const getTransactionDetails = deps.getTransactionDetails ?? Ibex.getTransactionDetails const accountsRepo = deps.accountsRepo ?? AccountsRepository() return { @@ -83,6 +94,19 @@ export const createCashWalletMigrationRuntimeServices = ( } 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: ({ @@ -99,18 +123,39 @@ export const createCashWalletMigrationRuntimeServices = ( amount: amount as FractionalCentAmount, memo, }), + createNoAmountInvoice: ({ + recipientWalletId, + memo, + }: { + recipientWalletId: WalletId + memo: string + }) => + noAmountInvoiceForRecipient({ + accountId: recipientWalletId, + 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 payInvoice({ accountId: senderWalletId as IbexAccountId, invoice: paymentRequest as Bolt11, + send, }) if (payment instanceof Error) return payment @@ -139,7 +184,7 @@ export const createCashWalletMigrationRuntimeServices = ( }, }, feeService: { - readFeeAmountUsdCents: async ( + readFeeAmountUsdtMicros: async ( migration: CashWalletMigration, ): Promise => { if (migration.balanceMovePaymentTransactionId === undefined) { @@ -148,16 +193,37 @@ export const createCashWalletMigrationRuntimeServices = ( ) } - const transaction = await getTransactionDetails( - migration.balanceMovePaymentTransactionId as IbexTransactionId, - ) - if (transaction instanceof Error) return transaction + 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", + ) + } - return feeAmountUsdCentsFromNumber( - numericFee(transaction.networkFee) ?? numericFee(transaction.fee), - ) + 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, diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts index 3206d4bb5..6bd152b8d 100644 --- a/src/app/cash-wallet-cutover/state-machine.ts +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -12,6 +12,7 @@ const transitions: Partial< balance_move_sent: ["balance_move_verified", "failed", "requires_operator_review"], balance_move_verified: [ "fee_reimbursement_invoice_created", + "fee_reimbursed", "failed", "requires_operator_review", ], diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 8e3111ec5..67b26250f 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,5 @@ import { assertCanTransition } from "./state-machine" -import { feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros } from "./amount-conversion" +import { usdCentsToUsdtMicros, usdtMicrosToUsdCentsCeil } from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -24,10 +24,18 @@ type CashWalletMigrationInvoiceService = { }): Promise } +type CashWalletMigrationNoAmountInvoiceService = { + createNoAmountInvoice(args: { + recipientWalletId: WalletId + memo: string + }): Promise +} + type CashWalletMigrationPaymentService = { payInvoice(args: { senderWalletId: WalletId paymentRequest: string + senderAmountUsdCents?: string }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> } @@ -114,10 +122,12 @@ 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 @@ -134,6 +144,7 @@ export const recordCashWalletMigrationBalance = async ({ patch: { sourceBalanceUsdCents, destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros, }, }) } @@ -156,9 +167,16 @@ export const sendCashWalletMigrationBalanceMovePayment = async ({ ) } + if (migration.sourceBalanceUsdCents === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "sourceBalanceUsdCents is required before balance move payment sending", + ) + } + const payment = await paymentService.payInvoice({ senderWalletId: migration.legacyUsdWalletId, paymentRequest: migration.balanceMoveInvoicePaymentRequest, + senderAmountUsdCents: migration.sourceBalanceUsdCents, }) if (payment instanceof Error) return payment @@ -241,7 +259,7 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ migrationsRepo, }: { migration: CashWalletMigration - invoiceService: CashWalletMigrationInvoiceService + invoiceService: CashWalletMigrationNoAmountInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository }): Promise => { const transition = assertCanTransition(migration.status, "invoice_created") @@ -253,9 +271,8 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ ) } - const invoice = await invoiceService.createInvoice({ + const invoice = await invoiceService.createNoAmountInvoice({ recipientWalletId: migration.destinationUsdtWalletId, - amount: migration.destinationAmountUsdtMicros, memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:balance-move`, }) if (invoice instanceof Error) return invoice @@ -277,15 +294,15 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ migration, invoiceService, migrationsRepo, - feeAmountUsdCents, + feeAmountUsdtMicros, }: { migration: CashWalletMigration invoiceService: CashWalletMigrationInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository - feeAmountUsdCents: string + feeAmountUsdtMicros: string }): Promise => { - const feeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) - if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + const feeAmountUsdCents = usdtMicrosToUsdCentsCeil(feeAmountUsdtMicros) + if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents const transition = assertCanTransition( migration.status, @@ -294,8 +311,8 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ if (transition instanceof Error) return transition const invoice = await invoiceService.createInvoice({ - recipientWalletId: migration.legacyUsdWalletId, - amount: feeAmountUsdCents, + recipientWalletId: migration.destinationUsdtWalletId, + amount: feeAmountUsdtMicros, memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:fee-reimbursement`, }) if (invoice instanceof Error) return invoice @@ -315,12 +332,37 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ }) } +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, migrationsRepo, }: { migration: CashWalletMigration + treasuryWalletId: WalletId paymentService: CashWalletMigrationPaymentService migrationsRepo: CashWalletMigrationTransitionRepository }): Promise => { @@ -334,7 +376,7 @@ export const sendCashWalletMigrationFeeReimbursementPayment = async ({ } const payment = await paymentService.payInvoice({ - senderWalletId: migration.destinationUsdtWalletId, + senderWalletId: treasuryWalletId, paymentRequest: migration.feeReimbursementInvoicePaymentRequest, }) if (payment instanceof Error) return payment 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 87eed84b2..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! 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/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/schema.graphql b/src/graphql/public/schema.graphql index e0bac3237..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 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/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/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index a2a6b8d1d..7db76ae4b 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -43,6 +43,11 @@ type LockMigrationArgs = { runId: string } +type MarkMigrationFailedArgs = Omit & { + error: Error + status: "failed" | "requires_operator_review" +} + const defaultConfig = (): CashWalletCutoverConfig => ({ state: "pre", cutoverVersion: 1, @@ -76,6 +81,7 @@ const resultToMigration = (record: CashWalletMigrationRecord): CashWalletMigrati status: record.status, sourceBalanceUsdCents: record.sourceBalanceUsdCents, destinationAmountUsdtMicros: record.destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros: record.destinationStartingBalanceUsdtMicros, feeAmountUsdCents: record.feeAmountUsdCents, feeAmountUsdtMicros: record.feeAmountUsdtMicros, balanceMoveInvoicePaymentRequest: record.balanceMoveInvoicePaymentRequest, @@ -238,6 +244,37 @@ export const CashWalletCutoverRepository = () => { } } + 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, @@ -285,6 +322,7 @@ export const CashWalletCutoverRepository = () => { transitionMigration, acquireMigrationLock, releaseMigrationLock, + markMigrationFailed, listRunnableMigrations, countByStatus, } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 18391f8e5..a15023362 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -651,6 +651,7 @@ const CashWalletMigrationSchema = new Schema({ status: { type: String, required: true, index: true }, sourceBalanceUsdCents: String, destinationAmountUsdtMicros: String, + destinationStartingBalanceUsdtMicros: String, feeAmountUsdCents: String, feeAmountUsdtMicros: String, balanceMoveInvoicePaymentRequest: String, diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index e3e25c34b..332c918f5 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -129,6 +129,7 @@ interface CashWalletMigrationRecord { status: CashWalletMigrationStatus sourceBalanceUsdCents?: string destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string feeAmountUsdCents?: string feeAmountUsdtMicros?: string balanceMoveInvoicePaymentRequest?: string diff --git a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts index 3d77174a3..db42bc5f0 100644 --- a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -1,6 +1,8 @@ import { + destinationShortfallUsdtMicros, feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros, + usdtMicrosToUsdCentsCeil, } from "@app/cash-wallet-cutover/amount-conversion" describe("cash wallet cutover amount conversion", () => { @@ -15,9 +17,34 @@ describe("cash wallet cutover amount conversion", () => { expect(feeUsdCentsToUsdtMicros("7")).toBe("70000") }) + it("rounds USDT micros up to USD cents for fee audit fields", () => { + expect(usdtMicrosToUsdCentsCeil("0")).toBe("0") + expect(usdtMicrosToUsdCentsCeil("1")).toBe("1") + expect(usdtMicrosToUsdCentsCeil("10000")).toBe("1") + expect(usdtMicrosToUsdCentsCeil("10001")).toBe("2") + }) + + it("computes destination USDT shortfall from the observed balance delta", () => { + expect( + destinationShortfallUsdtMicros({ + targetUsdtMicros: "10000000", + startingUsdtMicros: "5000000", + currentUsdtMicros: "14930000", + }), + ).toBe("70000") + expect( + destinationShortfallUsdtMicros({ + targetUsdtMicros: "10000000", + startingUsdtMicros: "5000000", + currentUsdtMicros: "15000000", + }), + ).toBe("0") + }) + it("rejects invalid or fractional cent inputs", () => { expect(usdCentsToUsdtMicros("1.5")).toBeInstanceOf(Error) expect(usdCentsToUsdtMicros("abc")).toBeInstanceOf(Error) expect(usdCentsToUsdtMicros("-1")).toBeInstanceOf(Error) + expect(usdtMicrosToUsdCentsCeil("1.5")).toBeInstanceOf(Error) }) }) diff --git a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts index f36dd5102..b807cd96f 100644 --- a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts @@ -25,6 +25,7 @@ describe("cash wallet migration step handlers", () => { }, balanceReader: { readSourceBalanceUsdCents: jest.fn(async () => "1234"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "5000000"), }, invoiceService: { createInvoice: jest.fn( @@ -34,6 +35,13 @@ describe("cash wallet migration step handlers", () => { paymentHash: "hash" as PaymentHash, }) as LnInvoice, ), + createNoAmountInvoice: jest.fn( + async () => + ({ + paymentRequest: "lnbc1-no-amount" as EncodedPaymentRequest, + paymentHash: "noAmountHash" as PaymentHash, + }) as LnInvoice, + ), }, paymentService: { payInvoice: jest.fn(async () => ({ @@ -44,7 +52,10 @@ describe("cash wallet migration step handlers", () => { verifyBalanceMove: jest.fn(async () => true), }, feeService: { - readFeeAmountUsdCents: jest.fn(async () => "7"), + readFeeAmountUsdtMicros: jest.fn(async () => "70000"), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(async () => "treasury-wallet-id" as WalletId), }, pointerService: { flipDefaultWallet: jest.fn(async () => ({ @@ -87,8 +98,134 @@ describe("cash wallet migration step handlers", () => { expect(services.balanceReader.readSourceBalanceUsdCents).toHaveBeenCalledWith( migration("provisioned"), ) - expect(services.feeService.readFeeAmountUsdCents).toHaveBeenCalledWith( + expect(services.balanceReader.readDestinationBalanceUsdtMicros).toHaveBeenCalledWith( + migration("provisioned"), + ) + expect(services.feeService.readFeeAmountUsdtMicros).toHaveBeenCalledWith( + migration("balance_move_verified"), + ) + }) + + it("skips balance move and fee reimbursement for zero-balance migrations", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to }) => migration(to)), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "0"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "0"), + }, + invoiceService: { + createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), + }, + paymentService: { + payInvoice: jest.fn(), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(), + }, + feeService: { + readFeeAmountUsdtMicros: jest.fn(), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + const result = await handlers.balance_read({ + ...migration("balance_read"), + sourceBalanceUsdCents: "0", + destinationAmountUsdtMicros: "0", + }) + + expect(result).toMatchObject({ status: "pointer_flipped" }) + expect(services.pointerService.flipDefaultWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationWalletId: "usdt-wallet-id", + }) + expect(services.invoiceService.createInvoice).not.toHaveBeenCalled() + expect(services.invoiceService.createNoAmountInvoice).not.toHaveBeenCalled() + expect(services.paymentService.payInvoice).not.toHaveBeenCalled() + expect(services.balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() + expect(services.feeService.readFeeAmountUsdtMicros).not.toHaveBeenCalled() + expect(services.treasuryService.getTreasuryWalletId).not.toHaveBeenCalled() + }) + + it("skips fee reimbursement invoice creation when the destination shortfall is zero", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to, patch }) => ({ + ...migration(to), + ...patch, + })), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "1000"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "0"), + }, + invoiceService: { + createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), + }, + paymentService: { + payInvoice: jest.fn(), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(async () => true), + }, + feeService: { + readFeeAmountUsdtMicros: jest.fn(async () => "0"), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + const result = await handlers.balance_move_verified( migration("balance_move_verified"), ) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }) + expect(services.invoiceService.createInvoice).not.toHaveBeenCalled() + expect(services.treasuryService.getTreasuryWalletId).not.toHaveBeenCalled() }) }) diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts index 2a6e9d17c..316fa2443 100644 --- a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts @@ -33,6 +33,10 @@ describe("cash wallet cutover migration state machine", () => { ).toBeInstanceOf(Error) }) + it("allows skipping fee reimbursement when there is no shortfall", () => { + expect(assertCanTransition("balance_move_verified", "fee_reimbursed")).toBe(true) + }) + it("resumes from stored checkpoint without repeating completed side effects", () => { expect(nextResumeStatus("invoice_created")).toBe("invoice_created") expect(nextResumeStatus("balance_move_sent")).toBe("balance_move_sent") diff --git a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts index 4cfd5e1f6..32811fb58 100644 --- a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts @@ -4,6 +4,7 @@ jest.mock("@app/accounts", () => ({ })) jest.mock("@app/wallets", () => ({ addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), getBalanceForWallet: jest.fn(), })) jest.mock("@services/mongoose", () => ({ @@ -41,16 +42,21 @@ describe("primary cash wallet cutover orchestrator", () => { transitionMigration: jest.fn(async () => migration("started")), listRunnableMigrations: jest.fn(async () => [started]), acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(), releaseMigrationLock: jest.fn(async () => locked), } const runtimeServices = { now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), provisioningService: { ensureDestinationWallet: jest.fn() }, - balanceReader: { readSourceBalanceUsdCents: jest.fn() }, - invoiceService: { createInvoice: jest.fn() }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(), + readDestinationBalanceUsdtMicros: jest.fn(), + }, + invoiceService: { createInvoice: jest.fn(), createNoAmountInvoice: jest.fn() }, paymentService: { payInvoice: jest.fn() }, balanceVerifier: { verifyBalanceMove: jest.fn() }, - feeService: { readFeeAmountUsdCents: jest.fn() }, + feeService: { readFeeAmountUsdtMicros: jest.fn() }, + treasuryService: { getTreasuryWalletId: jest.fn() }, pointerService: { flipDefaultWallet: jest.fn() }, legacyWalletVerifier: { verifyLegacyWalletZero: jest.fn() }, } diff --git a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts index ab33cc4a2..6987c3873 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -26,6 +26,7 @@ describe("cash wallet migration batch runner", () => { const migrationsRepo = { listRunnableMigrations: jest.fn(async () => runnable), acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(), releaseMigrationLock: jest.fn(async () => locked), } const executor = jest.fn(async () => completedStep) @@ -67,6 +68,7 @@ describe("cash wallet migration batch runner", () => { const migrationsRepo = { listRunnableMigrations: jest.fn(async () => [migration("started", "migration-1")]), acquireMigrationLock: jest.fn(async () => lockError), + markMigrationFailed: jest.fn(), releaseMigrationLock: jest.fn(), } const executor = jest.fn() @@ -92,6 +94,10 @@ describe("cash wallet migration batch runner", () => { const migrationsRepo = { listRunnableMigrations: jest.fn(async () => [locked]), acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(async () => ({ + ...locked, + status: "requires_operator_review" as const, + })), releaseMigrationLock: jest.fn(async () => locked), } const executor = jest.fn(async () => executionError) @@ -107,11 +113,14 @@ describe("cash wallet migration batch runner", () => { }) expect(result).toEqual({ attempted: 1, advanced: 0, failed: 1, skipped: 0 }) - expect(migrationsRepo.releaseMigrationLock).toHaveBeenCalledWith({ + expect(migrationsRepo.markMigrationFailed).toHaveBeenCalledWith({ id: "migration-1", workerId: "worker-1", cutoverVersion: 7, runId: "run-7", + error: executionError, + status: "requires_operator_review", }) + expect(migrationsRepo.releaseMigrationLock).not.toHaveBeenCalled() }) }) diff --git a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts index b89a49e38..1a83083b1 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -1,5 +1,5 @@ import { CouldNotUpdateError } from "@domain/errors" -import { USDAmount, WalletCurrency } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" jest.mock("@app/accounts", () => ({ @@ -8,6 +8,7 @@ jest.mock("@app/accounts", () => ({ })) jest.mock("@app/wallets", () => ({ addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), getBalanceForWallet: jest.fn(), })) jest.mock("@services/mongoose", () => ({ @@ -16,12 +17,21 @@ jest.mock("@services/mongoose", () => ({ jest.mock("@services/ibex/client", () => ({ __esModule: true, default: { + addInvoice: jest.fn(), payInvoice: jest.fn(), getTransactionDetails: jest.fn(), }, })) import { createCashWalletMigrationRuntimeServices } from "@app/cash-wallet-cutover/runtime-services" +import Ibex from "@services/ibex/client" + +const ibexAddInvoiceResponse = { + invoice: { + bolt11: + "lnbc140n1p3k6yzupp53p305l6de6s9xw2j0qaa59pl7lahys4f2uavwncll9z2vq0syvvsdqqcqzpgxqzuysp5mdgsaa734eg7srwx92rsn3hyc4xzt5tphfpadl5c6fanhppwaz4s9qyyssqm6yhnnhl8jltwjtclzk4g7nxr99ycsp4sqd6vksevqh06h8l3gm5fdhtl59t6g3fsalv26sj5zvwhxwlghc9wcfgkrjrtuh4873ejnspc5xksy", + }, +} const migration = (patch: Partial = {}): CashWalletMigration => ({ id: "migration-id", @@ -54,6 +64,23 @@ describe("cash wallet migration runtime services", () => { }) }) + it("reads destination USDT balances as micros", async () => { + const deps = { + getBalanceForWallet: jest.fn(async () => USDTAmount.smallestUnits("5000000")), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = + await services.balanceReader.readDestinationBalanceUsdtMicros(migration()) + + expect(result).toBe("5000000") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "usdt-wallet-id", + currency: WalletCurrency.Usdt, + }) + }) + it("ensures the expected destination USDT wallet exists", async () => { const deps = { addWalletIfNonexistent: jest.fn(async () => ({ @@ -76,7 +103,27 @@ describe("cash wallet migration runtime services", () => { }) }) - it("extracts the IBEX transaction id after paying an invoice", async () => { + it("creates no-amount destination invoices through IBEX", async () => { + jest.mocked(Ibex.addInvoice).mockResolvedValue(ibexAddInvoiceResponse as never) + + const services = createCashWalletMigrationRuntimeServices() + + const result = await services.invoiceService.createNoAmountInvoice({ + recipientWalletId: "usdt-wallet-id" as WalletId, + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + }) + + expect(result).toMatchObject({ + paymentRequest: ibexAddInvoiceResponse.invoice.bolt11, + }) + expect(Ibex.addInvoice).toHaveBeenCalledWith({ + accountId: "usdt-wallet-id", + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + expiration: 900, + }) + }) + + it("extracts the IBEX transaction id after paying an invoice with a sender-side USD cap", async () => { const deps = { payInvoice: jest.fn(async () => ({ transaction: { id: "ibex-tx-id" }, @@ -88,13 +135,15 @@ describe("cash wallet migration runtime services", () => { const result = await services.paymentService.payInvoice({ senderWalletId: "legacy-usd-wallet-id" as WalletId, paymentRequest: "lnbc1payment", + senderAmountUsdCents: "1000", }) expect(result).toEqual({ transactionId: "ibex-tx-id" }) - expect(deps.payInvoice).toHaveBeenCalledWith({ - accountId: "legacy-usd-wallet-id", - invoice: "lnbc1payment", - }) + const paymentArgs = deps.payInvoice.mock.calls[0][0] + expect(paymentArgs.accountId).toBe("legacy-usd-wallet-id") + expect(paymentArgs.invoice).toBe("lnbc1payment") + expect(paymentArgs.send).toBeInstanceOf(USDAmount) + expect(paymentArgs.send.asCents()).toBe("1000") }) it("returns an error when IBEX payment response has no transaction id", async () => { @@ -110,21 +159,26 @@ describe("cash wallet migration runtime services", () => { expect(result).toBeInstanceOf(Error) }) - it("reads the balance move fee as rounded-up USD cents", async () => { + it("computes the fee reimbursement as the exact destination USDT shortfall", async () => { const deps = { - getTransactionDetails: jest.fn(async () => ({ - networkFee: 0.077, - })), + getBalanceForWallet: jest.fn(async () => USDTAmount.smallestUnits("14930000")), } const services = createCashWalletMigrationRuntimeServices(deps) - const result = await services.feeService.readFeeAmountUsdCents( - migration({ balanceMovePaymentTransactionId: "ibex-tx-id" }), + const result = await services.feeService.readFeeAmountUsdtMicros( + migration({ + balanceMovePaymentTransactionId: "ibex-tx-id", + destinationAmountUsdtMicros: "10000000", + destinationStartingBalanceUsdtMicros: "5000000", + }), ) - expect(result).toBe("8") - expect(deps.getTransactionDetails).toHaveBeenCalledWith("ibex-tx-id") + expect(result).toBe("70000") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "usdt-wallet-id", + currency: WalletCurrency.Usdt, + }) }) it("flips the default wallet and returns the previous default wallet id", async () => { diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 6681a3e72..1feb58287 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -11,6 +11,7 @@ import { recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, verifyCashWalletMigrationLegacyZero, @@ -136,6 +137,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("balance_read"), sourceBalanceUsdCents: "1234", destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", })), } @@ -143,12 +145,14 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("provisioned"), migrationsRepo, sourceBalanceUsdCents: "1234", + destinationStartingBalanceUsdtMicros: "5000000", }) expect(result).toMatchObject({ status: "balance_read", sourceBalanceUsdCents: "1234", destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ id: "migration-id", @@ -159,6 +163,7 @@ describe("cash wallet migration worker checkpoints", () => { patch: { sourceBalanceUsdCents: "1234", destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", }, }) }) @@ -178,7 +183,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("creates a balance move invoice on the destination wallet", async () => { + it("creates a no-amount balance move invoice on the destination wallet", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("invoice_created"), @@ -191,7 +196,7 @@ describe("cash wallet migration worker checkpoints", () => { paymentHash: "paymentHash" as PaymentHash, } as LnInvoice const invoiceService = { - createInvoice: jest.fn(async () => invoice), + createNoAmountInvoice: jest.fn(async () => invoice), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -208,9 +213,8 @@ describe("cash wallet migration worker checkpoints", () => { balanceMoveInvoicePaymentRequest: "lnbc1balance-move", balanceMoveInvoicePaymentHash: "paymentHash", }) - expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + expect(invoiceService.createNoAmountInvoice).toHaveBeenCalledWith({ recipientWalletId: "usdt-wallet-id", - amount: "12340000", memo: "cash-wallet-cutover:run-7:migration-id:balance-move", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ @@ -231,7 +235,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(), } const invoiceService = { - createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -241,7 +245,7 @@ describe("cash wallet migration worker checkpoints", () => { }) expect(result).toBeInstanceOf(Error) - expect(invoiceService.createInvoice).not.toHaveBeenCalled() + expect(invoiceService.createNoAmountInvoice).not.toHaveBeenCalled() expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) @@ -251,7 +255,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(), } const invoiceService = { - createInvoice: jest.fn(async () => error), + createNoAmountInvoice: jest.fn(async () => error), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -267,7 +271,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("sends the balance move payment from the legacy wallet", async () => { + it("sends the balance move payment from the legacy wallet capped to the recorded source balance", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("balance_move_sending"), @@ -284,6 +288,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: { ...migration("invoice_created"), balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + sourceBalanceUsdCents: "1000", }, paymentService, migrationsRepo, @@ -296,6 +301,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(paymentService.payInvoice).toHaveBeenCalledWith({ senderWalletId: "legacy-usd-wallet-id", paymentRequest: "lnbc1balance-move", + senderAmountUsdCents: "1000", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ id: "migration-id", @@ -328,6 +334,28 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + it("rejects balance move payment sending when the source balance is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + it("marks the balance move payment as sent after a transaction id is recorded", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ @@ -446,12 +474,12 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("creates a fee reimbursement invoice on the legacy wallet", async () => { + it("creates a fee reimbursement invoice on the destination wallet for the exact USDT shortfall", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("fee_reimbursement_invoice_created"), - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", })), @@ -468,19 +496,19 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70001", }) expect(result).toMatchObject({ status: "fee_reimbursement_invoice_created", - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", }) expect(invoiceService.createInvoice).toHaveBeenCalledWith({ - recipientWalletId: "legacy-usd-wallet-id", - amount: "7", + recipientWalletId: "usdt-wallet-id", + amount: "70001", memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ @@ -490,8 +518,8 @@ describe("cash wallet migration worker checkpoints", () => { cutoverVersion: 7, runId: "run-7", patch: { - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", }, @@ -510,7 +538,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "0.07", + feeAmountUsdtMicros: "0.07", }) expect(result).toBeInstanceOf(Error) @@ -531,14 +559,46 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", }) expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("sends the fee reimbursement payment from the destination wallet", async () => { + it("skips fee reimbursement when there is no destination shortfall", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursed"), + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + })), + } + + const result = await skipCashWalletMigrationFeeReimbursement({ + migration: migration("balance_move_verified"), + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_verified", + to: "fee_reimbursed", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }, + }) + }) + + it("sends the fee reimbursement payment from the treasury wallet", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("fee_reimbursement_sending"), @@ -556,6 +616,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("fee_reimbursement_invoice_created"), feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", }, + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -565,7 +626,7 @@ describe("cash wallet migration worker checkpoints", () => { feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", }) expect(paymentService.payInvoice).toHaveBeenCalledWith({ - senderWalletId: "usdt-wallet-id", + senderWalletId: "treasury-wallet-id", paymentRequest: "lnbc1fee-reimbursement", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ diff --git a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts new file mode 100644 index 000000000..66a2fceea --- /dev/null +++ b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts @@ -0,0 +1,81 @@ +jest.mock("@services/mongoose/cash-wallet-cutover", () => ({ + CashWalletCutoverRepository: jest.fn(), +})) + +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" +import CashWalletCutoverUpdateMutation from "@graphql/admin/root/mutation/cash-wallet-cutover-update" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +describe("cash wallet cutover GraphQL surface", () => { + const updatedAt = new Date("2026-05-22T12:00:00Z") + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns the public cutover flag state", async () => { + const getConfig = jest.fn(async () => ({ + state: "in_progress" as const, + cutoverVersion: 7, + runId: "run-7", + scheduledAt: new Date("2026-05-22T13:00:00Z"), + updatedAt, + })) + jest.mocked(CashWalletCutoverRepository).mockReturnValue({ getConfig } as never) + + const result = await CashWalletCutoverQuery.resolve?.( + undefined, + {}, + {} as GraphQLPublicContext, + {} as never, + ) + + expect(result).toMatchObject({ + state: "in_progress", + cutoverVersion: 7, + runId: "run-7", + scheduledAt: new Date("2026-05-22T13:00:00Z"), + }) + }) + + it("lets admins mutate the cutover flag", async () => { + const updateConfig = jest.fn(async () => ({ + state: "in_progress" as const, + cutoverVersion: 8, + runId: "run-8", + updatedBy: "admin-user-id", + updatedAt, + })) + jest.mocked(CashWalletCutoverRepository).mockReturnValue({ updateConfig } as never) + + const result = await CashWalletCutoverUpdateMutation.resolve?.( + undefined, + { + input: { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + }, + { user: { id: "admin-user-id" } } as GraphQLAdminContext, + {} as never, + ) + + expect(updateConfig).toHaveBeenCalledWith( + { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + "admin-user-id", + ) + expect(result).toMatchObject({ + errors: [], + cashWalletCutover: { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + }) + }) +}) diff --git a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts index e036d25bb..fcb76e7de 100644 --- a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -1,3 +1,4 @@ +import { CouldNotUpdateError } from "@domain/errors" import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" import { CashWalletCutoverConfig, CashWalletMigration } from "@services/mongoose/schema" @@ -191,4 +192,51 @@ describe("CashWalletCutoverRepository", () => { expect(limit).toHaveBeenCalledWith(10) expect(result).toEqual([]) }) + + it("marks migration failures durably and clears the worker lock", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "requires_operator_review", + idempotencyKey: "run-2:account-id", + attempts: 2, + lastError: "execution failed", + lockedAt: null, + lockedBy: null, + updatedAt, + } as never) + + const error = new CouldNotUpdateError("execution failed") + const result = await repo.markMigrationFailed({ + id: "migration-id", + workerId: "worker-1", + cutoverVersion: 2, + runId: "run-2", + status: "requires_operator_review", + error, + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "migration-id", lockedBy: "worker-1", cutoverVersion: 2, runId: "run-2" }, + expect.objectContaining({ + $set: expect.objectContaining({ + status: "requires_operator_review", + lastError: "execution failed", + lockedAt: null, + lockedBy: null, + }), + $inc: { attempts: 1 }, + }), + { new: true }, + ) + expect(result).toMatchObject({ + status: "requires_operator_review", + attempts: 2, + lastError: "execution failed", + }) + }) }) diff --git a/yarn.lock b/yarn.lock index ec1990392..8212e936f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,39 +14,39 @@ resolved "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz" integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== -"@apollo/client@~3.2.5 || ~3.3.0 || ~3.4.0 || ~3.5.0 || ~3.6.0 || ~3.7.0 || ~3.8.0 || ~3.9.0 || ~3.10.0 || ~3.11.0 || ~3.12.0 || ~3.13.0": - version "3.13.9" - resolved "https://registry.npmjs.org/@apollo/client/-/client-3.13.9.tgz" - integrity sha512-RStSzQfL1XwL6/NWd7W8avhGQYTgPCtJ+qHkkTTSj9Upp3VVm6Oppv81YWdXG1FgEpDPW4hvCrTUELdcC4inCQ== +"@apollo/client@3.8.3": + version "3.8.3" + resolved "https://registry.npmjs.org/@apollo/client/-/client-3.8.3.tgz" + integrity sha512-mK86JM6hCpMEBGDgdO9U8ZYS8r9lPjXE1tVGpJMdSFUsIcXpmEfHUAbbFpPtYmxn8Qa7XsYy0dwDaDhpf4UUPw== dependencies: "@graphql-typed-document-node/core" "^3.1.1" - "@wry/caches" "^1.0.0" + "@wry/context" "^0.7.3" "@wry/equality" "^0.5.6" - "@wry/trie" "^0.5.0" + "@wry/trie" "^0.4.3" graphql-tag "^2.12.6" hoist-non-react-statics "^3.3.2" - optimism "^0.18.0" + optimism "^0.17.5" prop-types "^15.7.2" - rehackt "^0.1.0" + response-iterator "^0.2.6" symbol-observable "^4.0.0" ts-invariant "^0.10.3" tslib "^2.3.0" zen-observable-ts "^1.2.5" -"@apollo/client@3.8.3": - version "3.8.3" - resolved "https://registry.npmjs.org/@apollo/client/-/client-3.8.3.tgz" - integrity sha512-mK86JM6hCpMEBGDgdO9U8ZYS8r9lPjXE1tVGpJMdSFUsIcXpmEfHUAbbFpPtYmxn8Qa7XsYy0dwDaDhpf4UUPw== +"@apollo/client@~3.2.5 || ~3.3.0 || ~3.4.0 || ~3.5.0 || ~3.6.0 || ~3.7.0 || ~3.8.0 || ~3.9.0 || ~3.10.0 || ~3.11.0 || ~3.12.0 || ~3.13.0": + version "3.13.9" + resolved "https://registry.npmjs.org/@apollo/client/-/client-3.13.9.tgz" + integrity sha512-RStSzQfL1XwL6/NWd7W8avhGQYTgPCtJ+qHkkTTSj9Upp3VVm6Oppv81YWdXG1FgEpDPW4hvCrTUELdcC4inCQ== dependencies: "@graphql-typed-document-node/core" "^3.1.1" - "@wry/context" "^0.7.3" + "@wry/caches" "^1.0.0" "@wry/equality" "^0.5.6" - "@wry/trie" "^0.4.3" + "@wry/trie" "^0.5.0" graphql-tag "^2.12.6" hoist-non-react-statics "^3.3.2" - optimism "^0.17.5" + optimism "^0.18.0" prop-types "^15.7.2" - response-iterator "^0.2.6" + rehackt "^0.1.0" symbol-observable "^4.0.0" ts-invariant "^0.10.3" tslib "^2.3.0" @@ -213,7 +213,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-crypto/sha256-js@^5.2.0", "@aws-crypto/sha256-js@5.2.0": +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz" integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== @@ -229,7 +229,7 @@ dependencies: tslib "^2.6.2" -"@aws-crypto/util@^5.2.0", "@aws-crypto/util@5.2.0": +"@aws-crypto/util@5.2.0", "@aws-crypto/util@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz" integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== @@ -706,7 +706,7 @@ "@smithy/types" "^4.11.0" tslib "^2.6.2" -"@aws-sdk/types@^3.222.0", "@aws-sdk/types@3.968.0": +"@aws-sdk/types@3.968.0", "@aws-sdk/types@^3.222.0": version "3.968.0" resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-3.968.0.tgz" integrity sha512-Wuumj/1cuiuXTMdHmvH88zbEl+5Pw++fOFQuMCF4yP0R+9k1lwX8rVst+oy99xaxtdluJZXrsccoZoA67ST1Ow== @@ -798,7 +798,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== -"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.0", "@babel/core@^7.12.3", "@babel/core@^7.13.0", "@babel/core@^7.22.20", "@babel/core@^7.23.9", "@babel/core@^7.27.4", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.8.0": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.22.20", "@babel/core@^7.23.9", "@babel/core@^7.27.4": version "7.28.5" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== @@ -1787,7 +1787,7 @@ "@firebase/util" "1.9.3" tslib "^2.1.0" -"@firebase/database-types@^0.10.4", "@firebase/database-types@0.10.4": +"@firebase/database-types@0.10.4", "@firebase/database-types@^0.10.4": version "0.10.4" resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz" integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ== @@ -1857,16 +1857,16 @@ resolved "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz" integrity sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA== -"@google-cloud/promisify@^3.0.0": - version "3.0.1" - resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-3.0.1.tgz" - integrity sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA== - "@google-cloud/promisify@<4.1.0": version "4.0.0" resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz" integrity sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g== +"@google-cloud/promisify@^3.0.0": + version "3.0.1" + resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-3.0.1.tgz" + integrity sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA== + "@google-cloud/storage@^6.9.5": version "6.12.0" resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-6.12.0.tgz" @@ -1953,6 +1953,14 @@ tslib "^2.4.0" unixify "1.0.0" +"@graphql-tools/merge@8.3.1": + version "8.3.1" + resolved "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.3.1.tgz" + integrity sha512-BMm99mqdNZbEYeTPK3it9r9S6rsZsQKtlqJsSBknAclXq2pGEfOxjcIZi+kBSkHZKPKCRrYDd5vY0+rUmIHVLg== + dependencies: + "@graphql-tools/utils" "8.9.0" + tslib "^2.4.0" + "@graphql-tools/merge@^8.1.2", "@graphql-tools/merge@^8.4.1": version "8.4.2" resolved "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz" @@ -1969,14 +1977,6 @@ "@graphql-tools/utils" "^10.9.1" tslib "^2.4.0" -"@graphql-tools/merge@8.3.1": - version "8.3.1" - resolved "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.3.1.tgz" - integrity sha512-BMm99mqdNZbEYeTPK3it9r9S6rsZsQKtlqJsSBknAclXq2pGEfOxjcIZi+kBSkHZKPKCRrYDd5vY0+rUmIHVLg== - dependencies: - "@graphql-tools/utils" "8.9.0" - tslib "^2.4.0" - "@graphql-tools/mock@^8.1.2": version "8.7.20" resolved "https://registry.npmjs.org/@graphql-tools/mock/-/mock-8.7.20.tgz" @@ -1994,16 +1994,7 @@ dependencies: tslib "^2.4.0" -"@graphql-tools/schema@^10.0.25": - version "10.0.25" - resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz" - integrity sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw== - dependencies: - "@graphql-tools/merge" "^9.1.1" - "@graphql-tools/utils" "^10.9.1" - tslib "^2.4.0" - -"@graphql-tools/schema@^8.0.0", "@graphql-tools/schema@^8.5.1", "@graphql-tools/schema@8.5.1": +"@graphql-tools/schema@8.5.1", "@graphql-tools/schema@^8.0.0", "@graphql-tools/schema@^8.5.1": version "8.5.1" resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.5.1.tgz" integrity sha512-0Esilsh0P/qYcB5DKQpiKeQs/jevzIadNTaT0jeWklPMwNbT7yMX4EqZany7mbeRRlSRwMzNzL5olyFdffHBZg== @@ -2013,17 +2004,16 @@ tslib "^2.4.0" value-or-promise "1.0.11" -"@graphql-tools/schema@^9.0.1": - version "9.0.19" - resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz" - integrity sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w== +"@graphql-tools/schema@^10.0.25": + version "10.0.25" + resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz" + integrity sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw== dependencies: - "@graphql-tools/merge" "^8.4.1" - "@graphql-tools/utils" "^9.2.1" + "@graphql-tools/merge" "^9.1.1" + "@graphql-tools/utils" "^10.9.1" tslib "^2.4.0" - value-or-promise "^1.0.12" -"@graphql-tools/schema@^9.0.18": +"@graphql-tools/schema@^9.0.1", "@graphql-tools/schema@^9.0.18": version "9.0.19" resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz" integrity sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w== @@ -2033,6 +2023,13 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/utils@8.9.0": + version "8.9.0" + resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.9.0.tgz" + integrity sha512-pjJIWH0XOVnYGXCqej8g/u/tsfV4LvLlj0eATKQu5zwnxd/TiTHq7Cg313qUPTFFHZ3PP5wJ15chYVtLDwaymg== + dependencies: + tslib "^2.4.0" + "@graphql-tools/utils@^10.9.1": version "10.9.1" resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.9.1.tgz" @@ -2052,13 +2049,6 @@ "@graphql-typed-document-node/core" "^3.1.1" tslib "^2.4.0" -"@graphql-tools/utils@8.9.0": - version "8.9.0" - resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.9.0.tgz" - integrity sha512-pjJIWH0XOVnYGXCqej8g/u/tsfV4LvLlj0eATKQu5zwnxd/TiTHq7Cg313qUPTFFHZ3PP5wJ15chYVtLDwaymg== - dependencies: - tslib "^2.4.0" - "@graphql-tools/webpack-loader-runtime@7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@graphql-tools/webpack-loader-runtime/-/webpack-loader-runtime-7.0.0.tgz" @@ -2078,6 +2068,14 @@ resolved "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== +"@grpc/grpc-js@1.9.4": + version "1.9.4" + resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.4.tgz" + integrity sha512-oEnzYiDuEsBydZBtP84BkpduLsE1nSAO4KrhTLHRzNrIQE647fhchmosTQsJdCo8X9zBBt+l5+fNk+m/yCFJ/Q== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + "@grpc/grpc-js@^1.9.3": version "1.14.0" resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz" @@ -2094,25 +2092,17 @@ "@grpc/proto-loader" "^0.7.0" "@types/node" ">=12.12.47" -"@grpc/grpc-js@1.9.4": - version "1.9.4" - resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.4.tgz" - integrity sha512-oEnzYiDuEsBydZBtP84BkpduLsE1nSAO4KrhTLHRzNrIQE647fhchmosTQsJdCo8X9zBBt+l5+fNk+m/yCFJ/Q== - dependencies: - "@grpc/proto-loader" "^0.7.8" - "@types/node" ">=12.12.47" - -"@grpc/proto-loader@^0.7.0", "@grpc/proto-loader@^0.7.9": - version "0.7.15" - resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz" - integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== +"@grpc/proto-loader@0.7.10": + version "0.7.10" + resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.10.tgz" + integrity sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ== dependencies: lodash.camelcase "^4.3.0" long "^5.0.0" - protobufjs "^7.2.5" + protobufjs "^7.2.4" yargs "^17.7.2" -"@grpc/proto-loader@^0.7.8": +"@grpc/proto-loader@^0.7.0", "@grpc/proto-loader@^0.7.8", "@grpc/proto-loader@^0.7.9": version "0.7.15" resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz" integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== @@ -2132,16 +2122,6 @@ protobufjs "^7.5.3" yargs "^17.7.2" -"@grpc/proto-loader@0.7.10": - version "0.7.10" - resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.10.tgz" - integrity sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.2.4" - yargs "^17.7.2" - "@hono/node-server@^1.19.9": version "1.19.9" resolved "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz" @@ -2354,13 +2334,6 @@ strip-ansi "^6.0.0" v8-to-istanbul "^9.0.1" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jest/schemas@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz" @@ -2368,6 +2341,13 @@ dependencies: "@sinclair/typebox" "^0.34.0" +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + "@jest/source-map@^29.6.3": version "29.6.3" resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz" @@ -2439,19 +2419,7 @@ slash "^3.0.0" write-file-atomic "^5.0.1" -"@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== - dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jest/types@^30.0.0", "@jest/types@30.2.0": +"@jest/types@30.2.0", "@jest/types@^30.0.0": version "30.2.0" resolved "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz" integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== @@ -2464,6 +2432,18 @@ "@types/yargs" "^17.0.33" chalk "^4.1.2" +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@josephg/resolvable@^1.0.0": version "1.0.1" resolved "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz" @@ -2495,14 +2475,6 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -2511,6 +2483,14 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@js-sdsl/ordered-map@^4.4.2": version "4.4.2" resolved "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz" @@ -2620,13 +2600,6 @@ resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz" integrity sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w== -"@noble/curves@~1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz" - integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA== - dependencies: - "@noble/hashes" "1.3.1" - "@noble/curves@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz" @@ -2634,20 +2607,12 @@ dependencies: "@noble/hashes" "1.3.2" -"@noble/hashes@^1.2.0": - version "1.8.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@noble/hashes@~1.3.0": - version "1.3.3" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz" - integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== - -"@noble/hashes@~1.3.1": - version "1.3.3" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz" - integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== +"@noble/curves@~1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz" + integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA== + dependencies: + "@noble/hashes" "1.3.1" "@noble/hashes@1.3.1": version "1.3.1" @@ -2659,6 +2624,16 @@ resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/hashes@^1.2.0": + version "1.8.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1": + version "1.3.3" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz" + integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -2667,7 +2642,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -2685,14 +2660,14 @@ resolved "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz" integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== -"@opentelemetry/api-logs@>=0.39.1", "@opentelemetry/api-logs@0.43.0": +"@opentelemetry/api-logs@0.43.0": version "0.43.0" resolved "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.43.0.tgz" integrity sha512-0CXMOYPXgAdLM2OzVkiUfAL6QQwWVhnMfUXCqLsITY42FZ9TxAhZIHkoc4mfVxvPuXsBnRYGR8UQZX86p87z4A== dependencies: "@opentelemetry/api" "^1.0.0" -"@opentelemetry/api@^1.0.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.6.0", "@opentelemetry/api@>=1.0.0 <1.10.0", "@opentelemetry/api@>=1.0.0 <1.7.0", "@opentelemetry/api@>=1.3.0 <1.10.0", "@opentelemetry/api@>=1.3.0 <1.7.0", "@opentelemetry/api@>=1.4.0 <1.7.0": +"@opentelemetry/api@^1.0.0", "@opentelemetry/api@^1.6.0": version "1.6.0" resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.6.0.tgz" integrity sha512-OWlrQAnWn9577PhVgqjUvMr1pg57Bc4jv0iL4w0PRuOSRvq67rvHW9Ie/dZVMvCzhSCB+UxhcY/PmCmFj33Q+g== @@ -2702,13 +2677,6 @@ resolved "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz" integrity sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA== -"@opentelemetry/core@^1.17.0", "@opentelemetry/core@1.30.1": - version "1.30.1" - resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz" - integrity sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ== - dependencies: - "@opentelemetry/semantic-conventions" "1.28.0" - "@opentelemetry/core@1.17.0": version "1.17.0" resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-1.17.0.tgz" @@ -2716,6 +2684,13 @@ dependencies: "@opentelemetry/semantic-conventions" "1.17.0" +"@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.17.0": + version "1.30.1" + resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz" + integrity sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ== + dependencies: + "@opentelemetry/semantic-conventions" "1.28.0" + "@opentelemetry/exporter-trace-otlp-http@^0.43.0": version "0.43.0" resolved "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.43.0.tgz" @@ -2779,7 +2754,7 @@ "@opentelemetry/instrumentation" "^0.46.0" "@opentelemetry/semantic-conventions" "^1.0.0" -"@opentelemetry/instrumentation@^0.43.0", "@opentelemetry/instrumentation@0.43.0": +"@opentelemetry/instrumentation@0.43.0", "@opentelemetry/instrumentation@^0.43.0": version "0.43.0" resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.43.0.tgz" integrity sha512-S1uHE+sxaepgp+t8lvIDuRgyjJWisAb733198kwQTUc9ZtYQ2V2gmyCtR1x21ePGVLoMiX/NWY7WA290hwkjJQ== @@ -2861,14 +2836,6 @@ resolved "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz" integrity sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g== -"@opentelemetry/resources@^1.17.0", "@opentelemetry/resources@1.30.1": - version "1.30.1" - resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz" - integrity sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA== - dependencies: - "@opentelemetry/core" "1.30.1" - "@opentelemetry/semantic-conventions" "1.28.0" - "@opentelemetry/resources@1.17.0": version "1.17.0" resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.17.0.tgz" @@ -2877,6 +2844,14 @@ "@opentelemetry/core" "1.17.0" "@opentelemetry/semantic-conventions" "1.17.0" +"@opentelemetry/resources@1.30.1", "@opentelemetry/resources@^1.17.0": + version "1.30.1" + resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz" + integrity sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA== + dependencies: + "@opentelemetry/core" "1.30.1" + "@opentelemetry/semantic-conventions" "1.28.0" + "@opentelemetry/sdk-logs@0.43.0": version "0.43.0" resolved "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.43.0.tgz" @@ -2885,14 +2860,6 @@ "@opentelemetry/core" "1.17.0" "@opentelemetry/resources" "1.17.0" -"@opentelemetry/sdk-metrics@^1.9.1": - version "1.30.1" - resolved "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz" - integrity sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog== - dependencies: - "@opentelemetry/core" "1.30.1" - "@opentelemetry/resources" "1.30.1" - "@opentelemetry/sdk-metrics@1.17.0": version "1.17.0" resolved "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.17.0.tgz" @@ -2902,14 +2869,13 @@ "@opentelemetry/resources" "1.17.0" lodash.merge "^4.6.2" -"@opentelemetry/sdk-trace-base@^1.17.0", "@opentelemetry/sdk-trace-base@1.30.1": +"@opentelemetry/sdk-metrics@^1.9.1": version "1.30.1" - resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz" - integrity sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg== + resolved "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz" + integrity sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog== dependencies: "@opentelemetry/core" "1.30.1" "@opentelemetry/resources" "1.30.1" - "@opentelemetry/semantic-conventions" "1.28.0" "@opentelemetry/sdk-trace-base@1.17.0": version "1.17.0" @@ -2920,6 +2886,15 @@ "@opentelemetry/resources" "1.17.0" "@opentelemetry/semantic-conventions" "1.17.0" +"@opentelemetry/sdk-trace-base@1.30.1", "@opentelemetry/sdk-trace-base@^1.17.0": + version "1.30.1" + resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz" + integrity sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg== + dependencies: + "@opentelemetry/core" "1.30.1" + "@opentelemetry/resources" "1.30.1" + "@opentelemetry/semantic-conventions" "1.28.0" + "@opentelemetry/sdk-trace-node@^1.17.0": version "1.30.1" resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz" @@ -2932,11 +2907,6 @@ "@opentelemetry/sdk-trace-base" "1.30.1" semver "^7.5.2" -"@opentelemetry/semantic-conventions@^1.0.0", "@opentelemetry/semantic-conventions@^1.17.0": - version "1.37.0" - resolved "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz" - integrity sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA== - "@opentelemetry/semantic-conventions@1.17.0": version "1.17.0" resolved "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.17.0.tgz" @@ -2947,8 +2917,15 @@ resolved "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== +"@opentelemetry/semantic-conventions@^1.0.0", "@opentelemetry/semantic-conventions@^1.17.0": + version "1.37.0" + resolved "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz" + integrity sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA== + "@ory/client@^1.2.6": - version "v1.22.7" + version "1.22.37" + resolved "https://registry.yarnpkg.com/@ory/client/-/client-1.22.37.tgz#9b63d5c04730ccad1536429a5085286f55eb50c2" + integrity sha512-UEYHSveRRD3TCT0JB54ZKQoRMEHn7HTsNsMOjz2t3bzCs5aNjgpjYIBK4z5zE2PwNrcInZyQXfRiwK+LIUaOug== dependencies: axios "^1.6.1" @@ -2990,11 +2967,71 @@ "@otplib/plugin-crypto" "^12.0.1" "@otplib/plugin-thirty-two" "^12.0.1" +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + "@parcel/watcher-darwin-arm64@2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz" integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + "@parcel/watcher@^2.4.1": version "2.5.1" resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz" @@ -3166,6 +3203,11 @@ resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@scure/base@1.1.1": + version "1.1.1" + resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz" + integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== + "@scure/base@^1.1.1": version "1.2.6" resolved "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz" @@ -3176,11 +3218,6 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz" integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== -"@scure/base@1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz" - integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== - "@scure/bip32@1.3.1": version "1.3.1" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz" @@ -3755,7 +3792,52 @@ resolved "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.85.tgz" integrity sha512-jTikp+i4nO4Ofe6qGm4I3sFeebD1OvueBCHITux5tQKD6umN1c2z4CRGv6K49NIz/qEpUcdr6Qny6K+3yibVFQ== -"@swc/core@*", "@swc/core@^1.3.68", "@swc/core@>=1.2.50", "@swc/core@1.3.85": +"@swc/core-darwin-x64@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.85.tgz#ce623656ee427876423f07d898fbd7fa767d39bd" + integrity sha512-3uHYkjVU+2F+YbVYtq5rH0uCJIztFTALaS3mQEfQUZKXZ5/8jD5titTCRqFKtSlQg0CzaFZgsYsuqwYBmgN0mA== + +"@swc/core-linux-arm-gnueabihf@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.85.tgz#9eb958e3b2c4af0d208dc2d1e71270797605c71b" + integrity sha512-ouHzAHsFaEOkRuoTAOI/8n2m8BQAAnb4vr/xbMhhDOmix0lp5eNsW5Iac/EcJ2uG6B3n7P2K8oycj9SWkj+pfw== + +"@swc/core-linux-arm64-gnu@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.85.tgz#2932636d5414709c56b9a670ca804dc317201a41" + integrity sha512-/Z1CZOWiO+NqJEh1J20PIxQFHMH43upQJ1l7FJ5Z7+MyuYF8WkeJ7OSovau729pBR+38vvvccEJrMZIztfv7hQ== + +"@swc/core-linux-arm64-musl@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.85.tgz#3b312c57ac0b6861d87a7c0f7ff24e232eb792d5" + integrity sha512-gfh7CfKavi076dbMBTzfdawSGcYfZ4+1Q+8aRkSesqepKHcIWIJti8Cf3zB4a6CHNhJe+VN0Gb7DEfumydAm1w== + +"@swc/core-linux-x64-gnu@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.85.tgz#d9fd7ff6ba4e693274604926b82027cb4c2a3306" + integrity sha512-lWVqjHKzofb9q1qrBM4dLqO7CIisp08/xMS5Hz9DWex1gTc5F2b6yJO6Ceqwa256GMweJcdP6A5EvEFQAiZ5dg== + +"@swc/core-linux-x64-musl@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.85.tgz#0a80efac999fb125131385a8937227819549939f" + integrity sha512-EPJmlfqC05TUetnlErxNRyIp7Nc3B2w9abET6oQ/EgldeAeQnZ3M6svMViET/c2QSomgrU3rdP+Qcozkt62/4A== + +"@swc/core-win32-arm64-msvc@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.85.tgz#9808ee0d35eeb315efa7b73edcca80001451c9f3" + integrity sha512-ibckJDZw8kNosciMexwk0z75ZyUhwtiFMV9rSBpup0opa7NNCUCoERCJ1e9LRyMdhsVUoLpZg/KZiHCdTw96hQ== + +"@swc/core-win32-ia32-msvc@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.85.tgz#9b85ba050f333199e88e54ba0dd7b72302af64be" + integrity sha512-hY4MpHGUVQHL1T2kgRXOigDho4DTIpVPYzJ4uyy8VQRbS7GzN5XtvdGP/fA4zp8+2BQjcig+6J7Y92SY15ouNQ== + +"@swc/core-win32-x64-msvc@1.3.85": + version "1.3.85" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.85.tgz#5c7f3f366804b512cf26e1731d8714f0409f1204" + integrity sha512-ktxWOMFJ0iqKn6WUHtXqi4CS7xkyHmrRtjllGRuGqxmLmDX/HSOfuQ55Tm1KXKk5oHLacJkUbOSF2kBrpZ8dpg== + +"@swc/core@1.3.85": version "1.3.85" resolved "https://registry.npmjs.org/@swc/core/-/core-1.3.85.tgz" integrity sha512-qnoxp+2O0GtvRdYnXgR1v8J7iymGGYpx6f6yCK9KxipOZOjrlKILFANYlghQxZyPUfXwK++TFxfSlX4r9wK+kg== @@ -3778,7 +3860,7 @@ resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/jest@^0.2.26", "@swc/jest@^0.2.29": +"@swc/jest@^0.2.29": version "0.2.39" resolved "https://registry.npmjs.org/@swc/jest/-/jest-0.2.39.tgz" integrity sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA== @@ -3955,7 +4037,7 @@ dependencies: dotenv "*" -"@types/eslint@^8.44.2", "@types/eslint@>=8.0.0": +"@types/eslint@^8.44.2": version "8.56.12" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz" integrity sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g== @@ -3968,17 +4050,16 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/express-serve-static-core@^4.17.18": - version "4.19.7" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz" - integrity sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg== +"@types/express-serve-static-core@4.17.31": + version "4.17.31" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz" + integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q== dependencies: "@types/node" "*" "@types/qs" "*" "@types/range-parser" "*" - "@types/send" "*" -"@types/express-serve-static-core@^4.17.33": +"@types/express-serve-static-core@^4.17.18", "@types/express-serve-static-core@^4.17.33": version "4.19.7" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz" integrity sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg== @@ -3988,25 +4069,6 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express-serve-static-core@4.17.31": - version "4.17.31" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz" - integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - -"@types/express@*", "@types/express@^4.17.15", "@types/express@^4.17.20": - version "4.17.24" - resolved "https://registry.npmjs.org/@types/express/-/express-4.17.24.tgz" - integrity sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.33" - "@types/qs" "*" - "@types/serve-static" "*" - "@types/express@4.17.14": version "4.17.14" resolved "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz" @@ -4027,6 +4089,16 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/express@^4.17.15", "@types/express@^4.17.20": + version "4.17.24" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.24.tgz" + integrity sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/form-data@0.0.33": version "0.0.33" resolved "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz" @@ -4203,7 +4275,7 @@ resolved "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz" integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== -"@types/markdown-it@*", "@types/markdown-it@^14.1.1": +"@types/markdown-it@^14.1.1": version "14.1.2" resolved "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz" integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== @@ -4248,20 +4320,10 @@ dependencies: undici-types "~6.21.0" -"@types/node@^10.0.3": - version "10.17.60" - resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" - integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== - -"@types/node@^10.1.0": - version "10.17.60" - resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" - integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== - -"@types/node@^8.0.0": - version "8.10.66" - resolved "https://registry.npmjs.org/@types/node/-/node-8.10.66.tgz" - integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw== +"@types/node@20.7.1": + version "20.7.1" + resolved "https://registry.npmjs.org/@types/node/-/node-20.7.1.tgz" + integrity sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg== "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "24.9.1" @@ -4270,17 +4332,15 @@ dependencies: undici-types "~7.16.0" -"@types/node@>=13.7.0": - version "24.9.1" - resolved "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz" - integrity sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg== - dependencies: - undici-types "~7.16.0" +"@types/node@^10.0.3", "@types/node@^10.1.0": + version "10.17.60" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" + integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== -"@types/node@20.7.1": - version "20.7.1" - resolved "https://registry.npmjs.org/@types/node/-/node-20.7.1.tgz" - integrity sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg== +"@types/node@^8.0.0": + version "8.10.66" + resolved "https://registry.npmjs.org/@types/node/-/node-8.10.66.tgz" + integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw== "@types/nodemon@^1.19.2": version "1.19.6" @@ -4304,7 +4364,7 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react@*", "@types/react@^18.2.21": +"@types/react@^18.2.21": version "18.3.26" resolved "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz" integrity sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA== @@ -4319,16 +4379,6 @@ dependencies: "@types/node" "*" -"@types/request@^2.48.8": - version "2.48.13" - resolved "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz" - integrity sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg== - dependencies: - "@types/caseless" "*" - "@types/node" "*" - "@types/tough-cookie" "*" - form-data "^2.5.5" - "@types/request@2.48.9": version "2.48.9" resolved "https://registry.npmjs.org/@types/request/-/request-2.48.9.tgz" @@ -4339,6 +4389,16 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/request@^2.48.8": + version "2.48.13" + resolved "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz" + integrity sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.5" + "@types/rimraf@^3.0.2": version "3.0.2" resolved "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz" @@ -4457,7 +4517,7 @@ resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz" integrity sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw== -"@typescript-eslint/eslint-plugin@^5.0.0 || ^6.0.0 || ^7.0.0", "@typescript-eslint/eslint-plugin@^6.7.0": +"@typescript-eslint/eslint-plugin@^6.7.0": version "6.21.0" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz" integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA== @@ -4474,7 +4534,7 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/parser@^6.0.0 || ^6.0.0-alpha", "@typescript-eslint/parser@^6.7.2": +"@typescript-eslint/parser@^6.7.2": version "6.21.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz" integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== @@ -4526,33 +4586,7 @@ resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz" integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== -"@typescript-eslint/typescript-estree@^4.33.0": - version "4.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz" - integrity sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" - debug "^4.3.1" - globby "^11.0.3" - is-glob "^4.0.1" - semver "^7.3.5" - tsutils "^3.21.0" - -"@typescript-eslint/typescript-estree@^5.55.0": - version "5.62.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz" - integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== - dependencies: - "@typescript-eslint/types" "5.62.0" - "@typescript-eslint/visitor-keys" "5.62.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/typescript-estree@5.62.0": +"@typescript-eslint/typescript-estree@5.62.0", "@typescript-eslint/typescript-estree@^5.55.0": version "5.62.0" resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz" integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== @@ -4579,19 +4613,18 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/utils@^5.10.0": - version "5.62.0" - resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz" - integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ== +"@typescript-eslint/typescript-estree@^4.33.0": + version "4.33.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz" + integrity sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@types/json-schema" "^7.0.9" - "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.62.0" - "@typescript-eslint/types" "5.62.0" - "@typescript-eslint/typescript-estree" "5.62.0" - eslint-scope "^5.1.1" - semver "^7.3.7" + "@typescript-eslint/types" "4.33.0" + "@typescript-eslint/visitor-keys" "4.33.0" + debug "^4.3.1" + globby "^11.0.3" + is-glob "^4.0.1" + semver "^7.3.5" + tsutils "^3.21.0" "@typescript-eslint/utils@6.21.0": version "6.21.0" @@ -4606,6 +4639,20 @@ "@typescript-eslint/typescript-estree" "6.21.0" semver "^7.5.4" +"@typescript-eslint/utils@^5.10.0": + version "5.62.0" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz" + integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@types/json-schema" "^7.0.9" + "@types/semver" "^7.3.12" + "@typescript-eslint/scope-manager" "5.62.0" + "@typescript-eslint/types" "5.62.0" + "@typescript-eslint/typescript-estree" "5.62.0" + eslint-scope "^5.1.1" + semver "^7.3.7" + "@typescript-eslint/visitor-keys@4.33.0": version "4.33.0" resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz" @@ -4685,16 +4732,16 @@ commander "^8.2.0" globby "^12.0.2" -abbrev@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz" - integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== - abbrev@1: version "1.1.1" resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -4735,7 +4782,7 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8, acorn@^8.11.0, acorn@^8.4.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.11.0, acorn@^8.4.1, acorn@^8.8.2, acorn@^8.9.0: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -4745,11 +4792,6 @@ adm-zip@0.5.10: resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz" integrity sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ== -agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== - agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -4757,6 +4799,11 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ajv-draft-04@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz" @@ -4779,7 +4826,7 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.12.0, ajv@^8.17.1, ajv@^8.5.0, "ajv@4.11.8 - 8": +ajv@^8.0.0, ajv@^8.12.0, ajv@^8.17.1: version "8.17.1" resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz" integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== @@ -5154,6 +5201,11 @@ async-retry@^1.2.1, async-retry@^1.3.3: dependencies: retry "0.13.1" +async@3.2.4: + version "3.2.4" + resolved "https://registry.npmjs.org/async/-/async-3.2.4.tgz" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + async@^2.6.0: version "2.6.4" resolved "https://registry.npmjs.org/async/-/async-2.6.4.tgz" @@ -5166,11 +5218,6 @@ async@^3.2.0, async@^3.2.3, async@~3.2.0: resolved "https://registry.npmjs.org/async/-/async-3.2.6.tgz" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== -async@3.2.4: - version "3.2.4" - resolved "https://registry.npmjs.org/async/-/async-3.2.4.tgz" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== - asyncjs-util@1.2.12: version "1.2.12" resolved "https://registry.npmjs.org/asyncjs-util/-/asyncjs-util-1.2.12.tgz" @@ -5217,7 +5264,7 @@ axios@^0.26.1: dependencies: follow-redirects "^1.14.8" -axios@^1.12.0, axios@^1.3.4, axios@^1.5.0, axios@^1.6.0, axios@^1.6.1, "axios@>= 0.17.0": +axios@^1.12.0, axios@^1.3.4, axios@^1.5.0, axios@^1.6.0, axios@^1.6.1: version "1.12.2" resolved "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz" integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== @@ -5368,16 +5415,16 @@ batch@0.6.1: resolved "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +bech32@2.0.0, bech32@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz" + integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== + bech32@^1.1.2: version "1.1.4" resolved "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz" integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== -bech32@^2.0.0, bech32@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz" - integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== - bignumber.js@^9.0.0: version "9.3.1" resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz" @@ -5433,23 +5480,11 @@ bitcoin-cli-ts@^24.0.3: json-rpc-2.0 "^1.6.0" undici "^5.22.1" -bitcoin-ops@^1.3.0, bitcoin-ops@1.4.1: +bitcoin-ops@1.4.1, bitcoin-ops@^1.3.0: version "1.4.1" resolved "https://registry.npmjs.org/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz" integrity sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow== -bitcoinjs-lib@^6.0.0, bitcoinjs-lib@^6.1.4: - version "6.1.7" - resolved "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz" - integrity sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg== - dependencies: - "@noble/hashes" "^1.2.0" - bech32 "^2.0.0" - bip174 "^2.1.1" - bs58check "^3.0.1" - typeforce "^1.11.3" - varuint-bitcoin "^1.1.2" - bitcoinjs-lib@6.1.3: version "6.1.3" resolved "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.3.tgz" @@ -5474,6 +5509,18 @@ bitcoinjs-lib@6.1.5: typeforce "^1.11.3" varuint-bitcoin "^1.1.2" +bitcoinjs-lib@^6.0.0, bitcoinjs-lib@^6.1.4: + version "6.1.7" + resolved "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz" + integrity sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg== + dependencies: + "@noble/hashes" "^1.2.0" + bech32 "^2.0.0" + bip174 "^2.1.1" + bs58check "^3.0.1" + typeforce "^1.11.3" + varuint-bitcoin "^1.1.2" + bl@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" @@ -5488,53 +5535,15 @@ bluebird@^3.7.2: resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -bn.js@^4.11.8: - version "4.12.2" - resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz" - integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== - -bn.js@^4.11.9: - version "4.12.2" - resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz" - integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== - bn.js@5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@^1.19.0, body-parser@^1.20.1, body-parser@1.20.3: - version "1.20.3" - resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz" - integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== - dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.13.0" - raw-body "2.5.2" - type-is "~1.6.18" - unpipe "1.0.0" - -body-parser@^2.2.1: - version "2.2.2" - resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz" - integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== - dependencies: - bytes "^3.1.2" - content-type "^1.0.5" - debug "^4.4.3" - http-errors "^2.0.0" - iconv-lite "^0.7.0" - on-finished "^2.4.1" - qs "^6.14.1" - raw-body "^3.0.1" - type-is "^2.0.1" +bn.js@^4.11.8, bn.js@^4.11.9: + version "4.12.2" + resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz" + integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== body-parser@1.20.1: version "1.20.1" @@ -5572,6 +5581,39 @@ body-parser@1.20.2: type-is "~1.6.18" unpipe "1.0.0" +body-parser@1.20.3, body-parser@^1.19.0, body-parser@^1.20.1: + version "1.20.3" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +body-parser@^2.2.1: + version "2.2.2" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz" + integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.3" + http-errors "^2.0.0" + iconv-lite "^0.7.0" + on-finished "^2.4.1" + qs "^6.14.1" + raw-body "^3.0.1" + type-is "^2.0.1" + body@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/body/-/body-5.1.0.tgz" @@ -5645,7 +5687,7 @@ brorand@^1.1.0: resolved "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz" integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== -browserslist@^4.24.0, browserslist@^4.26.3, "browserslist@>= 4.21.0": +browserslist@^4.24.0, browserslist@^4.26.3: version "4.27.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz" integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== @@ -5670,14 +5712,6 @@ bs58@^5.0.0: dependencies: base-x "^4.0.0" -bs58check@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz" - integrity sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ== - dependencies: - "@noble/hashes" "^1.2.0" - bs58 "^5.0.0" - bs58check@<3.0.0: version "2.1.2" resolved "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz" @@ -5687,6 +5721,14 @@ bs58check@<3.0.0: create-hash "^1.1.0" safe-buffer "^5.1.2" +bs58check@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz" + integrity sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ== + dependencies: + "@noble/hashes" "^1.2.0" + bs58 "^5.0.0" + bser@2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz" @@ -5694,9 +5736,9 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@^5.5.0: +bson@^5.4.0, bson@^5.5.0: version "5.5.1" - resolved "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz" + resolved "https://registry.yarnpkg.com/bson/-/bson-5.5.1.tgz#f5849d405711a7f23acdda9a442375df858e6833" integrity sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g== bson@^6.10.4: @@ -5730,16 +5772,16 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -bytes@^3.1.2, bytes@~3.1.2, bytes@3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - bytes@1: version "1.0.0" resolved "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz" integrity sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ== +bytes@3.1.2, bytes@^3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" @@ -5951,16 +5993,16 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -clone@^1.0.2: - version "1.0.4" - resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz" - integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== - clone@2.x: version "2.1.2" resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + cluster-key-slot@^1.1.0: version "1.1.2" resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz" @@ -6003,16 +6045,16 @@ color-support@^1.1.2: resolved "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -colorette@^2.0.7: - version "2.0.20" - resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz" - integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== - colorette@2.0.19: version "2.0.19" resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== +colorette@^2.0.7: + version "2.0.20" + resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + colors@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz" @@ -6045,12 +6087,7 @@ commander@^8.2.0: resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== -commander@^9.1.0: - version "9.5.0" - resolved "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz" - integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== - -commander@^9.5.0: +commander@^9.1.0, commander@^9.5.0: version "9.5.0" resolved "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz" integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== @@ -6136,11 +6173,6 @@ console.table@^0.10.0: dependencies: easy-table "1.1.0" -content-disposition@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz" - integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== - content-disposition@0.5.4: version "0.5.4" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" @@ -6148,6 +6180,11 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" +content-disposition@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz" + integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== + content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" @@ -6171,32 +6208,27 @@ cookie-parser@^1.4.6: cookie "0.7.2" cookie-signature "1.0.6" -cookie-signature@^1.2.1: - version "1.2.2" - resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz" - integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== - cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@^0.5.0, cookie@0.5.0: +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + +cookie@0.5.0, cookie@^0.5.0: version "0.5.0" resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookie@^0.7.1: - version "0.7.2" - resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - cookie@0.7.1: version "0.7.1" resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== -cookie@0.7.2: +cookie@0.7.2, cookie@^0.7.1: version "0.7.2" resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -6213,7 +6245,7 @@ core-util-is@~1.0.0: resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@^2.8.5, cors@2.8.5: +cors@2.8.5, cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -6297,7 +6329,7 @@ csv-writer@^1.6.0: resolved "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz" integrity sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g== -d@^1.0.1, d@^1.0.2, d@1: +d@1, d@^1.0.1, d@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/d/-/d-1.0.2.tgz" integrity sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw== @@ -6332,16 +6364,16 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dataloader@^2.2.2: - version "2.2.3" - resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz" - integrity sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA== - dataloader@2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.1.0.tgz" integrity sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ== +dataloader@^2.2.2: + version "2.2.3" + resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz" + integrity sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA== + datauri@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/datauri/-/datauri-4.1.0.tgz" @@ -6372,34 +6404,20 @@ dayjs@^1.11.9: resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz" integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA== -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: - ms "^2.1.1" + ms "2.0.0" -debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@4, debug@4.x: +debug@4, debug@4.x, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - debug@4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" @@ -6407,6 +6425,13 @@ debug@4.3.4: dependencies: ms "2.1.2" +debug@^3.1.0, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + dedent@^1.0.0, dedent@^1.5.1: version "1.7.0" resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz" @@ -6472,7 +6497,7 @@ denque@^2.1.0: resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== -depd@^2.0.0, depd@~2.0.0, depd@2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -6792,7 +6817,7 @@ easy-table@1.1.0: optionalDependencies: wcwidth ">=1.0.1" -ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -6856,7 +6881,7 @@ emoji-regex@^9.2.2: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encodeurl@^2.0.0: +encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== @@ -6866,11 +6891,6 @@ encodeurl@~1.0.2: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - encoding-sniffer@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz" @@ -7126,7 +7146,7 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" -eslint-config-prettier@^9.0.0, "eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0": +eslint-config-prettier@^9.0.0: version "9.1.2" resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz" integrity sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ== @@ -7213,7 +7233,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^7.0.0 || ^8.0.0", eslint@^8.49.0, eslint@>=7.0.0, eslint@>=8.0.0: +eslint@^8.49.0: version "8.57.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -7281,16 +7301,16 @@ espree@^9.0.0, espree@^9.6.0, espree@^9.6.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.4.1" -esprima@^4.0.0, esprima@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - esprima@1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz" integrity sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A== +esprima@^4.0.0, esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + esquery@^1.4.2: version "1.6.0" resolved "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz" @@ -7305,12 +7325,7 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^4.2.0: +estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== @@ -7348,16 +7363,16 @@ eventemitter2@~0.4.13: resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz" integrity sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ== -events@^3.3.0: - version "3.3.0" - resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - events@1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/events/-/events-1.1.1.tgz" integrity sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: version "3.0.6" resolved "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz" @@ -7427,31 +7442,68 @@ express-unless@^2.1.3: resolved "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz" integrity sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ== -express@^4.17.1, express@^4.18.2, "express@>= 4.11": - version "4.21.2" - resolved "https://registry.npmjs.org/express/-/express-4.21.2.tgz" - integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== +express@4.18.2: + version "4.18.2" + resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.3" + body-parser "1.20.1" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.7.1" + cookie "0.5.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" - encodeurl "~2.0.0" + encodeurl "~1.0.2" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.3.1" + finalhandler "1.2.0" fresh "0.5.2" http-errors "2.0.0" - merge-descriptors "1.0.3" + merge-descriptors "1.0.1" methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.12" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +express@^4.17.1, express@^4.18.2: + version "4.21.2" + resolved "https://registry.npmjs.org/express/-/express-4.21.2.tgz" + integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.3" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.7.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.12" proxy-addr "~2.0.7" qs "6.13.0" range-parser "~1.2.1" @@ -7498,43 +7550,6 @@ express@^5.0.1: type-is "^2.0.1" vary "^1.1.2" -express@4.18.2: - version "4.18.2" - resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.1" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - ext@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz" @@ -7613,13 +7628,6 @@ fast-uri@^3.0.1: resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz" integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== -fast-xml-parser@^4.2.2, fast-xml-parser@^4.4.1: - version "4.5.3" - resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz" - integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig== - dependencies: - strnum "^1.1.1" - fast-xml-parser@5.2.5: version "5.2.5" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz" @@ -7627,6 +7635,13 @@ fast-xml-parser@5.2.5: dependencies: strnum "^2.1.0" +fast-xml-parser@^4.2.2, fast-xml-parser@^4.4.1: + version "4.5.3" + resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz" + integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig== + dependencies: + strnum "^1.1.1" + fastq@^1.6.0: version "1.19.1" resolved "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz" @@ -7634,13 +7649,6 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -faye-websocket@~0.10.0: - version "0.10.0" - resolved "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz" - integrity sha512-Xhj93RXbMSq8urNCUq4p9l0P6hnySJ/7YNRhYNug0bLOuii7pKO7xQFb5mx9xZXWCar88pLPb805PvUkwrLZpQ== - dependencies: - websocket-driver ">=0.5.1" - faye-websocket@0.11.4: version "0.11.4" resolved "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz" @@ -7648,6 +7656,13 @@ faye-websocket@0.11.4: dependencies: websocket-driver ">=0.5.1" +faye-websocket@~0.10.0: + version "0.10.0" + resolved "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz" + integrity sha512-Xhj93RXbMSq8urNCUq4p9l0P6hnySJ/7YNRhYNug0bLOuii7pKO7xQFb5mx9xZXWCar88pLPb805PvUkwrLZpQ== + dependencies: + websocket-driver ">=0.5.1" + fb-watchman@^2.0.0, fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz" @@ -7711,18 +7726,6 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@^2.1.0: - version "2.1.1" - resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" - integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== - dependencies: - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - on-finished "^2.4.1" - parseurl "^1.3.3" - statuses "^2.0.1" - finalhandler@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz" @@ -7762,6 +7765,18 @@ finalhandler@1.3.1: statuses "2.0.1" unpipe "~1.0.0" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + find-cache-dir@^3.3.1: version "3.3.2" resolved "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz" @@ -7898,31 +7913,7 @@ form-data-encoder@^1.7.2: resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.9.0.tgz" integrity sha512-rahaRMkN8P8d/tgK/BLPX+WBVM27NbvdXBxqQujBtkDAIFspaRqN7Od7lfdGQA6KAD+f82fYCLBq1ipvcu8qLw== -form-data@^2.2.0: - version "2.5.5" - resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz" - integrity sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.35" - safe-buffer "^5.2.1" - -form-data@^2.5.0: - version "2.5.5" - resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz" - integrity sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.35" - safe-buffer "^5.2.1" - -form-data@^2.5.5: +form-data@^2.2.0, form-data@^2.5.0, form-data@^2.5.5: version "2.5.5" resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz" integrity sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A== @@ -7958,16 +7949,16 @@ forwarded@0.2.0: resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - fresh@0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + fs-extra@^10.0.1: version "10.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz" @@ -8064,7 +8055,7 @@ gaze@^1.1.0: dependencies: globule "^1.0.0" -gcp-metadata@^5.2.0, gcp-metadata@^5.3.0: +gcp-metadata@^5.3.0: version "5.3.0" resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz" integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== @@ -8247,19 +8238,7 @@ glob@~5.0.0: once "^1.3.0" path-is-absolute "^1.0.0" -glob@~7.1.1: - version "7.1.7" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@~7.1.6: +glob@~7.1.1, glob@~7.1.6: version "7.1.7" resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== @@ -8306,7 +8285,7 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.0.3, globby@^11.1.0, globby@11.1.0: +globby@11.1.0, globby@^11.0.3, globby@^11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -8406,16 +8385,16 @@ google-p12-pem@^4.0.0: dependencies: node-forge "^1.3.1" -google-protobuf@^3.21.2: - version "3.21.4" - resolved "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz" - integrity sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ== - google-protobuf@3.15.8: version "3.15.8" resolved "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.15.8.tgz" integrity sha512-2jtfdqTaSxk0cuBJBtTTWsot4WtR9RVr2rXg7x7OoqiuOKopPrwXpM1G4dXIkLcUNRh3RKzz76C8IOkksZSeOw== +google-protobuf@^3.21.2: + version "3.21.4" + resolved "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz" + integrity sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ== + gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" @@ -8431,7 +8410,7 @@ graphemer@^1.4.0: resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -"graphql-middleware@^2.0.0 || ^3.0.0 || ^4.0.0 || ^6.0.0", graphql-middleware@^6.1.33: +graphql-middleware@^6.1.33: version "6.1.35" resolved "https://registry.npmjs.org/graphql-middleware/-/graphql-middleware-6.1.35.tgz" integrity sha512-azawK7ApUYtcuPGRGBR9vDZu795pRuaFhO5fgomdJppdfKRt7jwncuh0b7+D3i574/4B+16CNWgVpnGVlg3ZCg== @@ -8475,7 +8454,7 @@ graphql-shield@^7.6.4: tslib "^2.4.0" yup "^0.32.0" -"graphql-subscriptions@^1.0.0 || ^2.0.0 || ^3.0.0", graphql-subscriptions@^2.0.0: +graphql-subscriptions@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz" integrity sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA== @@ -8499,16 +8478,23 @@ graphql-tools@^9.0.0: optionalDependencies: "@apollo/client" "~3.2.5 || ~3.3.0 || ~3.4.0 || ~3.5.0 || ~3.6.0 || ~3.7.0 || ~3.8.0 || ~3.9.0 || ~3.10.0 || ~3.11.0 || ~3.12.0 || ~3.13.0" -graphql-ws@^5.13.1, graphql-ws@^5.5.5, "graphql-ws@^5.5.5 || ^6.0.3": +graphql-ws@^5.13.1: version "5.16.2" resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.2.tgz" integrity sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ== -"graphql@^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql@^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql@^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql@^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", "graphql@^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql@^14.0.0 || ^15.0.0 || ^16.0.0", "graphql@^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", "graphql@^14.2.1 || ^15.0.0 || ^16.0.0", "graphql@^14.6.0 || ^15.0.0 || ^16.0.0", "graphql@^15.0.0 || ^16.0.0", "graphql@^15.3.0 || ^16.0.0", "graphql@^15.7.2 || ^16.0.0", graphql@^16.2.0, graphql@^16.3.0, graphql@^16.8.0, "graphql@>=0.11 <=16", "graphql@14.x || 15.x || 16.x": +graphql@^16.3.0, graphql@^16.8.0: version "16.11.0" resolved "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz" integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw== +grpc-tools@^1.12.4: + version "1.13.0" + resolved "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.0.tgz" + integrity sha512-7CbkJ1yWPfX0nHjbYG58BQThNhbICXBZynzCUxCb3LzX5X9B3hQbRY2STiRgIEiLILlK9fgl0z0QVGwPCdXf5g== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.5" + grpc_tools_node_protoc_ts@^5.3.3: version "5.3.3" resolved "https://registry.npmjs.org/grpc_tools_node_protoc_ts/-/grpc_tools_node_protoc_ts-5.3.3.tgz" @@ -8517,13 +8503,6 @@ grpc_tools_node_protoc_ts@^5.3.3: google-protobuf "3.15.8" handlebars "4.7.7" -grpc-tools@^1.12.4: - version "1.13.0" - resolved "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.0.tgz" - integrity sha512-7CbkJ1yWPfX0nHjbYG58BQThNhbICXBZynzCUxCb3LzX5X9B3hQbRY2STiRgIEiLILlK9fgl0z0QVGwPCdXf5g== - dependencies: - "@mapbox/node-pre-gyp" "^1.0.5" - grunt-cli@~1.4.3: version "1.4.3" resolved "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.4.3.tgz" @@ -8644,7 +8623,7 @@ grunt-sass@^3.0.2: resolved "https://registry.npmjs.org/grunt-sass/-/grunt-sass-3.1.0.tgz" integrity sha512-90s27H7FoCDcA8C8+R0GwC+ntYD3lG6S/jqcavWm3bn9RiJTmSfOvfbFa1PXx4NbBWuiGQMLfQTj/JvvqT5w6A== -grunt@>=0.4.5, grunt@>=1, grunt@>=1.4.1, grunt@~1.5.3: +grunt@~1.5.3: version "1.5.3" resolved "https://registry.npmjs.org/grunt/-/grunt-1.5.3.tgz" integrity sha512-mKwmo4X2d8/4c/BmcOETHek675uOqw0RuA/zy12jaspWqvTp4+ZeQF1W+OTpcbncnaBsfbQJ6l0l4j+Sn/GmaQ== @@ -8699,25 +8678,25 @@ gzip-size@^5.1.1: duplexer "^0.1.1" pify "^4.0.1" -handlebars@^4.7.7: - version "4.7.8" - resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz" - integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== +handlebars@4.7.7: + version "4.7.7" + resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== dependencies: minimist "^1.2.5" - neo-async "^2.6.2" + neo-async "^2.6.0" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: uglify-js "^3.1.4" -handlebars@4.7.7: - version "4.7.7" - resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== +handlebars@^4.7.7: + version "4.7.8" + resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== dependencies: minimist "^1.2.5" - neo-async "^2.6.0" + neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: @@ -8834,11 +8813,6 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4: - version "4.11.5" - resolved "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz" - integrity sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g== - hooker@~0.2.3: version "0.2.3" resolved "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz" @@ -8884,6 +8858,22 @@ http-basic@^8.1.1: http-response-object "^3.0.1" parse-cache-control "^1.0.1" +http-cache-semantics@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz" @@ -8905,17 +8895,6 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-parser-js@>=0.5.1: version "0.5.10" resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz" @@ -8988,7 +8967,14 @@ ibex-client@^2.2.0: api "^6.1.2" node-cache "^5.1.2" -iconv-lite@^0.6.3: +iconv-lite@0.4.24, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -9002,20 +8988,6 @@ iconv-lite@^0.7.0, iconv-lite@~0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@~0.4.13, iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -9051,7 +9023,7 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@1.4.2: +import-in-the-middle@1.4.2, import-in-the-middle@1.7.1: version "1.4.2" resolved "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz" integrity sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw== @@ -9061,16 +9033,6 @@ import-in-the-middle@1.4.2: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-in-the-middle@1.7.1: - version "1.7.1" - resolved "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.7.1.tgz" - integrity sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg== - dependencies: - acorn "^8.8.2" - acorn-import-assertions "^1.9.0" - cjs-module-lexer "^1.2.2" - module-details-from-path "^1.0.3" - import-local@^3.0.2: version "3.2.0" resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz" @@ -9097,7 +9059,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -9136,7 +9098,7 @@ interpret@~1.1.0: resolved "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz" integrity sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA== -invoices@^3.0.0, invoices@3.0.0: +invoices@3.0.0, invoices@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/invoices/-/invoices-3.0.0.tgz" integrity sha512-/WDTkfU2RMelQpQ54BwZssqGXYNWbPnWkZ/9QV57vAvD3RLdCDbhDuucOGti8CK3sgk8nmhRV6V0WfMrxojMmA== @@ -9155,7 +9117,7 @@ ioredis-cache@^2.0.0: dependencies: ioredis "4 - 5" -ioredis@^5.3.2, "ioredis@4 - 5": +"ioredis@4 - 5", ioredis@^5.3.2: version "5.8.2" resolved "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz" integrity sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q== @@ -9175,16 +9137,16 @@ ip-address@^10.0.1: resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz" integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== -ipaddr.js@^2.1.0: - version "2.2.0" - resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz" - integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== - ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +ipaddr.js@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== + is-absolute@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz" @@ -9603,11 +9565,6 @@ jackspeak@^4.1.1: dependencies: "@isaacs/cliui" "^8.0.2" -jest_workaround@^0.79.19: - version "0.79.19" - resolved "https://registry.npmjs.org/jest_workaround/-/jest_workaround-0.79.19.tgz" - integrity sha512-g/MtKSwyb4Ohnd5GHeJaduTgznkyst81x+eUBGOSGK7f8doWuRMPpt6XM/13sM2jLB2QNzT/7Djj7o2PhsozIA== - jest-changed-files@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz" @@ -9733,6 +9690,24 @@ jest-get-type@^29.6.3: resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz" integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== +jest-haste-map@30.2.0: + version "30.2.0" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz" + integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.0.1" + jest-util "30.2.0" + jest-worker "30.2.0" + micromatch "^4.0.8" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.3" + jest-haste-map@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz" @@ -9752,24 +9727,6 @@ jest-haste-map@^29.7.0: optionalDependencies: fsevents "^2.3.2" -jest-haste-map@30.2.0: - version "30.2.0" - resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz" - integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== - dependencies: - "@jest/types" "30.2.0" - "@types/node" "*" - anymatch "^3.1.3" - fb-watchman "^2.0.2" - graceful-fs "^4.2.11" - jest-regex-util "30.0.1" - jest-util "30.2.0" - jest-worker "30.2.0" - micromatch "^4.0.8" - walker "^1.0.8" - optionalDependencies: - fsevents "^2.3.3" - jest-junit@^16.0.0: version "16.0.0" resolved "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz" @@ -9827,16 +9784,16 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== -jest-regex-util@^29.6.3: - version "29.6.3" - resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz" - integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== - jest-regex-util@30.0.1: version "30.0.1" resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz" integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== +jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== + jest-resolve-dependencies@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz" @@ -9845,7 +9802,7 @@ jest-resolve-dependencies@^29.7.0: jest-regex-util "^29.6.3" jest-snapshot "^29.7.0" -jest-resolve@*, jest-resolve@^29.7.0: +jest-resolve@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz" integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== @@ -9941,18 +9898,6 @@ jest-snapshot@^29.7.0: pretty-format "^29.7.0" semver "^7.5.3" -jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - jest-util@30.2.0: version "30.2.0" resolved "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz" @@ -9965,6 +9910,18 @@ jest-util@30.2.0: graceful-fs "^4.2.11" picomatch "^4.0.2" +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz" @@ -9991,16 +9948,6 @@ jest-watcher@^29.7.0: jest-util "^29.7.0" string-length "^4.0.1" -jest-worker@^29.7.0: - version "29.7.0" - resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz" - integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== - dependencies: - "@types/node" "*" - jest-util "^29.7.0" - merge-stream "^2.0.0" - supports-color "^8.0.0" - jest-worker@30.2.0: version "30.2.0" resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz" @@ -10012,7 +9959,17 @@ jest-worker@30.2.0: merge-stream "^2.0.0" supports-color "^8.1.1" -jest@*, jest@^29.7.0: +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== + dependencies: + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -10022,6 +9979,11 @@ jest@*, jest@^29.7.0: import-local "^3.0.2" jest-cli "^29.7.0" +jest_workaround@^0.79.19: + version "0.79.19" + resolved "https://registry.npmjs.org/jest_workaround/-/jest_workaround-0.79.19.tgz" + integrity sha512-g/MtKSwyb4Ohnd5GHeJaduTgznkyst81x+eUBGOSGK7f8doWuRMPpt6XM/13sM2jLB2QNzT/7Djj7o2PhsozIA== + jose@^4.15.4: version "4.15.9" resolved "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz" @@ -10062,7 +10024,7 @@ js-sha1@^0.6.0: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^3.13.1: +js-yaml@^3.13.1, js-yaml@~3.14.0: version "3.14.1" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== @@ -10077,14 +10039,6 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -js-yaml@~3.14.0: - version "3.14.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - js2xmlparser@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz" @@ -10202,16 +10156,16 @@ json5@^2.2.0, json5@^2.2.2, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-parser@^3.2.0: - version "3.3.1" - resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz" - integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== - jsonc-parser@3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsonc-parser@^3.2.0: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsonfile@^6.0.1: version "6.2.0" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz" @@ -10386,7 +10340,7 @@ liftup@~3.0.1: rechoir "^0.7.0" resolve "^1.19.0" -lightning@^9.13.3, lightning@9.14.0: +lightning@9.14.0, lightning@^9.13.3: version "9.14.0" resolved "https://registry.npmjs.org/lightning/-/lightning-9.14.0.tgz" integrity sha512-08TEh9McKE6uMlCLulNPPyMzZcMBPzZDo2BMnRL+ImnkKZp8QCjioK4jdsl/41npDTsIgeSYdfk8vUrS0zSZng== @@ -10622,6 +10576,18 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lru-cache@6.0.0, lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +"lru-cache@7.10.1 - 7.13.1": + version "7.13.1" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.13.1.tgz" + integrity sha512-CHqbAq7NFlW3RSnoWXLJBxCWaZVBrfa9UEHId2M3AW8iEBurbqduNexEUCGc3SHc6iCYXNJCDi903LajSVAEPQ== + lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" @@ -10639,18 +10605,6 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -lru-cache@^6.0.0, lru-cache@6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -"lru-cache@7.10.1 - 7.13.1": - version "7.13.1" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.13.1.tgz" - integrity sha512-CHqbAq7NFlW3RSnoWXLJBxCWaZVBrfa9UEHId2M3AW8iEBurbqduNexEUCGc3SHc6iCYXNJCDi903LajSVAEPQ== - lru-memoizer@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz" @@ -10751,7 +10705,7 @@ markdown-it-anchor@^8.6.7: resolved "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz" integrity sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA== -markdown-it@*, markdown-it@^14.1.0: +markdown-it@^14.1.0: version "14.1.0" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz" integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg== @@ -10802,16 +10756,16 @@ mdurl@^2.0.0: resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -media-typer@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz" - integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + medici@^6.2.0: version "6.3.3" resolved "https://registry.npmjs.org/medici/-/medici-6.3.3.tgz" @@ -10838,11 +10792,6 @@ memory-pager@^1.0.2: resolved "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz" integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== -merge-descriptors@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz" - integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== - merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" @@ -10853,6 +10802,11 @@ merge-descriptors@1.0.3: resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" @@ -10895,24 +10849,19 @@ migrate-mongo@^10.0.0: date-fns "^2.28.0" fn-args "^5.0.0" fs-extra "^10.0.1" - lodash "^4.17.21" - p-each-series "^2.2.0" - -mime-db@^1.54.0: - version "1.54.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== - -"mime-db@>= 1.43.0 < 2": - version "1.54.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + lodash "^4.17.21" + p-each-series "^2.2.0" mime-db@1.52.0: version "1.52.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" @@ -10927,16 +10876,16 @@ mime-types@^3.0.0, mime-types@^3.0.2: dependencies: mime-db "^1.54.0" -mime@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - mime@1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimer@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/mimer/-/mimer-2.0.2.tgz" @@ -10957,28 +10906,35 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz" integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== -minimatch@^10.0.3: - version "10.0.3" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz" - integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== - dependencies: - "@isaacs/brace-expansion" "^5.0.0" - -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, "minimatch@2 || 3": +"minimatch@2 || 3", minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@9.0.3: + version "9.0.3" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== dependencies: brace-expansion "^2.0.1" -minimatch@^5.1.0: +minimatch@^10.0.3: + version "10.0.3" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + +minimatch@^5.0.1, minimatch@^5.1.0: version "5.1.6" resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== @@ -10992,34 +10948,13 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimatch@~3.0.2: - version "3.0.8" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz" - integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== - dependencies: - brace-expansion "^1.1.7" - -minimatch@~3.0.4: +minimatch@~3.0.2, minimatch@~3.0.4: version "3.0.8" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz" integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== dependencies: brace-expansion "^1.1.7" -minimatch@9.0.1: - version "9.0.1" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz" - integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== - dependencies: - brace-expansion "^2.0.1" - -minimatch@9.0.3: - version "9.0.3" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" @@ -11032,16 +10967,16 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.3, minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - minipass@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz" integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.3, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" @@ -11103,7 +11038,18 @@ mongodb-connection-string-url@^3.0.2: "@types/whatwg-url" "^11.0.2" whatwg-url "^14.1.0 || ^13.0.0" -"mongodb@^4.4.1 || ^5.0.0", mongodb@5.9.2: +mongodb@5.8.1: + version "5.8.1" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-5.8.1.tgz#dc201adfbd6c6d73401cdcf12ebdb75f14771faf" + integrity sha512-wKyh4kZvm6NrCPH8AxyzXm3JBoEf4Xulo0aUWh3hCgwgYJxyQ1KLST86ZZaSWdj6/kxYUA3+YZuyADCE61CMSg== + dependencies: + bson "^5.4.0" + mongodb-connection-string-url "^2.6.0" + socks "^2.7.1" + optionalDependencies: + "@mongodb-js/saslprep" "^1.1.0" + +mongodb@5.9.2: version "5.9.2" resolved "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz" integrity sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ== @@ -11123,7 +11069,20 @@ mongodb@^6.1.0: bson "^6.10.4" mongodb-connection-string-url "^3.0.2" -mongoose@^7.8.4, "mongoose@5 - 8": +"mongoose@5 - 8", mongoose@~7.5.1: + version "7.5.4" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-7.5.4.tgz#c83d0a3de8e1a13c19ce6cc5810bf93f0fb4c2a2" + integrity sha512-u97BOfBOoCxysnH5X0WeF/O39DO1di75dYU75xaSs9mL3Si0qmP0qLWvWpBRdVkiiRVw+eaqJyKwaq6RvKPVZw== + dependencies: + bson "^5.4.0" + kareem "2.5.1" + mongodb "5.8.1" + mpath "0.9.0" + mquery "5.0.0" + ms "2.1.3" + sift "16.0.1" + +mongoose@^7.8.4: version "7.8.7" resolved "https://registry.npmjs.org/mongoose/-/mongoose-7.8.7.tgz" integrity sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw== @@ -11141,27 +11100,27 @@ moo@^0.5.1: resolved "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz" integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== -morgan@^1.10.0: - version "1.10.1" - resolved "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz" - integrity sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A== +morgan@1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz" + integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== dependencies: basic-auth "~2.0.1" debug "2.6.9" depd "~2.0.0" on-finished "~2.3.0" - on-headers "~1.1.0" + on-headers "~1.0.2" -morgan@1.10.0: - version "1.10.0" - resolved "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz" - integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== +morgan@^1.10.0: + version "1.10.1" + resolved "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz" + integrity sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A== dependencies: basic-auth "~2.0.1" debug "2.6.9" depd "~2.0.0" on-finished "~2.3.0" - on-headers "~1.0.2" + on-headers "~1.1.0" mpath@0.9.0: version "0.9.0" @@ -11175,11 +11134,6 @@ mquery@5.0.0: dependencies: debug "4.x" -ms@^2.1.1, ms@^2.1.3, ms@2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -11190,6 +11144,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mustache@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz" @@ -11210,16 +11169,16 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== - negotiator@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.6.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" @@ -11334,14 +11293,7 @@ node-source-walk@^4.0.0, node-source-walk@^4.2.0, node-source-walk@^4.2.2: dependencies: "@babel/parser" "^7.0.0" -node-source-walk@^5.0.0: - version "5.0.2" - resolved "https://registry.npmjs.org/node-source-walk/-/node-source-walk-5.0.2.tgz" - integrity sha512-Y4jr/8SRS5hzEdZ7SGuvZGwfORvNsSsNRwDXx5WisiqzsVfeftDvRgfeqWNgZvWSJbgubTRVRYBzK6UO+ErqjA== - dependencies: - "@babel/parser" "^7.21.4" - -node-source-walk@^5.0.1: +node-source-walk@^5.0.0, node-source-walk@^5.0.1: version "5.0.2" resolved "https://registry.npmjs.org/node-source-walk/-/node-source-walk-5.0.2.tgz" integrity sha512-Y4jr/8SRS5hzEdZ7SGuvZGwfORvNsSsNRwDXx5WisiqzsVfeftDvRgfeqWNgZvWSJbgubTRVRYBzK6UO+ErqjA== @@ -11510,7 +11462,7 @@ oas-validator@^5.0.8: should "^13.2.1" yaml "^1.10.0" -oas@^20.0.0, oas@^20.11.0, oas@^20.5.0: +oas@^20.11.0, oas@^20.5.0: version "20.11.0" resolved "https://registry.npmjs.org/oas/-/oas-20.11.0.tgz" integrity sha512-Eio2qil8z86PD8KJTdyGPN7hbcF3dV0gc5B6mkSQgL3MLMVFE/XyVAmq/9BRh51zkIXOPmZpSIF3nStADbnkHA== @@ -11616,7 +11568,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@^2.4.1, on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -11654,7 +11606,7 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" -openapi-types@^12.1.0, openapi-types@>=7: +openapi-types@^12.1.0: version "12.1.3" resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz" integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== @@ -11929,16 +11881,6 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" -path-to-regexp@^6.2.0: - version "6.3.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz" - integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== - -path-to-regexp@^8.0.0: - version "8.3.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz" - integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== - path-to-regexp@0.1.12: version "0.1.12" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz" @@ -11949,6 +11891,16 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@^6.2.0: + version "6.3.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz" + integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== + +path-to-regexp@^8.0.0: + version "8.3.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz" + integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" @@ -11959,16 +11911,16 @@ pg-cloudflare@^1.2.7: resolved "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz" integrity sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg== -pg-connection-string@^2.9.1: - version "2.9.1" - resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz" - integrity sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w== - pg-connection-string@2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz" integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== +pg-connection-string@^2.9.1: + version "2.9.1" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz" + integrity sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w== + pg-int8@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz" @@ -11995,7 +11947,7 @@ pg-types@2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.11.3, pg@>=8.0: +pg@^8.11.3: version "8.16.3" resolved "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz" integrity sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw== @@ -12078,7 +12030,7 @@ pino-std-serializers@^6.0.0, pino-std-serializers@^6.2.2: resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz" integrity sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA== -pino@^8.17.1, pino@8.21.0: +pino@8.21.0, pino@^8.17.1: version "8.21.0" resolved "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz" integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== @@ -12148,7 +12100,7 @@ postcss-values-parser@^6.0.2: is-url-superb "^4.0.0" quote-unquote "^1.0.0" -postcss@^8.1.7, postcss@^8.2.9, postcss@^8.4.19, postcss@^8.4.23: +postcss@^8.1.7, postcss@^8.4.19, postcss@^8.4.23: version "8.5.6" resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -12233,7 +12185,7 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^3.0.3, prettier@>=3.0.0: +prettier@^3.0.3: version "3.6.2" resolved "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz" integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== @@ -12338,28 +12290,10 @@ protobufjs-cli@1.1.1: tmp "^0.2.1" uglify-js "^3.7.7" -protobufjs@^7.0.0, protobufjs@^7.2.4, protobufjs@^7.2.5, protobufjs@^7.5.3: - version "7.5.4" - resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz" - integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.4" - "@protobufjs/eventemitter" "^1.1.0" - "@protobufjs/fetch" "^1.1.0" - "@protobufjs/float" "^1.0.2" - "@protobufjs/inquire" "^1.1.0" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.0" - "@types/node" ">=13.7.0" - long "^5.0.0" - -protobufjs@7.2.4: - version "7.2.4" - resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz" - integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== +protobufjs@7.2.4, protobufjs@7.2.5, protobufjs@^7.0.0, protobufjs@^7.2.4, protobufjs@^7.2.5, protobufjs@^7.5.3: + version "7.2.5" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.5.tgz#45d5c57387a6d29a17aab6846dcc283f9b8e7f2d" + integrity sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2" @@ -12446,13 +12380,6 @@ pushdata-bitcoin@1.0.1: dependencies: bitcoin-ops "^1.3.0" -qs@^6.10.5, qs@^6.12.3, qs@^6.14.0, qs@^6.14.1, qs@^6.4.0, qs@^6.9.4: - version "6.14.1" - resolved "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz" - integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== - dependencies: - side-channel "^1.1.0" - qs@6.11.0: version "6.11.0" resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz" @@ -12467,6 +12394,13 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" +qs@^6.10.5, qs@^6.12.3, qs@^6.14.0, qs@^6.14.1, qs@^6.4.0, qs@^6.9.4: + version "6.14.1" + resolved "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz" + integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + dependencies: + side-channel "^1.1.0" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz" @@ -12511,24 +12445,6 @@ rate-limiter-flexible@^3.0.0: resolved "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-3.0.6.tgz" integrity sha512-tlvbee6lyse/XTWmsuBDS4MT8N65FyM151bPmQlFyfhv9+RIHs7d3rSTXoz0j35H910dM01mH0yTIeWYo8+aAw== -raw-body@^3.0.0, raw-body@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz" - integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.7.0" - unpipe "~1.0.0" - -raw-body@~1.1.0: - version "1.1.7" - resolved "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz" - integrity sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg== - dependencies: - bytes "1" - string_decoder "0.10" - raw-body@2.5.1: version "2.5.1" resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz" @@ -12549,6 +12465,24 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3.0.0, raw-body@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + +raw-body@~1.1.0: + version "1.1.7" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz" + integrity sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg== + dependencies: + bytes "1" + string_decoder "0.10" + rc@^1.2.7: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" @@ -12569,7 +12503,7 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react@*, "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", react@^18.2.0: +react@^18.2.0: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -12589,25 +12523,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.2.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.1.1: - version "3.6.2" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^3.4.0: - version "3.6.2" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^3.6.0: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -12686,7 +12602,9 @@ redis@3.1.2: redis-parser "^3.0.0" redlock@^5.0.0-beta.2: - version "v5.0.0-beta.2" + version "5.0.0-beta.2" + resolved "https://registry.yarnpkg.com/redlock/-/redlock-5.0.0-beta.2.tgz#a629c07e07d001c0fdd9f2efa614144c4416fe44" + integrity sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw== dependencies: node-abort-controller "^3.0.1" @@ -12905,14 +12823,7 @@ reusify@^1.0.4: resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rimraf@^2.6.1: - version "2.7.1" - resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^2.6.2: +rimraf@^2.6.1, rimraf@^2.6.2: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -12963,20 +12874,15 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0, safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-identifier@^0.4.1: version "0.4.2" @@ -13052,17 +12958,7 @@ secure-json-parse@^2.4.0: resolved "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz" integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== -semver@^6.0.0: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^6.3.0: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^6.3.1: +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -13072,23 +12968,6 @@ semver@^7.1.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2, semve resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -send@^1.1.0, send@^1.2.0: - version "1.2.1" - resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz" - integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== - dependencies: - debug "^4.4.3" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - fresh "^2.0.0" - http-errors "^2.0.1" - mime-types "^3.0.2" - ms "^2.1.3" - on-finished "^2.4.1" - range-parser "^1.2.1" - statuses "^2.0.2" - send@0.18.0: version "0.18.0" resolved "https://registry.npmjs.org/send/-/send-0.18.0.tgz" @@ -13127,6 +13006,23 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz" @@ -13140,7 +13036,17 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" -serve-static@^1.14.1, serve-static@1.16.2: +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +serve-static@1.16.2, serve-static@^1.14.1: version "1.16.2" resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz" integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== @@ -13160,16 +13066,6 @@ serve-static@^2.2.0: parseurl "^1.3.3" send "^1.2.0" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" @@ -13216,16 +13112,16 @@ setimmediate@^1.0.4, setimmediate@^1.0.5: resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== -setprototypeof@~1.2.0, setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz" integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== +setprototypeof@1.2.0, setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + sha.js@^2.4.0, sha.js@^2.4.11: version "2.4.12" resolved "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz" @@ -13398,19 +13294,11 @@ sonic-boom@^3.0.0, sonic-boom@^3.7.0: dependencies: atomic-sleep "^1.0.0" -source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0": +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map-support@^0.5.12: - version "0.5.21" - resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - source-map-support@0.5.13: version "0.5.13" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz" @@ -13419,6 +13307,14 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" +source-map-support@^0.5.12: + version "0.5.21" + resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map@^0.5.3: version "0.5.7" resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" @@ -13518,25 +13414,20 @@ static-eval@2.0.2: dependencies: escodegen "^1.8.1" -statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" - integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== - -"statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -statuses@~1.5.0: +"statuses@>= 1.4.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== stop-iteration-iterator@^1.1.0: version "1.1.0" @@ -13573,25 +13464,6 @@ stream-to-array@^2.3.0: dependencies: any-promise "^1.1.0" -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -string_decoder@0.10: - version "0.10.31" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" - integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== - string-length@^4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -13669,6 +13541,25 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@0.10: + version "0.10.31" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + stringify-object@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz" @@ -13721,7 +13612,7 @@ strip-final-newline@^2.0.0: resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== -strip-json-comments@^2.0.0: +strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== @@ -13731,11 +13622,6 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== - strnum@^1.1.1: version "1.1.2" resolved "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz" @@ -13778,14 +13664,7 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.1.1: +supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -13982,13 +13861,6 @@ tiny-lr@^1.1.1: object-assign "^4.1.0" qs "^6.4.0" -tiny-secp256k1@^2.2.3: - version "2.2.4" - resolved "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz" - integrity sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q== - dependencies: - uint8array-tools "0.0.7" - tiny-secp256k1@2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.2.tgz" @@ -14003,10 +13875,12 @@ tiny-secp256k1@2.2.3: dependencies: uint8array-tools "0.0.7" -tmp@^0.2.1: - version "0.2.5" - resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz" - integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== +tiny-secp256k1@^2.2.3: + version "2.2.4" + resolved "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz" + integrity sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q== + dependencies: + uint8array-tools "0.0.7" tmp@0.2.1: version "0.2.1" @@ -14015,6 +13889,11 @@ tmp@0.2.1: dependencies: rimraf "^3.0.0" +tmp@^0.2.1: + version "0.2.5" + resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz" @@ -14036,7 +13915,7 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -toidentifier@~1.0.1, toidentifier@1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -14121,7 +14000,7 @@ ts-node-dev@^2.0.0: ts-node "^10.4.0" tsconfig "^7.0.0" -ts-node@^10.4.0, ts-node@^10.9.1, ts-node@>=9.0.0: +ts-node@^10.4.0, ts-node@^10.9.1: version "10.9.2" resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -14140,17 +14019,7 @@ ts-node@^10.4.0, ts-node@^10.9.1, ts-node@>=9.0.0: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -tsconfig-paths@^3.10.1: - version "3.15.0" - resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz" - integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== - dependencies: - "@types/json5" "^0.0.29" - json5 "^1.0.2" - minimist "^1.2.6" - strip-bom "^3.0.0" - -tsconfig-paths@^3.15.0: +tsconfig-paths@^3.10.1, tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz" integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== @@ -14230,7 +14099,7 @@ twilio@^4.17.0: url-parse "^1.5.9" xmlbuilder "^13.0.2" -type-check@^0.4.0: +type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== @@ -14244,18 +14113,16 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - type-detect@4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.3.2.tgz" + integrity sha512-VpwuOgnTsQUUWi0id8Hl4/xiQ+OoaeJGe8dnFjzubJYe/lOc2/d1Qx/d3FqWR0FlpOG/cvukAXfB12A49Y4iiA== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" @@ -14266,11 +14133,6 @@ type-fest@^0.21.3: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-fest@4.3.2: - version "4.3.2" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.3.2.tgz" - integrity sha512-VpwuOgnTsQUUWi0id8Hl4/xiQ+OoaeJGe8dnFjzubJYe/lOc2/d1Qx/d3FqWR0FlpOG/cvukAXfB12A49Y4iiA== - type-is@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz" @@ -14348,30 +14210,20 @@ typeforce@^1.11.3, typeforce@^1.11.5, typeforce@^1.18.0: resolved "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz" integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g== -typescript@*, "typescript@^3.9.5 || ^4.9.5 || ^5", typescript@^5.2.2, typescript@>=2.7, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@>=4.2.0, typescript@>=4.7.2, typescript@>=5.0.0: - version "5.9.3" - resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - -typescript@^3.9.10: +typescript@^3.9.10, typescript@^3.9.7: version "3.9.10" resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz" integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== -typescript@^3.9.7: - version "3.9.10" - resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz" - integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== - -typescript@^4.0.0: +typescript@^4.0.0, typescript@^4.9.5: version "4.9.5" resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.2.2: + version "5.9.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" @@ -14421,16 +14273,16 @@ underscore.string@~3.3.5: sprintf-js "^1.1.1" util-deprecate "^1.0.2" -underscore@~1.13.2: - version "1.13.7" - resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz" - integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== - underscore@1.12.1: version "1.12.1" resolved "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz" integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== +underscore@~1.13.2: + version "1.13.7" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz" + integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== + undici-types@~6.21.0: version "6.21.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz" @@ -14493,7 +14345,7 @@ unixify@1.0.0: dependencies: normalize-path "^2.1.1" -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== @@ -14564,22 +14416,12 @@ uuid@^10.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^8.0.0: - version "8.3.2" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -uuid@^8.3.2: +uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - -uuid@^9.0.1: +uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -14640,17 +14482,17 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz" integrity sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg== -value-or-promise@^1.0.12: - version "1.0.12" - resolved "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz" - integrity sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q== - value-or-promise@1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.11.tgz" integrity sha512-41BrgH+dIbCFXClcSapVs5M6GkENd3gQOJpEfPDNa71LsUGMXDL0jMWpI/Rh7WhX+Aalfz2TTS3Zt5pUsbnhLg== -varuint-bitcoin@^1.1.2, varuint-bitcoin@1.1.2: +value-or-promise@^1.0.12: + version "1.0.12" + resolved "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz" + integrity sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q== + +varuint-bitcoin@1.1.2, varuint-bitcoin@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz" integrity sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw== @@ -14674,7 +14516,7 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" -wcwidth@^1.0.1, wcwidth@>=1.0.1: +wcwidth@>=1.0.1, wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz" integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== @@ -14907,6 +14749,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@8.14.2: + version "8.14.2" + resolved "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== + ws@^3.2.0: version "3.3.3" resolved "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz" @@ -14921,11 +14768,6 @@ ws@^8.14.1: resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz" integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== -ws@8.14.2: - version "8.14.2" - resolved "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz" - integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== - xml@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz" @@ -15040,7 +14882,7 @@ zod-to-json-schema@^3.25.0: resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== -zod@^3.0.0, zod@^3.22.0, zod@^3.22.2, "zod@^3.25 || ^4", "zod@^3.25 || ^4.0": +zod@^3.22.0, zod@^3.22.2, "zod@^3.25 || ^4.0": version "3.25.76" resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== From 29827edae52cb42ebef1a42fbfe328d2cd7b099f Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 25 May 2026 15:41:48 -0400 Subject: [PATCH 32/33] feat(cutover): add client-aware cash wallet presentation --- .../cash-wallet-cutover/client-capability.ts | 37 +++++ src/app/cash-wallet-cutover/errors.ts | 2 + src/app/cash-wallet-cutover/guard.ts | 35 ++++- src/app/cash-wallet-cutover/index.ts | 8 +- .../presentation-for-account.ts | 94 ++++++++++++ src/app/cash-wallet-cutover/presentation.ts | 82 +++++++++++ src/graphql/error-map.ts | 8 ++ .../mutation/intraledger-usd-payment-send.ts | 22 ++- .../ln-noamount-usd-invoice-fee-probe.ts | 28 ++-- .../ln-noamount-usd-invoice-payment-send.ts | 19 ++- .../root/mutation/ln-usd-invoice-create.ts | 21 ++- .../root/mutation/ln-usd-invoice-fee-probe.ts | 19 ++- .../root/mutation/onchain-usd-payment-send.ts | 31 ++-- .../root/query/account-default-wallet-id.ts | 14 +- .../root/query/account-default-wallet.ts | 23 +-- .../public/types/object/business-account.ts | 65 ++++++--- .../public/types/object/consumer-account.ts | 47 ++++-- src/graphql/shared/types/object/usd-wallet.ts | 35 ++++- src/servers/graphql-main-server.ts | 17 ++- src/servers/index.files.d.ts | 1 + src/servers/middlewares/session.ts | 5 +- src/servers/ws-server.ts | 19 ++- .../client-capability.spec.ts | 46 ++++++ .../cash-wallet-cutover/cutover-gate.spec.ts | 76 +++++++++- .../presentation-for-account.spec.ts | 135 ++++++++++++++++++ .../cash-wallet-cutover/presentation.spec.ts | 120 ++++++++++++++++ .../runtime-services.spec.ts | 4 +- .../app/cash-wallet-cutover/worker.spec.ts | 9 +- .../unit/graphql/cash-wallet-cutover.spec.ts | 4 +- .../shared/types/object/usd-wallet.spec.ts | 17 +++ 30 files changed, 939 insertions(+), 104 deletions(-) create mode 100644 src/app/cash-wallet-cutover/client-capability.ts create mode 100644 src/app/cash-wallet-cutover/presentation-for-account.ts create mode 100644 src/app/cash-wallet-cutover/presentation.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts create mode 100644 test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts 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/errors.ts b/src/app/cash-wallet-cutover/errors.ts index 1fa62da12..373dcd742 100644 --- a/src/app/cash-wallet-cutover/errors.ts +++ b/src/app/cash-wallet-cutover/errors.ts @@ -5,5 +5,7 @@ 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/guard.ts b/src/app/cash-wallet-cutover/guard.ts index e37f61369..214968d4c 100644 --- a/src/app/cash-wallet-cutover/guard.ts +++ b/src/app/cash-wallet-cutover/guard.ts @@ -2,9 +2,17 @@ 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", @@ -26,16 +34,16 @@ export const evaluateCashWalletCutoverGuard = ({ }: { cutover: CashWalletCutoverConfig migration?: CashWalletMigration | null -}): { route: "legacy_usd" | "eth_usdt" } | ApplicationError => { +}): { route: CashWalletCutoverRoute } | ApplicationError => { if (cutover.state === "pre") return { route: "legacy_usd" } - if (cutover.state === "complete") return { route: "eth_usdt" } + 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: "eth_usdt" } + return { route: "usdt" } } if (migration.status === "failed" || migration.status === "requires_operator_review") { return new CashWalletMigrationFailedError() @@ -46,3 +54,24 @@ export const evaluateCashWalletCutoverGuard = ({ 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/index.ts b/src/app/cash-wallet-cutover/index.ts index 357dabbd0..eae3f76bd 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,7 +1,13 @@ 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 } from "./guard" +export { + evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, +} from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" 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/graphql/error-map.ts b/src/graphql/error-map.ts index 4cf3864ff..480711d87 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -808,6 +808,14 @@ export const mapError = (error: ApplicationError): CustomApolloError => { 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 }) 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/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/types/object/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index 27096a000..496527e95 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,15 @@ 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`) + } + + return Number(BigInt(wholeMicros) / 10_000n) +} + const UsdWallet = GT.Object({ name: "UsdWallet", description: @@ -49,17 +59,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.asSmallestUnits(8)) + return usdtMicrosToUsdCents(balance.asSmallestUnits()) } return Number(balance.asCents(8)) }, 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/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts b/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts new file mode 100644 index 000000000..cfe6211e8 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts @@ -0,0 +1,46 @@ +import { + CASH_WALLET_USDT_CLIENT_CAPABILITY, + parseCashWalletClientCapabilities, +} from "@app/cash-wallet-cutover/client-capability" + +describe("cash wallet client capability parser", () => { + it("defaults missing headers to legacy compatibility", () => { + expect(parseCashWalletClientCapabilities({})).toEqual({ + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }) + }) + + it("treats unknown capabilities as legacy compatibility", () => { + expect( + parseCashWalletClientCapabilities({ + "x-flash-client-capabilities": "contacts-v2", + }), + ).toEqual({ + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }) + }) + + it("detects the USDT Cash Wallet capability", () => { + expect( + parseCashWalletClientCapabilities({ + "x-flash-client-capabilities": `contacts-v2, ${CASH_WALLET_USDT_CLIENT_CAPABILITY}`, + }), + ).toEqual({ + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }) + }) + + it("accepts native client connection-param casing", () => { + expect( + parseCashWalletClientCapabilities({ + "X-Flash-Client-Capabilities": CASH_WALLET_USDT_CLIENT_CAPABILITY, + }), + ).toEqual({ + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts index 0d79195a4..356ce2a5f 100644 --- a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts @@ -2,6 +2,7 @@ import { CashWalletCutoverInProgressError, CashWalletMigrationFailedError, evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, } from "@app/cash-wallet-cutover/guard" const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ @@ -24,6 +25,16 @@ const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ updatedAt: new Date("2026-05-19T00:00:00Z"), }) +const legacyClient = { + cashWalletPresentation: "legacy_compat" as const, + hasUsdtCashWalletSupport: false, +} + +const usdtClient = { + cashWalletPresentation: "usdt" as const, + hasUsdtCashWalletSupport: true, +} + describe("cash wallet cutover guard", () => { it("allows legacy route before cutover starts", () => { expect(evaluateCashWalletCutoverGuard({ cutover: config("pre") })).toEqual({ @@ -58,13 +69,13 @@ describe("cash wallet cutover guard", () => { } }) - it("routes completed accounts to ETH-USDT during cutover", () => { + it("routes completed accounts to USDT during cutover", () => { expect( evaluateCashWalletCutoverGuard({ cutover: config("in_progress"), migration: migration("complete"), }), - ).toEqual({ route: "eth_usdt" }) + ).toEqual({ route: "usdt" }) }) it("rejects failed and manual-review migrations", () => { @@ -78,9 +89,66 @@ describe("cash wallet cutover guard", () => { } }) - it("routes all accounts to ETH-USDT after global completion", () => { + it("routes all accounts to USDT after global completion", () => { expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({ - route: "eth_usdt", + route: "usdt", + }) + }) +}) + +describe("cash wallet cutover presentation", () => { + it("presents legacy USD before cutover starts", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("pre"), + client: usdtClient, + }), + ).toEqual({ + presentation: "legacy_usd", + }) + }) + + it("presents completed accounts as legacy-compatible for old clients", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("in_progress"), + migration: migration("complete"), + client: legacyClient, + }), + ).toEqual({ + presentation: "legacy_usd_compat", + }) + }) + + it("presents completed accounts as USDT for capable clients", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("in_progress"), + migration: migration("complete"), + client: usdtClient, + }), + ).toEqual({ + presentation: "usdt", + }) + }) + + it("uses client capability after global completion", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("complete"), + client: legacyClient, + }), + ).toEqual({ + presentation: "legacy_usd_compat", + }) + + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("complete"), + client: usdtClient, + }), + ).toEqual({ + presentation: "usdt", }) }) }) diff --git a/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts b/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts new file mode 100644 index 000000000..7e9557733 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts @@ -0,0 +1,135 @@ +jest.mock("@services/mongoose", () => ({ + CashWalletCutoverRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +import { + resolveCashWalletMutationWalletIdForAccount, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover/presentation-for-account" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = { id: "account-id", defaultWalletId: "legacy-usd-wallet-id" } as Account + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + accountId: account.id, + type: WalletType.Checking, + currency, + }) as Wallet + +const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "usdt-wallet-id", + currency: WalletCurrency.Usdt, +}) + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 2, + runId: "run-2", + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: account.id, + legacyUsdWalletId: legacyUsdWallet.id, + destinationUsdtWalletId: usdtWallet.id, + cutoverVersion: 2, + runId: "run-2", + status, + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +describe("cash wallet presentation for account", () => { + it("uses existing migration lookup and presents old clients as legacy-compatible after migration", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("in_progress")), + findMigrationByAccountId: jest.fn(async () => migration("complete")), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletPresentationForAccount({ + account, + client: { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + wallets: [legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + expect(migrationsRepo.findMigrationByAccountId).toHaveBeenCalledWith({ + accountId: account.id, + cutoverVersion: 2, + runId: "run-2", + }) + }) + + it("does not require migration lookup after global completion", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("complete")), + findMigrationByAccountId: jest.fn(), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletPresentationForAccount({ + account, + client: { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + wallets: [usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + expect(migrationsRepo.findMigrationByAccountId).not.toHaveBeenCalled() + }) + + it("routes old-client legacy USD mutation wallet ids to the active settlement wallet", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("in_progress")), + findMigrationByAccountId: jest.fn(async () => migration("complete")), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletMutationWalletIdForAccount({ + account, + walletId: legacyUsdWallet.id, + client: { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toBe(usdtWallet.id) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts new file mode 100644 index 000000000..8eb42fdc1 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts @@ -0,0 +1,120 @@ +import { + CashWalletMissingLegacyUsdWalletError, + CashWalletMissingUsdtWalletError, +} from "@app/cash-wallet-cutover/errors" +import { + cashWalletTransactionWalletIdsForPresentation, + resolveCashWalletPresentation, +} from "@app/cash-wallet-cutover/presentation" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + accountId: "account-id", + type: WalletType.Checking, + currency, + }) as Wallet + +const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "usdt-wallet-id", + currency: WalletCurrency.Usdt, +}) +const btcWallet = wallet({ + id: "btc-wallet-id", + currency: WalletCurrency.Btc, +}) + +describe("cash wallet presentation resolver", () => { + it("returns the legacy USD wallet as the active settlement wallet before cutover", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: legacyUsdWallet, + }) + }) + + it("presents legacy USD while routing settlement to USDT for old clients after migration", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd_compat" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + }) + + it("presents the USDT wallet directly for capable clients", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "usdt" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + }) + + it("returns cutover-state errors for missing presentation wallets", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd" }, + wallets: [usdtWallet], + }), + ).toBeInstanceOf(CashWalletMissingLegacyUsdWalletError) + + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd_compat" }, + wallets: [legacyUsdWallet], + }), + ).toBeInstanceOf(CashWalletMissingUsdtWalletError) + }) +}) + +describe("cash wallet transaction wallet ids for presentation", () => { + it("uses the active settlement wallet when defaulting legacy-compatible history", () => { + expect( + cashWalletTransactionWalletIdsForPresentation({ + presentation: { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }, + }), + ).toEqual([btcWallet.id, usdtWallet.id]) + }) + + it("remaps explicit legacy USD wallet ids to active settlement wallet ids", () => { + expect( + cashWalletTransactionWalletIdsForPresentation({ + walletIds: [legacyUsdWallet.id], + presentation: { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }, + }), + ).toEqual([usdtWallet.id]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts index 1a83083b1..45e793e60 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -139,7 +139,7 @@ describe("cash wallet migration runtime services", () => { }) expect(result).toEqual({ transactionId: "ibex-tx-id" }) - const paymentArgs = deps.payInvoice.mock.calls[0][0] + const paymentArgs = deps.payInvoice.mock.calls[0][0]! expect(paymentArgs.accountId).toBe("legacy-usd-wallet-id") expect(paymentArgs.invoice).toBe("lnbc1payment") expect(paymentArgs.send).toBeInstanceOf(USDAmount) @@ -188,7 +188,7 @@ describe("cash wallet migration runtime services", () => { defaultWalletId: "legacy-usd-wallet-id" as WalletId, })), }, - updateDefaultWalletId: jest.fn(async () => ({})), + updateDefaultWalletId: jest.fn(async () => ({ defaultWalletId: "usdt-wallet-id" })), } const services = createCashWalletMigrationRuntimeServices(deps) diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 1feb58287..0d27c14ae 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -89,7 +89,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("provisioned")), } const provisioningService = { - ensureDestinationWallet: jest.fn(async () => true), + ensureDestinationWallet: jest.fn(async () => true as const), } const result = await provisionCashWalletMigrationDestination({ @@ -177,6 +177,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("provisioned"), migrationsRepo, sourceBalanceUsdCents: "12.34", + destinationStartingBalanceUsdtMicros: "0", }) expect(result).toBeInstanceOf(Error) @@ -404,7 +405,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("balance_move_verified")), } const balanceVerifier = { - verifyBalanceMove: jest.fn(async () => true), + verifyBalanceMove: jest.fn(async () => true as const), } const result = await verifyCashWalletMigrationBalanceMove({ @@ -651,6 +652,7 @@ describe("cash wallet migration worker checkpoints", () => { const result = await sendCashWalletMigrationFeeReimbursementPayment({ migration: migration("fee_reimbursement_invoice_created"), + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -674,6 +676,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("fee_reimbursement_invoice_created"), feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", }, + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -788,7 +791,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("legacy_zero_verified")), } const legacyWalletVerifier = { - verifyLegacyWalletZero: jest.fn(async () => true), + verifyLegacyWalletZero: jest.fn(async () => true as const), } const result = await verifyCashWalletMigrationLegacyZero({ diff --git a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts index 66a2fceea..f8e102a1e 100644 --- a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts @@ -24,7 +24,7 @@ describe("cash wallet cutover GraphQL surface", () => { jest.mocked(CashWalletCutoverRepository).mockReturnValue({ getConfig } as never) const result = await CashWalletCutoverQuery.resolve?.( - undefined, + null, {}, {} as GraphQLPublicContext, {} as never, @@ -49,7 +49,7 @@ describe("cash wallet cutover GraphQL surface", () => { jest.mocked(CashWalletCutoverRepository).mockReturnValue({ updateConfig } as never) const result = await CashWalletCutoverUpdateMutation.resolve?.( - undefined, + null, { input: { state: "in_progress", diff --git a/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts b/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts new file mode 100644 index 000000000..272bd40d6 --- /dev/null +++ b/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts @@ -0,0 +1,17 @@ +import { usdtMicrosToUsdCents } from "@graphql/shared/types/object/usd-wallet" + +describe("UsdWallet legacy compatibility balance", () => { + it("converts USDT micros to USD cents from integer smallest units", () => { + expect(usdtMicrosToUsdCents("10000000")).toBe(1000) + }) + + it("accepts formatted USDT smallest units from precision calls", () => { + expect(usdtMicrosToUsdCents("10000000.00000000")).toBe(1000) + }) + + it("rejects non-zero fractional micros", () => { + expect(() => usdtMicrosToUsdCents("10000000.5")).toThrow( + "Cannot convert fractional USDT micros", + ) + }) +}) From 533967c85a2e535d7cf7e80dc292c779efccfc44 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 25 May 2026 16:22:39 -0400 Subject: [PATCH 33/33] chore(cutover): clean operator artifact whitespace --- .../admin/cash-wallet-cutover/01-query-admin-state.bru | 1 - .../admin/cash-wallet-cutover/02-set-scheduled-pre.bru | 1 - .../admin/cash-wallet-cutover/03-set-in-progress.bru | 1 - .../admin/cash-wallet-cutover/04-set-complete.bru | 1 - .../admin/cash-wallet-cutover/folder.bru | 1 - .../notoken/queries/cash-wallet-cutover.bru | 1 - operator-runs/eng-345-manual-347/progress.md | 10 +++++----- 7 files changed, 5 insertions(+), 11 deletions(-) diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru index dfdf9e130..1cc27b2f2 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru @@ -46,4 +46,3 @@ script:post-response { ]) }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru index 2240d8493..963c26430 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru @@ -59,4 +59,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru index 222d93e80..e44b497d4 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru @@ -58,4 +58,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru index cb9d8ee55..c8ab4e0f8 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru @@ -58,4 +58,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru index 88076d4b9..c180d00b2 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru @@ -2,4 +2,3 @@ meta { name: cash-wallet-cutover seq: 10 } - diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru index 79a2c7427..1c6f7cd06 100644 --- a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru +++ b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru @@ -42,4 +42,3 @@ script:post-response { ]) }) } - diff --git a/operator-runs/eng-345-manual-347/progress.md b/operator-runs/eng-345-manual-347/progress.md index 7e433b28a..2455d031d 100644 --- a/operator-runs/eng-345-manual-347/progress.md +++ b/operator-runs/eng-345-manual-347/progress.md @@ -6,12 +6,12 @@ - 2026-05-23T13:37:48.713Z created account 5: 6a11adabe55310755eeb02bf USD=9756a443-d30d-4b1c-a797-933ec2ced1d0 USDT=fa7c7780-9246-4088-beb6-56fcd98e3209 funding=25 - 2026-05-23T13:37:49.430Z created account 6: 6a11adace55310755eeb02d9 USD=ecaf103c-d024-419a-8bad-b0c61e8068be USDT=53884f7a-0a55-4406-8b60-3c6d68a6c180 funding=25 - 2026-05-23T13:37:50.263Z created account 7: 6a11adade55310755eeb02f3 USD=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c USDT=80f0de1f-b4e6-4904-8c3f-d90bad763ce2 funding=25 -- 2026-05-23T13:37:50.789Z ERROR -- 2026-05-23T13:39:59.481Z ERROR -- 2026-05-23T13:41:36.710Z ERROR +- 2026-05-23T13:37:50.789Z ERROR +- 2026-05-23T13:39:59.481Z ERROR +- 2026-05-23T13:41:36.710Z ERROR - 2026-05-23T13:42:04.155Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json -- 2026-05-23T13:42:26.832Z ERROR -- 2026-05-23T13:44:28.884Z ERROR +- 2026-05-23T13:42:26.832Z ERROR +- 2026-05-23T13:44:28.884Z ERROR - 2026-05-23T13:50:13.799Z preview planned=7 - 2026-05-23T13:50:27.921Z prepared migrations=7 - 2026-05-23T13:50:44.208Z reset singleton cutover config to pre for manual-eng-347