Fixing bitcoin rewards payout + close half modal delay + adding bitcoin withdrawls to activities list
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]>}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2415,6 +2415,7 @@
|
||||
"depositWithGoal": "Пополнение в {{goalName}}",
|
||||
"swapSimulationError": "Симуляция обмена недоступна",
|
||||
"savingsWalletError": "Сберегательный кошелёк не готов",
|
||||
"rewardsPayout": "Выплата вознаграждений в биткоинах"
|
||||
"rewardsPayout": "Выплата вознаграждений в биткоинах",
|
||||
"rewardsWithdrawl": "Вывод вознаграждений в биткоинах"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user