From 16e77af63d1d4e67ccda8756cac2103c18f3818d Mon Sep 17 00:00:00 2001 From: Adeolu01 Date: Thu, 23 Jul 2026 18:27:49 +0100 Subject: [PATCH] feat(vault): add matured lock withdrawal flow Give matured vault locks a real withdrawal path: eligibility, an explicit confirmation, a loading state, and distinct success and failure states. - Add `evaluateWithdrawalEligibility`, which derives maturity from the lock's unlock date rather than its stored status, so a lock that matures while the screen is open is not reported as locked. - Add `vaultStore.withdrawMaturedLock`, which re-checks eligibility at submission time, submits through the vault service when a contract is configured, and removes the lock only after the transfer resolves so a failure leaves the vault untouched. - Add `useMaturedLockWithdrawal`, a small state machine over idle / confirming / submitting / success / failed with cancel and retry. - Add `MaturedLockWithdrawalModal` rendering each of those steps, and wire it into the lock detail screen in place of the confirmation state that was set but never rendered. - Map failures to codes and fixed copy rather than raw RPC errors, and offer retry only where a second attempt can succeed. - State plainly in the confirmation and success states when no vault contract is configured and nothing moves on the network. - Document the flow's SDK assumptions, placeholder behaviour, and eligibility rules in docs/vault-integration-assumptions.md. Also fixes a duplicate `router` declaration in the vault tab that made the screen fail to parse, and repairs the vault screen test suite that the parse error had been masking. --- __tests__/maturedLockWithdrawal.test.ts | 108 ++++ __tests__/maturedLockWithdrawalFlow.test.tsx | 214 ++++++++ __tests__/vault.test.tsx | 55 ++- .../vaultStore.withdrawMaturedLock.test.ts | 164 +++++++ app/(tabs)/vault.tsx | 1 - docs/vault-integration-assumptions.md | 27 +- src/components/MaturedLockWithdrawalModal.tsx | 461 ++++++++++++++++++ src/components/VaultLockDetail.tsx | 95 ++-- src/components/index.ts | 2 + src/features/vault/index.ts | 23 + src/features/vault/maturedLockWithdrawal.ts | 189 +++++++ .../vault/useMaturedLockWithdrawal.ts | 117 +++++ src/hooks/useVault.ts | 2 + src/store/vaultStore.ts | 74 +++ 14 files changed, 1477 insertions(+), 55 deletions(-) create mode 100644 __tests__/maturedLockWithdrawal.test.ts create mode 100644 __tests__/maturedLockWithdrawalFlow.test.tsx create mode 100644 __tests__/vaultStore.withdrawMaturedLock.test.ts create mode 100644 src/components/MaturedLockWithdrawalModal.tsx create mode 100644 src/features/vault/index.ts create mode 100644 src/features/vault/maturedLockWithdrawal.ts create mode 100644 src/features/vault/useMaturedLockWithdrawal.ts diff --git a/__tests__/maturedLockWithdrawal.test.ts b/__tests__/maturedLockWithdrawal.test.ts new file mode 100644 index 0000000..91fb38f --- /dev/null +++ b/__tests__/maturedLockWithdrawal.test.ts @@ -0,0 +1,108 @@ +import { + createWithdrawalError, + describeWithdrawalError, + evaluateWithdrawalEligibility, + isVaultWithdrawalError, + toWithdrawalErrorCode, +} from '../src/features/vault/maturedLockWithdrawal'; + +const PUBLIC_KEY = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +const matured = { amount: '50.0000000', unlockDate: new Date(Date.now() - 1000).toISOString() }; +const pending = { + amount: '50.0000000', + unlockDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), +}; + +describe('evaluateWithdrawalEligibility', () => { + it('marks a matured lock with a loaded wallet as eligible', () => { + const eligibility = evaluateWithdrawalEligibility(matured, { publicKey: PUBLIC_KEY }); + + expect(eligibility.isEligible).toBe(true); + expect(eligibility.reason).toBeNull(); + expect(eligibility.message).toMatch(/matured/i); + expect(eligibility.availableFrom).toBeTruthy(); + }); + + it('blocks a lock that has not reached its unlock date and says when it will', () => { + const eligibility = evaluateWithdrawalEligibility(pending, { publicKey: PUBLIC_KEY }); + + expect(eligibility.isEligible).toBe(false); + expect(eligibility.reason).toBe('not-matured'); + expect(eligibility.message).toMatch(/unlock on/i); + expect(eligibility.message).toMatch(/remaining/i); + }); + + it('derives maturity from the unlock date rather than a stale status field', () => { + const justMatured = { amount: '1.0000000', unlockDate: new Date(1_000).toISOString() }; + + expect(evaluateWithdrawalEligibility(justMatured, { publicKey: PUBLIC_KEY, now: 500 }) + .isEligible).toBe(false); + expect(evaluateWithdrawalEligibility(justMatured, { publicKey: PUBLIC_KEY, now: 2_000 }) + .isEligible).toBe(true); + }); + + it('blocks withdrawal when no wallet is loaded', () => { + const eligibility = evaluateWithdrawalEligibility(matured, { publicKey: null }); + + expect(eligibility.isEligible).toBe(false); + expect(eligibility.reason).toBe('no-wallet'); + }); + + it('blocks a lock with no withdrawable amount', () => { + const eligibility = evaluateWithdrawalEligibility( + { ...matured, amount: '0' }, + { publicKey: PUBLIC_KEY } + ); + + expect(eligibility.isEligible).toBe(false); + expect(eligibility.reason).toBe('invalid-amount'); + }); + + it('treats an unparseable unlock date as not withdrawable', () => { + const eligibility = evaluateWithdrawalEligibility( + { amount: '5', unlockDate: 'not-a-date' }, + { publicKey: PUBLIC_KEY } + ); + + expect(eligibility.isEligible).toBe(false); + expect(eligibility.reason).toBe('not-matured'); + expect(eligibility.availableFrom).toBeNull(); + }); +}); + +describe('withdrawal errors', () => { + it('tags created errors with a recognizable code', () => { + const error = createWithdrawalError('network'); + + expect(isVaultWithdrawalError(error)).toBe(true); + expect(toWithdrawalErrorCode(error)).toBe('network'); + }); + + it('classifies unknown network failures from their message', () => { + expect(toWithdrawalErrorCode(new Error('Network request failed'))).toBe('network'); + expect(toWithdrawalErrorCode(new Error('contract trapped'))).toBe('unknown'); + expect(toWithdrawalErrorCode(undefined)).toBe('unknown'); + }); + + it('does not treat a plain error as a tagged withdrawal error', () => { + expect(isVaultWithdrawalError(new Error('boom'))).toBe(false); + expect(isVaultWithdrawalError('boom')).toBe(false); + }); + + it('offers retry only for failures that can succeed on a second attempt', () => { + expect(describeWithdrawalError('network').canRetry).toBe(true); + expect(describeWithdrawalError('secret-unavailable').canRetry).toBe(true); + expect(describeWithdrawalError('unknown').canRetry).toBe(true); + + expect(describeWithdrawalError('not-matured').canRetry).toBe(false); + expect(describeWithdrawalError('lock-not-found').canRetry).toBe(false); + expect(describeWithdrawalError('no-wallet').canRetry).toBe(false); + }); + + it('reassures the user that funds are untouched when a withdrawal fails', () => { + expect(describeWithdrawalError('network').message).toMatch(/still locked in the vault/i); + expect(describeWithdrawalError('unknown').message).toMatch(/still in the vault/i); + expect(describeWithdrawalError('secret-unavailable').message).toMatch(/nothing was withdrawn/i); + }); +}); diff --git a/__tests__/maturedLockWithdrawalFlow.test.tsx b/__tests__/maturedLockWithdrawalFlow.test.tsx new file mode 100644 index 0000000..d233995 --- /dev/null +++ b/__tests__/maturedLockWithdrawalFlow.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { VaultLockDetail } from '../src/components/VaultLockDetail'; +import { useVaultStore, Lock } from '../src/store/vaultStore'; +import { withdrawFromVault } from '../src/services/vault'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => {}), + removeItem: jest.fn(async () => {}), +})); + +jest.mock('../src/services/vault', () => ({ + isVaultConfigured: jest.fn(() => false), + getVaultContractId: jest.fn(() => ''), + getVaultBalance: jest.fn(async () => '0.0000000'), + depositToVault: jest.fn(async () => 'deposit-hash'), + withdrawFromVault: jest.fn(async () => 'withdraw-hash'), +})); + +jest.mock('../src/services/stellar', () => ({ + fetchXlmBalance: jest.fn(async () => '0.0000000'), + fetchTransactionsPage: jest.fn(async () => ({ records: [], nextCursor: null, hasMore: false })), + fundWithFriendbot: jest.fn(async () => {}), + mockFetchVaultBalance: jest.fn(async () => '0.0000000'), + mockDepositToVault: jest.fn(async () => true), + mockWithdrawFromVault: jest.fn(async () => true), +})); + +const mockWalletState = { + publicKey: 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH' as string | null, + getSecretKey: jest.fn(async () => 'SECRET'), +}; + +jest.mock('../src/store/walletStore', () => ({ + useWalletStore: jest.fn((selector?: (state: unknown) => unknown) => + selector ? selector(mockWalletState) : mockWalletState + ), +})); + +jest.mock('../src/hooks/useTheme', () => ({ + useTheme: () => ({ + isDark: true, + colors: { + primary: '#00E5FF', + primaryDark: '#00B8CC', + secondary: '#7B61FF', + background: '#0B0D17', + surface: '#15192B', + surfaceLight: '#1E243D', + textPrimary: '#FFFFFF', + textSecondary: '#A0AABF', + textMuted: '#637087', + border: '#2A314A', + success: '#00E676', + warning: '#FFC400', + error: '#FF3D00', + }, + }), +})); + +jest.mock('lucide-react-native', () => ({ + Calendar: () => null, + Clock: () => null, + DollarSign: () => null, + Lock: () => null, + Unlock: () => null, + Info: () => null, + Timer: () => null, + HelpCircle: () => null, + ArrowUpCircle: () => null, + CheckCircle: () => null, + AlertTriangle: () => null, + Network: () => null, + ShieldAlert: () => null, + X: () => null, +})); + +const maturedLock: Lock = { + id: 'lock-matured', + amount: '50.5000000', + unlockDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + status: 'matured', + createdAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), +}; + +const lockedLock: Lock = { + id: 'lock-pending', + amount: '10.0000000', + unlockDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + status: 'locked', + createdAt: new Date().toISOString(), +}; + +const seedVault = (isConfigured = false) => { + useVaultStore.setState({ + locks: [maturedLock, lockedLock], + isConfigured, + contractId: isConfigured ? 'CDVAULTCONTRACTID' : '', + isSubmitting: false, + }); +}; + +describe('matured lock withdrawal flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockWalletState.publicKey = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + seedVault(); + }); + + it('shows eligibility and offers withdrawal for a matured lock', () => { + const { getByText, getByTestId } = render(); + + getByText('Ready to withdraw'); + getByText(/ready to withdraw whenever you like/i); + expect(getByTestId('withdraw-funds-button').props.accessibilityState.disabled).toBe(false); + }); + + it('does not offer withdrawal while the lock is still locked', () => { + const { queryByTestId, getByText } = render(); + + expect(queryByTestId('withdraw-funds-button')).toBeNull(); + getByText(/You can withdraw on/i); + }); + + it('disables withdrawal and explains why when no wallet is loaded', () => { + mockWalletState.publicKey = null; + const { getByTestId, getByText } = render(); + + expect(getByTestId('withdraw-funds-button').props.accessibilityState.disabled).toBe(true); + getByText('Connect a wallet before withdrawing from the vault.'); + }); + + it('requires an explicit confirmation step before withdrawing', () => { + const { getByTestId, getByText, getAllByText, queryByTestId } = render( + + ); + + expect(queryByTestId('matured-lock-withdrawal-modal')).toBeNull(); + + fireEvent.press(getByTestId('withdraw-funds-button')); + + getByText('Withdraw matured lock'); + getByText('Matured on'); + // The amount appears on the lock card and again in the confirmation. + expect(getAllByText('50.5000000 XLM')).toHaveLength(2); + getByTestId('confirm-withdrawal-button'); + }); + + it('states that no funds move while no vault contract is configured', () => { + const { getByTestId, getByText } = render(); + fireEvent.press(getByTestId('withdraw-funds-button')); + + getByText(/No vault contract is configured yet/i); + }); + + it('shows a loading state and then a success state, removing the lock', async () => { + seedVault(true); + const { getByTestId, getByText } = render(); + + fireEvent.press(getByTestId('withdraw-funds-button')); + fireEvent.press(getByTestId('confirm-withdrawal-button')); + + getByTestId('withdrawal-loading'); + + await waitFor(() => getByText('Withdrawal complete')); + getByText('withdraw-hash'); + expect(useVaultStore.getState().locks.map((lock) => lock.id)).toEqual([lockedLock.id]); + }); + + it('cancels back out of the confirmation without withdrawing', () => { + const { getByTestId, getByText, queryByText } = render(); + + fireEvent.press(getByTestId('withdraw-funds-button')); + fireEvent.press(getByText('Cancel')); + + expect(queryByText('Withdraw matured lock')).toBeNull(); + expect(useVaultStore.getState().locks).toHaveLength(2); + }); + + it('surfaces a retryable failure and succeeds on retry', async () => { + seedVault(true); + (withdrawFromVault as jest.Mock).mockRejectedValueOnce(new Error('Network request failed')); + + const { getByTestId, getByText } = render(); + + fireEvent.press(getByTestId('withdraw-funds-button')); + fireEvent.press(getByTestId('confirm-withdrawal-button')); + + await waitFor(() => getByText('Network problem')); + getByText(/still locked in the vault/i); + expect(useVaultStore.getState().locks).toHaveLength(2); + + fireEvent.press(getByTestId('retry-withdrawal-button')); + + await waitFor(() => getByText('Withdrawal complete')); + expect(useVaultStore.getState().locks).toHaveLength(1); + }); + + it('does not offer a retry for a failure that cannot succeed on a second attempt', async () => { + seedVault(true); + useVaultStore.setState({ locks: [lockedLock] }); + + const { getByTestId, getByText, queryByTestId } = render( + + ); + + fireEvent.press(getByTestId('withdraw-funds-button')); + fireEvent.press(getByTestId('confirm-withdrawal-button')); + + await waitFor(() => getByText('Lock unavailable')); + expect(queryByTestId('retry-withdrawal-button')).toBeNull(); + }); +}); diff --git a/__tests__/vault.test.tsx b/__tests__/vault.test.tsx index 35b33d0..3b69696 100644 --- a/__tests__/vault.test.tsx +++ b/__tests__/vault.test.tsx @@ -63,6 +63,8 @@ const VALID_AMOUNT = '10'; const mockDeposit = jest.fn(); const mockWithdraw = jest.fn(); const mockLoadBalance = jest.fn(); +const mockLoadLocks = jest.fn(); +const mockAddLock = jest.fn(); function setupStores(overrides: Record = {}) { mockDeposit.mockResolvedValue('tx_hash_1234567890abcdef'); @@ -77,14 +79,20 @@ function setupStores(overrides: Record = {}) { mockUseVaultStore.mockReturnValue({ balance: '50.0000000', + locks: [], isConfigured: true, contractId: 'CABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', isLoadingBalance: false, + isLoadingLocks: false, isSubmitting: false, balanceError: null, loadBalance: mockLoadBalance, + loadLocks: mockLoadLocks, + addLock: mockAddLock, + unlockLock: jest.fn(), deposit: mockDeposit, withdraw: mockWithdraw, + withdrawMaturedLock: jest.fn(), ...overrides, } as any); } @@ -131,7 +139,7 @@ describe('AC1 – consistent loading states', () => { const { getByText, getByPlaceholderText, getAllByText } = render(); fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Lock Funds (30 days)')); + fireEvent.press(getByText('Set Aside for 30 Days')); await waitFor(() => { expect(getAllByText('Confirm Lock').length).toBeGreaterThanOrEqual(1); @@ -172,19 +180,30 @@ describe('AC1 – consistent loading states', () => { // ──────────────────────────────────────────────────────────────────────── describe('AC2 – duplicate submission prevention', () => { - it('disables the deposit button when isSubmitting is true', async () => { + it('disables the deposit button while a deposit is in flight', async () => { + const { getByText, getByPlaceholderText, getAllByText, rerender } = render(); + + fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); + fireEvent.press(getByText('Deposit')); + await waitFor(() => getAllByText('Confirm Deposit').length > 0); + setupStores({ isSubmitting: true }); - const { getByText } = render(); + rerender(); - expect(getByText('Depositing…')).toBeTruthy(); + await waitFor(() => expect(getByText('Depositing…')).toBeTruthy()); }); - it('disables the lock button when isSubmitting is true', async () => { + it('disables the lock button while a lock is in flight', async () => { + const { getByText, getByPlaceholderText, getAllByText, rerender } = render(); + + fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); + fireEvent.press(getByText('Set Aside for 30 Days')); + await waitFor(() => getAllByText('Confirm Lock').length > 0); + setupStores({ isSubmitting: true }); - const { getByText } = render(); + rerender(); - const lockBtn = getByText('Lock Funds (30 days)').parent; - expect(lockBtn).toBeDefined(); + await waitFor(() => expect(getByText('Locking…')).toBeTruthy()); }); it('calls deposit exactly once when confirm is pressed', async () => { @@ -208,10 +227,16 @@ describe('AC2 – duplicate submission prevention', () => { describe('AC3 – clear loading copy', () => { it('shows "Depositing…" on the deposit button while submitting', async () => { + const { getByText, getByPlaceholderText, getAllByText, rerender } = render(); + + fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); + fireEvent.press(getByText('Deposit')); + await waitFor(() => getAllByText('Confirm Deposit').length > 0); + setupStores({ isSubmitting: true }); - const { getByText } = render(); + rerender(); - expect(getByText('Depositing…')).toBeTruthy(); + await waitFor(() => expect(getByText('Depositing…')).toBeTruthy()); }); it('shows "Processing deposit…" on the modal confirm button while loading', async () => { @@ -337,20 +362,18 @@ describe('AC4 – error and success states', () => { expect(queryByText('Confirm Deposit')).toBeNull(); }); - it('shows placeholder notice for lock action via modal', async () => { + it('records the lock and reports it as a mock via the modal', async () => { const { getByText, getByPlaceholderText, getAllByText } = render(); fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Lock Funds (30 days)')); + fireEvent.press(getByText('Set Aside for 30 Days')); await waitFor(() => getAllByText('Confirm Lock').length > 0); fireEvent.press(getAllByText('Confirm Lock')[1]); await waitFor(() => { - expect(alertSpy).toHaveBeenCalledWith( - 'Notice', - expect.stringContaining('not yet implemented') - ); + expect(mockAddLock).toHaveBeenCalledWith(VALID_AMOUNT, expect.any(String)); + expect(alertSpy).toHaveBeenCalledWith('Success', expect.stringContaining('(mock)')); }); }); diff --git a/__tests__/vaultStore.withdrawMaturedLock.test.ts b/__tests__/vaultStore.withdrawMaturedLock.test.ts new file mode 100644 index 0000000..460a28a --- /dev/null +++ b/__tests__/vaultStore.withdrawMaturedLock.test.ts @@ -0,0 +1,164 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useVaultStore, Lock } from '../src/store/vaultStore'; +import { withdrawFromVault } from '../src/services/vault'; +import { mockWithdrawFromVault } from '../src/services/stellar'; +import { toWithdrawalErrorCode } from '../src/features/vault/maturedLockWithdrawal'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => {}), + removeItem: jest.fn(async () => {}), +})); + +jest.mock('../src/services/vault', () => ({ + isVaultConfigured: jest.fn(() => false), + getVaultContractId: jest.fn(() => ''), + getVaultBalance: jest.fn(async () => '0.0000000'), + depositToVault: jest.fn(async () => 'deposit-hash'), + withdrawFromVault: jest.fn(async () => 'withdraw-hash'), +})); + +jest.mock('../src/services/stellar', () => ({ + fetchXlmBalance: jest.fn(async () => '0.0000000'), + fetchTransactionsPage: jest.fn(async () => ({ records: [], nextCursor: null, hasMore: false })), + fundWithFriendbot: jest.fn(async () => {}), + mockFetchVaultBalance: jest.fn(async () => '0.0000000'), + mockDepositToVault: jest.fn(async () => true), + mockWithdrawFromVault: jest.fn(async () => true), +})); + +const PUBLIC_KEY = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +const maturedLock: Lock = { + id: 'lock-matured', + amount: '50.5000000', + unlockDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + status: 'matured', + createdAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), +}; + +const pendingLock: Lock = { + id: 'lock-pending', + amount: '10.0000000', + unlockDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + // A stale status: the lock has not actually matured yet. + status: 'matured', + createdAt: new Date().toISOString(), +}; + +const getSecretKey = jest.fn(async () => 'SECRET'); + +const seedStore = (overrides: Partial> = {}) => { + useVaultStore.setState({ + locks: [maturedLock, pendingLock], + isConfigured: false, + contractId: '', + isSubmitting: false, + balance: '0.0000000', + ...overrides, + }); +}; + +describe('vaultStore.withdrawMaturedLock', () => { + beforeEach(() => { + jest.clearAllMocks(); + seedStore(); + }); + + it('withdraws a matured lock through the placeholder when no contract is configured', async () => { + const result = await useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey }); + + expect(result).toEqual({ amount: '50.5000000', hash: null, isPreview: true }); + expect(mockWithdrawFromVault).toHaveBeenCalled(); + expect(withdrawFromVault).not.toHaveBeenCalled(); + expect(getSecretKey).not.toHaveBeenCalled(); + }); + + it('submits through the vault service and returns the hash when a contract is configured', async () => { + seedStore({ isConfigured: true, contractId: 'CDVAULT' }); + + const result = await useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey }); + + expect(withdrawFromVault).toHaveBeenCalledWith('SECRET', '50.5000000'); + expect(result).toEqual({ amount: '50.5000000', hash: 'withdraw-hash', isPreview: false }); + }); + + it('removes the lock and persists the remaining locks after a successful withdrawal', async () => { + await useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey }); + + expect(useVaultStore.getState().locks.map((lock) => lock.id)).toEqual([pendingLock.id]); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@pocketpay_vault_locks', + JSON.stringify([pendingLock]) + ); + }); + + it('clears the submitting flag once the withdrawal resolves', async () => { + await useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey }); + + expect(useVaultStore.getState().isSubmitting).toBe(false); + }); + + it('rejects a lock whose unlock date has not passed, even if its status says matured', async () => { + await expect( + useVaultStore + .getState() + .withdrawMaturedLock(pendingLock.id, { publicKey: PUBLIC_KEY, getSecretKey }) + ).rejects.toMatchObject({ code: 'not-matured' }); + + expect(mockWithdrawFromVault).not.toHaveBeenCalled(); + expect(useVaultStore.getState().locks).toHaveLength(2); + }); + + it('rejects when the lock is no longer in the vault', async () => { + await expect( + useVaultStore + .getState() + .withdrawMaturedLock('missing-lock', { publicKey: PUBLIC_KEY, getSecretKey }) + ).rejects.toMatchObject({ code: 'lock-not-found' }); + }); + + it('rejects when no wallet is loaded', async () => { + await expect( + useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: null, getSecretKey }) + ).rejects.toMatchObject({ code: 'no-wallet' }); + }); + + it('rejects without touching the vault when the wallet key cannot be read', async () => { + seedStore({ isConfigured: true }); + const noSecret = jest.fn(async () => null); + + await expect( + useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey: noSecret }) + ).rejects.toMatchObject({ code: 'secret-unavailable' }); + + expect(withdrawFromVault).not.toHaveBeenCalled(); + expect(useVaultStore.getState().locks).toHaveLength(2); + }); + + it('keeps the lock and reports a network failure when submission fails', async () => { + seedStore({ isConfigured: true }); + (withdrawFromVault as jest.Mock).mockRejectedValueOnce(new Error('Network request failed')); + + const error = await useVaultStore + .getState() + .withdrawMaturedLock(maturedLock.id, { publicKey: PUBLIC_KEY, getSecretKey }) + .catch((caught) => caught); + + expect(toWithdrawalErrorCode(error)).toBe('network'); + expect(useVaultStore.getState().locks).toHaveLength(2); + expect(useVaultStore.getState().isSubmitting).toBe(false); + }); +}); diff --git a/app/(tabs)/vault.tsx b/app/(tabs)/vault.tsx index ac1c5d2..0df3d10 100644 --- a/app/(tabs)/vault.tsx +++ b/app/(tabs)/vault.tsx @@ -25,7 +25,6 @@ export default function VaultScreen() { const router = useRouter(); const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); - const router = useRouter(); // Wallet & Vault stores const { publicKey, getSecretKey, balance: walletBalance } = useWalletStore(); diff --git a/docs/vault-integration-assumptions.md b/docs/vault-integration-assumptions.md index 3e9f678..6f95b62 100644 --- a/docs/vault-integration-assumptions.md +++ b/docs/vault-integration-assumptions.md @@ -42,7 +42,32 @@ To ensure the app remains testable and runs in a demo environment without contra --- -## 3. Known Gaps and Risks +## 3. Matured Lock Withdrawal + +The matured-lock withdrawal flow (`src/features/vault/useMaturedLockWithdrawal.ts`, `src/components/MaturedLockWithdrawalModal.tsx`, and `vaultStore.withdrawMaturedLock`) is built against the interface above and steps through **eligibility → confirmation → loading → success/failure**. + +### Assumptions + +| Assumption | Detail | Impact if wrong | +| :--- | :--- | :--- | +| **Whole-lock withdrawal** | A matured lock is withdrawn in full; the UI offers no partial amount. | Partial withdrawal would need an amount step in the confirmation modal. | +| **Withdrawal call shape** | The client calls `withdrawFromVault(secretKey, amount)`, which invokes `withdraw(to: Address, amount: i128)` for the signing account. | A `lock_id`-based `withdraw`/`unlock` signature would change the store action's arguments, not the flow. | +| **Contract enforces maturity** | The contract rejects a withdrawal before the unlock date; the client check is a UX guard, not the security boundary. | A client-only check would be bypassable by changing device time (see *Ledger vs. Device Time*). | +| **Lock lifecycle** | Locks are removed from local storage only after the transfer resolves. Once `get_locks` is live, the contract becomes the source of truth and the local removal drops out. | A failure must never destroy the lock record — hence removal after, not before, submission. | +| **Confirmation is terminal** | `getTransaction` returning `SUCCESS` means the funds have moved; no separate settlement polling is modelled. | A pending/queued status would need an extra "processing" state in the modal. | +| **Errors are mapped, not raw** | Failures are classified into codes (`network`, `secret-unavailable`, `not-matured`, …) and rendered from `describeWithdrawalError`. | Raw Soroban error strings would leak into the UI. | + +### Placeholder behaviour + +When `EXPO_PUBLIC_VAULT_CONTRACT_ID` is unset the flow runs against `mockWithdrawFromVault`. In that mode the modal states plainly that no vault contract is configured and that no funds move on the network, and the success state shows no transaction hash. The UI must not imply a live withdrawal until a contract is configured. + +### Eligibility rules + +Eligibility is evaluated by `evaluateWithdrawalEligibility` from the lock's `unlockDate` rather than its stored `status`, because `status` is only recomputed when locks are loaded. A lock is withdrawable when a wallet is loaded, the amount is positive, and the unlock date has passed. Eligibility is re-checked inside the store at submission time so a stale screen cannot submit an immature lock. + +--- + +## 4. Known Gaps and Risks Below are the identified gaps and risks that require coordination across repositories before production deployment: diff --git a/src/components/MaturedLockWithdrawalModal.tsx b/src/components/MaturedLockWithdrawalModal.tsx new file mode 100644 index 0000000..6a57841 --- /dev/null +++ b/src/components/MaturedLockWithdrawalModal.tsx @@ -0,0 +1,461 @@ +import React, { useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + ActivityIndicator, + ScrollView, +} from 'react-native'; +import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; +import { useTheme } from '../hooks/useTheme'; +import { + ArrowUpCircle, + CheckCircle, + AlertTriangle, + Network, + ShieldAlert, + X, +} from 'lucide-react-native'; +import type { MaturedLockWithdrawalResult } from '../store/vaultStore'; +import type { WithdrawalErrorCopy, WithdrawalStep } from '../features/vault'; + +interface MaturedLockWithdrawalModalProps { + step: WithdrawalStep; + amount: string; + /** Localized unlock date, shown so the user can see why this lock is eligible. */ + availableFrom: string | null; + /** True when no vault contract is configured and nothing moves on-chain. */ + isPreview: boolean; + result: MaturedLockWithdrawalResult | null; + error: WithdrawalErrorCopy | null; + contractId?: string; + onConfirm: () => void; + onCancel: () => void; + onRetry: () => void; + onClose: () => void; +} + +const PREVIEW_NOTICE = + 'No vault contract is configured yet, so this is a preview of the withdrawal flow — no funds move on the network.'; + +const LIVE_NOTICE = + 'This submits a withdrawal to the Soroban Savings Vault on Testnet. The transaction is signed on this device.'; + +/** + * Confirmation, loading, success, and failure steps for withdrawing a matured + * vault lock. Rendering is driven entirely by `step`; the flow state itself + * lives in `useMaturedLockWithdrawal`. + */ +export const MaturedLockWithdrawalModal: React.FC = ({ + step, + amount, + availableFrom, + isPreview, + result, + error, + contractId, + onConfirm, + onCancel, + onRetry, + onClose, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const isSubmitting = step === 'submitting'; + + return ( + {} : onClose} + testID="matured-lock-withdrawal-modal" + > + + + + {step === 'confirming' && ( + <> + + + + + + + + + + + Withdraw matured lock + + + This lock has matured. Confirm to return the full amount to your wallet. + + + + + Amount + + {amount} XLM + + + + {availableFrom ? ( + + Matured on + {availableFrom} + + ) : null} + + + + + Network + + + Testnet + + + + {contractId ? ( + + Contract + + {contractId} + + + ) : null} + + + + + + {isPreview ? PREVIEW_NOTICE : LIVE_NOTICE} + + + + + + Cancel + + + + Confirm Withdrawal + + + + )} + + {isSubmitting && ( + + + Withdrawing {amount} XLM + + Hold on while the withdrawal is processed. Do not close the app. + + + )} + + {step === 'success' && ( + + + + + + Withdrawal complete + + + {result?.amount ?? amount} XLM is on its way back to your wallet. + + + {result?.hash ? ( + + Transaction + + {result.hash} + + + ) : null} + + {result?.isPreview ? ( + + + {PREVIEW_NOTICE} + + ) : null} + + + Done + + + )} + + {step === 'failed' && ( + + + + + + {error?.title ?? 'Withdrawal failed'} + + + {error?.message ?? + 'The withdrawal did not go through and your funds are still in the vault.'} + + + + + Close + + + {error?.canRetry !== false && ( + + Try Again + + )} + + + )} + + + + + ); +}; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + alignItems: 'center', + padding: SIZES.lg, + }, + card: { + width: '100%', + maxWidth: 400, + backgroundColor: colors.surface, + borderRadius: RADIUS.xl, + padding: SIZES.xl, + borderWidth: 1, + borderColor: colors.border, + }, + header: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginBottom: SIZES.md, + position: 'relative', + }, + iconContainer: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: 'center', + alignItems: 'center', + }, + closeButton: { + position: 'absolute', + right: 0, + top: 0, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: colors.surfaceLight, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + color: colors.textPrimary, + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: SIZES.sm, + }, + description: { + color: colors.textSecondary, + fontSize: 13, + textAlign: 'center', + lineHeight: 20, + marginBottom: SIZES.lg, + }, + detailsContainer: { + backgroundColor: colors.background, + borderRadius: RADIUS.md, + padding: SIZES.md, + marginBottom: SIZES.md, + }, + detailRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: SIZES.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + detailLabel: { + color: colors.textSecondary, + fontSize: 14, + }, + labelWithIcon: { + flexDirection: 'row', + alignItems: 'center', + }, + detailValue: { + color: colors.textPrimary, + fontSize: 14, + fontWeight: '600', + maxWidth: '55%', + textAlign: 'right', + }, + detailValueMono: { + color: colors.textPrimary, + fontSize: 12, + fontFamily: 'monospace', + maxWidth: '55%', + }, + networkBadge: { + backgroundColor: 'rgba(255, 196, 0, 0.15)', + paddingHorizontal: SIZES.sm, + paddingVertical: 2, + borderRadius: RADIUS.sm, + }, + networkBadgeText: { + color: colors.warning, + fontSize: 12, + fontWeight: '600', + }, + disclaimer: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: 'rgba(160, 170, 191, 0.08)', + borderRadius: RADIUS.sm, + padding: SIZES.sm, + marginBottom: SIZES.lg, + }, + disclaimerText: { + color: colors.textMuted, + fontSize: 11, + lineHeight: 16, + flex: 1, + }, + actions: { + flexDirection: 'row', + gap: SIZES.sm, + width: '100%', + }, + actionButton: { + flex: 1, + height: 50, + borderRadius: RADIUS.lg, + justifyContent: 'center', + alignItems: 'center', + }, + fullWidthButton: { + flexGrow: 0, + width: '100%', + }, + cancelButton: { + backgroundColor: colors.surfaceLight, + }, + cancelButtonText: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + }, + confirmButton: { + backgroundColor: colors.primary, + }, + confirmButtonText: { + color: colors.background, + fontSize: 15, + fontWeight: '600', + }, + statusContent: { + alignItems: 'center', + }, + statusTitle: { + color: colors.textPrimary, + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + marginTop: SIZES.md, + marginBottom: SIZES.sm, + }, + statusMessage: { + color: colors.textSecondary, + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + marginBottom: SIZES.lg, + }, + hashBox: { + width: '100%', + backgroundColor: colors.background, + borderRadius: RADIUS.md, + padding: SIZES.md, + marginBottom: SIZES.lg, + }, + hashLabel: { + color: colors.textMuted, + fontSize: 12, + marginBottom: 2, + }, + hashValue: { + color: colors.textPrimary, + fontSize: 12, + fontFamily: 'monospace', + }, + }); diff --git a/src/components/VaultLockDetail.tsx b/src/components/VaultLockDetail.tsx index 99b15ec..0068d87 100644 --- a/src/components/VaultLockDetail.tsx +++ b/src/components/VaultLockDetail.tsx @@ -1,11 +1,13 @@ -import React, { useMemo, useState } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; import { useTheme } from '../hooks/useTheme'; import { useVaultStore, Lock } from '../store/vaultStore'; import { useRouter } from 'expo-router'; import { Calendar, Clock, DollarSign, Lock as LockIcon, Unlock, Info, Timer, HelpCircle } from 'lucide-react-native'; import { formatTimeRemaining, getEligibilityText } from '../utils/lockTime'; +import { useMaturedLockWithdrawal } from '../features/vault/useMaturedLockWithdrawal'; +import { MaturedLockWithdrawalModal } from './MaturedLockWithdrawalModal'; interface VaultLockDetailProps { lock: Lock; @@ -15,44 +17,22 @@ export const VaultLockDetail: React.FC = ({ lock }) => { const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); const router = useRouter(); - const unlockLock = useVaultStore((state) => state.unlockLock); - - const [isConfirmVisible, setIsConfirmVisible] = useState(false); - const [isWithdrawing, setIsWithdrawing] = useState(false); + const contractId = useVaultStore((state) => state.contractId); + const withdrawal = useMaturedLockWithdrawal(lock); const isMatured = lock.status === 'matured'; const unlockDate = new Date(lock.unlockDate); const createdAt = new Date(lock.createdAt); const countdown = formatTimeRemaining(lock.unlockDate); const eligibility = getEligibilityText(lock.unlockDate, lock.status); + const canWithdraw = withdrawal.eligibility.isEligible; - const handleWithdraw = () => { - setIsConfirmVisible(true); - }; - - const performWithdrawal = async () => { - setIsWithdrawing(true); - try { - // TODO: Replace setTimeout with SDK withdrawal method once integrated. - // e.g. await useVaultStore().withdraw(secretKey, publicKey, lock.amount); - await new Promise(res => setTimeout(res, 1500)); - - Alert.alert('Success', `Successfully withdrawn ${lock.amount} XLM to your wallet.`, [ - { - text: 'OK', - onPress: () => { - setIsConfirmVisible(false); - router.back(); - // Call unlockLock slightly later to avoid rendering "Lock Not Found" mid-transition - setTimeout(() => unlockLock(lock.id), 300); - } - } - ]); - } catch (error) { - Alert.alert('Withdrawal Failed', 'Could not process the withdrawal. Please try again.'); - } finally { - setIsWithdrawing(false); - } + const handleWithdrawalClosed = () => { + const succeeded = withdrawal.step === 'success'; + withdrawal.close(); + // The lock is gone from the vault once the withdrawal settles, so leave the + // detail screen rather than rendering its "not found" state. + if (succeeded) router.back(); }; return ( @@ -118,19 +98,48 @@ export const VaultLockDetail: React.FC = ({ lock }) => { + {/* A matured lock can still be blocked — e.g. no wallet is loaded. */} + {isMatured && !canWithdraw ? ( + {withdrawal.eligibility.message} + ) : null} + {isMatured && ( - - Withdraw Funds + + + Withdraw Funds + )} + + {/* Inline education card */} @@ -240,12 +249,24 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ alignItems: 'center', marginTop: SIZES.lg, }, + withdrawButtonDisabled: { + backgroundColor: colors.surfaceLight, + }, withdrawButtonText: { color: colors.background, fontSize: 16, fontWeight: '600', marginLeft: SIZES.xs, }, + withdrawButtonTextDisabled: { + color: colors.textMuted, + }, + blockedText: { + color: colors.warning, + fontSize: 13, + lineHeight: 18, + marginTop: SIZES.sm, + }, // ── Inline education card ────────────────────────────────────── educationCard: { backgroundColor: colors.surface, diff --git a/src/components/index.ts b/src/components/index.ts index 8e603f8..9909dd4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,4 +9,6 @@ export { VaultConfirmModal, VaultAction } from "./VaultConfirmModal"; export { VaultIntroModal } from "./VaultIntroModal"; export { VaultLockEducationModal } from "./VaultLockEducationModal"; export { VaultLockList } from "./VaultLockList"; +export { VaultLockDetail } from "./VaultLockDetail"; +export { MaturedLockWithdrawalModal } from "./MaturedLockWithdrawalModal"; export { SigningConfirmModal } from "./SigningConfirmModal"; diff --git a/src/features/vault/index.ts b/src/features/vault/index.ts new file mode 100644 index 0000000..7ecb344 --- /dev/null +++ b/src/features/vault/index.ts @@ -0,0 +1,23 @@ +export { useVaultDepositForm } from './useVaultDepositForm'; +export type { UseVaultDepositFormReturn } from './useVaultDepositForm'; + +export { useMaturedLockWithdrawal } from './useMaturedLockWithdrawal'; +export type { + UseMaturedLockWithdrawalReturn, + WithdrawalStep, +} from './useMaturedLockWithdrawal'; + +export { + createWithdrawalError, + describeWithdrawalError, + evaluateWithdrawalEligibility, + isVaultWithdrawalError, + toWithdrawalErrorCode, +} from './maturedLockWithdrawal'; +export type { + VaultWithdrawalError, + VaultWithdrawalErrorCode, + WithdrawalEligibility, + WithdrawalErrorCopy, + WithdrawalIneligibilityReason, +} from './maturedLockWithdrawal'; diff --git a/src/features/vault/maturedLockWithdrawal.ts b/src/features/vault/maturedLockWithdrawal.ts new file mode 100644 index 0000000..7ed2c0d --- /dev/null +++ b/src/features/vault/maturedLockWithdrawal.ts @@ -0,0 +1,189 @@ +/** + * Matured lock withdrawal — eligibility rules and failure vocabulary. + * + * Pure helpers shared by the vault store, the withdrawal hook, and the + * withdrawal modal so all three agree on when a lock may be withdrawn and how a + * failure is described to the user. + * + * SDK assumptions for this flow are documented in + * docs/vault-integration-assumptions.md ("Matured lock withdrawal"). + */ + +import type { Lock } from '../../store/vaultStore'; +import { formatTimeRemaining } from '../../utils/lockTime'; + +export type WithdrawalIneligibilityReason = + | 'not-matured' + | 'no-wallet' + | 'invalid-amount'; + +export interface WithdrawalEligibility { + isEligible: boolean; + /** Set only when `isEligible` is false. */ + reason: WithdrawalIneligibilityReason | null; + /** Plain-language explanation, safe to render directly. */ + message: string; + /** Localized unlock date, or null when the lock has no usable unlock date. */ + availableFrom: string | null; +} + +const formatUnlockDate = (unlockDate: Date): string => + unlockDate.toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + +/** + * Decide whether a lock can be withdrawn right now. + * + * Maturity is derived from `unlockDate` rather than the stored `status` field: + * `status` is only recomputed when locks are loaded, so a lock that matured + * while the screen was open would otherwise still read as locked. + */ +export function evaluateWithdrawalEligibility( + lock: Pick, + options: { publicKey?: string | null; now?: number } = {} +): WithdrawalEligibility { + const { publicKey, now = Date.now() } = options; + + const unlockDate = new Date(lock.unlockDate); + const hasUnlockDate = !isNaN(unlockDate.getTime()); + const availableFrom = hasUnlockDate ? formatUnlockDate(unlockDate) : null; + + if (!publicKey) { + return { + isEligible: false, + reason: 'no-wallet', + message: 'Connect a wallet before withdrawing from the vault.', + availableFrom, + }; + } + + const amount = Number(lock.amount); + if (!isFinite(amount) || amount <= 0) { + return { + isEligible: false, + reason: 'invalid-amount', + message: 'This lock has no withdrawable amount.', + availableFrom, + }; + } + + if (!hasUnlockDate || unlockDate.getTime() > now) { + const remaining = hasUnlockDate ? formatTimeRemaining(lock.unlockDate) : ''; + const message = availableFrom + ? `These funds unlock on ${availableFrom}. ${remaining}`.trim() + : 'Withdrawal is not yet available for this lock.'; + + return { isEligible: false, reason: 'not-matured', message, availableFrom }; + } + + return { + isEligible: true, + reason: null, + message: 'This lock has matured. The full amount can be returned to your wallet.', + availableFrom, + }; +} + +export type VaultWithdrawalErrorCode = + | 'lock-not-found' + | 'not-matured' + | 'no-wallet' + | 'invalid-amount' + | 'secret-unavailable' + | 'network' + | 'unknown'; + +export interface VaultWithdrawalError extends Error { + code: VaultWithdrawalErrorCode; +} + +/** + * Build a tagged withdrawal error. + * + * A plain `Error` with a `code` property is used instead of an `Error` + * subclass: subclassed built-ins do not survive transpilation reliably across + * the Hermes/Babel targets this app builds for, which would break `instanceof`. + */ +export function createWithdrawalError( + code: VaultWithdrawalErrorCode, + message?: string +): VaultWithdrawalError { + const error = new Error(message ?? describeWithdrawalError(code).message) as VaultWithdrawalError; + error.code = code; + return error; +} + +export function isVaultWithdrawalError(value: unknown): value is VaultWithdrawalError { + return value instanceof Error && typeof (value as VaultWithdrawalError).code === 'string'; +} + +export interface WithdrawalErrorCopy { + title: string; + message: string; + /** Whether offering the user a retry makes sense for this failure. */ + canRetry: boolean; +} + +/** Map a failure code to user-facing copy. Never surfaces raw RPC errors. */ +export function describeWithdrawalError(code: VaultWithdrawalErrorCode): WithdrawalErrorCopy { + switch (code) { + case 'lock-not-found': + return { + title: 'Lock unavailable', + message: 'This lock is no longer in your vault. Refresh the vault and try again.', + canRetry: false, + }; + case 'not-matured': + return { + title: 'Not ready yet', + message: + 'This lock has not matured. You can withdraw it once its unlock date has passed.', + canRetry: false, + }; + case 'no-wallet': + return { + title: 'No wallet available', + message: 'Connect a wallet before withdrawing from the vault.', + canRetry: false, + }; + case 'invalid-amount': + return { + title: 'Nothing to withdraw', + message: 'This lock has no withdrawable amount.', + canRetry: false, + }; + case 'secret-unavailable': + return { + title: 'Could not sign', + message: + 'Your wallet key could not be read from secure storage, so nothing was withdrawn. Reopen the app and try again.', + canRetry: true, + }; + case 'network': + return { + title: 'Network problem', + message: + 'The withdrawal could not be completed because the network was unreachable. Your funds are still locked in the vault.', + canRetry: true, + }; + default: + return { + title: 'Withdrawal failed', + message: + 'The withdrawal did not go through and your funds are still in the vault. Please try again.', + canRetry: true, + }; + } +} + +/** Classify an unknown thrown value into a withdrawal error code. */ +export function toWithdrawalErrorCode(error: unknown): VaultWithdrawalErrorCode { + if (isVaultWithdrawalError(error)) return error.code; + + const message = error instanceof Error ? error.message : String(error ?? ''); + if (/network|fetch|timeout|unreachable|connection/i.test(message)) return 'network'; + return 'unknown'; +} diff --git a/src/features/vault/useMaturedLockWithdrawal.ts b/src/features/vault/useMaturedLockWithdrawal.ts new file mode 100644 index 0000000..c2c72ac --- /dev/null +++ b/src/features/vault/useMaturedLockWithdrawal.ts @@ -0,0 +1,117 @@ +/** + * Matured lock withdrawal flow. + * + * Drives the eligibility → confirm → submitting → success/failure sequence for + * withdrawing a single matured vault lock. The hook owns the flow state; the + * store owns the eligibility re-check and the transfer itself. + * + * SDK assumptions are documented in docs/vault-integration-assumptions.md + * ("Matured lock withdrawal"). + */ + +import { useCallback, useMemo, useState } from 'react'; +import { useVaultStore, Lock, MaturedLockWithdrawalResult } from '../../store/vaultStore'; +import { useWalletStore } from '../../store/walletStore'; +import { + describeWithdrawalError, + evaluateWithdrawalEligibility, + toWithdrawalErrorCode, + WithdrawalEligibility, + WithdrawalErrorCopy, +} from './maturedLockWithdrawal'; + +export type WithdrawalStep = 'idle' | 'confirming' | 'submitting' | 'success' | 'failed'; + +export interface UseMaturedLockWithdrawalReturn { + step: WithdrawalStep; + eligibility: WithdrawalEligibility; + /** True while no vault contract is configured, i.e. nothing moves on-chain. */ + isPreview: boolean; + /** Populated once the withdrawal succeeds. */ + result: MaturedLockWithdrawalResult | null; + /** Populated once the withdrawal fails. */ + error: WithdrawalErrorCopy | null; + /** Open the confirmation step. No-op when the lock is not eligible. */ + start: () => void; + /** Submit the withdrawal from the confirmation step. */ + confirm: () => Promise; + /** Leave the confirmation step. Ignored while submitting. */ + cancel: () => void; + /** Re-attempt a failed withdrawal. */ + retry: () => Promise; + /** Close the flow from any terminal step. */ + close: () => void; +} + +export function useMaturedLockWithdrawal(lock: Lock): UseMaturedLockWithdrawalReturn { + const publicKey = useWalletStore((state) => state.publicKey); + const getSecretKey = useWalletStore((state) => state.getSecretKey); + const withdrawMaturedLock = useVaultStore((state) => state.withdrawMaturedLock); + const isConfigured = useVaultStore((state) => state.isConfigured); + + const [step, setStep] = useState('idle'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const eligibility = useMemo( + () => evaluateWithdrawalEligibility(lock, { publicKey }), + // `unlockDate`/`amount` drive the result; recompute when either changes. + [lock.amount, lock.unlockDate, publicKey] + ); + + const start = useCallback(() => { + if (!eligibility.isEligible) return; + setError(null); + setResult(null); + setStep('confirming'); + }, [eligibility.isEligible]); + + const submit = useCallback(async () => { + setError(null); + setStep('submitting'); + try { + const withdrawal = await withdrawMaturedLock(lock.id, { publicKey, getSecretKey }); + setResult(withdrawal); + setStep('success'); + } catch (caught) { + setError(describeWithdrawalError(toWithdrawalErrorCode(caught))); + setStep('failed'); + } + }, [lock.id, publicKey, getSecretKey, withdrawMaturedLock]); + + const confirm = useCallback(async () => { + // Guard against a double tap on the confirm button. + if (step === 'submitting') return; + await submit(); + }, [step, submit]); + + const cancel = useCallback(() => { + if (step === 'submitting') return; + setStep('idle'); + }, [step]); + + const retry = useCallback(async () => { + if (step !== 'failed') return; + await submit(); + }, [step, submit]); + + const close = useCallback(() => { + if (step === 'submitting') return; + setStep('idle'); + setError(null); + setResult(null); + }, [step]); + + return { + step, + eligibility, + isPreview: !isConfigured, + result, + error, + start, + confirm, + cancel, + retry, + close, + }; +} diff --git a/src/hooks/useVault.ts b/src/hooks/useVault.ts index eb34839..9a9a122 100644 --- a/src/hooks/useVault.ts +++ b/src/hooks/useVault.ts @@ -18,6 +18,7 @@ export function useVault() { unlockLock, deposit, withdraw, + withdrawMaturedLock, } = useVaultStore(); const findLock = (id: string) => locks.find(lock => lock.id === id); @@ -37,6 +38,7 @@ export function useVault() { unlockLock, deposit, withdraw, + withdrawMaturedLock, findLock, }; } diff --git a/src/store/vaultStore.ts b/src/store/vaultStore.ts index 0dc74b1..99be87c 100644 --- a/src/store/vaultStore.ts +++ b/src/store/vaultStore.ts @@ -12,6 +12,11 @@ import { mockDepositToVault, mockWithdrawFromVault, } from '../services/stellar'; +import { + createWithdrawalError, + evaluateWithdrawalEligibility, + toWithdrawalErrorCode, +} from '../features/vault/maturedLockWithdrawal'; const LOCKS_KEY = '@pocketpay_vault_locks'; @@ -39,6 +44,29 @@ interface VaultState { unlockLock: (lockId: string) => Promise; deposit: (secretKey: string, publicKey: string, amount: string) => Promise; withdraw: (secretKey: string, publicKey: string, amount: string) => Promise; + /** + * Withdraw a single matured lock back to the wallet. + * + * Re-checks eligibility at submission time, moves the funds, then drops the + * lock — the lock is only removed once the transfer resolves, so a failure + * leaves the vault untouched. Rejects with a `VaultWithdrawalError`. + */ + withdrawMaturedLock: ( + lockId: string, + params: { publicKey: string | null; getSecretKey: () => Promise } + ) => Promise; +} + +export interface MaturedLockWithdrawalResult { + /** Withdrawn amount in XLM, echoed back for the success screen. */ + amount: string; + /** On-chain transaction hash, or null when no contract is configured. */ + hash: string | null; + /** + * True when no vault contract is configured and the withdrawal ran against + * the local placeholder rather than the network. + */ + isPreview: boolean; } export const useVaultStore = create((set, get) => ({ @@ -151,4 +179,50 @@ export const useVaultStore = create((set, get) => ({ set({ isSubmitting: false }); } }, + + withdrawMaturedLock: async (lockId, { publicKey, getSecretKey }) => { + const lock = get().locks.find((candidate) => candidate.id === lockId); + if (!lock) throw createWithdrawalError('lock-not-found'); + + // Eligibility is re-derived here rather than trusted from the UI: the lock + // may have been opened before it matured, or the screen may be stale. + const eligibility = evaluateWithdrawalEligibility(lock, { publicKey }); + if (!eligibility.isEligible) { + throw createWithdrawalError(eligibility.reason ?? 'unknown', eligibility.message); + } + + const isConfigured = get().isConfigured; + set({ isSubmitting: true }); + try { + let hash: string | null = null; + + if (isConfigured) { + const secretKey = await getSecretKey(); + if (!secretKey) throw createWithdrawalError('secret-unavailable'); + hash = await withdrawFromVault(secretKey, lock.amount); + } else { + // No contract configured: run the local placeholder so the flow is + // exercisable end to end without moving anything on the network. + await mockWithdrawFromVault('', lock.amount); + } + + // Only drop the lock once the funds have actually moved. + const remainingLocks = get().locks.filter((candidate) => candidate.id !== lockId); + await AsyncStorage.setItem(LOCKS_KEY, JSON.stringify(remainingLocks)); + set({ locks: remainingLocks }); + + if (publicKey) { + await get().loadBalance(publicKey); + } + + return { amount: lock.amount, hash, isPreview: !isConfigured }; + } catch (error) { + throw createWithdrawalError( + toWithdrawalErrorCode(error), + error instanceof Error ? error.message : undefined + ); + } finally { + set({ isSubmitting: false }); + } + }, }));