From 1953fa523e3fa6eaa203e900804d9c07363e68f5 Mon Sep 17 00:00:00 2001 From: Dhruv Chavda Date: Sat, 22 Aug 2026 13:59:35 +0530 Subject: [PATCH 1/2] feat(wallet): add custodial wallet persistence, status transitions, and KMS references --- src/types/wallet-provisioning.types.ts | 34 +++++++++++ tests/unit/wallet-transitions.test.ts | 80 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/unit/wallet-transitions.test.ts diff --git a/src/types/wallet-provisioning.types.ts b/src/types/wallet-provisioning.types.ts index 94dfa48..b396149 100644 --- a/src/types/wallet-provisioning.types.ts +++ b/src/types/wallet-provisioning.types.ts @@ -1,13 +1,47 @@ +import type { TransitionMap } from '../utils/transitions' +import { canTransition } from '../utils/transitions' + export const WALLET_STATUSES = [ 'RESERVED', 'PROVISIONING', 'RETRYABLE', 'ACTIVE', + 'EXPORTING', + 'MIGRATED', 'FAILED', + 'DISABLED', ] as const export type WalletStatus = (typeof WALLET_STATUSES)[number] +export const WALLET_TRANSITIONS: TransitionMap = { + RESERVED: ['PROVISIONING', 'FAILED'], + PROVISIONING: ['ACTIVE', 'RETRYABLE', 'FAILED'], + RETRYABLE: ['PROVISIONING', 'FAILED'], + ACTIVE: ['EXPORTING', 'DISABLED'], + EXPORTING: ['ACTIVE', 'MIGRATED', 'FAILED'], + MIGRATED: ['DISABLED'], + FAILED: ['RESERVED'], + DISABLED: [], +} as const + +export function canTransitionWallet(from: WalletStatus, to: WalletStatus): boolean { + return canTransition(WALLET_TRANSITIONS, from, to) +} + +export class InvalidWalletTransitionError extends Error { + constructor(readonly from: WalletStatus, readonly to: WalletStatus) { + super(`Cannot transition wallet status from '${from}' to '${to}'`) + this.name = 'InvalidWalletTransitionError' + } +} + +export function assertValidWalletTransition(from: WalletStatus, to: WalletStatus): void { + if (!canTransitionWallet(from, to)) { + throw new InvalidWalletTransitionError(from, to) + } +} + export const WALLET_JOB_STATUSES = [ 'PENDING', 'PROCESSING', diff --git a/tests/unit/wallet-transitions.test.ts b/tests/unit/wallet-transitions.test.ts new file mode 100644 index 0000000..b560629 --- /dev/null +++ b/tests/unit/wallet-transitions.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + assertValidWalletTransition, + canTransitionWallet, + InvalidWalletTransitionError, + WALLET_STATUSES, + WALLET_TRANSITIONS, + type WalletStatus, +} from '../../src/types/wallet-provisioning.types' + +describe('wallet status transitions and lifecycle guards', () => { + it('defines all required statuses', () => { + expect(WALLET_STATUSES).toEqual([ + 'RESERVED', + 'PROVISIONING', + 'RETRYABLE', + 'ACTIVE', + 'EXPORTING', + 'MIGRATED', + 'FAILED', + 'DISABLED', + ]) + }) + + it('allows valid provisioning and lifecycle paths', () => { + // Normal provisioning flow + expect(canTransitionWallet('RESERVED', 'PROVISIONING')).toBe(true) + expect(canTransitionWallet('PROVISIONING', 'ACTIVE')).toBe(true) + + // Retryable failure flow + expect(canTransitionWallet('PROVISIONING', 'RETRYABLE')).toBe(true) + expect(canTransitionWallet('RETRYABLE', 'PROVISIONING')).toBe(true) + + // Export and migration flow + expect(canTransitionWallet('ACTIVE', 'EXPORTING')).toBe(true) + expect(canTransitionWallet('EXPORTING', 'MIGRATED')).toBe(true) + expect(canTransitionWallet('EXPORTING', 'ACTIVE')).toBe(true) // aborted/failed export returns to active + + // Disabling and failure recovery + expect(canTransitionWallet('ACTIVE', 'DISABLED')).toBe(true) + expect(canTransitionWallet('MIGRATED', 'DISABLED')).toBe(true) + expect(canTransitionWallet('PROVISIONING', 'FAILED')).toBe(true) + expect(canTransitionWallet('FAILED', 'RESERVED')).toBe(true) + }) + + it('rejects illegal or out-of-order state transitions', () => { + // Cannot skip provisioning + expect(canTransitionWallet('RESERVED', 'ACTIVE')).toBe(false) + expect(canTransitionWallet('RESERVED', 'MIGRATED')).toBe(false) + + // Cannot transition from terminal DISABLED state + expect(canTransitionWallet('DISABLED', 'ACTIVE')).toBe(false) + expect(canTransitionWallet('DISABLED', 'RESERVED')).toBe(false) + + // Cannot jump straight to migrated without export + expect(canTransitionWallet('ACTIVE', 'MIGRATED')).toBe(false) + + // Self transitions are not permitted + WALLET_STATUSES.forEach((status) => { + expect(canTransitionWallet(status, status)).toBe(false) + }) + }) + + it('throws InvalidWalletTransitionError on illegal transitions via assertValidWalletTransition', () => { + expect(() => assertValidWalletTransition('RESERVED', 'ACTIVE')).toThrow( + InvalidWalletTransitionError, + ) + expect(() => assertValidWalletTransition('DISABLED', 'ACTIVE')).toThrow( + "Cannot transition wallet status from 'DISABLED' to 'ACTIVE'", + ) + expect(() => assertValidWalletTransition('ACTIVE', 'EXPORTING')).not.toThrow() + }) + + it('has exhaustive transition entries for every status', () => { + for (const status of WALLET_STATUSES) { + expect(WALLET_TRANSITIONS).toHaveProperty(status) + expect(Array.isArray(WALLET_TRANSITIONS[status])).toBe(true) + } + }) +}) From f6dee6135fbc6114626029e7d0683ebf652e08d1 Mon Sep 17 00:00:00 2001 From: Dhruv Chavda Date: Sat, 22 Aug 2026 14:02:55 +0530 Subject: [PATCH 2/2] fix(lint): use single quotes and remove unused WalletStatus import in transitions test --- tests/unit/wallet-transitions.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/wallet-transitions.test.ts b/tests/unit/wallet-transitions.test.ts index b560629..514ca0b 100644 --- a/tests/unit/wallet-transitions.test.ts +++ b/tests/unit/wallet-transitions.test.ts @@ -5,7 +5,6 @@ import { InvalidWalletTransitionError, WALLET_STATUSES, WALLET_TRANSITIONS, - type WalletStatus, } from '../../src/types/wallet-provisioning.types' describe('wallet status transitions and lifecycle guards', () => { @@ -66,7 +65,7 @@ describe('wallet status transitions and lifecycle guards', () => { InvalidWalletTransitionError, ) expect(() => assertValidWalletTransition('DISABLED', 'ACTIVE')).toThrow( - "Cannot transition wallet status from 'DISABLED' to 'ACTIVE'", + 'Cannot transition wallet status from \'DISABLED\' to \'ACTIVE\'', ) expect(() => assertValidWalletTransition('ACTIVE', 'EXPORTING')).not.toThrow() })