Fixing bitcoin rewards payout + close half modal delay + adding bitcoin withdrawls to activities list

This commit is contained in:
Blake Kaufman
2026-03-02 19:16:38 +01:00
parent 9029d0b7ba
commit 806f9a1385
16 changed files with 512 additions and 151 deletions
@@ -42,6 +42,7 @@ import { useActiveCustodyAccount } from '../../../../../context-store/activeAcco
import { deriveSparkGiftMnemonic } from '../../../../functions/gift/deriveGiftWallet';
import displayCorrectDenomination from '../../../../functions/displayCorrectDenomination';
import {
FONT,
HIDDEN_OPACITY,
INSET_WINDOW_WIDTH,
} from '../../../../constants/theme';
@@ -63,9 +64,19 @@ import {
bulkUpdateSparkTransactions,
} from '../../../../functions/spark/transactions';
import { setFlashnetTransfer } from '../../../../functions/spark/handleFlashnetTransferIds';
import SkeletonTextPlaceholder from '../../../../functions/CustomElements/skeletonTextView';
import { createBalancePoller } from '../../../../functions/pollingManager';
import {
getLocalStorageItem,
setLocalStorageItem,
} from '../../../../functions/localStorage';
const confirmTxAnimation = require('../../../../assets/confirmTxAnimation.json');
// Persists { pollTimestamp: number, balance: number } so the balance poller
// can be skipped when no new interest payment has arrived since the last poll.
const SAVINGS_INTEREST_POLL_CACHE_KEY = 'savings_interest_poll_cache';
const MIN_STEP_MS = 800;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -98,6 +109,7 @@ export default function WithdrawFromSavingsHalfModal({
useUserBalanceContext();
const {
withdrawMoney,
withdrawlFromRewards,
refreshSavings,
refreshBalances,
savingsGoals,
@@ -107,6 +119,7 @@ export default function WithdrawFromSavingsHalfModal({
savingsBalance,
savingsWallet,
totalIntrestEarned,
interestPayouts,
} = useSavings();
const { sparkInformation } = useSparkWallet();
@@ -126,9 +139,19 @@ export default function WithdrawFromSavingsHalfModal({
// Tracks savings wallet init progress for the balanceType page
// 'idle' | 'loading' | 'ready' | 'error' — does NOT trigger FullLoadingScreen
const [sparkInitStatus, setSparkInitStatus] = useState('idle');
const [balanceReady, setBalanceReady] = useState(false);
const [loadingStep, setLoadingStep] = useState('processing');
const [skeletonLayout, setSkeletonLayout] = useState({
width: 80,
height: 23,
});
const maxLayoutRef = useRef({
width: 80,
height: 23,
});
const didInitSparkRef = useRef(false);
const pollerAbortRef = useRef(null);
// Cached savings wallet mnemonic — derived lazily on first use
const savingsWalletMnemonicRef = useRef(null);
@@ -236,6 +259,21 @@ export default function WithdrawFromSavingsHalfModal({
destinationOptions,
]);
const handleSkeletonLayout = useCallback(event => {
const { height, width } = event.nativeEvent.layout;
console.log(height, width);
const newH = Math.max(maxLayoutRef.current.height, height);
const newW = Math.max(maxLayoutRef.current.width, width);
console.log(height, width);
if (
newH !== maxLayoutRef.current.height ||
newW !== maxLayoutRef.current.width
) {
maxLayoutRef.current = { height: newH, width: newW };
setSkeletonLayout({ height: newH, width: newW });
}
}, []);
const localSatAmount = convertDisplayToSats(amountValue);
// USD value of what the user wants to withdraw (in micros)
@@ -302,14 +340,21 @@ export default function WithdrawFromSavingsHalfModal({
getSavingsWalletMnemonic,
]);
// Spark wallet init effect, scoped to the balanceType page.
// Initialises the savings wallet early so balances are verified before the
// user proceeds. Because initializeSparkWallet is idempotent, handleConfirm's
// own init call returns immediately after this.
// ─── BACKGROUND INIT EFFECT ───────────────────────────────────────────────
// Fires once when the balanceType page first mounts. The page renders
// immediately — the interest balance shows a skeleton shimmer until the
// balance poller settles on a confirmed value.
//
// 1. initializeSparkWallet — full init needed before any send can execute.
// 2. createBalancePoller — polls getSparkBalance until it stabilises,
// then sets walletBTCBalance and balanceReady.
// ─────────────────────────────────────────────────────────────────────────
useEffect(() => {
if (currentPage !== 'balanceType') return;
if (didInitSparkRef.current === true) return;
let cancelled = false;
if (didInitSparkRef.current) return;
didInitSparkRef.current = true;
const abortController = new AbortController();
pollerAbortRef.current = abortController;
const initWallet = async () => {
setSparkInitStatus('loading');
@@ -318,26 +363,85 @@ export default function WithdrawFromSavingsHalfModal({
const initResponse = await initializeSparkWallet(
savingsMnemonic,
false,
{
maxRetries: 4,
},
{ maxRetries: 4 },
);
if (cancelled) return;
const balance = await getSparkBalance(savingsMnemonic);
if (!balance.didWork) throw new Error('Balance error');
setWalletBTCBalance(Number(balance.balance));
setSparkInitStatus(initResponse ? 'ready' : 'error');
didInitSparkRef.current = true;
if (abortController.signal.aborted) return;
if (!initResponse) {
setSparkInitStatus('error');
return;
}
const sparkBalance = await getSparkBalance(savingsMnemonic);
setSparkInitStatus('ready');
// Savings balance only changes on interest payment receipt — skip poll
// if no new interest payment has arrived since the last successful poll.
let shouldPoll = true;
try {
const rawCache = await getLocalStorageItem(
SAVINGS_INTEREST_POLL_CACHE_KEY,
);
if (rawCache) {
const { pollTimestamp = 0, balance = null } = JSON.parse(rawCache);
const mostRecentPayoutPaidAt = interestPayouts[0]?.paidAt ?? 0;
const hasNewInterestPayment =
mostRecentPayoutPaidAt > pollTimestamp;
if (
!hasNewInterestPayment &&
balance !== null &&
sparkBalance.didWork &&
Number(sparkBalance.balance) === balance
) {
shouldPoll = false;
setWalletBTCBalance(balance);
setBalanceReady(true);
}
}
} catch {
// Parse failure — fall through to poll conservatively
}
if (!shouldPoll) return;
// Now poll until the balance stabilises so we show a confirmed number.
const mnemonicRef = { current: savingsMnemonic };
const poller = createBalancePoller(
savingsMnemonic,
mnemonicRef,
abortController,
async balanceResult => {
const newBalance = Number(balanceResult.balance);
setWalletBTCBalance(newBalance);
setBalanceReady(true);
// Persist poll result so future opens can skip the poller when
// no new interest payment has arrived.
setLocalStorageItem(
SAVINGS_INTEREST_POLL_CACHE_KEY,
JSON.stringify({
pollTimestamp: Date.now(),
balance: newBalance,
}),
);
},
null, // no initial balance — let poller establish the baseline
1,
);
await poller.start();
} catch {
if (!cancelled) setSparkInitStatus('error');
if (!abortController.signal.aborted) setSparkInitStatus('error');
}
};
initWallet();
return () => {
cancelled = true;
abortController.abort();
};
}, [currentPage, getSavingsWalletMnemonic]);
}, [getSavingsWalletMnemonic]);
const handleDenominationToggle = () => {
const nextDenom = getNextDenomination();
@@ -434,13 +538,45 @@ export default function WithdrawFromSavingsHalfModal({
if (!sendResponse?.didWork) throw new Error(sendResponse?.error);
// Record the incoming BTC transfer as unpaid until confirmed
addSingleUnpaidSparkTransaction({
id: sendResponse.response.id,
description: t('savings.withdraw.interestPaymentLabel'),
sendersPubkey: '',
details: {},
});
// Invalidate the balance cache so the next modal open reflects the
// post-withdrawal balance without needing to re-poll.
const remainingBalance = Math.max(
0,
(walletBTCBalance ?? 0) - amountSats,
);
setLocalStorageItem(
SAVINGS_INTEREST_POLL_CACHE_KEY,
JSON.stringify({
pollTimestamp: Date.now(),
balance: remainingBalance,
}),
);
await bulkUpdateSparkTransactions(
[
{
id: sendResponse.response.id,
paymentStatus: 'completed',
paymentType: 'spark',
accountId: sparkInformation.identityPubKey,
details: {
fee: 0,
totalFee: 0,
supportFee: 0,
amount: amountSats,
description: t('savings.withdraw.interestPaymentLabel'),
address: sparkInformation.sparkAddress,
time: Date.now() + 1000,
createdAt: Date.now() + 1000,
direction: 'INCOMING',
isSavings: true,
},
},
],
'fullUpdate',
);
await withdrawlFromRewards(amountSats);
const elapsed2 = Date.now() - step2Start;
if (elapsed2 < MIN_STEP_MS) await sleep(MIN_STEP_MS - elapsed2);
@@ -694,8 +830,8 @@ export default function WithdrawFromSavingsHalfModal({
};
const handleDone = async () => {
await refreshSavings();
if (refreshBalances) await refreshBalances({ force: true });
refreshSavings();
if (refreshBalances) refreshBalances({ force: true });
handleBackPressFunction();
};
@@ -758,26 +894,73 @@ export default function WithdrawFromSavingsHalfModal({
colorOverride={COLORS.white}
/>
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={t('savings.withdraw.interestOption')}
/>
<ThemeText
styles={styles.optionSubtitle}
content={
isInterestDisabled
? t('savings.withdraw.interestZeroHint')
: displayCorrectDenomination({
{isInterestDisabled ? (
<ThemeText
content={t('savings.withdraw.interestZeroHint')}
/>
) : (
<>
{/* Hidden component for layout measurement */}
<View
style={{
position: 'absolute',
opacity: 0,
pointerEvents: 'none',
}}
onLayout={handleSkeletonLayout}
>
<FormattedSatText
styles={styles.optionSubtitle}
balance={displayCorrectDenomination({
amount: interestSats,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'sats',
},
fiatStats,
})
}
/>
})}
useSizing={true}
/>
</View>
{/* Show skeleton shimmer until poller settles */}
<View
style={{
height: skeletonLayout.height,
justifyContent: 'center',
alignItems: 'center',
flexShrink: 1,
}}
>
<SkeletonTextPlaceholder
enabled={!balanceReady}
layout={skeletonLayout}
>
<ThemeText
styles={styles.optionSubtitle}
content={
isInterestDisabled
? t('savings.withdraw.interestZeroHint')
: displayCorrectDenomination({
amount: interestSats,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'sats',
},
fiatStats,
})
}
/>
</SkeletonTextPlaceholder>
</View>
</>
)}
</View>
</View>
{!isInterestDisabled && (
@@ -821,7 +1004,7 @@ export default function WithdrawFromSavingsHalfModal({
>
<ThemeText styles={styles.emojiText} content="🏦" />
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={t('savings.withdraw.savingsOption')}
@@ -901,7 +1084,7 @@ export default function WithdrawFromSavingsHalfModal({
colorOverride={COLORS.white}
/>
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={t('savings.withdraw.withdrawAll')}
@@ -953,7 +1136,7 @@ export default function WithdrawFromSavingsHalfModal({
>
<ThemeText styles={styles.emojiText} content="🏦" />
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={t('savings.withdraw.generalSavings')}
@@ -1009,7 +1192,7 @@ export default function WithdrawFromSavingsHalfModal({
>
<ThemeText styles={styles.emojiText} content={goal.emoji} />
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={goal.name}
@@ -1131,7 +1314,7 @@ export default function WithdrawFromSavingsHalfModal({
}
/>
</View>
<View>
<View style={{ flexShrink: 1 }}>
<ThemeText
styles={styles.optionTitle}
content={option.title}
@@ -1299,6 +1482,7 @@ export default function WithdrawFromSavingsHalfModal({
}
if (selectedBalanceType === 'interest') {
// balance is in Bitcoin
if (localSatAmount > interestSats) {
navigate.navigate('ErrorScreen', {
errorMessage: t(
@@ -1308,6 +1492,7 @@ export default function WithdrawFromSavingsHalfModal({
return;
}
// if amount is less than the swap amount
if (
selectedDestination === 'dollar' &&
localSatAmount < swapLimits.bitcoin
@@ -1326,37 +1511,38 @@ export default function WithdrawFromSavingsHalfModal({
});
return;
}
}
if (
selectedDestination === 'bitcoin' &&
localSatAmount <=
dollarsToSats(swapLimits.usd, poolInfoRef.currentPriceAInB)
) {
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.inAccount.swapsPage.minUSDError', {
min: displayCorrectDenomination({
amount: swapLimits.usd,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'fiat',
},
fiatStats,
convertAmount: false,
forceCurrency: 'USD',
} else {
// balance is in dollars
if (
selectedDestination === 'bitcoin' &&
localSatAmount <=
dollarsToSats(swapLimits.usd, poolInfoRef.currentPriceAInB)
) {
navigate.navigate('ErrorScreen', {
errorMessage: t('screens.inAccount.swapsPage.minUSDError', {
min: displayCorrectDenomination({
amount: swapLimits.usd,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: 'fiat',
},
fiatStats,
convertAmount: false,
forceCurrency: 'USD',
}),
}),
}),
});
return;
}
});
return;
}
if (fiatMicros > availableBalanceMicros) {
navigate.navigate('ErrorScreen', {
errorMessage: t(
'screens.inAccount.swapsPage.insufficientBalance',
),
});
return;
if (fiatMicros > availableBalanceMicros) {
navigate.navigate('ErrorScreen', {
errorMessage: t(
'screens.inAccount.swapsPage.insufficientBalance',
),
});
return;
}
}
setStep(prev => [...prev, 'confirm']);
@@ -1554,6 +1740,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: 10,
alignItems: 'center',
flexShrink: 1,
},
iconContainer: {
width: 48,
@@ -1570,10 +1757,15 @@ const styles = StyleSheet.create({
fontWeight: 500,
fontSize: SIZES.large,
includeFontPadding: false,
flexShrink: 1,
},
optionSubtitle: {
width: '100%',
opacity: 0.7,
includeFontPadding: false,
fontSize: SIZES.medium,
fontFamily: FONT.Title_Regular,
flexShrink: 1,
},
amountContainer: {
flex: 1,
@@ -12,6 +12,7 @@ const ICON_BY_TYPE = {
interest: 'Sparkles',
deposit: 'ArrowDown',
withdrawal: 'ArrowUp',
bitcoinWithdrawal: 'ArrowUp',
};
export default function SavingsTransactionComponenet({ item, isLastIndex }) {
@@ -20,6 +21,10 @@ export default function SavingsTransactionComponenet({ item, isLastIndex }) {
const { fiatStats } = useNodeContext();
const amount = fromMicros(item.amountMicros);
const isPositive = amount >= 0;
const showSats =
item.type === 'interest' || item.type === 'bitcoinWithdrawal';
return (
<View
style={[
@@ -54,11 +59,11 @@ export default function SavingsTransactionComponenet({ item, isLastIndex }) {
amount: amount,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: item.type === 'interest' ? 'sats' : 'fiat',
userBalanceDenomination: showSats ? 'sats' : 'fiat',
},
fiatStats,
forceCurrency: item.type === 'interest' ? null : 'USD',
convertAmount: item.type === 'interest' ? true : false,
forceCurrency: showSats ? null : 'USD',
convertAmount: showSats ? true : false,
})}`}
/>
</View>
@@ -84,6 +84,7 @@ export function computeGoalBalanceMicros(goalId, transactions) {
export function toLegacyDisplayTransaction(transaction, goalName, t) {
const signedAmountMicros = signedTransactionAmountMicros(transaction);
const isWithdrawal = transaction.type === 'withdrawal';
const isBtcWithdrawl = transaction.type === 'bitcoinWithdrawal';
const key = isWithdrawal
? goalName
@@ -99,7 +100,9 @@ export function toLegacyDisplayTransaction(transaction, goalName, t) {
type: transaction.type,
amountMicros: signedAmountMicros,
createdAt: transaction.timestamp,
description: t(key, { goalName }),
description: isBtcWithdrawl
? t('savings.rewardsWithdrawl')
: t(key, { goalName }),
};
}
@@ -133,10 +133,15 @@ const transformElementsForMask = (children, textColor) => {
if (child.type === Text) {
props.style = [
child.props?.style,
child.props?.styles,
{ color: textColor, backgroundColor: 'transparent' },
];
} else {
props.style = [child.props?.style, { backgroundColor: 'transparent' }];
props.style = [
child.props?.style,
child.props?.styles,
{ backgroundColor: 'transparent' },
];
}
if (child.props?.children) {
@@ -152,6 +157,7 @@ const transformElementsForMask = (children, textColor) => {
key={index}
style={[
child.props?.styles,
child.props?.style,
{
color: textColor,
backgroundColor: 'transparent',
+3 -2
View File
@@ -140,6 +140,7 @@ export const createBalancePoller = (
abortController,
onBalanceUpdate,
initialBalance,
customConfims = 3,
) => {
let hasIncreasedAtLeastOnce = false;
let sameValueIndex = 0;
@@ -193,8 +194,8 @@ export const createBalancePoller = (
sameValueIndex++;
if (
(hasIncreasedAtLeastOnce && sameValueIndex >= 4) ||
(!hasIncreasedAtLeastOnce && sameValueIndex >= 3)
(hasIncreasedAtLeastOnce && sameValueIndex >= customConfims + 1) ||
(!hasIncreasedAtLeastOnce && sameValueIndex >= customConfims)
) {
return true;
}
+131 -26
View File
@@ -67,7 +67,7 @@ export async function initSavingsDb() {
CREATE TABLE IF NOT EXISTS ${TRANSACTIONS_TABLE} (
id TEXT PRIMARY KEY NOT NULL,
goalId TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal')),
type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal', 'bitcoinWithdrawal')),
amountMicros INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
@@ -87,7 +87,8 @@ export async function initSavingsDb() {
CREATE INDEX IF NOT EXISTS idx_savings_tx_timestamp ON ${TRANSACTIONS_TABLE}(timestamp DESC);
`);
await migrateRemoveTransactionsForeignKey();
await migrateTransactionsTable();
await sqlLiteDB.execAsync('PRAGMA foreign_keys = ON;');
isInitialized = true;
return true;
@@ -103,42 +104,77 @@ export async function initSavingsDb() {
* by recreating the table. Safe to run on every init — the guard checks whether
* the FK is present before doing anything.
*/
async function migrateRemoveTransactionsForeignKey() {
async function migrateTransactionsTable() {
try {
// Check if the old FK-bearing table exists by inspecting its CREATE statement.
const row = await sqlLiteDB.getFirstAsync(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
[TRANSACTIONS_TABLE],
);
if (!row?.sql || !row.sql.includes('FOREIGN KEY')) {
return; // Already migrated or freshly created — nothing to do.
if (!row?.sql) return;
const normalizedSql = String(row.sql).toUpperCase();
if (
normalizedSql.includes('BITCOINWITHDRAWAL') &&
!normalizedSql.includes('FOREIGN KEY')
) {
return; // Already migrated
}
// Recreate the table without the FK using SQLite's recommended pattern.
await sqlLiteDB.execAsync(`
PRAGMA foreign_keys = OFF;
// Disable FK constraints before DDL
await sqlLiteDB.runAsync('PRAGMA foreign_keys = OFF');
ALTER TABLE ${TRANSACTIONS_TABLE} RENAME TO ${TRANSACTIONS_TABLE}_old;
CREATE TABLE ${TRANSACTIONS_TABLE} (
id TEXT PRIMARY KEY NOT NULL,
goalId TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal')),
amountMicros INTEGER NOT NULL,
timestamp INTEGER NOT NULL
await sqlLiteDB.withTransactionAsync(async () => {
await sqlLiteDB.runAsync(
`DROP TABLE IF EXISTS ${TRANSACTIONS_TABLE}_old`,
);
INSERT INTO ${TRANSACTIONS_TABLE} (id, goalId, type, amountMicros, timestamp)
SELECT id, goalId, type, amountMicros, timestamp FROM ${TRANSACTIONS_TABLE}_old;
await sqlLiteDB.runAsync(
`ALTER TABLE ${TRANSACTIONS_TABLE} RENAME TO ${TRANSACTIONS_TABLE}_old`,
);
DROP TABLE ${TRANSACTIONS_TABLE}_old;
await sqlLiteDB.runAsync(`
CREATE TABLE ${TRANSACTIONS_TABLE} (
id TEXT PRIMARY KEY NOT NULL,
goalId TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal', 'bitcoinWithdrawal')),
amountMicros INTEGER NOT NULL,
timestamp INTEGER NOT NULL
)
`);
CREATE INDEX IF NOT EXISTS idx_savings_tx_goal_timestamp ON ${TRANSACTIONS_TABLE}(goalId, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_savings_tx_timestamp ON ${TRANSACTIONS_TABLE}(timestamp DESC);
`);
await sqlLiteDB.runAsync(`
INSERT INTO ${TRANSACTIONS_TABLE} (id, goalId, type, amountMicros, timestamp)
SELECT
id,
goalId,
CASE
WHEN type IN ('deposit', 'withdrawal', 'bitcoinWithdrawal') THEN type
WHEN LOWER(type) IN ('bitcoin_withdrawal', 'bitcoinwithdrawal') THEN 'bitcoinWithdrawal'
ELSE 'withdrawal'
END,
amountMicros,
timestamp
FROM ${TRANSACTIONS_TABLE}_old
`);
await sqlLiteDB.runAsync(`DROP TABLE ${TRANSACTIONS_TABLE}_old`);
await sqlLiteDB.runAsync(`
CREATE INDEX IF NOT EXISTS idx_savings_tx_goal_timestamp
ON ${TRANSACTIONS_TABLE}(goalId, timestamp DESC)
`);
await sqlLiteDB.runAsync(`
CREATE INDEX IF NOT EXISTS idx_savings_tx_timestamp
ON ${TRANSACTIONS_TABLE}(timestamp DESC)
`);
});
} catch (err) {
console.error('migrateRemoveTransactionsForeignKey error:', err);
console.error('migrateTransactionsTable error:', err);
} finally {
await sqlLiteDB.runAsync('PRAGMA foreign_keys = ON');
}
}
@@ -399,7 +435,9 @@ export async function createSavingsTransaction(transaction) {
if (!transaction?.id) throw new Error('Transaction id is required');
if (!transaction?.goalId) throw new Error('goalId is required');
if (!['deposit', 'withdrawal'].includes(transaction.type)) {
if (
!['deposit', 'withdrawal', 'bitcoinWithdrawal'].includes(transaction.type)
) {
throw new Error('Transaction type must be "deposit" or "withdrawal"');
}
@@ -416,13 +454,80 @@ export async function createSavingsTransaction(transaction) {
await db.runAsync(
`INSERT INTO ${TRANSACTIONS_TABLE} (id, goalId, type, amountMicros, timestamp)
VALUES (?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
goalId = excluded.goalId,
type = excluded.type,
amountMicros = excluded.amountMicros,
timestamp = excluded.timestamp`,
[tx.id, tx.goalId, tx.type, tx.amountMicros, tx.timestamp],
);
return tx;
}
/**
* @param {SavingsTransaction[]} transactions
* @returns {Promise<SavingsTransaction[]>}
*/
export async function createSavingsTransactions(transactions) {
if (!Array.isArray(transactions)) {
throw new Error('transactions must be an array');
}
if (transactions.length === 0) return [];
const db = await getDatabase();
const normalized = transactions.map(transaction => {
if (!transaction?.id) throw new Error('Transaction id is required');
if (!transaction?.goalId) throw new Error('goalId is required');
if (
!['deposit', 'withdrawal', 'bitcoinWithdrawal'].includes(transaction.type)
) {
throw new Error('Transaction type must be "deposit" or "withdrawal"');
}
return {
id: transaction.id,
goalId: transaction.goalId,
type: transaction.type,
amountMicros: Math.max(
0,
Math.round(Number(transaction.amountMicros || 0)),
),
timestamp: Number(transaction.timestamp || Date.now()),
};
});
// Build multi-row insert
const placeholders = normalized.map(() => `(?, ?, ?, ?, ?)`).join(', ');
const values = normalized.flatMap(tx => [
tx.id,
tx.goalId,
tx.type,
tx.amountMicros,
tx.timestamp,
]);
await db.withTransactionAsync(async () => {
await db.runAsync(
`INSERT INTO ${TRANSACTIONS_TABLE}
(id, goalId, type, amountMicros, timestamp)
VALUES ${placeholders}
ON CONFLICT(id) DO UPDATE SET
goalId = excluded.goalId,
type = excluded.type,
amountMicros = excluded.amountMicros,
timestamp = excluded.timestamp`,
values,
);
});
return normalized;
}
/**
* @param {string} goalId
* @returns {Promise<SavingsTransaction[]>}
+12
View File
@@ -47,3 +47,15 @@ export async function getTokenTransactions(sparkAddress) {
return false;
}
}
export async function getBitcoinWithdrawls(sparkAddress) {
try {
await initializeSparkWalletViewer();
return await walletViewer.getTransfers({
sparkAddress: sparkAddress,
});
} catch (err) {
console.log('error getting token transactions', err);
return false;
}
}
+67 -38
View File
@@ -10,6 +10,7 @@ import React, {
import {
createSavingsGoal,
createSavingsTransaction,
createSavingsTransactions,
deleteSavingsGoal,
getAllPayoutsTransactions,
getAllSavingsTransactions,
@@ -45,6 +46,7 @@ import {
toMicros,
} from '../app/components/admin/homeComponents/savings/utils';
import {
getBitcoinWithdrawls,
getTokensBalance,
getTokenTransactions,
} from '../app/functions/spark/walletViewer';
@@ -369,6 +371,17 @@ export function SavingsProvider({ children }) {
[recordTransaction],
);
const withdrawlFromRewards = useCallback(
async amount => {
return recordTransaction({
goalId: UNALLOCATED_GOAL_ID,
type: 'bitcoinWithdrawal',
amount,
});
},
[recordTransaction],
);
const savingsGoals = useMemo(
() =>
goals.map(goal => {
@@ -405,56 +418,70 @@ export function SavingsProvider({ children }) {
const txs = await getAllSavingsTransactions();
if (txs.length) return;
const pastTxs = await getTokenTransactions(savingsWallet.sparkAddress);
const [pastTxs, pastBitcoinTxs] = await Promise.all([
getTokenTransactions(savingsWallet.sparkAddress),
getBitcoinWithdrawls(savingsWallet.sparkAddress),
]);
if (!pastTxs?.transactions?.length) return;
const tokenTxs = (pastTxs?.transactions ?? [])
.slice(0, 50)
.reduce((acc, tokenTx) => {
const tokenOutputs = tokenTx.tokenTransaction?.tokenOutputs;
if (!tokenOutputs?.length) return acc;
for (const tokenTx of pastTxs.transactions.slice(0, 50)) {
const tokenOutputs = tokenTx.tokenTransaction?.tokenOutputs;
const txHash = Buffer.from(
Object.values(tokenTx.tokenTransactionHash),
).toString('hex');
if (!tokenOutputs?.length) continue;
const ownerPublicKey = Buffer.from(
Object.values(tokenOutputs[0]?.ownerPublicKey),
).toString('hex');
// Get tx hash as id
const txHash = Buffer.from(
Object.values(tokenTx.tokenTransactionHash),
).toString('hex');
const amountMicros = tokenOutputs[0]?.tokenAmount
? Number(tokenBufferAmountToDecimal(tokenOutputs[0].tokenAmount))
: 0;
const ownerPublicKey = Buffer.from(
Object.values(tokenOutputs[0]?.ownerPublicKey),
).toString('hex');
if (!amountMicros) return acc;
const savingsWalletPubKey = savingsWallet.identityPublicKeyHex; // however you access this
acc.push({
id: txHash,
goalId: UNALLOCATED_GOAL_ID,
type:
ownerPublicKey !== savingsWallet.identityPublicKeyHex
? 'withdrawal'
: 'deposit',
amountMicros,
timestamp: new Date(
tokenTx.tokenTransaction.clientCreatedTimestamp,
).getTime(),
});
const didSend = ownerPublicKey !== savingsWalletPubKey;
return acc;
}, []);
// tokenAmount is a 16-byte big-endian buffer object
const rawAmount = tokenOutputs[0]?.tokenAmount;
const amountMicros = rawAmount
? Number(tokenBufferAmountToDecimal(rawAmount))
: 0;
if (!amountMicros) continue;
const timestamp = new Date(
tokenTx.tokenTransaction.clientCreatedTimestamp,
).getTime();
await createSavingsTransaction({
id: txHash,
const bitcoinTxs = (pastBitcoinTxs?.transfers ?? [])
.filter(
transfer =>
Buffer.from(transfer.senderIdentityPublicKey).toString('hex') ===
savingsWallet.identityPublicKeyHex,
)
.map(tx => ({
id: tx.id,
goalId: UNALLOCATED_GOAL_ID,
type: didSend ? 'withdrawal' : 'deposit',
amountMicros,
timestamp,
}).catch(err => {
// Likely a duplicate — safe to ignore
console.log(`[RestorePayments] Skipping ${txHash}:`, err.message);
});
type: 'bitcoinWithdrawal',
amountMicros: toMicros(tx.totalValue),
timestamp: new Date(tx.createdTime).getTime(),
}));
const newTxs = [...tokenTxs, ...bitcoinTxs];
if (newTxs.length) {
await createSavingsTransactions(newTxs);
}
const finalTxs = await getAllSavingsTransactions();
setTransactions(finalTxs);
setTransactions(await getAllSavingsTransactions());
} catch (err) {
console.log('error restoring tx history', err);
console.error('error restoring tx history', err);
}
},
[savingsWallet],
@@ -543,6 +570,7 @@ export function SavingsProvider({ children }) {
contributeToGoal({ amount, goalId }),
withdrawMoney: async ({ amount, goalId }) =>
withdrawFromGoal({ amount, goalId }),
withdrawlFromRewards,
refreshSavings: loadSavingsState,
refreshBalances,
refreshInterestPayouts,
@@ -557,6 +585,7 @@ export function SavingsProvider({ children }) {
createGoal,
contributeToGoal,
withdrawFromGoal,
withdrawlFromRewards,
savingsGoals,
allSavingsTransactions,
transactions,
+2 -1
View File
@@ -2409,6 +2409,7 @@
"depositWithGoal": "Einzahlung für {{goalName}}",
"swapSimulationError": "Umtauschsimulation nicht verfügbar.",
"savingsWalletError": "Spar-Wallet nicht bereit.",
"rewardsPayout": "BTC-Belohnungsauszahlung"
"rewardsPayout": "BTC-Belohnungsauszahlung",
"rewardsWithdrawl": "BTC-Belohnungsauszahlung"
}
}
+2 -1
View File
@@ -2461,6 +2461,7 @@
"depositWithGoal": "Deposit to {{goalName}}",
"swapSimulationError": "Swap simulation not available",
"savingsWalletError": "Savings wallet not ready",
"rewardsPayout": "Bitcoin rewards payout"
"rewardsPayout": "Bitcoin rewards payout",
"rewardsWithdrawl": "Bitcoin rewards withdrawl"
}
}
+2 -1
View File
@@ -2269,6 +2269,7 @@
"depositWithGoal": "Depósito a {{goalName}}",
"swapSimulationError": "Simulación de cambio no disponible",
"savingsWalletError": "Billetera de ahorros no lista",
"rewardsPayout": "Pago de recompensas en Bitcoin"
"rewardsPayout": "Pago de recompensas en Bitcoin",
"rewardsWithdrawl": "Retiro de recompensas en Bitcoin"
}
}
+2 -1
View File
@@ -2407,6 +2407,7 @@
"depositWithGoal": "Dépôt vers {{goalName}}",
"swapSimulationError": "Simulation d'échange non disponible",
"savingsWalletError": "Portefeuille d'épargne non prêt",
"rewardsPayout": "Versement de récompenses en Bitcoin"
"rewardsPayout": "Versement de récompenses en Bitcoin",
"rewardsWithdrawl": "Retrait des récompenses en Bitcoin"
}
}
+2 -1
View File
@@ -2407,6 +2407,7 @@
"depositWithGoal": "Deposito su {{goalName}}",
"swapSimulationError": "Simulazione di scambio non disponibile",
"savingsWalletError": "Wallet risparmio non pronto",
"rewardsPayout": "Pagamento ricompense in Bitcoin"
"rewardsPayout": "Pagamento ricompense in Bitcoin",
"rewardsWithdrawl": "Prelievo delle ricompense in Bitcoin"
}
}
+2 -1
View File
@@ -2407,6 +2407,7 @@
"depositWithGoal": "Depósito para {{goalName}}",
"swapSimulationError": "Simulação de troca não disponível",
"savingsWalletError": "Conta da Caixinha não está pronta",
"rewardsPayout": "Pagamento de juros"
"rewardsPayout": "Pagamento de juros",
"rewardsWithdrawl": "Saque de recompensas em Bitcoin"
}
}
+2 -1
View File
@@ -2415,6 +2415,7 @@
"depositWithGoal": "Пополнение в {{goalName}}",
"swapSimulationError": "Симуляция обмена недоступна",
"savingsWalletError": "Сберегательный кошелёк не готов",
"rewardsPayout": "Выплата вознаграждений в биткоинах"
"rewardsPayout": "Выплата вознаграждений в биткоинах",
"rewardsWithdrawl": "Вывод вознаграждений в биткоинах"
}
}
+2 -1
View File
@@ -2407,6 +2407,7 @@
"depositWithGoal": "Insättning till {{goalName}}",
"swapSimulationError": "Bytessimulering inte tillgänglig",
"savingsWalletError": "Sparplånboken är inte redo",
"rewardsPayout": "Utbetalning av Bitcoin-belöningar"
"rewardsPayout": "Utbetalning av Bitcoin-belöningar",
"rewardsWithdrawl": "Uttag av Bitcoin-belöningar"
}
}