Files
com.blitzwallet/context-store/savingsContext.js
T
Blake KaufmanandGitHub 924e04822b load bitcoin balance from wallet viewer (#888)
* load bitcoin balance from wallet viewer

* simplify withdrawl

* remove log
2026-06-03 14:49:33 -04:00

791 lines
24 KiB
JavaScript

import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
createSavingsGoal,
createSavingsTransaction,
createSavingsTransactions,
deleteSavingsGoal,
getAllPayoutsTransactions,
getAllSavingsTransactions,
getSavingsGoals,
setPayoutsTransactions,
updateSavingsGoal,
} from '../app/functions/savings/savingsStorage';
import customUUID from '../app/functions/customUUID';
import {
DEFAULT_GOAL_EMOJI,
STARTING_INDEX_FOR_SAVINGS_DERIVE,
} from '../app/constants';
import {
deriveSparkGiftMnemonic,
deriveSparkIdentityKey,
deriveSparkAddress,
} from '../app/functions/gift/deriveGiftWallet';
import {
getLocalStorageItem,
setLocalStorageItem,
} from '../app/functions/localStorage';
import { useKeysContext } from './keys';
import { useAppStatus } from './appStatus';
import {
computeGoalBalanceMicros,
computeSavingsBalanceMicros,
fetchSavingsInterestPayouts,
fromMicros,
normalizeGoalForUI,
resolveGoalAndAmount,
serializeGoalMetadata,
toLegacyDisplayTransaction,
toMicros,
} from '../app/components/admin/homeComponents/savings/utils';
import {
getBitcoinWithdrawls,
getBitcoinBalance,
initializeSparkWalletViewer,
getTokensBalance,
getTokenTransactions,
} from '../app/functions/spark/walletViewer';
import { useTranslation } from 'react-i18next';
import tokenBufferAmountToDecimal from '../app/functions/lrc20/bufferToDecimal';
const SavingsContext = createContext(null);
/**
* @property {string} id
* @property {string} name
* @property {number} targetAmountMicros
* @property {number} createdAt
* @property {number} updatedAt
* @property {string | null | undefined} [metadata]
*/
/**
* @typedef {'deposit' | 'withdrawal'} SavingsTransactionType
*/
/**
* @typedef {Object} SavingsTransaction
* @property {string} id
* @property {string} goalId
* @property {SavingsTransactionType} type
* @property {number} amountMicros
* @property {number} timestamp
*/
// Sentinel goalId used when a deposit/withdrawal is made against the savings
// wallet directly (not attributed to any specific goal).
export const UNALLOCATED_GOAL_ID = 'unallocated';
const SAVINGS_BALANCE_CACHE_KEY = 'savings_wallet_balance_micros';
const SAVINGS_BITCOIN_BALANCE_CACHE_KEY = 'savings_wallet_bitcoin_balance_sats';
const SAVINGS_BALANCE_FETCH_TIME_KEY = 'savings_wallet_balance_fetch_time';
const SAVINGS_PAYOUTS_FETCH_TIME_KEY = 'savings_payouts_fetch_time';
const TX_RECONCILIATION_WINDOW_MS = 10 * 60 * 1000;
const coerceBalanceNumber = balance => {
const candidate =
balance?.balance ??
balance?.availableBalance ??
balance?.availableToSendBalance ??
balance ??
0;
const parsed = Number(candidate);
return Number.isFinite(parsed) ? parsed : 0;
};
const isLikelySameSavingsTransaction = (existingTx, sparkTx) => {
if (existingTx?.id === sparkTx?.id) return true;
if (existingTx?.type !== sparkTx?.type) return false;
if (Number(existingTx?.amountMicros) !== Number(sparkTx?.amountMicros)) {
return false;
}
return (
Math.abs(Number(existingTx?.timestamp) - Number(sparkTx?.timestamp)) <=
TX_RECONCILIATION_WINDOW_MS
);
};
export function SavingsProvider({ children }) {
const { accountMnemoinc } = useKeysContext();
const { didGetToHomepage } = useAppStatus();
const [goals, setGoals] = useState([]);
const [transactions, setTransactions] = useState([]);
const [savingsWallet, setSavingsWallet] = useState(null);
const [walletBalanceMicros, setWalletBalanceMicros] = useState(null);
const [walletBitcoinBalanceSats, setWalletBitcoinBalanceSats] =
useState(null);
const [interestPayouts, setInterestPayouts] = useState([]);
const hasDerivedWalletRef = useRef(false);
const savingsWalletMnemonicRef = useRef(null);
const initPromiseRef = useRef(null);
const hasInitializedRef = useRef(false);
const [isInitializing, setIsInitializing] = useState(true);
const [initializationError, setInitializationError] = useState(null);
const { t } = useTranslation();
// Set to true by recordTransaction after a successful deposit/withdrawal so
// the next refreshBalances call knows the on-chain balance has changed.
const balanceDirtyRef = useRef(false);
// Timestamps (ms) of the last successful fetch — populated from AsyncStorage on load.
const balanceFetchTimeRef = useRef(0);
const payoutFetchTimeRef = useRef(0);
const updateWalletBalance = useCallback(micros => {
const now = String(Date.now());
setWalletBalanceMicros(micros);
setLocalStorageItem(SAVINGS_BALANCE_CACHE_KEY, String(micros));
setLocalStorageItem(SAVINGS_BALANCE_FETCH_TIME_KEY, now);
}, []);
const updateWalletBitcoinBalance = useCallback(sats => {
setWalletBitcoinBalanceSats(sats);
setLocalStorageItem(SAVINGS_BITCOIN_BALANCE_CACHE_KEY, String(sats));
}, []);
const deriveSingleSavingsWallet = useCallback(async () => {
if (hasDerivedWalletRef.current) return savingsWallet;
if (!accountMnemoinc) return null;
try {
const step1 = await deriveSparkGiftMnemonic(
accountMnemoinc,
STARTING_INDEX_FOR_SAVINGS_DERIVE,
);
if (!step1?.success) {
throw new Error(step1?.error);
}
const step2 = await deriveSparkIdentityKey(step1.derivedMnemonic, 1);
if (!step2?.success) {
throw new Error(step2?.error);
}
const sparkAddr = deriveSparkAddress(step2.publicKey);
if (!sparkAddr?.success) {
throw new Error(sparkAddr?.error);
}
const derivedWallet = {
derivationIndex: STARTING_INDEX_FOR_SAVINGS_DERIVE,
identityPublicKeyHex: step2.publicKeyHex,
sparkAddress: sparkAddr.address,
};
savingsWalletMnemonicRef.current = step1.derivedMnemonic;
hasDerivedWalletRef.current = true;
setSavingsWallet(derivedWallet);
return derivedWallet;
} catch (deriveError) {
return null;
}
}, [accountMnemoinc, savingsWallet]);
const loadSavingsState = useCallback(async () => {
try {
// Load cached balance immediately so UI renders without loading state
const [
cachedBalance,
cachedBitcoinBalance,
cachedBalanceFetchTime,
cachedPayoutFetchTime,
] = await Promise.all([
getLocalStorageItem(SAVINGS_BALANCE_CACHE_KEY),
getLocalStorageItem(SAVINGS_BITCOIN_BALANCE_CACHE_KEY),
getLocalStorageItem(SAVINGS_BALANCE_FETCH_TIME_KEY),
getLocalStorageItem(SAVINGS_PAYOUTS_FETCH_TIME_KEY),
]);
if (cachedBalance !== null && cachedBalance !== undefined) {
const parsed = Number(cachedBalance);
if (Number.isFinite(parsed)) {
setWalletBalanceMicros(parsed);
}
}
if (cachedBitcoinBalance !== null && cachedBitcoinBalance !== undefined) {
const parsed = Number(cachedBitcoinBalance);
if (Number.isFinite(parsed)) {
setWalletBitcoinBalanceSats(parsed);
}
}
// Restore cached timestamps into refs so the refresh gates work on first focus
if (cachedBalanceFetchTime) {
balanceFetchTimeRef.current = Number(cachedBalanceFetchTime);
}
if (cachedPayoutFetchTime) {
payoutFetchTimeRef.current = Number(cachedPayoutFetchTime);
}
const derivedWallet = await deriveSingleSavingsWallet();
const [
goalsFromStorage,
transactionsFromStorage,
payoutsTransactionsFromStorage,
] = await Promise.all([
getSavingsGoals(),
getAllSavingsTransactions(),
getAllPayoutsTransactions(),
]);
setGoals(goalsFromStorage);
setTransactions(transactionsFromStorage);
setInterestPayouts(payoutsTransactionsFromStorage);
console.log('savigs loaded succesfully');
return {
didWork: true,
savingsWallet: derivedWallet || savingsWallet,
goals: goalsFromStorage,
transactions: transactionsFromStorage,
interestPayouts: payoutsTransactionsFromStorage,
};
} catch (loadError) {
console.log(loadError, 'loading savings errro');
return { didWork: false, error: loadError?.message };
}
}, [deriveSingleSavingsWallet, savingsWallet]);
const createGoal = useCallback(async payload => {
try {
const name = String(payload?.name || '').trim();
if (!name) {
return {
didWork: false,
error: t('savings.howItWorks.context.goalNameRequired'),
};
}
const now = Date.now();
const nextGoal = {
id: payload?.id || customUUID(),
name,
targetAmountMicros: Math.max(
0,
Math.round(
Number(
payload?.targetAmountMicros ??
toMicros(payload?.targetAmount ?? payload?.amount ?? 0),
),
),
),
createdAt: now,
updatedAt: now,
metadata: serializeGoalMetadata({
emoji: payload?.emoji || DEFAULT_GOAL_EMOJI,
}),
};
const createdGoal = await createSavingsGoal(nextGoal);
setGoals(prev => [createdGoal, ...prev]);
return { didWork: true, goal: createdGoal };
} catch (createError) {
return { didWork: false, error: createError.message };
}
}, []);
const updateGoal = useCallback(async (goalId, patch) => {
try {
const metadata =
patch && Object.prototype.hasOwnProperty.call(patch, 'emoji')
? serializeGoalMetadata({ emoji: patch.emoji || DEFAULT_GOAL_EMOJI })
: undefined;
const updatedGoal = await updateSavingsGoal(goalId, {
name: patch?.name,
targetAmountMicros:
patch?.targetAmountMicros ??
(patch?.amount != null ? toMicros(patch.amount) : undefined),
metadata,
});
if (!updatedGoal) {
return { didWork: false, error: t('savings.context.goalNotFound') };
}
setGoals(prev =>
prev.map(goal => (goal.id === goalId ? updatedGoal : goal)),
);
return { didWork: true, goal: updatedGoal };
} catch (updateError) {
return { didWork: false, error: updateError.message };
}
}, []);
const removeGoal = useCallback(
async goalId => {
try {
const targetGoalId = goalId;
const didDelete = await deleteSavingsGoal(targetGoalId);
if (!didDelete) {
return { didWork: false, error: t('savings.context.goalNotFound') };
}
const nextGoals = goals.filter(goal => goal.id !== targetGoalId);
setGoals(nextGoals);
setTransactions(prev => prev.filter(tx => tx.goalId !== targetGoalId));
return { didWork: true };
} catch (removeError) {
return { didWork: false, error: removeError.message };
}
},
[goals],
);
const recordTransaction = useCallback(
async ({ goalId, type, amount }) => {
try {
// Resolve the effective goalId: null / undefined / UNALLOCATED_GOAL_ID
// all mean "no specific goal — record against the wallet directly".
const resolvedGoalId =
goalId && goalId !== UNALLOCATED_GOAL_ID
? goalId
: UNALLOCATED_GOAL_ID;
// For goal-attributed transactions, verify the goal still exists.
if (resolvedGoalId !== UNALLOCATED_GOAL_ID) {
const goal = goals.find(item => item.id === resolvedGoalId);
if (!goal)
return { didWork: false, error: t('savings.context.goalNotFound') };
}
const amountMicros =
amount != null && amount > 0
? toMicros(amount)
: Math.round(Number(amount || 0));
if (type === 'withdrawal') {
// For goal-specific withdrawals validate against the goal's tracked balance.
// For unallocated withdrawals validate against the live wallet balance.
const availableMicros =
resolvedGoalId !== UNALLOCATED_GOAL_ID
? computeGoalBalanceMicros(resolvedGoalId, transactions)
: walletBalanceMicros ??
computeSavingsBalanceMicros(transactions);
}
const transaction = await createSavingsTransaction({
id: customUUID(),
goalId: resolvedGoalId,
type,
amountMicros,
timestamp: Date.now(),
});
setTransactions(prev => [transaction, ...prev]);
// Mark balance as dirty so the next screen-focus triggers a live refetch
balanceDirtyRef.current = true;
return { didWork: true, transaction };
} catch (transactionError) {
return { didWork: false, error: transactionError.message };
}
},
[goals, transactions, walletBalanceMicros],
);
const contributeToGoal = useCallback(
async (goalIdOrPayload, maybeAmount) => {
const { goalId, amount } = resolveGoalAndAmount(
goalIdOrPayload,
maybeAmount,
);
// goalId may be a specific goal id, UNALLOCATED_GOAL_ID, or absent —
// recordTransaction normalises all absent/unallocated cases internally.
return recordTransaction({
goalId: goalId || UNALLOCATED_GOAL_ID,
type: 'deposit',
amount,
});
},
[recordTransaction],
);
const withdrawFromGoal = useCallback(
async (goalIdOrPayload, maybeAmount) => {
const { goalId, amount } = resolveGoalAndAmount(
goalIdOrPayload,
maybeAmount,
);
return recordTransaction({
goalId: goalId || UNALLOCATED_GOAL_ID,
type: 'withdrawal',
amount,
});
},
[recordTransaction],
);
const withdrawlFromRewards = useCallback(
async amount => {
return recordTransaction({
goalId: UNALLOCATED_GOAL_ID,
type: 'bitcoinWithdrawal',
amount,
});
},
[recordTransaction],
);
const savingsGoals = useMemo(
() =>
goals.map(goal => {
const normalizedGoal = normalizeGoalForUI(goal);
return {
...normalizedGoal,
currentAmountMicros: computeGoalBalanceMicros(goal.id, transactions),
};
}),
[goals, transactions],
);
const totalGoalsBalance = useMemo(
() =>
fromMicros(
savingsGoals.reduce((goalA, goalB) => {
return goalA + goalB.currentAmountMicros;
}, 0),
),
[savingsGoals, transactions],
);
const allSavingsTransactions = useMemo(() => {
const goalNameById = new Map(goals.map(goal => [goal.id, goal.name]));
return transactions.map(tx =>
toLegacyDisplayTransaction(tx, goalNameById.get(tx.goalId), t),
);
}, [goals, transactions, t]);
const restorePaymentsFromSpark = useCallback(
async ({ tokenTransactions, bitcoinWithdrawls, wallet } = {}) => {
try {
const activeWallet = wallet || savingsWallet;
if (!activeWallet?.sparkAddress) return;
const [pastTxs, pastBitcoinTxs] =
tokenTransactions || bitcoinWithdrawls
? [tokenTransactions, bitcoinWithdrawls]
: await Promise.all([
getTokenTransactions(activeWallet.sparkAddress),
getBitcoinWithdrawls(activeWallet.sparkAddress),
]);
const tokenTxs = (pastTxs?.transactions ?? [])
.slice(0, 50)
.reduce((acc, tokenTx) => {
const tokenOutputs = tokenTx.tokenTransaction?.tokenOutputs;
if (!tokenOutputs?.length) return acc;
const txHash = Buffer.from(
Object.values(tokenTx.tokenTransactionHash),
).toString('hex');
const ownerPublicKey = Buffer.from(
Object.values(tokenOutputs[0]?.ownerPublicKey),
).toString('hex');
const amountMicros = tokenOutputs[0]?.tokenAmount
? Number(tokenBufferAmountToDecimal(tokenOutputs[0].tokenAmount))
: 0;
if (!amountMicros) return acc;
acc.push({
id: txHash,
goalId: UNALLOCATED_GOAL_ID,
type:
ownerPublicKey !== activeWallet.identityPublicKeyHex
? 'withdrawal'
: 'deposit',
amountMicros,
timestamp: new Date(
tokenTx.tokenTransaction.clientCreatedTimestamp,
).getTime(),
});
return acc;
}, []);
const bitcoinTxs = (pastBitcoinTxs?.transfers ?? [])
.filter(
transfer =>
Buffer.from(transfer.senderIdentityPublicKey).toString('hex') ===
activeWallet.identityPublicKeyHex,
)
.map(tx => ({
id: tx.id,
goalId: UNALLOCATED_GOAL_ID,
type: 'bitcoinWithdrawal',
amountMicros: toMicros(tx.totalValue),
timestamp: new Date(tx.createdTime).getTime(),
}));
const newTxs = [...tokenTxs, ...bitcoinTxs];
const existingTxs = await getAllSavingsTransactions();
const txsToSave = newTxs.filter(
sparkTx =>
!existingTxs.some(existingTx =>
isLikelySameSavingsTransaction(existingTx, sparkTx),
),
);
if (txsToSave.length) {
await createSavingsTransactions(txsToSave);
}
setTransactions(await getAllSavingsTransactions());
} catch (err) {
console.error('error restoring tx history', err);
}
},
[savingsWallet],
);
const refreshBalances = useCallback(
async ({ force = false } = {}) => {
if (!savingsWallet?.sparkAddress) return { didWork: false };
const viewerReady = await initializeSparkWalletViewer(
savingsWalletMnemonicRef.current || accountMnemoinc,
);
if (!viewerReady) return { didWork: false };
const [balance, bitcoinBalance] = await Promise.all([
getTokensBalance(savingsWallet.sparkAddress),
getBitcoinBalance(savingsWallet.sparkAddress),
]);
await restorePaymentsFromSpark();
updateWalletBalance(coerceBalanceNumber(balance));
updateWalletBitcoinBalance(coerceBalanceNumber(bitcoinBalance));
return { didWork: true };
},
[
savingsWallet,
accountMnemoinc,
updateWalletBalance,
updateWalletBitcoinBalance,
restorePaymentsFromSpark,
],
);
const refreshInterestPayouts = useCallback(
async ({ force = false } = {}) => {
if (!savingsWallet?.identityPublicKeyHex) return { didWork: false };
const payouts = await fetchSavingsInterestPayouts(
savingsWallet.identityPublicKeyHex,
);
setPayoutsTransactions(payouts);
setInterestPayouts(payouts);
return { didWork: true };
},
[savingsWallet],
);
const initializeSavings = useCallback(
async ({ force = false } = {}) => {
if (initPromiseRef.current) return initPromiseRef.current;
if (hasInitializedRef.current && !force) {
return { didWork: true, skipped: true };
}
const shouldShowInitializing = !hasInitializedRef.current;
if (shouldShowInitializing) {
setIsInitializing(true);
}
const initPromise = (async () => {
try {
setInitializationError(null);
const loadedState = await loadSavingsState();
const activeWallet = loadedState?.savingsWallet || savingsWallet;
if (!activeWallet?.sparkAddress) {
const message = 'Savings wallet unavailable';
setInitializationError(message);
return { didWork: false, error: message };
}
const viewerReady = await initializeSparkWalletViewer(
savingsWalletMnemonicRef.current || accountMnemoinc,
);
if (!viewerReady) {
const message = 'Spark wallet viewer unavailable';
setInitializationError(message);
return { didWork: false, error: message };
}
const [
tokenBalance,
bitcoinBalance,
tokenTransactions,
bitcoinWithdrawls,
payouts,
] = await Promise.all([
getTokensBalance(activeWallet.sparkAddress),
getBitcoinBalance(activeWallet.sparkAddress),
getTokenTransactions(activeWallet.sparkAddress),
getBitcoinWithdrawls(activeWallet.sparkAddress),
activeWallet.identityPublicKeyHex
? fetchSavingsInterestPayouts(activeWallet.identityPublicKeyHex)
: Promise.resolve([]),
]);
updateWalletBalance(coerceBalanceNumber(tokenBalance));
updateWalletBitcoinBalance(coerceBalanceNumber(bitcoinBalance));
await restorePaymentsFromSpark({
tokenTransactions,
bitcoinWithdrawls,
wallet: activeWallet,
});
await setPayoutsTransactions(payouts);
setInterestPayouts(payouts);
setLocalStorageItem(
SAVINGS_PAYOUTS_FETCH_TIME_KEY,
String(Date.now()),
);
return { didWork: true };
} catch (initError) {
const message = initError?.message || 'Unable to initialize savings';
setInitializationError(message);
console.log(initError, 'initializing savings error');
return { didWork: false, error: message };
} finally {
hasInitializedRef.current = true;
initPromiseRef.current = null;
if (shouldShowInitializing) {
setIsInitializing(false);
}
}
})();
initPromiseRef.current = initPromise;
return initPromise;
},
[
accountMnemoinc,
loadSavingsState,
restorePaymentsFromSpark,
savingsWallet,
updateWalletBalance,
updateWalletBitcoinBalance,
],
);
useEffect(() => {
if (!didGetToHomepage) return;
initializeSavings();
}, [initializeSavings, didGetToHomepage]);
const savingsBalanceMicros = useMemo(() => {
// Live wallet balance is the source of truth (survives goal deletion)
if (walletBalanceMicros !== null) {
return walletBalanceMicros;
}
// Fallback to local transaction sum while API hasn't returned yet
return computeSavingsBalanceMicros(transactions);
}, [walletBalanceMicros, transactions]);
const totalIntrestEarned = useMemo(() => {
return interestPayouts?.reduce((prev, curr) => prev + curr.payoutSats, 0);
}, [interestPayouts.length]);
const value = useMemo(
() => ({
deriveSingleSavingsWallet,
savingsGoals,
allSavingsTransactions,
getGoalBalanceMicros: goalId =>
computeGoalBalanceMicros(goalId, transactions),
// Compatibility with existing UI calls.
savingsBalance: fromMicros(savingsBalanceMicros),
setSavingsGoal: async ({ name, amount, emoji, mode, goalId }) => {
const shouldUpdate = mode === 'update' || !!goalId;
if (shouldUpdate) {
const targetGoalId = goalId;
if (!targetGoalId) {
return { didWork: false, error: t('savings.context.goalNotFound') };
}
return updateGoal(targetGoalId, { name, amount, emoji });
}
// Default behavior: create a new logical goal so multiple goals can coexist.
return createGoal({ name, amount, emoji });
},
removeSavingsGoal: removeGoal,
addMoney: async ({ amount, goalId }) =>
contributeToGoal({ amount, goalId }),
withdrawMoney: async ({ amount, goalId }) =>
withdrawFromGoal({ amount, goalId }),
withdrawlFromRewards,
initializeSavings,
refreshSavings: loadSavingsState,
refreshBalances,
refreshInterestPayouts,
interestPayouts,
walletBalanceMicros,
walletBitcoinBalanceSats,
savingsWallet,
totalGoalsBalance,
totalIntrestEarned,
isInitializing,
initializationError,
}),
[
deriveSingleSavingsWallet,
createGoal,
contributeToGoal,
withdrawFromGoal,
withdrawlFromRewards,
savingsGoals,
allSavingsTransactions,
transactions,
savingsBalanceMicros,
updateGoal,
removeGoal,
initializeSavings,
loadSavingsState,
refreshBalances,
refreshInterestPayouts,
interestPayouts,
walletBalanceMicros,
walletBitcoinBalanceSats,
savingsWallet,
totalGoalsBalance,
totalIntrestEarned,
isInitializing,
initializationError,
],
);
return (
<SavingsContext.Provider value={value}>{children}</SavingsContext.Provider>
);
}
export function useSavings() {
return useContext(SavingsContext);
}