BlueWallet/hooks/useWidgetCommunication.ios.ts
PeterXMR d7261d4d2a FIX: ElectrumTransaction confirmation fields are optional until mined (#8093)
Electrum's `blockchain.transaction.get` verbose response does not include
`blockhash`, `confirmations`, `time`, or `blocktime` when the transaction
is still in the mempool. Both `ElectrumTransaction` in
`blue_modules/BlueElectrum.ts` and the sibling `Transaction` type in
`class/wallets/types.ts` declared all four as required, which silently
let unguarded access compile and crash at runtime on real mempool data.

- Mark the four confirmation-only fields optional on both types. They
  describe the same shape and have the same bug.
- Export `ElectrumTransaction` so a regression test can reference it.
- Collapse the two-line `tx.timestamp = tx.blocktime; if (!tx.blocktime)
  tx.timestamp = ...` pattern in `abstract-hd-electrum-wallet.ts` into a
  single `||` fallback — type-safe and runtime-equivalent.
- Add nullish-coalesce guards at the two call sites that compared
  `confirmations` directly to a number. In `useWidgetCommunication.ios.ts`,
  `t.confirmations ?? 0` keeps the filter semantically unchanged. In
  `PaymentCodesList.tsx`, normalize once via
  `notificationTx?.confirmations ?? 0` and use the local in both the
  `> 0` (already confirmed) and `=== 0` (mempool / unconfirmed alert)
  branches — otherwise a mempool notification tx would skip both branches
  and the code would create a duplicate notification transaction.
- Add `tests/unit/electrum-transaction-types.test.ts` documenting that a
  mempool-shaped object satisfies the type.
2026-05-28 09:43:46 +01:00

176 lines
6.6 KiB
TypeScript

import { useEffect, useRef } from 'react';
import DefaultPreference from 'react-native-default-preference';
import { Transaction, TWallet } from '../class/wallets/types';
import { useSettings } from '../hooks/context/useSettings';
import { useStorage } from '../hooks/context/useStorage';
import { GROUP_IO_BLUEWALLET } from '../blue_modules/currency';
import debounce from '../blue_modules/debounce';
enum WidgetCommunicationKeys {
AllWalletsSatoshiBalance = 'WidgetCommunicationAllWalletsSatoshiBalance',
AllWalletsLatestTransactionTime = 'WidgetCommunicationAllWalletsLatestTransactionTime',
DisplayBalanceAllowed = 'WidgetCommunicationDisplayBalanceAllowed',
LatestTransactionIsUnconfirmed = 'WidgetCommunicationLatestTransactionIsUnconfirmed',
}
const WIDGET_ENABLED = '1';
const WIDGET_DISABLED = '0';
const WIDGET_CLEARED_VALUE = '0';
const secondsToMilliseconds = (seconds: number): number => seconds * 1000;
DefaultPreference.setName(GROUP_IO_BLUEWALLET);
export const isBalanceDisplayAllowed = async (): Promise<boolean> => {
try {
const displayBalance = await DefaultPreference.get(WidgetCommunicationKeys.DisplayBalanceAllowed);
if (displayBalance === WIDGET_ENABLED) {
return true;
} else if (displayBalance === WIDGET_DISABLED) {
return false;
} else {
// Preference not set, initialize to enabled by default
await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_ENABLED);
return true;
}
} catch (error) {
console.error('Failed to get DisplayBalanceAllowed:', error);
return true;
}
};
export const setBalanceDisplayAllowed = async (allowed: boolean): Promise<void> => {
try {
if (allowed) {
await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_ENABLED);
} else {
await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_DISABLED);
// Clear widget data immediately when disabling
await Promise.all([
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, WIDGET_CLEARED_VALUE),
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, WIDGET_CLEARED_VALUE),
]);
}
console.debug('setBalanceDisplayAllowed:', allowed);
} catch (error) {
console.error('Failed to set DisplayBalanceAllowed:', error);
}
};
export const calculateBalanceAndTransactionTime = async (
wallets: TWallet[],
walletsInitialized: boolean,
): Promise<{
allWalletsBalance: number;
latestTransactionTime: number | string;
}> => {
if (!walletsInitialized || !(await isBalanceDisplayAllowed())) {
return { allWalletsBalance: 0, latestTransactionTime: 0 };
}
const results = await Promise.allSettled(
wallets.map(async wallet => {
if (wallet.hideBalance) return { balance: 0, latestTransactionTime: 0 };
const balance = await wallet.getBalance();
const transactions: Transaction[] = await wallet.getTransactions();
const confirmedTransactions = transactions.filter(t => (t.confirmations ?? 0) > 0);
const latestTransactionTime =
confirmedTransactions.length > 0
? secondsToMilliseconds(Math.max(...confirmedTransactions.map(t => t.timestamp || t.time || 0)))
: WidgetCommunicationKeys.LatestTransactionIsUnconfirmed;
return { balance, latestTransactionTime };
}),
);
const allWalletsBalance = results.reduce((acc, result) => acc + (result.status === 'fulfilled' ? result.value.balance : 0), 0);
const latestTransactionTime = results.reduce(
(max, result) =>
result.status === 'fulfilled' && typeof result.value.latestTransactionTime === 'number' && result.value.latestTransactionTime > max
? result.value.latestTransactionTime
: max,
0,
);
return { allWalletsBalance, latestTransactionTime };
};
export const syncWidgetBalanceWithWallets = async (
wallets: TWallet[],
walletsInitialized: boolean,
cachedBalance: { current: number },
cachedLatestTransactionTime: { current: number | string },
): Promise<void> => {
try {
const { allWalletsBalance, latestTransactionTime } = await calculateBalanceAndTransactionTime(wallets, walletsInitialized);
if (cachedBalance.current !== allWalletsBalance || cachedLatestTransactionTime.current !== latestTransactionTime) {
await Promise.all([
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, String(allWalletsBalance)),
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, String(latestTransactionTime)),
]);
cachedBalance.current = allWalletsBalance;
cachedLatestTransactionTime.current = latestTransactionTime;
}
} catch (error) {
console.error('Failed to sync widget balance with wallets:', error);
}
};
const debouncedSyncWidgetBalanceWithWallets = debounce(
async (
wallets: TWallet[],
walletsInitialized: boolean,
cachedBalance: { current: number },
cachedLatestTransactionTime: { current: number | string },
) => {
await syncWidgetBalanceWithWallets(wallets, walletsInitialized, cachedBalance, cachedLatestTransactionTime);
},
500,
);
const useWidgetCommunication = (): void => {
const { wallets, walletsInitialized } = useStorage();
const { isWidgetBalanceDisplayAllowed } = useSettings();
const cachedBalance = useRef<number>(0);
const cachedLatestTransactionTime = useRef<number | string>(0);
// Handle widget data clearing when the setting is disabled
useEffect(() => {
const clearWidgetData = async () => {
if (walletsInitialized && !isWidgetBalanceDisplayAllowed) {
try {
await Promise.all([
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, WIDGET_CLEARED_VALUE),
DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, WIDGET_CLEARED_VALUE),
]);
cachedBalance.current = 0;
cachedLatestTransactionTime.current = 0;
console.debug('Widget data cleared due to setting being disabled');
} catch (error) {
console.error('Failed to clear widget data:', error);
}
}
};
clearWidgetData();
}, [isWidgetBalanceDisplayAllowed, walletsInitialized]);
// Sync widget data when wallets change or setting is enabled
useEffect(() => {
if (walletsInitialized) {
debouncedSyncWidgetBalanceWithWallets(wallets, walletsInitialized, cachedBalance, cachedLatestTransactionTime);
}
}, [wallets, walletsInitialized, isWidgetBalanceDisplayAllowed]);
useEffect(() => {
return () => {
debouncedSyncWidgetBalanceWithWallets.cancel();
};
}, []);
};
export default useWidgetCommunication;