Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
293629e
fix(graphql): make usdt wallet balance nullable (#369)
forge0x May 25, 2026
f8e1e71
feat(cutover): add cash wallet migration state primitives
forge0x May 19, 2026
0abbddd
feat(cutover): persist cash wallet cutover state
forge0x May 19, 2026
fb519b5
feat(cutover): add cash wallet write guard
forge0x May 19, 2026
86a1204
feat(cutover): classify cash wallet migration candidates
forge0x May 20, 2026
1d77a82
feat(cutover): add cash wallet preflight summary
forge0x May 20, 2026
11ec908
feat(cutover): collect cash wallet discovery results
forge0x May 20, 2026
6eaab8c
feat(cutover): plan primary cash wallet migrations
forge0x May 20, 2026
f321798
feat(cutover): upsert planned migration records
forge0x May 20, 2026
9654a51
feat(cutover): prepare primary migration batch
forge0x May 20, 2026
0a85eff
feat(cutover): start migration worker checkpoint
forge0x May 20, 2026
c6de5d7
feat(cutover): record migration source balance
forge0x May 20, 2026
ff14a2c
feat(cutover): create balance move invoice checkpoint
forge0x May 20, 2026
cbc7bde
feat(cutover): send balance move payment checkpoint
forge0x May 20, 2026
49c1303
feat(cutover): verify balance move checkpoint
forge0x May 20, 2026
cc57275
feat(cutover): create fee reimbursement invoice checkpoint
forge0x May 20, 2026
acb481a
feat(cutover): complete fee reimbursement checkpoint
forge0x May 20, 2026
54547f9
feat(cutover): flip default wallet checkpoint
forge0x May 20, 2026
825dc58
feat(cutover): complete migration worker checkpoints
forge0x May 20, 2026
85a846a
feat(cutover): provision destination checkpoint
forge0x May 20, 2026
09b44be
feat(cutover): dispatch migration worker steps
forge0x May 20, 2026
544788c
feat(cutover): run locked migration batches
forge0x May 20, 2026
0c03cf5
feat(cutover): build migration step handlers
forge0x May 20, 2026
8169fcc
feat(cutover): wire migration runtime services
forge0x May 20, 2026
1540210
feat(cutover): orchestrate primary migration batches
forge0x May 20, 2026
1d4a49d
feat(cutover): add migration lifecycle controls
forge0x May 20, 2026
4ab0b6e
fix(cutover): align migration indexes with run ids
forge0x May 20, 2026
dcb39d9
feat(cutover): add operator command script
forge0x May 20, 2026
0d234cb
chore(cutover): format migration state helpers
forge0x May 20, 2026
93e72d1
feat(cutover): preview dry-run migration plan
forge0x May 20, 2026
d30b61d
fix(cutover): satisfy production build types
forge0x May 20, 2026
768c55e
feat(cutover): add operator controls and verification
forge0x May 25, 2026
8cd9bea
feat(cutover): add client-aware cash wallet presentation
forge0x May 25, 2026
77d3b5c
fix(cutover): harden operator cutover run (#376)
forge0x Jun 1, 2026
0d7757b
chore(cutover): remove local run artifacts
forge0x Jun 1, 2026
64d5377
chore(cutover): trim bridge PR noise
forge0x Jun 1, 2026
1ed6304
chore(cutover): move unit tests to separate PR
forge0x Jun 1, 2026
7b4137d
Potential fix for pull request finding
islandbitcoin Jun 1, 2026
390b832
Potential fix for pull request finding
islandbitcoin Jun 1, 2026
4b7c7a6
Potential fix for pull request finding
islandbitcoin Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ type BridgeWithdrawal
amount: String!
createdAt: String!
currency: String!
failureReason: String
id: ID!
status: String!
}
Expand Down Expand Up @@ -501,6 +502,29 @@ type CashoutOffer
walletId: WalletId!
}

type CashWalletCutover
@join__type(graph: PUBLIC)
{
completedAt: Timestamp
cutoverVersion: Int!
pauseReason: String
pausedAt: Timestamp
runId: String
scheduledAt: Timestamp
startedAt: Timestamp
state: CashWalletCutoverState!
updatedAt: Timestamp!
updatedBy: String
}

enum CashWalletCutoverState
@join__type(graph: PUBLIC)
{
COMPLETE @join__enumValue(graph: PUBLIC)
IN_PROGRESS @join__enumValue(graph: PUBLIC)
PRE @join__enumValue(graph: PUBLIC)
}

"""(Positive) Cent amount (1/100 of a dollar)"""
scalar CentAmount
@join__type(graph: PUBLIC)
Expand Down Expand Up @@ -1651,6 +1675,7 @@ type Query
btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice")
btcPriceList(range: PriceGraphRange!): [PricePoint]
businessMapMarkers: [MapMarker!]!
cashWalletCutover: CashWalletCutover!
currencyList: [Currency!]!
globals: Globals
isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload
Expand Down Expand Up @@ -2039,7 +2064,7 @@ type UsdtWallet implements Wallet
@join__type(graph: PUBLIC)
{
accountId: ID!
balance: FractionalCentAmount!
balance: FractionalCentAmount
id: ID!
isExternal: Boolean!
lnurlp: Lnurl
Expand Down
54 changes: 54 additions & 0 deletions src/app/cash-wallet-cutover/amount-conversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { InvalidCashWalletCutoverAmountError } from "./errors"

const USDT_MICROS_PER_USD_CENT = 10_000n

const parseNonNegativeInteger = (
value: string,
): bigint | InvalidCashWalletCutoverAmountError => {
if (!/^\d+$/.test(value)) {
return new InvalidCashWalletCutoverAmountError(
`Invalid non-negative integer amount: ${value}`,
)
}
return BigInt(value)
}

export const usdCentsToUsdtMicros = (
usdCents: string,
): string | InvalidCashWalletCutoverAmountError => {
const parsed = parseNonNegativeInteger(usdCents)
if (parsed instanceof Error) return parsed
return (parsed * USDT_MICROS_PER_USD_CENT).toString()
}

export const feeUsdCentsToUsdtMicros = usdCentsToUsdtMicros

export const usdtMicrosToUsdCentsCeil = (
usdtMicros: string,
): string | InvalidCashWalletCutoverAmountError => {
const parsed = parseNonNegativeInteger(usdtMicros)
if (parsed instanceof Error) return parsed
if (parsed === 0n) return "0"
return ((parsed + USDT_MICROS_PER_USD_CENT - 1n) / USDT_MICROS_PER_USD_CENT).toString()
}

export const destinationShortfallUsdtMicros = ({
targetUsdtMicros,
startingUsdtMicros,
currentUsdtMicros,
}: {
targetUsdtMicros: string
startingUsdtMicros: string
currentUsdtMicros: string
}): string | InvalidCashWalletCutoverAmountError => {
const target = parseNonNegativeInteger(targetUsdtMicros)
if (target instanceof Error) return target
const starting = parseNonNegativeInteger(startingUsdtMicros)
if (starting instanceof Error) return starting
const current = parseNonNegativeInteger(currentUsdtMicros)
if (current instanceof Error) return current

const received = current > starting ? current - starting : 0n
if (received >= target) return "0"
return (target - received).toString()
}
37 changes: 37 additions & 0 deletions src/app/cash-wallet-cutover/client-capability.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
): 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 }
}
78 changes: 78 additions & 0 deletions src/app/cash-wallet-cutover/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { WalletCurrency } from "@domain/shared"
import { WalletType } from "@domain/wallets"

export type CashWalletCutoverDiscoveryStatus =
| "legacy_default"
| "already_usdt"
| "residual_legacy_usd"
| "missing_legacy_usd"
| "missing_destination_usdt"

export type CashWalletCutoverDiscovery = {
status: CashWalletCutoverDiscoveryStatus
accountId: AccountId
accountUuid?: AccountUuid
legacyUsdWalletId?: WalletId
destinationUsdtWalletId?: WalletId
previousDefaultWalletId: WalletId
}

export const classifyCashWalletsForCutover = ({
account,
wallets,
}: {
account: Account
wallets: Wallet[]
}): CashWalletCutoverDiscovery => {
const legacyUsdWallet = wallets.find(
(wallet) =>
wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usd,
)
const destinationUsdtWallet = wallets.find(
(wallet) =>
wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usdt,
)

const base = {
accountId: account.id,
accountUuid: account.uuid,
legacyUsdWalletId: legacyUsdWallet?.id,
destinationUsdtWalletId: destinationUsdtWallet?.id,
previousDefaultWalletId: account.defaultWalletId,
}

if (!legacyUsdWallet) return { ...base, status: "missing_legacy_usd" }
if (!destinationUsdtWallet) return { ...base, status: "missing_destination_usdt" }

if (account.defaultWalletId === legacyUsdWallet.id) {
return { ...base, status: "legacy_default" }
}

if (account.defaultWalletId === destinationUsdtWallet.id) {
return { ...base, status: "already_usdt" }
}

return { ...base, status: "residual_legacy_usd" }
}

export const discoverCashWalletCutoverAccounts = async ({
accountsRepo,
walletsRepo,
}: {
accountsRepo: Pick<IAccountsRepository, "listUnlockedAccounts">
walletsRepo: Pick<IWalletsRepository, "listByAccountId">
}): Promise<CashWalletCutoverDiscovery[] | RepositoryError> => {
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
}
11 changes: 11 additions & 0 deletions src/app/cash-wallet-cutover/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { DomainError, ValidationError } from "@domain/shared"

export class InvalidCashWalletCutoverAmountError extends ValidationError {}
export class InvalidCashWalletMigrationTransitionError extends ValidationError {}
export class InvalidCashWalletCutoverStateTransitionError extends ValidationError {}
export class CashWalletCutoverInProgressError extends ValidationError {}
export class CashWalletMigrationFailedError extends DomainError {}
export class CashWalletMissingLegacyUsdWalletError extends DomainError {}
export class CashWalletMissingUsdtWalletError extends DomainError {}
export class CashWalletCutoverPreflightError extends DomainError {}
export class CashWalletCutoverTreasuryInsufficientBalanceError extends DomainError {}
39 changes: 39 additions & 0 deletions src/app/cash-wallet-cutover/executor.ts
Original file line number Diff line number Diff line change
@@ -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<CashWalletMigration | ApplicationError>

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<CashWalletMigration | ApplicationError> => {
if (terminalStatuses.includes(migration.status)) return migration

return handlers[migration.status as RunnableCashWalletMigrationStatus](migration)
}
77 changes: 77 additions & 0 deletions src/app/cash-wallet-cutover/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
CashWalletCutoverInProgressError,
CashWalletMigrationFailedError,
} from "./errors"
import { CashWalletClientCapabilities } from "./client-capability"

export { CashWalletCutoverInProgressError, CashWalletMigrationFailedError }

export type CashWalletCutoverRoute = "legacy_usd" | "usdt"
export type CashWalletCutoverPresentation = "legacy_usd" | "legacy_usd_compat" | "usdt"

export type CashWalletCutoverDecision = {
presentation: CashWalletCutoverPresentation
}

const ACTIVE_STATUSES: CashWalletMigrationStatus[] = [
"started",
"provisioned",
"balance_read",
"invoice_created",
"balance_move_sending",
"balance_move_sent",
"balance_move_verified",
"fee_reimbursement_invoice_created",
"fee_reimbursement_sending",
"fee_reimbursed",
"pointer_flipped",
"rollback_started",
]

export const evaluateCashWalletCutoverGuard = ({
cutover,
migration,
}: {
cutover: CashWalletCutoverConfig
migration?: CashWalletMigration | null
}): { route: CashWalletCutoverRoute } | ApplicationError => {
if (cutover.state === "pre") return { route: "legacy_usd" }
if (cutover.state === "complete") return { route: "usdt" }

if (!migration || migration.status === "not_started") return { route: "legacy_usd" }
if (
migration.status === "complete" ||
migration.status === "skipped_already_migrated"
) {
return { route: "usdt" }
}
if (migration.status === "failed" || migration.status === "requires_operator_review") {
return new CashWalletMigrationFailedError()
}
if (ACTIVE_STATUSES.includes(migration.status)) {
return new CashWalletCutoverInProgressError()
}

return { route: "legacy_usd" }
}

export const evaluateCashWalletCutoverPresentation = ({
cutover,
migration,
client,
}: {
cutover: CashWalletCutoverConfig
migration?: CashWalletMigration | null
client: CashWalletClientCapabilities
}): CashWalletCutoverDecision | ApplicationError => {
const guard = evaluateCashWalletCutoverGuard({ cutover, migration })
if (guard instanceof Error) return guard

if (guard.route === "legacy_usd") {
return { presentation: "legacy_usd" }
}

return {
presentation: client.hasUsdtCashWalletSupport ? "usdt" : "legacy_usd_compat",
}
}
Loading