diff --git a/__tests__/context-store/giftContext.test.js b/__tests__/context-store/giftContext.test.js new file mode 100644 index 00000000..bc84908a --- /dev/null +++ b/__tests__/context-store/giftContext.test.js @@ -0,0 +1,203 @@ +/* eslint-env jest */ +// --------------------------------------------------------------------------- +// giftContext — restore-on-Domesday must never persist the derived gift seed. +// +// M1: the expired-gift path used to write `restoreKey: derivedMnemonic` (a raw +// BIP39 seed) into the plaintext SQLite gift DB. We mount GiftProvider, drive +// the homepage restore flow, and assert: +// - Expired gifts are saved WITHOUT any restoreKey / plaintext mnemonic. +// - Active gifts are still saved encrypted (encryptedText present, no +// plaintext seed), so the existing claim flow keeps working. +// --------------------------------------------------------------------------- + +import React from 'react'; +import ReactTestRenderer, { act } from 'react-test-renderer'; + +const mockAppStatus = { didGetToHomepage: false }; +const mockGlobalCtx = { masterInfoObject: { uuid: 'me-uuid' } }; +const mockKeys = { + accountMnemoinc: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', +}; +const mockLocal = { + get: jest.fn(async () => 'false'), + set: jest.fn(async () => {}), +}; +const mockStorage = { + bulkDeleteGiftsLocal: jest.fn(async () => true), + bulkSaveGiftsLocal: jest.fn(async () => true), + deleteGiftLocal: jest.fn(async () => true), + getAllLocalGifts: jest.fn(async () => []), + saveGiftLocal: jest.fn(async () => true), + updateGiftLocal: jest.fn(async () => ({})), +}; +const mockDb = { + addGiftToDatabase: jest.fn(async () => true), + bulkAddGiftsToDatabase: jest.fn(async () => true), + bulkDeleteGiftsFromDatabase: jest.fn(async () => true), + deleteGift: jest.fn(async () => true), + handleGiftCheck: jest.fn(async () => ({ didWork: true, wasClaimed: false })), + reloadGiftsOnDomesday: jest.fn(async () => []), + updateGiftInDatabase: jest.fn(async () => true), +}; +const mockMnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const CUTOFF = 1763650239108; + +jest.mock('../../context-store/appStatus', () => ({ + __esModule: true, + useAppStatus: () => ({ + didGetToHomepage: mockAppStatus.didGetToHomepage, + }), +})); + +jest.mock('../../context-store/context', () => ({ + __esModule: true, + useGlobalContextProvider: () => ({ + masterInfoObject: mockGlobalCtx.masterInfoObject, + }), +})); + +jest.mock('../../context-store/keys', () => ({ + __esModule: true, + useKeysContext: () => ({ + accountMnemoinc: mockKeys.accountMnemoinc, + }), +})); + +jest.mock('../../app/functions', () => ({ + __esModule: true, + getLocalStorageItem: (...a) => mockLocal.get(...a), + setLocalStorageItem: (...a) => mockLocal.set(...a), +})); + +jest.mock('../../app/functions/gift/giftsStorage', () => ({ + __esModule: true, + bulkDeleteGiftsLocal: (...a) => mockStorage.bulkDeleteGiftsLocal(...a), + bulkSaveGiftsLocal: (...a) => mockStorage.bulkSaveGiftsLocal(...a), + deleteGiftLocal: (...a) => mockStorage.deleteGiftLocal(...a), + getAllLocalGifts: (...a) => mockStorage.getAllLocalGifts(...a), + saveGiftLocal: (...a) => mockStorage.saveGiftLocal(...a), + updateGiftLocal: (...a) => mockStorage.updateGiftLocal(...a), +})); + +jest.mock('../../db', () => ({ + __esModule: true, + addGiftToDatabase: (...a) => mockDb.addGiftToDatabase(...a), + bulkAddGiftsToDatabase: (...a) => mockDb.bulkAddGiftsToDatabase(...a), + bulkDeleteGiftsFromDatabase: (...a) => mockDb.bulkDeleteGiftsFromDatabase(...a), + deleteGift: (...a) => mockDb.deleteGift(...a), + handleGiftCheck: (...a) => mockDb.handleGiftCheck(...a), + reloadGiftsOnDomesday: (...a) => mockDb.reloadGiftsOnDomesday(...a), + updateGiftInDatabase: (...a) => mockDb.updateGiftInDatabase(...a), +})); + +jest.mock('../../app/functions/gift/deriveGiftWallet', () => ({ + __esModule: true, + deriveSparkGiftMnemonic: jest.fn(async () => ({ + success: true, + derivedMnemonic: mockMnemonic, + })), +})); + +jest.mock('../../app/functions/seed', () => ({ + __esModule: true, + deriveKeyFromMnemonic: jest.fn(async () => ({ + success: true, + derivedMnemonic: mockMnemonic, + })), +})); + +jest.mock('../../app/constants', () => ({ + __esModule: true, + GIFT_DERIVE_PATH_CUTOFF: CUTOFF, +})); + +jest.mock('../../app/functions/messaging/encodingAndDecodingMessages', () => ({ + __esModule: true, + encriptMessage: jest.fn(() => 'ENCRYPTED_CIPHERTEXT'), +})); + +const { GiftProvider } = require('../../context-store/giftContext'); + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function mount() { + await act(async () => { + ReactTestRenderer.create(React.createElement(GiftProvider, null, null)); + }); + await flush(); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockAppStatus.didGetToHomepage = false; + mockLocal.get.mockResolvedValue('false'); + mockLocal.set.mockResolvedValue(undefined); + mockStorage.getAllLocalGifts.mockResolvedValue([]); + mockStorage.saveGiftLocal.mockResolvedValue(true); + mockStorage.updateGiftLocal.mockResolvedValue({}); + mockDb.reloadGiftsOnDomesday.mockResolvedValue([]); + mockDb.handleGiftCheck.mockResolvedValue({ didWork: true, wasClaimed: false }); + mockDb.updateGiftInDatabase.mockResolvedValue(true); +}); + +describe('giftContext — expired gift restore', () => { + test('persists the gift WITHOUT a restoreKey or plaintext seed', async () => { + mockDb.reloadGiftsOnDomesday.mockResolvedValue([ + { + uuid: 'gift-expired-1', + createdBy: 'me-uuid', + createdTime: CUTOFF + 1, + expireTime: Date.now() - 1000, + giftNum: 1001, + state: 'Unclaimed', + }, + ]); + mockAppStatus.didGetToHomepage = true; + + await mount(); + + expect(mockDb.reloadGiftsOnDomesday).toHaveBeenCalledWith('me-uuid'); + expect(mockStorage.saveGiftLocal).toHaveBeenCalledTimes(1); + + const persisted = mockStorage.saveGiftLocal.mock.calls[0][0]; + expect('restoreKey' in persisted).toBe(false); + expect(JSON.stringify(persisted)).not.toContain(mockMnemonic); + // The expired path is local-only; nothing is written back to Firestore. + expect(mockDb.updateGiftInDatabase).not.toHaveBeenCalled(); + }); +}); + +describe('giftContext — active gift restore', () => { + test('persists the seed only as encryptedText, never in plaintext', async () => { + mockDb.reloadGiftsOnDomesday.mockResolvedValue([ + { + uuid: 'gift-active-1', + createdBy: 'me-uuid', + createdTime: CUTOFF + 1, + expireTime: Date.now() + 100000, + giftNum: 1002, + state: 'Unclaimed', + }, + ]); + mockAppStatus.didGetToHomepage = true; + + await mount(); + + expect(mockDb.updateGiftInDatabase).toHaveBeenCalledTimes(1); + expect(mockStorage.saveGiftLocal).toHaveBeenCalledTimes(1); + + const persisted = mockStorage.saveGiftLocal.mock.calls[0][0]; + expect(persisted.encryptedText).toBe('ENCRYPTED_CIPHERTEXT'); + expect('restoreKey' in persisted).toBe(false); + expect(JSON.stringify(persisted)).not.toContain(mockMnemonic); + }); +}); diff --git a/__tests__/functions/gift/deriveGiftRestoreKey.test.js b/__tests__/functions/gift/deriveGiftRestoreKey.test.js new file mode 100644 index 00000000..24c77969 --- /dev/null +++ b/__tests__/functions/gift/deriveGiftRestoreKey.test.js @@ -0,0 +1,97 @@ +/* eslint-env jest */ +// --------------------------------------------------------------------------- +// deriveGiftRestoreKey — on-demand restore key re-derivation (M1 storage fix). +// +// The gift wallet seed used to be persisted as `restoreKey` in the local +// SQLite gift DB. It is now never stored; it is re-derived on demand from the +// account mnemonic + gift index. These tests pin the contract: +// 1. Post-cutoff gifts use the Spark scheme (m/8797555'/giftNum'/0'). +// 2. Pre-cutoff gifts use the legacy scheme (m/44'/0'/0'/0/giftNum). +// 3. The re-derived value is byte-identical to the value that was previously +// persisted, so reclaiming still yields the exact same wallet seed. +// --------------------------------------------------------------------------- +import { validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +jest.mock('../../../app/constants', () => ({ + __esModule: true, + GIFT_DERIVE_PATH_CUTOFF: 1763650239108, + IS_LETTER_REGEX: /^[A-Za-z]$/, +})); + +const { + deriveGiftRestoreKey, + deriveSparkGiftMnemonic, +} = require('../../../app/functions/gift/deriveGiftWallet'); +const { deriveKeyFromMnemonic } = require('../../../app/functions/seed'); + +const ACCOUNT_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const CUTOFF = 1763650239108; + +describe('deriveGiftRestoreKey', () => { + test('post-cutoff gifts use the Spark derivation scheme', async () => { + const giftNum = 1001; + const expected = (await deriveSparkGiftMnemonic(ACCOUNT_MNEMONIC, giftNum)) + .derivedMnemonic; + + await expect( + deriveGiftRestoreKey(ACCOUNT_MNEMONIC, giftNum, CUTOFF + 1), + ).resolves.toBe(expected); + }); + + test('pre-cutoff gifts use the legacy derivation scheme', async () => { + const giftNum = 42; + const expected = (await deriveKeyFromMnemonic(ACCOUNT_MNEMONIC, giftNum)) + .derivedMnemonic; + + await expect( + deriveGiftRestoreKey(ACCOUNT_MNEMONIC, giftNum, CUTOFF - 1), + ).resolves.toBe(expected); + }); + + test('gifts created exactly at the cutoff use the legacy scheme', async () => { + const giftNum = 7; + const expected = (await deriveKeyFromMnemonic(ACCOUNT_MNEMONIC, giftNum)) + .derivedMnemonic; + + await expect( + deriveGiftRestoreKey(ACCOUNT_MNEMONIC, giftNum, CUTOFF), + ).resolves.toBe(expected); + }); + + test('is deterministic for identical inputs', async () => { + const a = await deriveGiftRestoreKey(ACCOUNT_MNEMONIC, 1001, CUTOFF + 1); + const b = await deriveGiftRestoreKey(ACCOUNT_MNEMONIC, 1001, CUTOFF + 1); + expect(a).toBe(b); + }); + + test('derives a different key per gift index', async () => { + const a = await deriveGiftRestoreKey(ACCOUNT_MNEMONIC, 1001, CUTOFF + 1); + const b = await deriveGiftRestoreKey(ACCOUNT_MNEMONIC, 1002, CUTOFF + 1); + expect(a).not.toBe(b); + }); + + test('returns a valid 12-word BIP39 mnemonic', async () => { + const key = await deriveGiftRestoreKey(ACCOUNT_MNEMONIC, 1001, CUTOFF + 1); + expect(key.split(' ')).toHaveLength(12); + expect(validateMnemonic(key, wordlist)).toBe(true); + }); + + test('matches the restore key previously persisted by giftContext', async () => { + // Backwards compatibility: the value giftContext used to persist as + // `restoreKey` must be exactly what re-deriving on demand produces. + const giftNum = 1001; + const previouslyStored = (await deriveSparkGiftMnemonic( + ACCOUNT_MNEMONIC, + giftNum, + )).derivedMnemonic; + + const onDemand = await deriveGiftRestoreKey( + ACCOUNT_MNEMONIC, + giftNum, + CUTOFF + 1, + ); + expect(onDemand).toBe(previouslyStored); + }); +}); diff --git a/__tests__/functions/gift/giftsStorageScrub.test.js b/__tests__/functions/gift/giftsStorageScrub.test.js new file mode 100644 index 00000000..c2906f7d --- /dev/null +++ b/__tests__/functions/gift/giftsStorageScrub.test.js @@ -0,0 +1,178 @@ +/* eslint-env jest */ +// --------------------------------------------------------------------------- +// giftsStorage — the derived gift seed (restoreKey) must never cross the +// storage boundary, in either direction: +// - Writes (saveGiftLocal / bulkSaveGiftsLocal) strip it before persisting, +// even if a caller still passes a legacy gift object. +// - Reads (getAllLocalGifts / getGiftByUuid) strip it from rows written by +// older app versions. +// - updateGiftLocal scrubs legacy rows from disk on their next update +// (the merged object is written back without restoreKey). +// --------------------------------------------------------------------------- + +const mockRows = new Map(); + +const mockDb = { + execAsync: jest.fn(async () => {}), + getFirstAsync: jest.fn(async (_sql, params) => { + if (!params) return null; + return mockRows.get(params[0]) || null; + }), + getAllAsync: jest.fn(async () => [...mockRows.values()]), + runAsync: jest.fn(async (sql, params = []) => { + if (sql.includes('INTO giftsTable')) { + for (let i = 0; i < params.length; i += 4) { + const [uuid, createdBy, storageObject, lastUpdated] = params.slice( + i, + i + 4, + ); + mockRows.set(uuid, { uuid, createdBy, storageObject, lastUpdated }); + } + return { changes: params.length / 4 }; + } + if (sql.includes('UPDATE giftsTable')) { + // Migration scrub: `SET storageObject = ? WHERE uuid = ?` (no lastUpdated). + if (!sql.includes('lastUpdated')) { + const [storageObject, uuid] = params; + const prev = mockRows.get(uuid); + mockRows.set(uuid, { ...prev, uuid, storageObject }); + return { changes: 1 }; + } + const [storageObject, lastUpdated, createdBy, uuid] = params; + mockRows.set(uuid, { uuid, createdBy, storageObject, lastUpdated }); + return { changes: 1 }; + } + if (sql.includes('DELETE FROM giftsTable')) { + mockRows.delete(params[0]); + return { changes: 1 }; + } + return { changes: 0 }; + }), + withTransactionAsync: jest.fn(async fn => fn()), +}; + +jest.mock('expo-sqlite', () => ({ + __esModule: true, + openDatabaseAsync: jest.fn(async () => mockDb), +})); + +const { + saveGiftLocal, + getAllLocalGifts, + getGiftByUuid, + updateGiftLocal, + bulkSaveGiftsLocal, + initGiftDb, +} = require('../../../app/functions/gift/giftsStorage'); + +const UUID = 'gift-uuid-1'; +const CREATED_BY = 'me-uuid'; + +function seedRowWithRestoreKey(uuid = UUID) { + mockRows.set(uuid, { + uuid, + createdBy: CREATED_BY, + storageObject: JSON.stringify({ + uuid, + createdBy: CREATED_BY, + giftNum: 1001, + state: 'Expired', + restoreKey: 'twelve secret words go here', + }), + lastUpdated: 1, + }); +} + +function persistedObject(uuid = UUID) { + return JSON.parse(mockRows.get(uuid).storageObject); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockRows.clear(); +}); + +describe('giftsStorage — write path never persists restoreKey', () => { + test('saveGiftLocal strips restoreKey before persisting', async () => { + await saveGiftLocal({ + uuid: UUID, + createdBy: CREATED_BY, + giftNum: 1001, + restoreKey: 'twelve secret words go here', + }); + + expect('restoreKey' in persistedObject()).toBe(false); + }); + + test('saveGiftLocal does not mutate the caller gift object', async () => { + const gift = { + uuid: UUID, + createdBy: CREATED_BY, + restoreKey: 'twelve secret words go here', + }; + + await saveGiftLocal(gift); + + expect(gift.restoreKey).toBe('twelve secret words go here'); + }); + + test('bulkSaveGiftsLocal strips restoreKey before persisting', async () => { + await bulkSaveGiftsLocal([ + { + uuid: UUID, + createdBy: CREATED_BY, + giftNum: 1001, + restoreKey: 'twelve secret words go here', + }, + ]); + + expect('restoreKey' in persistedObject()).toBe(false); + }); +}); + +describe('giftsStorage — read path never surfaces a legacy restoreKey', () => { + test('getAllLocalGifts strips a legacy persisted restoreKey', async () => { + seedRowWithRestoreKey(); + + const gifts = await getAllLocalGifts(); + + expect(gifts).toHaveLength(1); + expect('restoreKey' in gifts[0]).toBe(false); + }); + + test('getGiftByUuid strips a legacy persisted restoreKey', async () => { + seedRowWithRestoreKey(); + + const gift = await getGiftByUuid(UUID); + + expect(gift).toBeTruthy(); + expect('restoreKey' in gift).toBe(false); + }); +}); + +describe('giftsStorage — legacy rows are scrubbed on write-back', () => { + test('updateGiftLocal removes restoreKey from a legacy row on the next update', async () => { + seedRowWithRestoreKey(); + + await updateGiftLocal(UUID, { state: 'Claimed' }); + + const stored = persistedObject(); + expect(stored.state).toBe('Claimed'); + expect('restoreKey' in stored).toBe(false); + }); +}); + +describe('giftsStorage — init migration scrubs seeds already at rest', () => { + test('initGiftDb rewrites a legacy Expired row so restoreKey leaves disk', async () => { + // An already-Expired legacy row is never touched by updateGiftLocal again, + // so the one-time init scrub is what actually removes it from disk. + seedRowWithRestoreKey(); + expect('restoreKey' in persistedObject()).toBe(true); + + await initGiftDb(); + + const stored = persistedObject(); + expect(stored.state).toBe('Expired'); + expect('restoreKey' in stored).toBe(false); + }); +}); diff --git a/app/components/admin/homeComponents/gifts/claimGiftScreen.js b/app/components/admin/homeComponents/gifts/claimGiftScreen.js index 7aefd7f9..afed327d 100644 --- a/app/components/admin/homeComponents/gifts/claimGiftScreen.js +++ b/app/components/admin/homeComponents/gifts/claimGiftScreen.js @@ -12,7 +12,6 @@ import displayCorrectDenomination from '../../../../functions/displayCorrectDeno import CustomButton from '../../../../functions/CustomElements/button'; import { CENTER, - GIFT_DERIVE_PATH_CUTOFF, SIZES, STARTING_INDEX_FOR_GIFTS_DERIVE, USDB_TOKEN_ID, @@ -41,8 +40,10 @@ import { bulkUpdateSparkTransactions } from '../../../../functions/spark/transac import { updateConfirmAnimation } from '../../../../functions/lottieViewColorTransformer'; import { useGlobalThemeContext } from '../../../../../context-store/theme'; import LottieView from 'lottie-react-native'; -import { deriveSparkGiftMnemonic } from '../../../../functions/gift/deriveGiftWallet'; -import { deriveKeyFromMnemonic } from '../../../../functions/seed'; +import { + deriveGiftRestoreKey, + deriveSparkGiftMnemonic, +} from '../../../../functions/gift/deriveGiftWallet'; import { dollarsToSats } from '../../../../functions/spark/flashnet'; import { useFlashnet } from '../../../../../context-store/flashnetContext'; @@ -109,22 +110,15 @@ export default function ClaimGiftScreen({ throw new Error(t('screens.inAccount.giftPages.claimPage.notExpired')); } - let giftWalletMnemonic; - if (savedGift.createdTime > GIFT_DERIVE_PATH_CUTOFF) { - giftWalletMnemonic = await deriveSparkGiftMnemonic( - accountMnemoinc, - savedGift.giftNum, - ); - } else { - giftWalletMnemonic = await deriveKeyFromMnemonic( - accountMnemoinc, - savedGift.giftNum, - ); - } + const giftSeed = await deriveGiftRestoreKey( + accountMnemoinc, + savedGift.giftNum, + savedGift.createdTime, + ); return { ...savedGift, - giftSeed: giftWalletMnemonic.derivedMnemonic, + giftSeed, }; }, [expertMode, url, customGiftIndex, accountMnemoinc, t]); diff --git a/app/functions/gift/deriveGiftWallet.js b/app/functions/gift/deriveGiftWallet.js index 7de19ecc..ca345b23 100644 --- a/app/functions/gift/deriveGiftWallet.js +++ b/app/functions/gift/deriveGiftWallet.js @@ -3,6 +3,8 @@ import { entropyToMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { mnemonicToSeedAsync } from '../nostrCompatability'; import { bech32m } from 'bech32'; +import { GIFT_DERIVE_PATH_CUTOFF } from '../../constants'; +import { deriveKeyFromMnemonic } from '../seed'; /** * Derives a mnemonic for a Spark Wallet gift at a specific index @@ -44,6 +46,37 @@ export async function deriveSparkGiftMnemonic( } } +/** + * Re-derives a gift's restore key (the gift wallet's BIP39 seed) on demand from + * the account mnemonic + gift index. The seed is deliberately NOT stored + * anywhere — it is always recomputed when a gift needs to be reclaimed, mirroring + * the pool wallet approach. + * + * The derivation scheme is chosen by the gift's creation time so the re-derived + * mnemonic is identical to the one used when the gift was created, keeping + * backwards compatibility with gifts persisted before the scheme change. + * + * @param {string} accountMnemonic - The master account mnemonic phrase + * @param {number} giftNum - The gift's derivation index + * @param {number} createdTime - The gift's creation timestamp; selects the pre/post-cutoff derivation scheme + * @returns {Promise} The derived restore mnemonic + */ +export async function deriveGiftRestoreKey( + accountMnemonic, + giftNum, + createdTime, +) { + const result = + createdTime > GIFT_DERIVE_PATH_CUTOFF + ? await deriveSparkGiftMnemonic(accountMnemonic, giftNum) + : await deriveKeyFromMnemonic(accountMnemonic, giftNum); + + if (!result.success) { + throw new Error(result.error || 'Failed to derive gift restore key'); + } + return result.derivedMnemonic; +} + /** * Derives the expected identity key that Spark will generate internally * Spark uses m/8797555'/accountNumber'/0' where accountNumber defaults to 1 diff --git a/app/functions/gift/giftsStorage.js b/app/functions/gift/giftsStorage.js index 9ab900fa..1c1e3ce1 100644 --- a/app/functions/gift/giftsStorage.js +++ b/app/functions/gift/giftsStorage.js @@ -2,6 +2,18 @@ import * as SQLite from 'expo-sqlite'; export const CACHED_GIFTS = 'SAVED_GIFTS'; +// The derived gift seed used to be persisted as `restoreKey` before it was +// re-derived on demand. Never let it cross the storage boundary in either +// direction so it can't leak to SQLite (and legacy rows get scrubbed on write). +const withoutRestoreKey = gift => { + if (gift && typeof gift === 'object' && 'restoreKey' in gift) { + const clean = { ...gift }; + delete clean.restoreKey; + return clean; + } + return gift; +}; + let sqlLiteDB = null; let isInitialized = false; let initPromise = null; @@ -71,6 +83,29 @@ export const initGiftDb = async () => { console.warn('Index creation warning (can be ignored):', indexError); } + // One-time scrub of any legacy rows that still hold a plaintext `restoreKey` + // at rest. New writes are already clean and reads strip it, but a gift + // already marked Expired is never rewritten, so its seed would otherwise + // sit on disk forever. LIKE no-ops on clean databases. + try { + const dirty = await sqlLiteDB.getAllAsync( + `SELECT uuid, storageObject FROM giftsTable WHERE storageObject LIKE '%"restoreKey"%'`, + ); + for (const row of dirty) { + try { + const clean = withoutRestoreKey(JSON.parse(row.storageObject)); + await sqlLiteDB.runAsync( + `UPDATE giftsTable SET storageObject = ? WHERE uuid = ?`, + [JSON.stringify(clean), row.uuid], + ); + } catch (rowErr) { + console.warn('Skipping unscrubbable gift row:', row.uuid, rowErr); + } + } + } catch (scrubErr) { + console.warn('Legacy restoreKey scrub failed (non-fatal):', scrubErr); + } + isInitialized = true; console.log('Gift database initialized successfully'); return true; @@ -118,7 +153,7 @@ export const saveGiftLocal = async giftObj => { ); } - const serialized = JSON.stringify(giftObj); + const serialized = JSON.stringify(withoutRestoreKey(giftObj)); const lastUpdated = giftObj.lastUpdated || Date.now(); if (!existing) { @@ -199,7 +234,7 @@ export const getAllLocalGifts = async (limit = null) => { const gifts = result .map(r => { try { - return JSON.parse(r.storageObject); + return withoutRestoreKey(JSON.parse(r.storageObject)); } catch (parseErr) { console.error( 'Error parsing gift object:', @@ -238,7 +273,7 @@ export const getGiftByUuid = async uuid => { } try { - return JSON.parse(result.storageObject); + return withoutRestoreKey(JSON.parse(result.storageObject)); } catch (parseErr) { console.error('Error parsing gift object:', parseErr); return null; @@ -280,7 +315,7 @@ export const updateGiftLocal = async (uuid, updatedFields) => { // Parse existing gift and merge with updates let existingGift; try { - existingGift = JSON.parse(existing.storageObject); + existingGift = withoutRestoreKey(JSON.parse(existing.storageObject)); } catch (parseErr) { throw new Error('Failed to parse existing gift data'); } @@ -343,7 +378,7 @@ export const bulkSaveGiftsLocal = async gifts => { const values = gifts.flatMap(gift => [ gift.uuid, gift.createdBy, - JSON.stringify(gift), + JSON.stringify(withoutRestoreKey(gift)), gift.lastUpdated || now, ]); await db.withTransactionAsync(async () => { diff --git a/context-store/giftContext.js b/context-store/giftContext.js index 56d681dd..4dc48772 100644 --- a/context-store/giftContext.js +++ b/context-store/giftContext.js @@ -164,152 +164,160 @@ export function GiftProvider({ children }) { [updateGiftList], ); - const checkForRefunds = useCallback(async giftList => { - try { - if (isCheckingRefunds.current) return; - isCheckingRefunds.current = true; - const localGifts = await (giftList - ? Promise.resolve(giftList) - : getAllLocalGifts()); + const checkForRefunds = useCallback( + async giftList => { + try { + if (isCheckingRefunds.current) return; + isCheckingRefunds.current = true; + const localGifts = await (giftList + ? Promise.resolve(giftList) + : getAllLocalGifts()); - const giftArray = Object.values(localGifts); - const now = Date.now(); + const giftArray = Object.values(localGifts); + const now = Date.now(); - const expiredGifts = giftArray.filter(item => { - return item.state === 'Unclaimed' && now >= item.expireTime; - }); - console.log(expiredGifts, 'expired gifts'); + const expiredGifts = giftArray.filter(item => { + return item.state === 'Unclaimed' && now >= item.expireTime; + }); + console.log(expiredGifts, 'expired gifts'); - if (expiredGifts.length === 0) { - console.log('No expired gifts to check'); + if (expiredGifts.length === 0) { + console.log('No expired gifts to check'); + return; + } + + console.log(`Checking ${expiredGifts.length} expired gifts...`); + + const checkPromises = expiredGifts.map(card => + handleGiftCheck(card.uuid) + .then(response => ({ card, response })) + .catch(error => { + console.error(`Error checking gift ${card.uuid}:`, error); + return { card, response: null }; + }), + ); + + const results = await Promise.all(checkPromises); + + // Batch database updates + const updatePromises = results + .filter(({ response }) => response?.didWork) + .map(async ({ card, response }) => { + console.log(card, response); + try { + if (response.wasClaimed) { + await deleteGift(card.uuid); + } + + await updateGiftLocal(card.uuid, { + state: response.wasClaimed ? 'Claimed' : 'Expired', + }); + + console.log( + `Updated gift ${card.uuid}:`, + response.wasClaimed ? 'Claimed' : 'Expired', + ); + } catch (error) { + console.error(`Error updating gift ${card.uuid}:`, error); + } + }); + + await Promise.all(updatePromises); + await updateGiftList(); + console.log(`Processed ${updatePromises.length} gift updates`); + } catch (err) { + console.log('error checking for gift refunds', err.message); + } finally { + isCheckingRefunds.current = false; + } + }, + [updateGiftList], + ); + + const handleGiftRestoreOnDomeseday = useCallback( + async giftList => { + // If we have gifts that means we are not restoring and do not need to get gifts in database + if (giftList?.length) return; + + const didCheckDBForGifts = JSON.parse( + await getLocalStorageItem('checkForOutstandingGifts'), + ); + + // We already checked for outstanding gifts and none exist so don't check again + if (didCheckDBForGifts) return; + + const outstandingGifts = await reloadGiftsOnDomesday( + masterInfoObject.uuid, + ); + + // If no gifts exist, mark as checked and return + if (!outstandingGifts.length) { + await setLocalStorageItem( + 'checkForOutstandingGifts', + JSON.stringify(true), + ); return; } - console.log(`Checking ${expiredGifts.length} expired gifts...`); + const now = Date.now(); - const checkPromises = expiredGifts.map(card => - handleGiftCheck(card.uuid) - .then(response => ({ card, response })) - .catch(error => { - console.error(`Error checking gift ${card.uuid}:`, error); - return { card, response: null }; - }), - ); + // Process all gifts in parallel and wait for completion + const reconstructedGifts = await Promise.all( + outstandingGifts.map(async item => { + // Gift is expired + if (item.expireTime < now) { + await saveGiftLocal(item); + return item; + } else { + // Active gift - re-derive the seed to re-encrypt for sharing + let giftWalletMnemonic; - const results = await Promise.all(checkPromises); - - // Batch database updates - const updatePromises = results - .filter(({ response }) => response?.didWork) - .map(async ({ card, response }) => { - console.log(card, response); - try { - if (response.wasClaimed) { - await deleteGift(card.uuid); + if (item.createdTime > GIFT_DERIVE_PATH_CUTOFF) { + giftWalletMnemonic = await deriveSparkGiftMnemonic( + accountMnemoinc, + item.giftNum, + ); + } else { + giftWalletMnemonic = await deriveKeyFromMnemonic( + accountMnemoinc, + item.giftNum, + ); } - await updateGiftLocal(card.uuid, { - state: response.wasClaimed ? 'Claimed' : 'Expired', - }); - - console.log( - `Updated gift ${card.uuid}:`, - response.wasClaimed ? 'Claimed' : 'Expired', + // Update with new secret for sharing + const randomSecret = randomBytes(32); + const randomPubkey = getPublicKey(randomSecret); + const encryptedMnemonic = encriptMessage( + randomSecret, + randomPubkey, + giftWalletMnemonic.derivedMnemonic, ); - } catch (error) { - console.error(`Error updating gift ${card.uuid}:`, error); + const urls = createGiftUrl(item.uuid, randomSecret); + + const updatedGift = { + ...item, + claimURL: urls.webUrl, + encryptedText: encryptedMnemonic, + }; + + await Promise.all([ + updateGiftInDatabase(updatedGift), + saveGiftLocal(updatedGift), + ]); + + return updatedGift; } - }); + }), + ); - await Promise.all(updatePromises); - await updateGiftList(); - console.log(`Processed ${updatePromises.length} gift updates`); - } catch (err) { - console.log('error checking for gift refunds', err.message); - } finally { - isCheckingRefunds.current = false; - } - }, [updateGiftList]); - - const handleGiftRestoreOnDomeseday = useCallback(async giftList => { - // If we have gifts that means we are not restoring and do not need to get gifts in database - if (giftList?.length) return; - - const didCheckDBForGifts = JSON.parse( - await getLocalStorageItem('checkForOutstandingGifts'), - ); - - // We already checked for outstanding gifts and none exist so don't check again - if (didCheckDBForGifts) return; - - const outstandingGifts = await reloadGiftsOnDomesday(masterInfoObject.uuid); - - // If no gifts exist, mark as checked and return - if (!outstandingGifts.length) { + dispatch({ type: 'BULK_ADD_GIFTS', payload: reconstructedGifts }); await setLocalStorageItem( 'checkForOutstandingGifts', JSON.stringify(true), ); - return; - } - - const now = Date.now(); - - // Process all gifts in parallel and wait for completion - const reconstructedGifts = await Promise.all( - outstandingGifts.map(async item => { - let giftWalletMnemonic; - - if (item.createdTime > GIFT_DERIVE_PATH_CUTOFF) { - giftWalletMnemonic = await deriveSparkGiftMnemonic( - accountMnemoinc, - item.giftNum, - ); - } else { - giftWalletMnemonic = await deriveKeyFromMnemonic( - accountMnemoinc, - item.giftNum, - ); - } - - // Gift is expired - just add restore key - if (item.expireTime < now) { - const expiredGift = { - ...item, - restoreKey: giftWalletMnemonic.derivedMnemonic, - }; - await saveGiftLocal(expiredGift); - return expiredGift; - } else { - // Active gift - update with new secret for sharing - const randomSecret = randomBytes(32); - const randomPubkey = getPublicKey(randomSecret); - const encryptedMnemonic = encriptMessage( - randomSecret, - randomPubkey, - giftWalletMnemonic.derivedMnemonic, - ); - const urls = createGiftUrl(item.uuid, randomSecret); - - const updatedGift = { - ...item, - claimURL: urls.webUrl, - encryptedText: encryptedMnemonic, - }; - - await Promise.all([ - updateGiftInDatabase(updatedGift), - saveGiftLocal(updatedGift), - ]); - - return updatedGift; - } - }), - ); - - dispatch({ type: 'BULK_ADD_GIFTS', payload: reconstructedGifts }); - await setLocalStorageItem('checkForOutstandingGifts', JSON.stringify(true)); - }, [accountMnemoinc, masterInfoObject.uuid]); + }, + [accountMnemoinc, masterInfoObject.uuid], + ); useEffect(() => { if (!didGetToHomepage) return;