* load bitcoin balance from wallet viewer * simplify withdrawl * remove log
791 lines
24 KiB
JavaScript
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);
|
|
}
|