migrate to derive on demand for expired gifts (#1039)

This commit is contained in:
Blake Kaufman
2026-08-01 15:43:07 -04:00
committed by GitHub
parent 0b4c004de9
commit 457b5915a2
7 changed files with 698 additions and 150 deletions
+203
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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]);
+33
View File
@@ -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<string>} 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
+40 -5
View File
@@ -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 () => {
+137 -129
View File
@@ -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;