Files
com.blitzwallet/app/functions/pollingManager.js
T
Blake KaufmanandGitHub e1ab822b7d Fix balance issue (#981)
* get fresh balance on load

* improve balance version matchup + unbounded debounce

* bump to 50

* using sdk balance event

* bumping sdk

* fixing minor bugs

* remove uneeded log

* fixing border radius

* lower to 50 txs
2026-06-30 07:22:29 -04:00

285 lines
8.2 KiB
JavaScript

import { getSparkBalance } from './spark';
import { fullRestoreSparkState } from './spark/restore';
/**
* Reads the balance with a hard timeout. getSparkBalance can hang indefinitely
* when the underlying WebView request's timeout is neutered (it returns early
* without settling while the app is backgrounded). An unsettled read parks the
* supervisor's await forever and wedges the whole balance lane, so we race the
* read against a timer that resolves to a benign {didWork:false}.
* @param {string} mnemonic
* @param {number} timeoutMs
* @returns {Promise<{didWork: boolean, balance?: number, tokensObj?: object}>}
*/
export const getBalanceWithTimeout = async (mnemonic, timeoutMs = 15000) => {
let timer = null;
console.log('getting balance with timeout');
try {
return await Promise.race([
getSparkBalance(mnemonic),
new Promise(resolve => {
timer = setTimeout(() => resolve({ didWork: false }), timeoutMs);
}),
]);
} catch (err) {
console.log('err', err);
return { didWork: false };
} finally {
if (timer) clearTimeout(timer);
}
};
/**
* Generic polling utility with exponential backoff
* @param {Object} config - Configuration object
* @param {Function} config.pollFn - Async function to poll (receives delayIndex)
* @param {Function} config.shouldContinue - Function that returns boolean to continue polling
* @param {Function} config.onUpdate - Callback when poll succeeds with new data
* @param {Array<number>} config.delays - Array of delay intervals in ms
* @param {AbortController} config.abortController - Optional abort controller
* @param {Function} config.validateResult - Function to validate if result should stop polling
* @returns {Object} - Returns cleanup function and current state
*/
export const createPollingManager = ({
pollFn,
shouldContinue,
onUpdate,
delays = [1000, 2000, 5000, 15000],
abortController,
validateResult = () => true,
initialBalance,
}) => {
let timeoutRef = null;
let previousResult = initialBalance || null;
const cleanup = () => {
if (timeoutRef) {
clearTimeout(timeoutRef);
timeoutRef = null;
}
};
const poll = async (delayIndex = 0, resolve, reject) => {
try {
// Check abort conditions
if (abortController?.signal?.aborted || !shouldContinue()) {
console.log('Polling stopped:', {
aborted: abortController?.signal?.aborted,
shouldContinue: shouldContinue(),
});
cleanup();
resolve({ success: false, reason: 'aborted' });
return;
}
// Check if we've exhausted all delays
if (delayIndex >= delays.length) {
console.log('Polling completed after all retries');
cleanup();
resolve({
success: false,
reason: 'max_retries',
result: previousResult,
});
return;
}
timeoutRef = setTimeout(async () => {
let globalResult;
try {
// Re-check abort conditions before executing
if (abortController?.signal?.aborted || !shouldContinue()) {
cleanup();
resolve({
success: false,
reason: 'aborted',
});
return;
}
// Execute the poll function
globalResult = await pollFn(delayIndex);
// Check abort AFTER async operation
if (abortController?.signal?.aborted || !shouldContinue()) {
cleanup();
resolve({
success: false,
reason: 'aborted',
});
return;
}
// Validate if we should continue
if (validateResult(globalResult, previousResult)) {
// Call update callback
if (onUpdate) {
await onUpdate(globalResult, delayIndex);
}
// Success - stop polling
cleanup();
resolve({ success: true, result: globalResult });
return;
}
// Continue to next delay
poll(delayIndex + 1, resolve, reject);
} catch (err) {
console.log('Error in polling iteration, continuing:', err);
poll(delayIndex + 1, resolve, reject);
} finally {
if (
globalResult != null &&
(previousResult == null || globalResult !== previousResult)
) {
// Only update if balance changes
previousResult = globalResult;
}
}
}, delays[delayIndex]);
} catch (err) {
console.log('Error in poll setup:', err);
cleanup();
reject(err);
}
};
return {
start: () => {
if (abortController?.signal?.aborted) {
console.log('Poller: Already aborted before start');
return Promise.resolve({
success: false,
reason: 'aborted',
result: previousResult,
});
}
return new Promise((resolve, reject) => poll(0, resolve, reject));
},
cleanup,
};
};
export const createBalancePoller = (
mnemonic,
currentMnemonicRef,
abortController,
onBalanceUpdate,
initialBalance,
customConfims = 2,
) => {
let hasIncreasedAtLeastOnce = false;
let sameValueIndex = 0;
// Wrap initialBalance in the expected format
const wrappedInitialBalance =
typeof initialBalance === 'number'
? { didWork: true, balance: initialBalance, tokensObj: {} }
: initialBalance;
return createPollingManager({
pollFn: async () => {
return await getBalanceWithTimeout(mnemonic);
},
shouldContinue: () => mnemonic === currentMnemonicRef.current,
validateResult: (newResult, previousResult) => {
console.log(
'Validate:',
{
newBalance: newResult?.balance,
prevBalance: previousResult?.balance,
},
{ hasIncreasedAtLeastOnce, sameValueIndex },
);
if (!newResult?.didWork) {
console.log('New result invalid');
return false;
}
// previousResult might be null on first run
if (!previousResult?.didWork) {
console.log('Previous result invalid, continuing');
return false;
}
const newBalance = Number(newResult.balance);
const previousBalance = Number(previousResult.balance);
if (Number.isNaN(newBalance) || Number.isNaN(previousBalance)) {
return false;
}
if (newBalance !== previousBalance) {
console.log('Balance changed — resetting');
hasIncreasedAtLeastOnce = true;
sameValueIndex = 0;
return false;
}
sameValueIndex++;
// if (
// (hasIncreasedAtLeastOnce && sameValueIndex >= customConfims + 1) ||
// (!hasIncreasedAtLeastOnce && sameValueIndex >= customConfims)
// ) {
// return true;
// }
if (sameValueIndex >= customConfims) return true;
return false;
},
onUpdate: async (balanceResult, delayIndex) => {
console.log(
`Balance updated to ${balanceResult.balance} after ${delayIndex} attempts`,
);
await onBalanceUpdate(balanceResult);
},
abortController,
delays: [
1000, 1500, 1500, 1500, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000,
2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000,
],
initialBalance: wrappedInitialBalance,
});
};
export const createRestorePoller = (
mnemonic,
isSendingPayment,
currentMnemonicRef,
abortController,
onRestoreComplete,
sparkInfo,
sendWebViewRequest,
) => {
return createPollingManager({
pollFn: async delayIndex => {
const result = await fullRestoreSparkState({
sparkAddress: sparkInfo.sparkAddress,
batchSize: 2,
isSendingPayment: isSendingPayment,
mnemonic,
identityPubKey: sparkInfo.identityPubKey,
sendWebViewRequest,
isInitialRestore: false,
});
return result;
},
shouldContinue: () => mnemonic === currentMnemonicRef.current,
validateResult: result => {
return typeof result === 'number' && result > 0;
},
onUpdate: (result, delayIndex) => {
console.log(
`Restore completed after ${delayIndex + 1} attempts with ${result} txs`,
);
onRestoreComplete(result);
},
abortController,
delays: [0, 2000, 5000],
});
};