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
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// mergeAndCacheTokens lets the token-balance:update handler update the token map
|
||||
// from the event payload (the SDK's full getTokenBalanceMap()) without a second
|
||||
// balance read. These tests pin the two properties the handler relies on:
|
||||
// 1. live token balances are normalized to Number (string or BigInt input),
|
||||
// 2. a token absent from the new full map is zeroed (proving the payload is
|
||||
// treated as authoritative, not merged additively).
|
||||
jest.mock('../../../app/functions/localStorage', () => {
|
||||
const store = {};
|
||||
return {
|
||||
getLocalStorageItem: jest.fn(async key =>
|
||||
key in store ? store[key] : null,
|
||||
),
|
||||
setLocalStorageItem: jest.fn(async (key, val) => {
|
||||
store[key] = val;
|
||||
}),
|
||||
__reset: () => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const localStorage = require('../../../app/functions/localStorage');
|
||||
const {
|
||||
mergeAndCacheTokens,
|
||||
} = require('../../../app/functions/lrc20/cachedTokens');
|
||||
|
||||
const MNEMONIC = 'test mnemonic';
|
||||
|
||||
const tokenEntry = (balance, maxSupply) => ({
|
||||
tokenMetadata: { maxSupply, name: 'TKN' },
|
||||
balance,
|
||||
});
|
||||
|
||||
describe('mergeAndCacheTokens', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.__reset();
|
||||
});
|
||||
|
||||
it('normalizes string balances from the WebView payload to Number', async () => {
|
||||
const merged = await mergeAndCacheTokens(
|
||||
{ btknAAA: tokenEntry('500', '1000') },
|
||||
MNEMONIC,
|
||||
);
|
||||
|
||||
expect(merged.btknAAA.balance).toBe(500);
|
||||
expect(merged.btknAAA.tokenMetadata.maxSupply).toBe(1000);
|
||||
expect(typeof merged.btknAAA.balance).toBe('number');
|
||||
});
|
||||
|
||||
it('normalizes BigInt balances from the native payload to Number', async () => {
|
||||
const merged = await mergeAndCacheTokens(
|
||||
{ btknAAA: tokenEntry(500n, 1000n) },
|
||||
MNEMONIC,
|
||||
);
|
||||
|
||||
expect(merged.btknAAA.balance).toBe(500);
|
||||
expect(merged.btknAAA.tokenMetadata.maxSupply).toBe(1000);
|
||||
});
|
||||
|
||||
it('zeroes a previously held token absent from the new full map', async () => {
|
||||
await mergeAndCacheTokens(
|
||||
{
|
||||
btknAAA: tokenEntry('500', '1000'),
|
||||
btknBBB: tokenEntry('200', '1000'),
|
||||
},
|
||||
MNEMONIC,
|
||||
);
|
||||
|
||||
// Next event reports only btknAAA — btknBBB must be retained but zeroed,
|
||||
// never left at its stale balance.
|
||||
const merged = await mergeAndCacheTokens(
|
||||
{ btknAAA: tokenEntry('700', '1000') },
|
||||
MNEMONIC,
|
||||
);
|
||||
|
||||
expect(merged.btknAAA.balance).toBe(700);
|
||||
expect(merged.btknBBB.balance).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty map and writes nothing harmful for an empty payload', async () => {
|
||||
const merged = await mergeAndCacheTokens({}, MNEMONIC);
|
||||
expect(merged).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// getBalanceWithTimeout wraps getSparkBalance in a hard timeout so a read that
|
||||
// never settles (e.g. a WebView request whose timeout is neutered while the app
|
||||
// is backgrounded) can't park the balance supervisor's await forever — which
|
||||
// would wedge the entire balance lane (its running guard is only cleared in the
|
||||
// supervisor's finally).
|
||||
jest.mock('../../app/functions/spark', () => ({
|
||||
getSparkBalance: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../app/functions/spark/restore', () => ({
|
||||
fullRestoreSparkState: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getSparkBalance } = require('../../app/functions/spark');
|
||||
const { getBalanceWithTimeout } = require('../../app/functions/pollingManager');
|
||||
|
||||
describe('getBalanceWithTimeout', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns the real balance when the read resolves before the timeout', async () => {
|
||||
getSparkBalance.mockResolvedValue({ didWork: true, balance: 42 });
|
||||
await expect(getBalanceWithTimeout('mnemonic', 1000)).resolves.toEqual({
|
||||
didWork: true,
|
||||
balance: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves to didWork:false when the read hangs past the timeout', async () => {
|
||||
jest.useFakeTimers();
|
||||
// Never resolves — simulates a hung/neutered WebView read.
|
||||
getSparkBalance.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const promise = getBalanceWithTimeout('mnemonic', 1000);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
await expect(promise).resolves.toEqual({ didWork: false });
|
||||
});
|
||||
|
||||
it('resolves to didWork:false when the read rejects', async () => {
|
||||
// getSparkBalance swallows its own errors today, but if a throw ever
|
||||
// escapes the race the wrapper must still honor its {didWork} contract so
|
||||
// callers (e.g. applyIncomingPaymentSnapshot) never dereference undefined.
|
||||
getSparkBalance.mockRejectedValue(new Error('boom'));
|
||||
await expect(getBalanceWithTimeout('mnemonic', 1000)).resolves.toEqual({
|
||||
didWork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
|
||||
chainIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 22.5,
|
||||
borderRadius: 44,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -71,14 +71,14 @@ export async function initializeSparkSession({
|
||||
// Fire immediately — never blocks the critical path
|
||||
cleanStalePendingSparkLightningTransactions();
|
||||
|
||||
// Skip getSparkBalance if a snapshot already exists for this account
|
||||
// (loading screen applied it; the balance poller will confirm/update it)
|
||||
let skipBalanceFetch = false;
|
||||
// Always fetch a live balance on connect. The cached snapshot is already
|
||||
// painted by the loading screen for instant first-paint, but returning
|
||||
// users must still get an authoritative read so a stale snapshot can never
|
||||
// persist until a manual pull-to-refresh.
|
||||
let balanceSnapshot = false;
|
||||
if (cachedIdentityPubKey) {
|
||||
const snapshot = await getAccountBalanceSnapshot(cachedIdentityPubKey);
|
||||
skipBalanceFetch = snapshot !== null;
|
||||
balanceSnapshot = await getAccountBalanceSnapshot(cachedIdentityPubKey);
|
||||
}
|
||||
|
||||
// Only fetch fresh txs when restoring — returning users keep prev.transactions
|
||||
const needsFreshTxs = !hasRestoreCompleted;
|
||||
const txsPromise =
|
||||
@@ -87,9 +87,7 @@ export async function initializeSparkSession({
|
||||
: null;
|
||||
|
||||
const [balance, sparkAddress, freshIdentityPubKey] = await Promise.all([
|
||||
skipBalanceFetch
|
||||
? Promise.resolve({ didWork: false })
|
||||
: getSparkBalance(mnemonic),
|
||||
getSparkBalance(mnemonic),
|
||||
getSparkAddress(mnemonic),
|
||||
cachedIdentityPubKey
|
||||
? Promise.resolve(cachedIdentityPubKey)
|
||||
@@ -114,6 +112,7 @@ export async function initializeSparkSession({
|
||||
const storageObject = {
|
||||
identityPubKey,
|
||||
sparkAddress: sparkAddress.response,
|
||||
...(balanceSnapshot ?? {}),
|
||||
didConnect: true,
|
||||
};
|
||||
setSparkInformation(prev => ({
|
||||
@@ -141,9 +140,8 @@ export async function initializeSparkSession({
|
||||
? transactions
|
||||
: null;
|
||||
|
||||
setSparkInformation(prev => ({...prev, ...storageObject}));
|
||||
if (txToUse && filterAndSetTransactions)
|
||||
filterAndSetTransactions(txToUse);
|
||||
setSparkInformation(prev => ({ ...prev, ...storageObject }));
|
||||
if (txToUse && filterAndSetTransactions) filterAndSetTransactions(txToUse);
|
||||
return storageObject;
|
||||
} catch (err) {
|
||||
console.log('Set spark error', err);
|
||||
|
||||
@@ -76,3 +76,18 @@ export const mergeTokensWithCache = (currentTokens, cachedTokens, mnemonic) => {
|
||||
|
||||
return { ...cachedTokens, [hashedMnemoinc]: merged };
|
||||
};
|
||||
|
||||
// Merges a fresh full token-balance map into the on-disk cache and returns the
|
||||
// merged token map for this mnemonic. Mirrors the cache step getSparkBalance
|
||||
// runs, so a caller already holding a live token map (e.g. the
|
||||
// token-balance:update event payload) can update without a second balance read.
|
||||
export const mergeAndCacheTokens = async (currentTokens, mnemonic) => {
|
||||
const cachedTokens = await migrateCachedTokens(mnemonic);
|
||||
const allTokens = mergeTokensWithCache(
|
||||
currentTokens ?? {},
|
||||
cachedTokens,
|
||||
mnemonic,
|
||||
);
|
||||
await saveCachedTokens(allTokens);
|
||||
return allTokens[sha256Hash(mnemonic)];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
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
|
||||
@@ -153,7 +181,7 @@ export const createBalancePoller = (
|
||||
|
||||
return createPollingManager({
|
||||
pollFn: async () => {
|
||||
return await getSparkBalance(mnemonic);
|
||||
return await getBalanceWithTimeout(mnemonic);
|
||||
},
|
||||
shouldContinue: () => mnemonic === currentMnemonicRef.current,
|
||||
validateResult: (newResult, previousResult) => {
|
||||
|
||||
@@ -2,13 +2,25 @@ import { ensureSparkDatabaseReady } from './transactions';
|
||||
|
||||
const TABLE = 'account_balance_snapshots';
|
||||
|
||||
// Token maps coming from the native runtime can carry BigInt fields in their
|
||||
// metadata (the merge only down-converts balance/maxSupply). JSON.stringify
|
||||
// throws on BigInt, which would otherwise drop the entire snapshot row, so
|
||||
// serialize BigInt as its decimal string.
|
||||
const bigIntSafeReplacer = (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value;
|
||||
|
||||
export async function saveAccountBalanceSnapshot(identityPubKey, balance, tokensObj) {
|
||||
try {
|
||||
const db = await ensureSparkDatabaseReady();
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO ${TABLE} (identityPubKey, balance, tokens, updatedAt)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[identityPubKey, balance, JSON.stringify(tokensObj ?? {}), Date.now()],
|
||||
[
|
||||
identityPubKey,
|
||||
balance,
|
||||
JSON.stringify(tokensObj ?? {}, bigIntSafeReplacer),
|
||||
Date.now(),
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
console.log('Error saving account balance snapshot', err);
|
||||
|
||||
+325
-74
@@ -12,7 +12,7 @@ import {
|
||||
clearMnemonicCache,
|
||||
getCachedSparkTransactions,
|
||||
getSingleTxDetails,
|
||||
getSparkBalance,
|
||||
getSparkTransactions,
|
||||
getSparkStaticBitcoinL1AddressQuote,
|
||||
getUtxosForDepositAddress,
|
||||
initializeFlashnet,
|
||||
@@ -56,6 +56,12 @@ import i18n from 'i18next';
|
||||
import {
|
||||
INCOMING_SPARK_TX_NAME,
|
||||
incomingSparkTransaction,
|
||||
BALANCE_UPDATE_EVENT_NAME,
|
||||
sparkBalanceUpdateEmitter,
|
||||
TOKEN_BALANCE_UPDATE_EVENT_NAME,
|
||||
sparkTokenBalanceUpdateEmitter,
|
||||
STREAM_STATUS_EVENT_NAME,
|
||||
sparkStreamStatusEmitter,
|
||||
OPERATION_TYPES,
|
||||
sendWebViewRequestGlobal,
|
||||
useWebView,
|
||||
@@ -63,11 +69,12 @@ import {
|
||||
import { useGlobalContextProvider } from './context';
|
||||
import { useAuthContext } from './authContext';
|
||||
import {
|
||||
createBalancePoller,
|
||||
createRestorePoller,
|
||||
getBalanceWithTimeout,
|
||||
} from '../app/functions/pollingManager';
|
||||
import { USDB_TOKEN_ID } from '../app/constants';
|
||||
import { saveAccountBalanceSnapshot } from '../app/functions/spark/balanceSnapshots';
|
||||
import { mergeAndCacheTokens } from '../app/functions/lrc20/cachedTokens';
|
||||
import {
|
||||
cleanupOptimization,
|
||||
checkIfOptimizationNeeded,
|
||||
@@ -172,6 +179,7 @@ const SparkWalletProvider = ({ children }) => {
|
||||
identityPubKey: '',
|
||||
sparkAddress: '',
|
||||
transactions: [],
|
||||
didConnect: false,
|
||||
});
|
||||
const sessionTimeRef = useRef(Date.now());
|
||||
const newestPaymentTimeRef = useRef(Date.now());
|
||||
@@ -190,6 +198,7 @@ const SparkWalletProvider = ({ children }) => {
|
||||
const authResetKeyRef = useRef(authResetkey);
|
||||
const balanceVersionRef = useRef(0);
|
||||
const hasRunInitBalancePoll = useRef(false);
|
||||
const foregroundReconcileAppStateRef = useRef(appState);
|
||||
|
||||
const txLaneQueueRef = useRef(Promise.resolve());
|
||||
const uiLaneQueueRef = useRef(Promise.resolve());
|
||||
@@ -211,6 +220,10 @@ const SparkWalletProvider = ({ children }) => {
|
||||
updateType: null,
|
||||
timestamp: 0,
|
||||
});
|
||||
// Tracks whether the Spark event stream has dropped, so a later
|
||||
// stream:connected is recognized as a *re*connect (which warrants one
|
||||
// reconcile read) rather than the benign initial connect.
|
||||
const streamWasDisconnectedRef = useRef(false);
|
||||
const homepageTxPreferance = masterInfoObject.homepageTxPreferance;
|
||||
const hideSmallPaymentsHomepage = masterInfoObject.hideSmallPaymentsHomepage;
|
||||
|
||||
@@ -271,12 +284,14 @@ const SparkWalletProvider = ({ children }) => {
|
||||
tokens: sparkInformation.tokens,
|
||||
identityPubKey: sparkInformation.identityPubKey,
|
||||
sparkAddress: sparkInformation.sparkAddress,
|
||||
didConnect: sparkInformation.didConnect,
|
||||
};
|
||||
}, [
|
||||
sparkInformation.balance,
|
||||
sparkInformation.tokens,
|
||||
sparkInformation.identityPubKey,
|
||||
sparkInformation.sparkAddress,
|
||||
sparkInformation.didConnect,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -360,6 +375,8 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
// Debounce refs
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const debounceMaxWaitRef = useRef(null);
|
||||
const latestIncomingBalanceRef = useRef(null);
|
||||
const pendingTransferIds = useRef(new Set());
|
||||
|
||||
const toggleIsSendingPayment = useCallback(isSending => {
|
||||
@@ -434,25 +451,34 @@ const SparkWalletProvider = ({ children }) => {
|
||||
console.error('Error writing placeholder transactions:', error);
|
||||
}
|
||||
|
||||
// ─── Step 2: Fetch full tx details (blocked untill app is foregrounded)
|
||||
// ─── Step 2: Fetch tx details in a SINGLE batched call (one WebView
|
||||
// message) instead of one round-trip per transfer.
|
||||
let cachedTransfers = [];
|
||||
|
||||
for (const transferId of transferIdsToProcess) {
|
||||
try {
|
||||
try {
|
||||
const idSet = new Set(transferIdsToProcess);
|
||||
const { transfers = [] } = await getSparkTransactions(
|
||||
Math.max(50, transferIdsToProcess.length),
|
||||
undefined,
|
||||
currentMnemonicRef.current,
|
||||
);
|
||||
cachedTransfers = transfers.filter(transfer => idSet.has(transfer.id));
|
||||
|
||||
// Fallback only for ids NOT in the batch window (e.g. an older transfer
|
||||
// that settled while many newer transfers arrived in the same burst).
|
||||
// Normal load hits zero of these, so the single-message goal holds; this
|
||||
// just stops a dropped id from being stuck as a pending placeholder.
|
||||
const foundIds = new Set(cachedTransfers.map(transfer => transfer.id));
|
||||
const missingIds = transferIdsToProcess.filter(id => !foundIds.has(id));
|
||||
for (const transferId of missingIds) {
|
||||
const transfer = await getSingleTxDetails(
|
||||
currentMnemonicRef.current,
|
||||
transferId,
|
||||
);
|
||||
|
||||
if (!transfer) continue;
|
||||
cachedTransfers.push(transfer);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error processing incoming payment:',
|
||||
transferId,
|
||||
error,
|
||||
);
|
||||
if (transfer) cachedTransfers.push(transfer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching batched incoming payments:', error);
|
||||
}
|
||||
|
||||
const paymentObjects = [];
|
||||
@@ -926,6 +952,12 @@ const SparkWalletProvider = ({ children }) => {
|
||||
async (epoch, result) => {
|
||||
const { identityPubKey } = sparkInfoRef.current;
|
||||
|
||||
// Stamp the ordering guard at ENTRY (before the awaits below) so a
|
||||
// concurrent incoming-payment snapshot that enters later can't be
|
||||
// overwritten by this confirmed snapshot resolving after it. Shared with
|
||||
// applyIncomingPaymentSnapshot, which also stamps at entry.
|
||||
const myVersion = ++balanceVersionRef.current;
|
||||
|
||||
balanceEpochRef.current.applied = Math.max(
|
||||
balanceEpochRef.current.applied,
|
||||
epoch,
|
||||
@@ -959,7 +991,6 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
filterAndSetTransactions(freshTxs);
|
||||
|
||||
const myVersion = ++balanceVersionRef.current;
|
||||
setSparkInformation(prev => {
|
||||
if (myVersion < balanceVersionRef.current) return prev;
|
||||
return {
|
||||
@@ -1003,33 +1034,20 @@ const SparkWalletProvider = ({ children }) => {
|
||||
})`,
|
||||
);
|
||||
|
||||
const poller = createBalancePoller(
|
||||
mnemonic,
|
||||
currentMnemonicRef,
|
||||
abortController,
|
||||
async balanceResult => {
|
||||
if (abortController.signal.aborted) return;
|
||||
if (runId !== balanceSupervisorRunIdRef.current) return;
|
||||
await applyConfirmedBalanceSnapshot(epochToResolve, balanceResult);
|
||||
},
|
||||
sparkInfoRef.current.balance,
|
||||
);
|
||||
|
||||
balancePollingTimeoutRef.current = poller;
|
||||
const response = await poller.start();
|
||||
// Single authoritative reconcile read. The displayed balance number is
|
||||
// driven in real time by balance:update events (balanceUpdateHandler);
|
||||
// this one-shot read exists only to reconcile transactions, release
|
||||
// forced-pending flags, and recover a balance whose event was missed
|
||||
// (e.g. while backgrounded). It replaces the old converge-by-polling
|
||||
// loop (createBalancePoller) that issued up to ~24 reads per intent and
|
||||
// could self-contend under load.
|
||||
const balanceResult = await getBalanceWithTimeout(mnemonic);
|
||||
|
||||
if (runId !== balanceSupervisorRunIdRef.current) return;
|
||||
if (abortController.signal.aborted) return;
|
||||
if (mnemonic !== currentMnemonicRef.current) return;
|
||||
if (response.reason === 'aborted') return;
|
||||
|
||||
if (response.reason === 'max_retries') {
|
||||
const fallbackResult =
|
||||
response.result?.didWork === true
|
||||
? response.result
|
||||
: await getSparkBalance(mnemonic);
|
||||
await applyConfirmedBalanceSnapshot(epochToResolve, fallbackResult);
|
||||
}
|
||||
await applyConfirmedBalanceSnapshot(epochToResolve, balanceResult);
|
||||
|
||||
balanceEpochRef.current.applied = Math.max(
|
||||
balanceEpochRef.current.applied,
|
||||
@@ -1040,16 +1058,23 @@ const SparkWalletProvider = ({ children }) => {
|
||||
} catch (err) {
|
||||
console.log('[BalanceLane] poller error', err);
|
||||
} finally {
|
||||
isBalancePollerRunningRef.current = false;
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
// Only release shared lane state if WE still own the lane. A newer run
|
||||
// (e.g. started by applyIncomingPaymentSnapshot, which bumps the runId
|
||||
// and clears the running flag) may have taken over while our awaits were
|
||||
// parked; a stale run's finally must not clobber the newer run's running
|
||||
// flag or null its abort controller.
|
||||
if (runId === balanceSupervisorRunIdRef.current) {
|
||||
isBalancePollerRunningRef.current = false;
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
|
||||
if (
|
||||
balanceEpochRef.current.applied < balanceEpochRef.current.target &&
|
||||
mnemonic === currentMnemonicRef.current
|
||||
) {
|
||||
setTimeout(() => {
|
||||
runBalanceSupervisor();
|
||||
}, 60);
|
||||
if (
|
||||
balanceEpochRef.current.applied < balanceEpochRef.current.target &&
|
||||
mnemonic === currentMnemonicRef.current
|
||||
) {
|
||||
setTimeout(() => {
|
||||
runBalanceSupervisor();
|
||||
}, 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [applyConfirmedBalanceSnapshot]);
|
||||
@@ -1081,6 +1106,12 @@ const SparkWalletProvider = ({ children }) => {
|
||||
const mnemonic = currentMnemonicRef.current;
|
||||
const { identityPubKey } = sparkInfoRef.current;
|
||||
|
||||
// Stamp the ordering guard at ENTRY (before any await) so the
|
||||
// most-recently-entered (newest) snapshot wins. Stamping after the awaits
|
||||
// ordered writes by completion time, letting an older burst that resolved
|
||||
// late overwrite a newer one and regress the displayed balance.
|
||||
const myVersion = ++balanceVersionRef.current;
|
||||
|
||||
if (balancePollingAbortControllerRef.current) {
|
||||
balancePollingAbortControllerRef.current.abort();
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
@@ -1094,7 +1125,7 @@ const SparkWalletProvider = ({ children }) => {
|
||||
forcedPendingBySparkIdRef.current.clear();
|
||||
|
||||
const [balanceResponse, freshTxs] = await Promise.all([
|
||||
getSparkBalance(mnemonic),
|
||||
getBalanceWithTimeout(mnemonic),
|
||||
identityPubKey
|
||||
? getAllSparkTransactions({
|
||||
limit: null,
|
||||
@@ -1109,7 +1140,7 @@ const SparkWalletProvider = ({ children }) => {
|
||||
Number.isFinite(numericPassedBalance)
|
||||
? numericPassedBalance
|
||||
: sparkInfoRef.current.balance,
|
||||
balanceResponse.didWork
|
||||
balanceResponse?.didWork
|
||||
? balanceResponse.tokensObj
|
||||
: sparkInfoRef.current.tokens,
|
||||
);
|
||||
@@ -1122,7 +1153,6 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
filterAndSetTransactions(freshTxs);
|
||||
|
||||
const myVersion = ++balanceVersionRef.current;
|
||||
setSparkInformation(prev => {
|
||||
if (myVersion < balanceVersionRef.current) return prev;
|
||||
return {
|
||||
@@ -1130,7 +1160,7 @@ const SparkWalletProvider = ({ children }) => {
|
||||
balance: Number.isFinite(numericPassedBalance)
|
||||
? numericPassedBalance
|
||||
: prev.balance,
|
||||
tokens: balanceResponse.didWork
|
||||
tokens: balanceResponse?.didWork
|
||||
? balanceResponse.tokensObj
|
||||
: prev.tokens,
|
||||
};
|
||||
@@ -1186,18 +1216,106 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
// Add transferId to pending set
|
||||
pendingTransferIds.current.add(transferId);
|
||||
// Always flush with the most recent balance, even when the max-wait timer
|
||||
// (set on the first event of the burst) fires.
|
||||
latestIncomingBalanceRef.current = balance;
|
||||
|
||||
// Clear existing timeout if any
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
const flush = () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
if (debounceMaxWaitRef.current) {
|
||||
clearTimeout(debounceMaxWaitRef.current);
|
||||
debounceMaxWaitRef.current = null;
|
||||
}
|
||||
debouncedHandleIncomingPayment(latestIncomingBalanceRef.current);
|
||||
};
|
||||
|
||||
// Trailing debounce: flush 500ms after the last event…
|
||||
if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = setTimeout(flush, 500);
|
||||
|
||||
// …but cap the wait at 1.5s so a sustained burst (events arriving faster
|
||||
// than every 500ms, which would perpetually reset the trailing timer and
|
||||
// never flush) still applies the balance periodically.
|
||||
if (!debounceMaxWaitRef.current) {
|
||||
debounceMaxWaitRef.current = setTimeout(flush, 1500);
|
||||
}
|
||||
|
||||
// Set new timeout for debounced execution (500ms delay)
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
debouncedHandleIncomingPayment(balance);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Authoritative writer for the displayed sats balance. balance:update fires on
|
||||
// every balance change (deposits, transfers, swaps, claims) with the real
|
||||
// current { available, owned, incoming }; we display `available` (parity with
|
||||
// the SDK's deprecated `balance` field). This is the single fast path that
|
||||
// makes sends/swaps/deposits reflect immediately — previously only inbound
|
||||
// claims had a push event and everything else waited on the poller.
|
||||
const balanceUpdateHandler = useCallback(snapshot => {
|
||||
const available = Number(snapshot?.available);
|
||||
if (!Number.isFinite(available)) return;
|
||||
// Value-gate: ignore no-op events so a burst of inbound transfers (each
|
||||
// emitting balance:update) can't trigger a render / DB-write storm.
|
||||
if (available === sparkInfoRef.current.balance) return;
|
||||
|
||||
// Entry-stamped ordering guard, shared with applyConfirmedBalanceSnapshot
|
||||
// and applyIncomingPaymentSnapshot, so a late-resolving snapshot can't
|
||||
// overwrite a newer event's value.
|
||||
const myVersion = ++balanceVersionRef.current;
|
||||
const { identityPubKey } = sparkInfoRef.current;
|
||||
|
||||
saveAccountBalanceSnapshot(
|
||||
identityPubKey,
|
||||
available,
|
||||
sparkInfoRef.current.tokens,
|
||||
);
|
||||
|
||||
setSparkInformation(prev => {
|
||||
if (myVersion < balanceVersionRef.current) return prev;
|
||||
if (prev.balance === available) return prev;
|
||||
return { ...prev, balance: available };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// token-balance:update fires when a token tx finalizes and carries the full
|
||||
// current token-balance map (getTokenBalanceMap() in the SDK). We merge that
|
||||
// payload straight into the cache instead of issuing another getSparkBalance
|
||||
// round-trip — same result, one fewer WebView read per event. The WebView
|
||||
// runtime delivers the already-normalized map; the native runtime delivers the
|
||||
// raw SDK Map and is normalized at registration (see addListeners).
|
||||
const tokenBalanceUpdateHandler = useCallback(async tokensObject => {
|
||||
const mnemonic = currentMnemonicRef.current;
|
||||
if (!mnemonic) return;
|
||||
const merged = await mergeAndCacheTokens(tokensObject ?? {}, mnemonic);
|
||||
if (mnemonic !== currentMnemonicRef.current) return;
|
||||
setSparkInformation(prev => ({ ...prev, tokens: merged }));
|
||||
// Persist tokens so a token-only change survives a cold start, matching
|
||||
// applyConfirmedBalanceSnapshot / applyIncomingPaymentSnapshot.
|
||||
saveAccountBalanceSnapshot(
|
||||
sparkInfoRef.current.identityPubKey,
|
||||
sparkInfoRef.current.balance,
|
||||
merged,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Stream lifecycle. A connect after a drop means events may have been missed
|
||||
// while the stream was down, so fire one reconcile read. The initial connect
|
||||
// is benign and skipped.
|
||||
const streamStatusHandler = useCallback(
|
||||
status => {
|
||||
if (status === 'disconnected' || status === 'reconnecting') {
|
||||
streamWasDisconnectedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (status !== 'connected') return;
|
||||
if (!streamWasDisconnectedRef.current) return;
|
||||
streamWasDisconnectedRef.current = false;
|
||||
if (AppState.currentState !== 'active') return;
|
||||
if (!sparkInfoRef.current.didConnect) return;
|
||||
requestBalanceReconcile('streamReconnect', { shouldForcePending: false });
|
||||
},
|
||||
[requestBalanceReconcile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sparkInformation.identityPubKey) {
|
||||
console.log('Skipping listener setup - no identity pub key yet');
|
||||
@@ -1208,6 +1326,15 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
sparkTransactionsEventEmitter.on(SPARK_TX_UPDATE_ENVENT_NAME, handleUpdate);
|
||||
incomingSparkTransaction.on(INCOMING_SPARK_TX_NAME, transferHandler);
|
||||
sparkBalanceUpdateEmitter.on(
|
||||
BALANCE_UPDATE_EVENT_NAME,
|
||||
balanceUpdateHandler,
|
||||
);
|
||||
sparkTokenBalanceUpdateEmitter.on(
|
||||
TOKEN_BALANCE_UPDATE_EVENT_NAME,
|
||||
tokenBalanceUpdateHandler,
|
||||
);
|
||||
sparkStreamStatusEmitter.on(STREAM_STATUS_EVENT_NAME, streamStatusHandler);
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up spark event listeners');
|
||||
@@ -1219,8 +1346,27 @@ const SparkWalletProvider = ({ children }) => {
|
||||
INCOMING_SPARK_TX_NAME,
|
||||
transferHandler,
|
||||
);
|
||||
sparkBalanceUpdateEmitter.removeListener(
|
||||
BALANCE_UPDATE_EVENT_NAME,
|
||||
balanceUpdateHandler,
|
||||
);
|
||||
sparkTokenBalanceUpdateEmitter.removeListener(
|
||||
TOKEN_BALANCE_UPDATE_EVENT_NAME,
|
||||
tokenBalanceUpdateHandler,
|
||||
);
|
||||
sparkStreamStatusEmitter.removeListener(
|
||||
STREAM_STATUS_EVENT_NAME,
|
||||
streamStatusHandler,
|
||||
);
|
||||
};
|
||||
}, [sparkInformation.identityPubKey, handleUpdate, transferHandler]);
|
||||
}, [
|
||||
sparkInformation.identityPubKey,
|
||||
handleUpdate,
|
||||
transferHandler,
|
||||
balanceUpdateHandler,
|
||||
tokenBalanceUpdateHandler,
|
||||
streamStatusHandler,
|
||||
]);
|
||||
|
||||
const addListeners = async mode => {
|
||||
console.log('Adding Spark listeners...');
|
||||
@@ -1240,8 +1386,44 @@ const SparkWalletProvider = ({ children }) => {
|
||||
|
||||
if (mode === 'full') {
|
||||
if (runtime === 'native') {
|
||||
if (!sparkWallet[walletHash]?.listenerCount('transfer:claimed')) {
|
||||
sparkWallet[walletHash]?.on('transfer:claimed', transferHandler);
|
||||
const nativeWallet = sparkWallet[walletHash];
|
||||
if (nativeWallet) {
|
||||
if (!nativeWallet.listenerCount('transfer:claimed')) {
|
||||
nativeWallet.on('transfer:claimed', transferHandler);
|
||||
}
|
||||
if (!nativeWallet.listenerCount('balance:update')) {
|
||||
nativeWallet.on('balance:update', balanceUpdateHandler);
|
||||
}
|
||||
if (!nativeWallet.listenerCount('token-balance:update')) {
|
||||
// Native delivers the raw SDK event ({ tokenBalances: Map });
|
||||
// normalize it to the same token map the WebView runtime posts so
|
||||
// tokenBalanceUpdateHandler can stay runtime-agnostic.
|
||||
nativeWallet.on('token-balance:update', event => {
|
||||
const tokensObject = {};
|
||||
for (const [id, data] of event?.tokenBalances ?? []) {
|
||||
tokensObject[id] = {
|
||||
...data,
|
||||
balance: data.availableToSendBalance,
|
||||
};
|
||||
}
|
||||
tokenBalanceUpdateHandler(tokensObject);
|
||||
});
|
||||
}
|
||||
if (!nativeWallet.listenerCount('stream:connected')) {
|
||||
nativeWallet.on('stream:connected', () =>
|
||||
streamStatusHandler('connected'),
|
||||
);
|
||||
}
|
||||
if (!nativeWallet.listenerCount('stream:disconnected')) {
|
||||
nativeWallet.on('stream:disconnected', () =>
|
||||
streamStatusHandler('disconnected'),
|
||||
);
|
||||
}
|
||||
if (!nativeWallet.listenerCount('stream:reconnecting')) {
|
||||
nativeWallet.on('stream:reconnecting', () =>
|
||||
streamStatusHandler('reconnecting'),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await sendWebViewRequestGlobal(OPERATION_TYPES.addListeners, {
|
||||
@@ -1387,7 +1569,10 @@ const SparkWalletProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeListeners = async (onlyClearIntervals = false) => {
|
||||
const removeListeners = async (
|
||||
onlyClearIntervals = false,
|
||||
abortBalanceLane = true,
|
||||
) => {
|
||||
console.log('Removing spark listeners');
|
||||
|
||||
cleanStatusAndLRC20Intervals();
|
||||
@@ -1401,11 +1586,26 @@ const SparkWalletProvider = ({ children }) => {
|
||||
const hashedMnemonic = sha256Hash(prevAccountMnemoincRef.current);
|
||||
|
||||
if (runtime === 'native') {
|
||||
if (
|
||||
prevAccountMnemoincRef.current &&
|
||||
sparkWallet[hashedMnemonic]?.listenerCount('transfer:claimed')
|
||||
) {
|
||||
sparkWallet[hashedMnemonic]?.removeAllListeners('transfer:claimed');
|
||||
const nativeWallet = sparkWallet[hashedMnemonic];
|
||||
if (prevAccountMnemoincRef.current && nativeWallet) {
|
||||
if (nativeWallet.listenerCount('transfer:claimed')) {
|
||||
nativeWallet.removeAllListeners('transfer:claimed');
|
||||
}
|
||||
if (nativeWallet.listenerCount('balance:update')) {
|
||||
nativeWallet.removeAllListeners('balance:update');
|
||||
}
|
||||
if (nativeWallet.listenerCount('token-balance:update')) {
|
||||
nativeWallet.removeAllListeners('token-balance:update');
|
||||
}
|
||||
if (nativeWallet.listenerCount('stream:connected')) {
|
||||
nativeWallet.removeAllListeners('stream:connected');
|
||||
}
|
||||
if (nativeWallet.listenerCount('stream:disconnected')) {
|
||||
nativeWallet.removeAllListeners('stream:disconnected');
|
||||
}
|
||||
if (nativeWallet.listenerCount('stream:reconnecting')) {
|
||||
nativeWallet.removeAllListeners('stream:reconnecting');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await sendWebViewRequestGlobal(OPERATION_TYPES.removeListeners, {
|
||||
@@ -1420,6 +1620,10 @@ const SparkWalletProvider = ({ children }) => {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
if (debounceMaxWaitRef.current) {
|
||||
clearTimeout(debounceMaxWaitRef.current);
|
||||
debounceMaxWaitRef.current = null;
|
||||
}
|
||||
// Clear pending transfer IDs
|
||||
pendingTransferIds.current.clear();
|
||||
|
||||
@@ -1428,13 +1632,18 @@ const SparkWalletProvider = ({ children }) => {
|
||||
clearInterval(updatePendingPaymentsIntervalRef.current);
|
||||
updatePendingPaymentsIntervalRef.current = null;
|
||||
}
|
||||
// Clear balance polling lane
|
||||
if (balancePollingAbortControllerRef.current) {
|
||||
balancePollingAbortControllerRef.current.abort();
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
// Clear balance polling lane — only when abandoning this account's session
|
||||
// (background/account-switch/reset). A same-account foreground listener
|
||||
// reconfigure (null->full) must NOT abort the foregroundRecovery poll it
|
||||
// races with, or the foreground balance reconcile is silently killed.
|
||||
if (abortBalanceLane) {
|
||||
if (balancePollingAbortControllerRef.current) {
|
||||
balancePollingAbortControllerRef.current.abort();
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
}
|
||||
balanceSupervisorRunIdRef.current += 1;
|
||||
isBalancePollerRunningRef.current = false;
|
||||
}
|
||||
balanceSupervisorRunIdRef.current += 1;
|
||||
isBalancePollerRunningRef.current = false;
|
||||
|
||||
if (txPollingTimeoutRef.current) {
|
||||
clearTimeout(txPollingTimeoutRef.current);
|
||||
@@ -1502,8 +1711,10 @@ const SparkWalletProvider = ({ children }) => {
|
||||
identityPubKey: '',
|
||||
sparkAddress: '',
|
||||
transactions: [],
|
||||
didConnect: false,
|
||||
};
|
||||
handledTransfers.current = new Set();
|
||||
streamWasDisconnectedRef.current = false;
|
||||
prevListenerType.current = null;
|
||||
prevAppState.current = 'active';
|
||||
prevAccountId.current = null;
|
||||
@@ -1615,7 +1826,12 @@ const SparkWalletProvider = ({ children }) => {
|
||||
prevId !== sparkInfoRef.current.identityPubKey) &&
|
||||
appState === 'active'
|
||||
) {
|
||||
await removeListeners();
|
||||
// Only a genuine account switch (a prior account existed and differs)
|
||||
// should tear down the balance lane; a same-account null->full
|
||||
// foreground reconfigure must leave the foregroundRecovery poll alive.
|
||||
const accountChanged =
|
||||
!!prevId && prevId !== sparkInfoRef.current.identityPubKey;
|
||||
await removeListeners(false, accountChanged);
|
||||
if (newType) await addListeners(newType);
|
||||
prevListenerType.current = newType;
|
||||
prevAccountId.current = sparkInfoRef.current.identityPubKey;
|
||||
@@ -1979,6 +2195,41 @@ const SparkWalletProvider = ({ children }) => {
|
||||
requestBalanceReconcile,
|
||||
]);
|
||||
|
||||
// Balance-lane lifecycle:
|
||||
// • On background: abort any in-flight poll and release the lane. A read
|
||||
// can't settle in the background (the WebView request timeout is neutered),
|
||||
// so its await would park and the supervisor's finally would never run,
|
||||
// wedging the lane for the rest of the session.
|
||||
// • On background→active: fire one authoritative reconcile. This lands
|
||||
// balance received while backgrounded (whose debounce may have been
|
||||
// throttled) and recovers a lane that was idled in the background.
|
||||
useEffect(() => {
|
||||
const prev = foregroundReconcileAppStateRef.current;
|
||||
foregroundReconcileAppStateRef.current = appState;
|
||||
if (appState === 'background') {
|
||||
if (balancePollingAbortControllerRef.current) {
|
||||
balancePollingAbortControllerRef.current.abort();
|
||||
balancePollingAbortControllerRef.current = null;
|
||||
}
|
||||
balanceSupervisorRunIdRef.current += 1;
|
||||
isBalancePollerRunningRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (appState !== 'active' || prev === 'active') return;
|
||||
if (!sparkInformation.didConnect) return;
|
||||
if (!sparkInformation.identityPubKey) return;
|
||||
|
||||
requestBalanceReconcile('foregroundRecovery', {
|
||||
shouldForcePending: false,
|
||||
});
|
||||
}, [
|
||||
appState,
|
||||
sparkInformation.didConnect,
|
||||
sparkInformation.identityPubKey,
|
||||
requestBalanceReconcile,
|
||||
]);
|
||||
|
||||
// This function connects to the spark node and sets the session up
|
||||
|
||||
const connectToSparkWallet = useCallback(
|
||||
|
||||
@@ -160,6 +160,15 @@ const rejectIfNotConnectedToInternet = [
|
||||
|
||||
export const INCOMING_SPARK_TX_NAME = 'RECEIVED_CONTACTS EVENT';
|
||||
export const incomingSparkTransaction = new EventEmitter();
|
||||
|
||||
export const BALANCE_UPDATE_EVENT_NAME = 'SPARK_BALANCE_UPDATE';
|
||||
export const sparkBalanceUpdateEmitter = new EventEmitter();
|
||||
|
||||
export const TOKEN_BALANCE_UPDATE_EVENT_NAME = 'SPARK_TOKEN_BALANCE_UPDATE';
|
||||
export const sparkTokenBalanceUpdateEmitter = new EventEmitter();
|
||||
|
||||
export const STREAM_STATUS_EVENT_NAME = 'SPARK_STREAM_STATUS';
|
||||
export const sparkStreamStatusEmitter = new EventEmitter();
|
||||
const WASM_ERRORS = [
|
||||
'WASM',
|
||||
'WebAssembly',
|
||||
@@ -357,7 +366,7 @@ export const WebViewProvider = ({ children }) => {
|
||||
const messageRateLimiter = useRef({
|
||||
count: 0,
|
||||
windowStart: Date.now(),
|
||||
maxPerSecond: 20,
|
||||
maxPerSecond: 50,
|
||||
});
|
||||
|
||||
const blockAndResetWebview = useCallback(
|
||||
@@ -641,28 +650,6 @@ export const WebViewProvider = ({ children }) => {
|
||||
const handleWebViewResponse = useCallback(
|
||||
event => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const windowDuration = now - messageRateLimiter.current.windowStart;
|
||||
if (windowDuration > 1000) {
|
||||
// Reset window
|
||||
messageRateLimiter.current.count = 0;
|
||||
messageRateLimiter.current.windowStart = now;
|
||||
}
|
||||
messageRateLimiter.current.count++;
|
||||
|
||||
if (
|
||||
messageRateLimiter.current.count >
|
||||
messageRateLimiter.current.maxPerSecond
|
||||
) {
|
||||
console.error(
|
||||
`SECURITY: Rate limit exceeded (${messageRateLimiter.current.count} msgs/sec)`,
|
||||
);
|
||||
|
||||
resetWebViewState(true, true);
|
||||
forceNativeMode('rate limit exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(event.nativeEvent.data);
|
||||
|
||||
if (message.type === 'handshake:reply' && message.pubW) {
|
||||
@@ -742,6 +729,41 @@ export const WebViewProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsolicited SDK push events (incoming payment, balance/token balance,
|
||||
// stream status) are not request/response traffic and must not count
|
||||
// toward the flood limiter. A burst of legitimate inbound payments would
|
||||
// otherwise trip it and force the one-way native fallback.
|
||||
const isSdkPushEvent = !!(
|
||||
content.incomingPayment ||
|
||||
content.balanceUpdate ||
|
||||
content.tokenBalanceUpdate ||
|
||||
content.streamStatus
|
||||
);
|
||||
|
||||
if (!isSdkPushEvent) {
|
||||
const now = Date.now();
|
||||
const windowDuration = now - messageRateLimiter.current.windowStart;
|
||||
if (windowDuration > 1000) {
|
||||
// Reset window
|
||||
messageRateLimiter.current.count = 0;
|
||||
messageRateLimiter.current.windowStart = now;
|
||||
}
|
||||
messageRateLimiter.current.count++;
|
||||
|
||||
if (
|
||||
messageRateLimiter.current.count >
|
||||
messageRateLimiter.current.maxPerSecond
|
||||
) {
|
||||
console.error(
|
||||
`SECURITY: Rate limit exceeded (${messageRateLimiter.current.count} msgs/sec)`,
|
||||
);
|
||||
|
||||
resetWebViewState(true, true);
|
||||
forceNativeMode('rate limit exceeded');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (content.error) throw new Error(content.error);
|
||||
|
||||
if (content.incomingPayment) {
|
||||
@@ -752,6 +774,23 @@ export const WebViewProvider = ({ children }) => {
|
||||
data.balance,
|
||||
);
|
||||
}
|
||||
if (content.balanceUpdate) {
|
||||
const data = JSON.parse(content.result);
|
||||
sparkBalanceUpdateEmitter.emit(BALANCE_UPDATE_EVENT_NAME, data);
|
||||
}
|
||||
if (content.tokenBalanceUpdate) {
|
||||
const data = JSON.parse(content.result);
|
||||
sparkTokenBalanceUpdateEmitter.emit(
|
||||
TOKEN_BALANCE_UPDATE_EVENT_NAME,
|
||||
data.tokensObject,
|
||||
);
|
||||
}
|
||||
if (content.streamStatus) {
|
||||
sparkStreamStatusEmitter.emit(
|
||||
STREAM_STATUS_EVENT_NAME,
|
||||
content.streamStatus,
|
||||
);
|
||||
}
|
||||
if (content.isResponse && content.id) {
|
||||
const resolve = pendingRequests.current[content.id];
|
||||
if (resolve) {
|
||||
@@ -1322,8 +1361,8 @@ export const WebViewProvider = ({ children }) => {
|
||||
const requests = [...queuedRequests.current];
|
||||
queuedRequests.current = [];
|
||||
|
||||
// Process sequentially to avoid triggering the rate limiter (20 msgs/sec).
|
||||
// Parallel dispatch via Promise.allSettled could fire 21+ messages at once,
|
||||
// Process sequentially to avoid triggering the rate limiter (50 msgs/sec).
|
||||
// Parallel dispatch via Promise.allSettled could fire 51+ messages at once,
|
||||
// permanently killing the WebView.
|
||||
for (const { action, args, encrypt, resolve, reject } of requests) {
|
||||
try {
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"rn-qr-generator": "^2.0.0",
|
||||
"spark-web-context": "https://github.com/blitzwallet/spark-web-context.git#d646287a415df53aaf403310e96944fe38a8bf0d",
|
||||
"spark-web-context": "https://github.com/blitzwallet/spark-web-context.git#0755f4dad341cc17ce1eb4d2684a39bb83a8839d",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"text-encoding": "^0.7.0",
|
||||
|
||||
@@ -5069,7 +5069,7 @@ __metadata:
|
||||
react-native-worklets: ^0.8.3
|
||||
react-test-renderer: 19.1.0
|
||||
rn-qr-generator: ^2.0.0
|
||||
spark-web-context: "https://github.com/blitzwallet/spark-web-context.git#d646287a415df53aaf403310e96944fe38a8bf0d"
|
||||
spark-web-context: "https://github.com/blitzwallet/spark-web-context.git#0755f4dad341cc17ce1eb4d2684a39bb83a8839d"
|
||||
stream-browserify: ^3.0.0
|
||||
supercluster: ^8.0.1
|
||||
text-encoding: ^0.7.0
|
||||
@@ -14284,16 +14284,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spark-web-context@https://github.com/blitzwallet/spark-web-context.git#d646287a415df53aaf403310e96944fe38a8bf0d":
|
||||
"spark-web-context@https://github.com/blitzwallet/spark-web-context.git#0755f4dad341cc17ce1eb4d2684a39bb83a8839d":
|
||||
version: 1.0.0
|
||||
resolution: "spark-web-context@https://github.com/blitzwallet/spark-web-context.git#commit=d646287a415df53aaf403310e96944fe38a8bf0d"
|
||||
resolution: "spark-web-context@https://github.com/blitzwallet/spark-web-context.git#commit=0755f4dad341cc17ce1eb4d2684a39bb83a8839d"
|
||||
dependencies:
|
||||
"@buildonspark/spark-sdk": ^0.8.5
|
||||
"@ecies/ciphers": ^0.2.4
|
||||
"@flashnet/sdk": ^0.5.7
|
||||
"@noble/hashes": ^2.0.1
|
||||
"@noble/secp256k1": ^3.0.0
|
||||
checksum: 7b186601e558a6a281ee503a169cbf531a55fc71f11b0929a2836bd447765af05291bf9562ff2d625cda0a5352ec1b4e8c6d061c73c88ee8306f362cfb57e479
|
||||
checksum: f9277cbe4035941d895524f52a174f33613093bdaa82743bf1f5e31575f24c5d500a74b7b9501bd703c0bcfd9d054355d80655c6bfdc6d28a95a3c8711d9c775
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user