migrate to derive on demand for expired gifts (#1039)
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user