1625 lines
59 KiB
JavaScript
1625 lines
59 KiB
JavaScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import WebView from 'react-native-webview';
|
|
import customUUID from '../app/functions/customUUID';
|
|
import EventEmitter from 'events';
|
|
import { sha256 } from '@noble/hashes/sha2';
|
|
import { hkdf } from '@noble/hashes/hkdf';
|
|
import { AppState, Platform } from 'react-native';
|
|
import { getSharedSecret, getPublicKey } from '@noble/secp256k1';
|
|
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
randomBytes,
|
|
} from 'react-native-quick-crypto';
|
|
import sha256Hash from '../app/functions/hash';
|
|
import { verifyAndPrepareWebView } from '../app/functions/webview/bundleVerification';
|
|
import DeviceInfo, {
|
|
getModel,
|
|
getSystemVersion,
|
|
} from 'react-native-device-info';
|
|
import { getLocalStorageItem, setLocalStorageItem } from '../app/functions';
|
|
import { useAppStatus } from './appStatus';
|
|
import { useActiveCustodyAccount } from './activeAccount';
|
|
import { useAuthContext } from './authContext';
|
|
import { navigationRef } from '../navigation/navigationService';
|
|
|
|
export const OPERATION_TYPES = {
|
|
// Spark
|
|
initWallet: 'initializeSparkWallet',
|
|
getIdentityKey: 'getSparkIdentityPubKey',
|
|
getBalance: 'getSparkBalance',
|
|
getL1Address: 'getSparkStaticBitcoinL1Address',
|
|
queryStaticL1Address: 'queryAllStaticDepositAddresses',
|
|
getUtxosForDepositAddress: 'getUtxosForDepositAddress',
|
|
getL1AddressQuote: 'getSparkStaticBitcoinL1AddressQuote',
|
|
claimStaticDepositAddress: 'claimnSparkStaticDepositAddress',
|
|
getSparkAddress: 'getSparkAddress',
|
|
sendSparkPayment: 'sendSparkPayment',
|
|
sendTokenPayment: 'sendSparkTokens',
|
|
getSparkLeaves: 'getSparkLeaves',
|
|
getSparkLeafExitNodes: 'getSparkLeafExitNodes',
|
|
getLightningFee: 'getSparkLightningPaymentFeeEstimate',
|
|
getBitcoinPaymentRequest: 'getSparkBitcoinPaymentRequest',
|
|
getBitcoinPaymentFee: 'getSparkBitcoinPaymentFeeEstimate',
|
|
getSparkPaymentFee: 'getSparkPaymentFeeEstimate',
|
|
receiveLightningPayment: 'receiveSparkLightningPayment',
|
|
getLightningSendRequest: 'getSparkLightningSendRequest',
|
|
getLightningPaymentStatus: 'getSparkLightningPaymentStatus',
|
|
sendLightningPayment: 'sendSparkLightningPayment',
|
|
sendBitcoinPayment: 'sendSparkBitcoinPayment',
|
|
getTransactions: 'getSparkTransactions',
|
|
getTokenTransactions: 'getSparkTokenTransactions',
|
|
addListeners: 'addWalletEventListener',
|
|
removeListeners: 'removeWalletEventListener',
|
|
disposeWallet: 'disposeSparkWallet',
|
|
setPrivacyEnabled: 'setPrivacyEnabled',
|
|
getSingleTxDetails: 'getSingleTxDetails',
|
|
createSatsInvoice: 'createSatsInvoice',
|
|
fufillSparkInvoices: 'fufillSparkInvoices',
|
|
batchTransferTokens: 'batchTransferTokens',
|
|
createTokensInvoice: 'createTokensInvoice',
|
|
claimSparkHodlLightningPayment: 'claimSparkHodlLightningPayment',
|
|
receiveSparkHodlLightningPayment: 'receiveSparkHodlLightningPayment',
|
|
querySparkHodlLightningPayments: 'querySparkHodlLightningPayments',
|
|
isOptimizationInProgress: 'isOptimizationInProgress',
|
|
|
|
// Flashnet
|
|
initializeFlashnet: 'initializeFlashnet',
|
|
listFlashnetPools: 'listFlashnetPools',
|
|
findBestPool: 'findBestPool',
|
|
getPoolDetails: 'getPoolDetails',
|
|
listAllPools: 'listAllPools',
|
|
minFlashnetSwapAmounts: 'minFlashnetSwapAmounts',
|
|
simulateSwap: 'simulateSwap',
|
|
executeSwap: 'executeSwap',
|
|
swapBitcoinToToken: 'swapBitcoinToToken',
|
|
swapTokenToBitcoin: 'swapTokenToBitcoin',
|
|
getLightningPaymentQuote: 'getLightningPaymentQuote',
|
|
payLightningWithToken: 'payLightningWithToken',
|
|
getUserSwapHistory: 'getUserSwapHistory',
|
|
requestClawback: 'requestClawback',
|
|
checkClawbackEligibility: 'checkClawbackEligibility',
|
|
checkClawbackStatus: 'checkClawbackStatus',
|
|
requestBatchClawback: 'requestBatchClawback',
|
|
listClawbackableTransfers: 'listClawbackableTransfers',
|
|
|
|
// Wallet optimizations
|
|
abortOptimization: 'abortOptimization',
|
|
isOptimizationRunning: 'isOptimizationRunning',
|
|
checkIfOptimizationNeeded: 'checkIfOptimizationNeeded',
|
|
runLeafOptimization: 'runLeafOptimization',
|
|
runTokenOptimization: 'runTokenOptimization',
|
|
};
|
|
|
|
const longOperations = new Set([
|
|
OPERATION_TYPES.claimStaticDepositAddress,
|
|
OPERATION_TYPES.sendSparkPayment,
|
|
OPERATION_TYPES.sendTokenPayment,
|
|
OPERATION_TYPES.getBitcoinPaymentRequest,
|
|
OPERATION_TYPES.getBitcoinPaymentFee,
|
|
OPERATION_TYPES.sendLightningPayment,
|
|
OPERATION_TYPES.sendBitcoinPayment,
|
|
OPERATION_TYPES.initWallet,
|
|
OPERATION_TYPES.initializeFlashnet,
|
|
OPERATION_TYPES.executeSwap,
|
|
OPERATION_TYPES.swapBitcoinToToken,
|
|
OPERATION_TYPES.swapTokenToBitcoin,
|
|
OPERATION_TYPES.payLightningWithToken,
|
|
OPERATION_TYPES.requestClawback,
|
|
OPERATION_TYPES.runLeafOptimization,
|
|
OPERATION_TYPES.runTokenOptimization,
|
|
OPERATION_TYPES.claimSparkHodlLightningPayment,
|
|
OPERATION_TYPES.receiveSparkHodlLightningPayment,
|
|
OPERATION_TYPES.fufillSparkInvoices,
|
|
OPERATION_TYPES.batchTransferTokens,
|
|
OPERATION_TYPES.getSparkLeaves,
|
|
OPERATION_TYPES.getSparkLeafExitNodes,
|
|
]);
|
|
|
|
const mediumOperations = new Set([
|
|
OPERATION_TYPES.getBalance,
|
|
OPERATION_TYPES.queryStaticL1Address,
|
|
OPERATION_TYPES.getUtxosForDepositAddress,
|
|
OPERATION_TYPES.getL1AddressQuote,
|
|
OPERATION_TYPES.getSparkAddress,
|
|
OPERATION_TYPES.getL1Address,
|
|
OPERATION_TYPES.receiveLightningPayment,
|
|
OPERATION_TYPES.getLightningSendRequest,
|
|
OPERATION_TYPES.getLightningPaymentStatus,
|
|
OPERATION_TYPES.getTransactions,
|
|
OPERATION_TYPES.getSingleTxDetails,
|
|
OPERATION_TYPES.getTokenTransactions,
|
|
OPERATION_TYPES.setPrivacyEnabled,
|
|
OPERATION_TYPES.simulateSwap,
|
|
OPERATION_TYPES.requestBatchClawback,
|
|
OPERATION_TYPES.checkIfOptimizationNeeded,
|
|
OPERATION_TYPES.listClawbackableTransfers,
|
|
OPERATION_TYPES.createSatsInvoice,
|
|
OPERATION_TYPES.createTokensInvoice,
|
|
OPERATION_TYPES.getLightningPaymentQuote,
|
|
OPERATION_TYPES.getLightningFee,
|
|
OPERATION_TYPES.getSparkPaymentFee,
|
|
OPERATION_TYPES.getUserSwapHistory,
|
|
OPERATION_TYPES.checkClawbackEligibility,
|
|
OPERATION_TYPES.checkClawbackStatus,
|
|
OPERATION_TYPES.isOptimizationInProgress,
|
|
]);
|
|
|
|
const rejectIfNotConnectedToInternet = new Set([
|
|
OPERATION_TYPES.claimStaticDepositAddress,
|
|
OPERATION_TYPES.sendSparkPayment,
|
|
OPERATION_TYPES.sendTokenPayment,
|
|
OPERATION_TYPES.getBitcoinPaymentRequest,
|
|
OPERATION_TYPES.getBitcoinPaymentFee,
|
|
OPERATION_TYPES.sendLightningPayment,
|
|
OPERATION_TYPES.sendBitcoinPayment,
|
|
OPERATION_TYPES.receiveLightningPayment,
|
|
OPERATION_TYPES.getL1Address,
|
|
// The quote action matched getL1Address under the old substring matching;
|
|
// listed explicitly so exact matching keeps rejecting it offline.
|
|
OPERATION_TYPES.getL1AddressQuote,
|
|
OPERATION_TYPES.getSparkAddress,
|
|
]);
|
|
|
|
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',
|
|
'WebAssembly.Compile is disallowed on the main thread',
|
|
"Cannot read properties of undefined (reading '__wbindgen_malloc')",
|
|
];
|
|
let handshakeComplete = false;
|
|
let forceReactNativeUse = null;
|
|
let globalSendWebViewRequest = null;
|
|
let globalPendingRequests = null;
|
|
let webviewFailureCount = 0;
|
|
const MAX_WEBVIEW_FAILURES = 2;
|
|
const MAX_QUEUED_REQUESTS = 50;
|
|
const QUEUED_REQUEST_TTL_MS = 5 * 60 * 1000;
|
|
|
|
// These two return fresh data per call, so concurrent identical calls are
|
|
// legitimate and must never be deduped/coalesced in the queue.
|
|
const QUEUE_DEDUPE_EXEMPT = [
|
|
OPERATION_TYPES.getSingleTxDetails,
|
|
OPERATION_TYPES.getUserSwapHistory,
|
|
];
|
|
|
|
const WV_STATES = {
|
|
UNLOADED: 'unloaded',
|
|
VERIFYING: 'verifying',
|
|
LOADING: 'loading',
|
|
LOADED: 'loaded',
|
|
HANDSHAKING: 'handshaking',
|
|
READY: 'ready',
|
|
ERROR: 'error',
|
|
};
|
|
|
|
// A reset can arrive in any state, so every state may transition to UNLOADED
|
|
// (including UNLOADED itself — e.g. an auth reset before verification).
|
|
const VALID_TRANSITIONS = {
|
|
[WV_STATES.UNLOADED]: [WV_STATES.VERIFYING, WV_STATES.UNLOADED],
|
|
[WV_STATES.VERIFYING]: [
|
|
WV_STATES.LOADING,
|
|
WV_STATES.ERROR,
|
|
WV_STATES.UNLOADED,
|
|
],
|
|
[WV_STATES.LOADING]: [WV_STATES.LOADED, WV_STATES.ERROR, WV_STATES.UNLOADED],
|
|
[WV_STATES.LOADED]: [
|
|
WV_STATES.HANDSHAKING,
|
|
WV_STATES.ERROR,
|
|
WV_STATES.UNLOADED,
|
|
],
|
|
[WV_STATES.HANDSHAKING]: [
|
|
WV_STATES.READY,
|
|
WV_STATES.ERROR,
|
|
WV_STATES.UNLOADED,
|
|
],
|
|
[WV_STATES.READY]: [WV_STATES.ERROR, WV_STATES.UNLOADED],
|
|
[WV_STATES.ERROR]: [WV_STATES.UNLOADED],
|
|
};
|
|
|
|
const WebViewContext = createContext(null);
|
|
|
|
// Derive AES-256 key via HKDF-SHA256 from sharedX (32 bytes)
|
|
function deriveAesKeyFromSharedX(sharedX, randomNonce) {
|
|
// sharedX should be Uint8Array or Buffer
|
|
const ikm =
|
|
sharedX instanceof Uint8Array ? sharedX : Uint8Array.from(sharedX);
|
|
// no salt, info = 'ecdh-aes-key'
|
|
const keyBytes = hkdf(
|
|
sha256,
|
|
ikm,
|
|
new Uint8Array(0),
|
|
new TextEncoder().encode('ecdh-aes-key:' + randomNonce),
|
|
32,
|
|
);
|
|
return Buffer.from(keyBytes); // Buffer of length 32
|
|
}
|
|
|
|
const setHandshakeComplete = value => {
|
|
handshakeComplete = value;
|
|
};
|
|
|
|
// On startup/loading routes a handshake failure must not emit a reconnect
|
|
// (state: true) — the login flow handles connection itself; emitting would
|
|
// race it. Shared by the reload-verification and handshake failure paths.
|
|
const isOnStartupRoute = () => {
|
|
const currentRoutes = navigationRef.getRootState().routes?.map(r => r.name);
|
|
return (
|
|
currentRoutes?.includes('Splash') ||
|
|
currentRoutes?.includes('SplashReload') ||
|
|
currentRoutes?.includes('Home') ||
|
|
currentRoutes?.includes('ConnectingToNodeLoadingScreen')
|
|
);
|
|
};
|
|
|
|
export const setForceReactNative = (value, reason = 'unknown') => {
|
|
if (value === true) {
|
|
console.warn(`forceReactNativeUse set to true. Reason: ${reason}`);
|
|
}
|
|
forceReactNativeUse = value;
|
|
};
|
|
|
|
const forceNativeMode = reason => {
|
|
console.warn(`Forcing React Native mode: ${reason}`);
|
|
forceReactNativeUse = true;
|
|
};
|
|
|
|
export const sendWebViewRequestGlobal = async (
|
|
action,
|
|
args = {},
|
|
encrypt = true,
|
|
) => {
|
|
if (!globalSendWebViewRequest) {
|
|
throw new Error(
|
|
'WebView not initialized. Ensure WebViewProvider is mounted.',
|
|
);
|
|
}
|
|
return globalSendWebViewRequest(action, args, encrypt);
|
|
};
|
|
|
|
export const getHandshakeComplete = () => {
|
|
if (forceReactNativeUse !== null) {
|
|
return false;
|
|
}
|
|
return handshakeComplete;
|
|
};
|
|
|
|
// Test seam: exposes the in-flight request ids so the harness can assert that
|
|
// interrupted requests are settled AND removed (no zombie hangs). Prod code never
|
|
// reads this — it mirrors the existing globalSendWebViewRequest wiring below.
|
|
export const __getPendingRequestIdsForTest = () =>
|
|
globalPendingRequests ? Object.keys(globalPendingRequests.current) : [];
|
|
|
|
export const WebViewProvider = ({ children }) => {
|
|
const { authResetkey } = useAuthContext();
|
|
const { currentWalletMnemoinc } = useActiveCustodyAccount();
|
|
const { appState, isConnectedToTheInternet, didGetToHomepage } =
|
|
useAppStatus();
|
|
const webViewRef = useRef(null);
|
|
const [isWebViewReady, setIsWebViewReady] = useState(false);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const isResetting = useRef(false);
|
|
const queuedRequests = useRef([]);
|
|
const pendingRequests = useRef({});
|
|
const activeTimeoutsRef = useRef({});
|
|
const sessionKeyRef = useRef(null);
|
|
const aesKeyRef = useRef(null);
|
|
const expectedNonceRef = useRef(null);
|
|
const [verifiedPath, setVerifiedPath] = useState('');
|
|
const expectedSequenceRef = useRef(0);
|
|
const nonceVerified = useRef(false);
|
|
const previousAppState = useRef(appState);
|
|
const prevConnectionStatus = useRef(isConnectedToTheInternet);
|
|
const internetConnectionRef = useRef(isConnectedToTheInternet);
|
|
const walletInitialized = useRef(false);
|
|
const isInitialRender = useRef(true);
|
|
const currentWalletMnemoincRef = useRef(currentWalletMnemoinc);
|
|
const didRunInit = useRef(null);
|
|
const isWebviewReadyRef = useRef(null);
|
|
const didRunHandshakeRef = useRef(false);
|
|
const didGetToHomepageRef = useRef(didGetToHomepage);
|
|
const [changeSparkConnectionState, setChangeSparkConnectionState] = useState({
|
|
state: null,
|
|
count: 0,
|
|
});
|
|
const wvState = useRef(WV_STATES.UNLOADED);
|
|
|
|
const transitionWvState = useCallback((newState, reason = '') => {
|
|
const current = wvState.current;
|
|
const valid = VALID_TRANSITIONS[current];
|
|
if (!valid || !valid.includes(newState)) {
|
|
console.warn(
|
|
`Invalid WebView state transition: ${current} → ${newState} (reason: ${reason})`,
|
|
);
|
|
return false;
|
|
}
|
|
console.log(`WebView state: ${current} → ${newState} (${reason})`);
|
|
wvState.current = newState;
|
|
|
|
// Derive isWebViewReady from state machine
|
|
const ready =
|
|
newState === WV_STATES.LOADED ||
|
|
newState === WV_STATES.HANDSHAKING ||
|
|
newState === WV_STATES.READY;
|
|
setIsWebViewReady(ready);
|
|
return true;
|
|
}, []);
|
|
|
|
const fileHash = !!verifiedPath ? process.env.WEBVIEW_BUNDLE_HASH : '';
|
|
|
|
useEffect(() => {
|
|
currentWalletMnemoincRef.current = currentWalletMnemoinc;
|
|
}, [currentWalletMnemoinc]);
|
|
|
|
useEffect(() => {
|
|
isWebviewReadyRef.current = isWebViewReady;
|
|
}, [isWebViewReady]);
|
|
|
|
// reset webview when app is stale in background
|
|
useEffect(() => {
|
|
if (isInitialRender.current) {
|
|
isInitialRender.current = false;
|
|
return;
|
|
}
|
|
|
|
// clear any qued requests
|
|
if (queuedRequests.current.length) {
|
|
console.log('[auth reset webview] clearing qued requests');
|
|
// Snapshot and clear BEFORE iterating: a reject handler can synchronously
|
|
// route back into a queue drain (see wrapped init resolve), so the array
|
|
// must already be empty to keep that re-entry from recursing infinitely.
|
|
const requestsToReject = queuedRequests.current;
|
|
queuedRequests.current = [];
|
|
requestsToReject.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
}
|
|
blockAndResetWebview();
|
|
}, [authResetkey]);
|
|
|
|
useEffect(() => {
|
|
didGetToHomepageRef.current = didGetToHomepage;
|
|
}, [didGetToHomepage]);
|
|
|
|
useEffect(() => {
|
|
internetConnectionRef.current = isConnectedToTheInternet;
|
|
}, [isConnectedToTheInternet]);
|
|
|
|
const messageRateLimiter = useRef({
|
|
count: 0,
|
|
windowStart: Date.now(),
|
|
maxPerSecond: 50,
|
|
});
|
|
|
|
const getNextSequence = useCallback(() => {
|
|
const current = expectedSequenceRef.current;
|
|
expectedSequenceRef.current = current + 1;
|
|
return current;
|
|
}, []);
|
|
|
|
// Settle-and-remove queued entries older than the TTL. Runs on every queue
|
|
// push AND at drain time: a stale read just refetches, but a stale send
|
|
// (e.g. a payment queued before a long background) must never execute
|
|
// minutes later.
|
|
const evictExpiredQueuedRequests = useCallback(() => {
|
|
const queue = queuedRequests.current;
|
|
const now = Date.now();
|
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
if (now - queue[i].queuedAt > QUEUED_REQUEST_TTL_MS) {
|
|
const [expired] = queue.splice(i, 1);
|
|
if (typeof expired.resolve === 'function') {
|
|
expired.resolve({ error: 'Request expired while queued' });
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Single entry point for deferring a request ({id, action, args, encrypt,
|
|
// resolve, reject}) into queuedRequests. Owns the queue policy:
|
|
// - expired entries are settled and evicted first (TTL),
|
|
// - an identical queued request coalesces — both callers settle with the
|
|
// same outcome from one message,
|
|
// - a full queue rejects the newcomer instead of growing unbounded.
|
|
const queueRequest = useCallback(
|
|
entry => {
|
|
const queue = queuedRequests.current;
|
|
const now = Date.now();
|
|
|
|
evictExpiredQueuedRequests();
|
|
|
|
const existing =
|
|
!QUEUE_DEDUPE_EXEMPT.includes(entry.action) &&
|
|
queue.find(
|
|
req =>
|
|
req.action === entry.action &&
|
|
JSON.stringify(req.args) === JSON.stringify(entry.args),
|
|
);
|
|
if (existing) {
|
|
console.log('Coalescing duplicate queued request:', entry.action);
|
|
const prevResolve = existing.resolve;
|
|
const prevReject = existing.reject;
|
|
existing.resolve = result => {
|
|
if (typeof prevResolve === 'function') prevResolve(result);
|
|
if (typeof entry.resolve === 'function') entry.resolve(result);
|
|
};
|
|
existing.reject = error => {
|
|
if (typeof prevReject === 'function') prevReject(error);
|
|
if (typeof entry.reject === 'function') entry.reject(error);
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (queue.length >= MAX_QUEUED_REQUESTS) {
|
|
console.warn('Request queue full, rejecting:', entry.action);
|
|
entry.reject(new Error('Request queue full'));
|
|
return;
|
|
}
|
|
|
|
queue.push({ ...entry, queuedAt: now });
|
|
},
|
|
[evictExpiredQueuedRequests],
|
|
);
|
|
|
|
const resetWebViewState = useCallback(
|
|
(
|
|
clearHandshake = false,
|
|
sparkConnectionState,
|
|
shouldClearPending = true,
|
|
) => {
|
|
console.log('Resetting WebView state', {
|
|
clearHandshake,
|
|
sparkConnectionState,
|
|
shouldClearPending,
|
|
});
|
|
// Transition to UNLOADED — this also sets isWebViewReady(false) via
|
|
// derived state (every state allows the reset transition).
|
|
transitionWvState(WV_STATES.UNLOADED, 'reset');
|
|
isResetting.current = true;
|
|
// setVerifiedPath('');
|
|
|
|
Object.entries(activeTimeoutsRef.current).forEach(([id, timeoutInfo]) => {
|
|
clearTimeout(timeoutInfo.timeoutId);
|
|
|
|
// Re-queue the request if it's not a handshake
|
|
// and if we're not clearing pending requests
|
|
if (!shouldClearPending && timeoutInfo.action !== 'handshake:init') {
|
|
// Find the pending request's resolve/reject functions
|
|
const originalResolve = pendingRequests.current[id];
|
|
if (originalResolve && timeoutInfo.originalRequest) {
|
|
const { action, args, encrypt } = timeoutInfo.originalRequest;
|
|
|
|
console.log(`Re-queueing interrupted request: ${action}`);
|
|
|
|
// IMPORTANT: We pass the original resolve function so the promise
|
|
// chain is preserved; a queue duplicate coalesces onto the existing
|
|
// entry so this promise still settles.
|
|
queueRequest({
|
|
id, // Keep original ID for tracking
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve: originalResolve,
|
|
reject: error => {
|
|
// Reject using the original resolve function (which handles both resolve/reject)
|
|
if (typeof originalResolve === 'function') {
|
|
originalResolve({ error: error.message || error });
|
|
}
|
|
},
|
|
});
|
|
|
|
// Remove from pendingRequests since it's now queued
|
|
delete pendingRequests.current[id];
|
|
}
|
|
}
|
|
});
|
|
activeTimeoutsRef.current = {};
|
|
|
|
// Only clear handshake if explicitly told to (actual failures)
|
|
// Don't clear it for normal app lifecycle resets
|
|
// Our app uses this to choose wheater to make calls to the webview or react native
|
|
if (clearHandshake) {
|
|
setHandshakeComplete(false);
|
|
}
|
|
|
|
// Always reset walletInitialized because WebView reload clears its internal state
|
|
// We'll need to reinitialize the wallet after handshake completes
|
|
walletInitialized.current = false;
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: sparkConnectionState,
|
|
count: prev.count + 1,
|
|
}));
|
|
|
|
// Settle everything still pending. When shouldClearPending is false the
|
|
// re-queued ids were already removed inside the loop above, so this only
|
|
// sweeps entries that could not be re-queued (handshake:init, lost
|
|
// bookkeeping) — leaving them would strand their promises forever.
|
|
// Resolving a pending handshake:init with a plain {error} makes
|
|
// initHandshake's await return (it does NOT throw), so its
|
|
// forceNativeMode catch intentionally does not run here.
|
|
Object.entries(pendingRequests.current).forEach(([id, resolve]) => {
|
|
if (typeof resolve === 'function') {
|
|
resolve({
|
|
error: 'Unable to finish action, request got cleaned up.',
|
|
});
|
|
}
|
|
});
|
|
pendingRequests.current = {};
|
|
|
|
sessionKeyRef.current = null;
|
|
expectedSequenceRef.current = 0;
|
|
aesKeyRef.current = null;
|
|
nonceVerified.current = false;
|
|
},
|
|
[queueRequest, transitionWvState],
|
|
);
|
|
|
|
// App-state / connectivity transitions clear per-request timers but leave the
|
|
// pending promise in place. For every in-flight request, either revive it or
|
|
// settle it so it can never hang forever:
|
|
// - bookkeeping still present -> re-arm the timer from the preserved entry
|
|
// (fields already carry handler/duration) so it settles via handleTimeout.
|
|
// - bookkeeping already wiped -> orphan; settle it now via the stored
|
|
// resolver (which does not self-delete from pendingRequests) and remove it.
|
|
const rearmOrSweepPendingRequests = useCallback(() => {
|
|
Object.keys(pendingRequests.current).forEach(id => {
|
|
const entry = activeTimeoutsRef.current[id];
|
|
if (entry) {
|
|
clearTimeout(entry.timeoutId);
|
|
entry.timeoutId = setTimeout(entry.handler, entry.duration);
|
|
} else {
|
|
const resolve = pendingRequests.current[id];
|
|
if (typeof resolve === 'function') {
|
|
resolve({ error: 'Request interrupted by app state change' });
|
|
}
|
|
delete pendingRequests.current[id];
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const reloadWebViewSecurely = useCallback(async () => {
|
|
try {
|
|
console.log('Re-verifying WebView before reload...');
|
|
if (forceReactNativeUse) return;
|
|
|
|
transitionWvState(WV_STATES.VERIFYING, 'reload verification');
|
|
|
|
// Re-verify the file
|
|
const { htmlPath, nonceHex, hashHex } = await verifyAndPrepareWebView(
|
|
Platform.OS === 'ios'
|
|
? require('spark-web-context')
|
|
: 'file:///android_asset/sparkContext.html',
|
|
);
|
|
|
|
// File is verified, safe to reload
|
|
console.log('File integrity verified, reloading WebView');
|
|
didRunInit.current = false;
|
|
expectedNonceRef.current = nonceHex;
|
|
setVerifiedPath(htmlPath);
|
|
setReloadKey(prev => prev + 1);
|
|
} catch (err) {
|
|
console.error('WebView re-verification failed:', err);
|
|
|
|
// On verification failure, force React Native mode
|
|
forceNativeMode('bundle verification failed');
|
|
setHandshakeComplete(false);
|
|
const blockReset = isOnStartupRoute();
|
|
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: blockReset ? null : true,
|
|
count: prev.count + 1,
|
|
}));
|
|
}
|
|
}, []);
|
|
|
|
const blockAndResetWebview = useCallback(
|
|
shouldClearPending => {
|
|
didRunInit.current = true; // Block handshakes during reload
|
|
|
|
resetWebViewState(false, false, shouldClearPending);
|
|
reloadWebViewSecurely(); // Will allow handshake to complete after state variables change. We are preventing a race condition here with the app state.
|
|
},
|
|
[resetWebViewState, reloadWebViewSecurely],
|
|
);
|
|
|
|
const encryptMessage = useCallback(plaintext => {
|
|
if (!aesKeyRef.current) throw new Error('AES key not initialized');
|
|
const iv = Buffer.from(randomBytes(12));
|
|
const cipher = createCipheriv('aes-256-gcm', aesKeyRef.current, iv);
|
|
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
|
encrypted += cipher.final('base64');
|
|
const authTag = cipher.getAuthTag().toString('base64'); // Get 16-byte auth tag
|
|
return `${encrypted}?iv=${iv.toString('base64')}&tag=${authTag}`;
|
|
}, []);
|
|
|
|
const decryptMessage = useCallback(encryptedText => {
|
|
if (!aesKeyRef.current) throw new Error('AES key not initialized');
|
|
if (!encryptedText.includes('?iv=') || !encryptedText.includes('&tag=')) {
|
|
throw new Error('Missing IV or auth tag');
|
|
}
|
|
const [ciphertext, params] = encryptedText.split('?iv=');
|
|
const [ivBase64, authTagBase64] = params.split('&tag=');
|
|
const iv = Buffer.from(ivBase64, 'base64');
|
|
const authTag = Buffer.from(authTagBase64, 'base64');
|
|
|
|
const decipher = createDecipheriv('aes-256-gcm', aesKeyRef.current, iv);
|
|
decipher.setAuthTag(authTag); // Set auth tag for verification
|
|
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
return decrypted;
|
|
}, []);
|
|
|
|
const sendWebViewRequestInternal = useCallback(
|
|
(action, args = {}, encrypt = true) => {
|
|
let resolveFn, rejectFn;
|
|
const promise = new Promise((resolve, reject) => {
|
|
resolveFn = resolve;
|
|
rejectFn = reject;
|
|
});
|
|
|
|
const execute = async () => {
|
|
const resolve = resolveFn;
|
|
const reject = rejectFn;
|
|
let timeoutId = null;
|
|
const id = customUUID();
|
|
try {
|
|
// If forceReactNativeUse is set, reject immediately
|
|
if (forceReactNativeUse === true) {
|
|
console.log(
|
|
'Forced React Native mode, rejecting WebView request:',
|
|
action,
|
|
);
|
|
return resolve({
|
|
error: 'Wallet initialization failed, using React Native(1)',
|
|
});
|
|
}
|
|
|
|
// Queue messages during reset/background
|
|
if (
|
|
(isResetting.current || AppState.currentState === 'background') &&
|
|
action !== 'handshake:init' &&
|
|
action !== 'initializeSparkWallet'
|
|
) {
|
|
console.log(
|
|
'WebView is resetting or in the background, queueing message:',
|
|
action,
|
|
);
|
|
queueRequest({ id, action, args, encrypt, resolve, reject });
|
|
return;
|
|
}
|
|
|
|
// Reject importent messages if app is not connected to the internet
|
|
if (
|
|
!internetConnectionRef.current &&
|
|
action !== 'handshake:init' &&
|
|
action !== 'initializeSparkWallet'
|
|
) {
|
|
console.log(
|
|
'App is not connected to the internet, queueing message:',
|
|
action,
|
|
);
|
|
if (rejectIfNotConnectedToInternet.has(action)) {
|
|
reject(new Error(`App is not connected to the internet`));
|
|
} else if (action !== OPERATION_TYPES.initWallet) {
|
|
//don't add init wallet, this will be added during webview reset
|
|
queueRequest({ id, action, args, encrypt, resolve, reject });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!webViewRef.current || !isWebviewReadyRef.current) {
|
|
console.log(
|
|
'WebView not ready or internet is not connected, queueing message:',
|
|
action,
|
|
webViewRef.current,
|
|
isWebviewReadyRef.current,
|
|
);
|
|
queueRequest({ id, action, args, encrypt, resolve, reject });
|
|
return;
|
|
}
|
|
|
|
const getTimeoutDuration = action => {
|
|
if (action === 'handshake:init') return 4000;
|
|
|
|
if (longOperations.has(action)) {
|
|
return 90000; // 90 seconds for payment operations
|
|
}
|
|
|
|
if (mediumOperations.has(action)) {
|
|
return 30000; // 30 seconds
|
|
}
|
|
|
|
return 10000; // 10 seconds
|
|
};
|
|
|
|
const timeoutDuration = getTimeoutDuration(action);
|
|
const startedAt = Date.now();
|
|
|
|
const handleTimeout = () => {
|
|
if (AppState.currentState !== 'active') {
|
|
console.log(
|
|
`Skipping timeout for ${action} because app is not active (${AppState.currentState})`,
|
|
);
|
|
// Timers can still fire in iOS 'inactive'; killing the request here
|
|
// would leave it in pendingRequests forever. Re-arm so the entry
|
|
// survives until an active-state firing settles it.
|
|
const entry = activeTimeoutsRef.current[id];
|
|
if (entry) {
|
|
entry.timeoutId = setTimeout(handleTimeout, timeoutDuration);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!pendingRequests.current[id]) {
|
|
console.log(`Request ${id} already resolved, skipping timeout`);
|
|
delete activeTimeoutsRef.current[id];
|
|
return;
|
|
}
|
|
|
|
console.error(`WebView request timeout for action: ${action}`);
|
|
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
|
|
// If handshake is complete and this is not a handshake action,
|
|
// the WebView bridge is functional — this is a service timeout.
|
|
// Don't kill the WebView for a backend service being slow.
|
|
if (handshakeComplete && action !== 'handshake:init') {
|
|
console.warn(
|
|
`Service timeout for ${action} — WebView bridge is healthy, not forcing native mode`,
|
|
);
|
|
} else {
|
|
// WebView-level failure (handshake timeout or bridge issue)
|
|
webviewFailureCount++;
|
|
console.warn(
|
|
`WebView failure #${webviewFailureCount} for ${action}`,
|
|
);
|
|
if (webviewFailureCount >= MAX_WEBVIEW_FAILURES) {
|
|
forceNativeMode(
|
|
`${MAX_WEBVIEW_FAILURES} consecutive WebView failures`,
|
|
);
|
|
}
|
|
resetWebViewState(true, true);
|
|
}
|
|
|
|
reject(
|
|
new Error(
|
|
`Call unresponsive (timeout after ${timeoutDuration}ms)`,
|
|
),
|
|
);
|
|
};
|
|
|
|
timeoutId = setTimeout(handleTimeout, timeoutDuration);
|
|
|
|
activeTimeoutsRef.current[id] = {
|
|
timeoutId,
|
|
startedAt,
|
|
duration: timeoutDuration,
|
|
handler: handleTimeout,
|
|
remaining: timeoutDuration,
|
|
action,
|
|
originalRequest: { action, args, encrypt }, //Store request details
|
|
};
|
|
|
|
const originalResolve = resolve;
|
|
pendingRequests.current[id] = result => {
|
|
const t = activeTimeoutsRef.current[id];
|
|
if (t?.timeoutId) clearTimeout(t.timeoutId);
|
|
delete activeTimeoutsRef.current[id];
|
|
originalResolve(result);
|
|
};
|
|
|
|
// Handle initializeSparkWallet specially
|
|
if (action === 'initializeSparkWallet') {
|
|
if (!getHandshakeComplete()) {
|
|
console.log('Handshake not complete, cannot initialize wallet');
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
forceNativeMode('handshake incomplete for wallet init');
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
return resolve({ isConnected: false });
|
|
}
|
|
if (!nonceVerified.current) {
|
|
console.log('Nonce not verified, cannot initialize wallet');
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
forceNativeMode('nonce not verified for wallet init');
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
return resolve({ isConnected: false });
|
|
}
|
|
|
|
// Wrap the resolve to check initialization result
|
|
const wrappedResolve = pendingRequests.current[id];
|
|
pendingRequests.current[id] = result => {
|
|
if (result?.error || result?.isConnected === false) {
|
|
console.warn(
|
|
'Wallet initialization failed, forcing React Native mode:',
|
|
result,
|
|
);
|
|
|
|
// forceReactNativeUse = true;
|
|
// setChangeSparkConnectionState(prev => ({
|
|
// state: true,
|
|
// count: prev.count + 1,
|
|
// }));
|
|
|
|
// Snapshot and clear BEFORE iterating. This forEach is the
|
|
// re-entrant frame in the crash: one of these rejects belongs to
|
|
// a re-queued init request whose reject calls THIS wrapped resolve
|
|
// again, which would re-enter this forEach on the same array.
|
|
// Clearing first makes that re-entry a no-op and breaks the loop.
|
|
const requestsToReject = queuedRequests.current;
|
|
queuedRequests.current = [];
|
|
requestsToReject.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
} else {
|
|
walletInitialized.current = true;
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
console.log('Wallet initialized successfully');
|
|
processQueuedRequests();
|
|
}
|
|
wrappedResolve(result);
|
|
};
|
|
} else if (action !== 'handshake:init') {
|
|
// For non-init actions, check if wallet was initialized
|
|
if (handshakeComplete && !walletInitialized.current) {
|
|
console.log(
|
|
'Wallet initialization in progress, queueing request:',
|
|
action,
|
|
);
|
|
|
|
// Queue the request instead of blocking. Ownership of the promise
|
|
// moves to the queue entry, so drop the timeout AND the
|
|
// pendingRequests entry (leaving it would let a later sweep
|
|
// settle the caller prematurely).
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
queueRequest({ id, action, args, encrypt, resolve, reject });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Hash into a copy, and only after every queue/store branch above:
|
|
// originalRequest and queued entries must hold the pre-hash args so a
|
|
// replay through this function hashes exactly once.
|
|
if (args.mnemonic && action !== 'initializeSparkWallet') {
|
|
args = { ...args, mnemonic: sha256Hash(args.mnemonic) };
|
|
}
|
|
|
|
const sequence = getNextSequence();
|
|
const timestamp = Date.now();
|
|
|
|
let payload = {
|
|
id,
|
|
action,
|
|
args,
|
|
sequence,
|
|
timestamp,
|
|
};
|
|
|
|
try {
|
|
if (encrypt && aesKeyRef.current) {
|
|
const encrypted = encryptMessage(JSON.stringify(payload));
|
|
payload = { type: 'secure:msg', encrypted };
|
|
} else if (encrypt && action !== 'handshake:init') {
|
|
// Fail closed: encryption was requested but no session key exists
|
|
// (pre-handshake or mid-reset race). Never downgrade the payload
|
|
// to plaintext.
|
|
throw new Error('Encryption required but AES key unavailable');
|
|
}
|
|
webViewRef.current.postMessage(JSON.stringify(payload));
|
|
} catch (err) {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
reject(err);
|
|
}
|
|
} catch (err) {
|
|
// Clean up timeout on error
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
if (id) {
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
}
|
|
console.log(
|
|
'Error sending webview request from internal function',
|
|
err,
|
|
);
|
|
reject(err);
|
|
}
|
|
};
|
|
execute();
|
|
return promise;
|
|
},
|
|
[encryptMessage, resetWebViewState, getNextSequence],
|
|
);
|
|
|
|
const processQueuedRequests = useCallback(
|
|
async connectionJustRestored => {
|
|
// Never execute work that expired while waiting (see helper).
|
|
evictExpiredQueuedRequests();
|
|
|
|
// After a soft reset, the WebView's internal state is cleared
|
|
// We must reinitialize the wallet before processing any queued requests
|
|
if (
|
|
(handshakeComplete &&
|
|
!walletInitialized.current &&
|
|
currentWalletMnemoincRef.current) ||
|
|
(currentWalletMnemoincRef.current && connectionJustRestored)
|
|
) {
|
|
console.log('Re-initializing wallet before processing queue');
|
|
try {
|
|
// No need to handle any state changes here, handled inside of the promise. But this might be where the stale connection state comes from. if a request is sent to the webview but not responded to the change to react-native woudnt have happpened before leaving everything in "not connected to spark".
|
|
const response = await sendWebViewRequestInternal(
|
|
OPERATION_TYPES.initWallet,
|
|
{ mnemonic: currentWalletMnemoincRef.current },
|
|
true,
|
|
);
|
|
if (!response?.isConnected) throw new Error('Wallet init failed');
|
|
} catch (err) {
|
|
console.log('Error re-initializing wallet:', err);
|
|
// forceReactNativeUse = true;
|
|
// Reject all queued requests since WebView is now unusable
|
|
// Snapshot and clear BEFORE iterating (re-entrancy safety).
|
|
const requestsToReject = queuedRequests.current;
|
|
queuedRequests.current = [];
|
|
requestsToReject.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
isResetting.current = false;
|
|
|
|
console.log(
|
|
`Processing ${queuedRequests.current.length} queued requests`,
|
|
);
|
|
|
|
if (
|
|
queuedRequests.current.length === 0 ||
|
|
!currentWalletMnemoincRef.current
|
|
) {
|
|
isResetting.current = false;
|
|
return;
|
|
}
|
|
|
|
const requests = [...queuedRequests.current];
|
|
queuedRequests.current = [];
|
|
|
|
// 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 {
|
|
const result = await sendWebViewRequestInternal(
|
|
action,
|
|
args,
|
|
encrypt,
|
|
);
|
|
if (typeof resolve === 'function') {
|
|
resolve(result);
|
|
}
|
|
} catch (error) {
|
|
if (typeof reject === 'function') {
|
|
reject(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
isResetting.current = false;
|
|
},
|
|
[sendWebViewRequestInternal, evictExpiredQueuedRequests],
|
|
);
|
|
|
|
const handleWebViewResponse = useCallback(
|
|
event => {
|
|
try {
|
|
const message = JSON.parse(event.nativeEvent.data);
|
|
|
|
if (message.type === 'handshake:reply' && message.pubW) {
|
|
const resolve = pendingRequests.current[message.id];
|
|
if (!resolve) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.error('Timeout: backend is unresponsive');
|
|
return;
|
|
}
|
|
if (!sessionKeyRef.current) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.error(
|
|
'SECURITY: Received handshake reply without active session key',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const shared = getSharedSecret(
|
|
Buffer.from(sessionKeyRef.current.privateKey),
|
|
Buffer.from(message.pubW, 'hex'),
|
|
true,
|
|
);
|
|
const sharedX = shared.slice(1, 33);
|
|
aesKeyRef.current = deriveAesKeyFromSharedX(
|
|
sharedX,
|
|
expectedNonceRef.current,
|
|
);
|
|
|
|
shared.fill(0);
|
|
sharedX.fill(0);
|
|
|
|
if (sessionKeyRef.current?.privateKey) {
|
|
sessionKeyRef.current.privateKey.fill(0);
|
|
}
|
|
sessionKeyRef.current = null;
|
|
|
|
const decodedNonce = decryptMessage(message.runtimeNonce);
|
|
if (expectedNonceRef.current !== decodedNonce) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.log('Invalid runtime nonce, something went wrong');
|
|
aesKeyRef.current = null;
|
|
return;
|
|
}
|
|
nonceVerified.current = true;
|
|
webviewFailureCount = 0;
|
|
console.log('Handshake complete. Got backend public key.');
|
|
transitionWvState(WV_STATES.READY, 'handshake complete');
|
|
setHandshakeComplete(true);
|
|
// resolve requset to avoid timeout
|
|
resolve({ didComplete: true });
|
|
delete pendingRequests.current[message.id];
|
|
|
|
setTimeout(() => {
|
|
processQueuedRequests();
|
|
}, 100);
|
|
return;
|
|
}
|
|
|
|
let content = message;
|
|
|
|
if (message.encrypted && aesKeyRef.current) {
|
|
const decrypted = decryptMessage(message.encrypted);
|
|
|
|
try {
|
|
content = JSON.parse(decrypted);
|
|
} catch (err) {
|
|
content = decrypted;
|
|
}
|
|
} else if (nonceVerified.current) {
|
|
// Once the handshake completed, every legitimate webview message is
|
|
// encrypted (the bundle only posts errors/CSP reports once sharedKey
|
|
// exists). Accepting plaintext here would let an unauthenticated
|
|
// message bypass GCM verification, e.g. to spoof a response.
|
|
console.warn('Dropping plaintext message received post-handshake');
|
|
return;
|
|
}
|
|
|
|
if (content.type === 'security:csp-violation') {
|
|
console.error('CSP VIOLATION DETECTED:', content);
|
|
|
|
resetWebViewState(true, true);
|
|
forceNativeMode('CSP violation');
|
|
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) {
|
|
// An error tied to a request id is a request-level resolution, not a
|
|
// bridge failure — settle just that request and leave the bridge (and
|
|
// every other in-flight request) alone. An error whose id no longer
|
|
// matches (e.g. the timeout already settled it) is dropped like the
|
|
// isResponse path drops stale ids. Only an id-less top-level error
|
|
// keeps the reset/failure-count behavior via the outer catch.
|
|
if (content.id) {
|
|
const resolve = pendingRequests.current[content.id];
|
|
if (typeof resolve === 'function') {
|
|
resolve({ error: content.error });
|
|
delete pendingRequests.current[content.id];
|
|
} else {
|
|
console.warn(
|
|
'Dropping error for unknown/settled request:',
|
|
content.id,
|
|
content.error,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
throw new Error(content.error);
|
|
}
|
|
|
|
// Push events are unsolicited SDK traffic. One malformed event (or one
|
|
// throwing listener) must be logged and dropped, never routed to the
|
|
// outer catch — that would reset the bridge and wipe every in-flight
|
|
// request over a single bad message.
|
|
if (content.incomingPayment) {
|
|
try {
|
|
const data = JSON.parse(content.result);
|
|
incomingSparkTransaction.emit(
|
|
INCOMING_SPARK_TX_NAME,
|
|
data.transferId,
|
|
data.balance,
|
|
content.walletId,
|
|
);
|
|
} catch (err) {
|
|
console.error('Dropping malformed incomingPayment event:', err);
|
|
}
|
|
}
|
|
if (content.balanceUpdate) {
|
|
try {
|
|
const data = JSON.parse(content.result);
|
|
sparkBalanceUpdateEmitter.emit(
|
|
BALANCE_UPDATE_EVENT_NAME,
|
|
data,
|
|
content.walletId,
|
|
);
|
|
} catch (err) {
|
|
console.error('Dropping malformed balanceUpdate event:', err);
|
|
}
|
|
}
|
|
if (content.tokenBalanceUpdate) {
|
|
try {
|
|
const data = JSON.parse(content.result);
|
|
sparkTokenBalanceUpdateEmitter.emit(
|
|
TOKEN_BALANCE_UPDATE_EVENT_NAME,
|
|
data.tokensObject,
|
|
content.walletId,
|
|
);
|
|
} catch (err) {
|
|
console.error('Dropping malformed tokenBalanceUpdate event:', err);
|
|
}
|
|
}
|
|
if (content.streamStatus) {
|
|
try {
|
|
sparkStreamStatusEmitter.emit(
|
|
STREAM_STATUS_EVENT_NAME,
|
|
content.streamStatus,
|
|
);
|
|
} catch (err) {
|
|
console.error('Dropping failed streamStatus emit:', err);
|
|
}
|
|
}
|
|
if (content.isResponse && content.id) {
|
|
const resolve = pendingRequests.current[content.id];
|
|
if (resolve) {
|
|
const result = JSON.parse(content.result || null);
|
|
// Check for WASM errors
|
|
if (
|
|
result?.error &&
|
|
typeof result.error === 'string' &&
|
|
WASM_ERRORS.some(errMsg => result.error.includes(errMsg))
|
|
) {
|
|
console.warn(
|
|
'WASM failed, switching to React Native implementation:',
|
|
result.error,
|
|
);
|
|
|
|
resetWebViewState(true, true);
|
|
forceNativeMode('WASM error');
|
|
|
|
setLocalStorageItem('FORCE_REACT_NATIVE', 'true');
|
|
}
|
|
webviewFailureCount = 0; // Reset on successful response
|
|
resolve(result);
|
|
|
|
delete pendingRequests.current[content.id];
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error handling WebView message:', err);
|
|
if (
|
|
typeof err.message === 'string' &&
|
|
// The webview reports these as e.g.
|
|
// "SECURITY: Rejected stale message: 5000ms old" — routine after a
|
|
// long background, so never spend a teardown/failure strike on it.
|
|
err.message.includes('Rejected stale message')
|
|
)
|
|
return;
|
|
webviewFailureCount++;
|
|
if (webviewFailureCount >= MAX_WEBVIEW_FAILURES) {
|
|
forceNativeMode('repeated WebView errors');
|
|
}
|
|
resetWebViewState(true, true);
|
|
}
|
|
},
|
|
[
|
|
decryptMessage,
|
|
resetWebViewState,
|
|
processQueuedRequests,
|
|
transitionWvState,
|
|
],
|
|
);
|
|
|
|
// Handle app state changes
|
|
useEffect(() => {
|
|
const appStateChanged = previousAppState.current !== appState;
|
|
const connectionChanged =
|
|
prevConnectionStatus.current !== isConnectedToTheInternet;
|
|
|
|
if ((!appStateChanged && !connectionChanged) || forceReactNativeUse) {
|
|
return; // Nothing changed
|
|
}
|
|
|
|
if (appState === 'background') {
|
|
console.log(
|
|
'App going to background - pausing timers (keeping bookkeeping so foreground can re-arm)',
|
|
);
|
|
// Clear live timers so an accumulated/stale timer can't fire a spurious
|
|
// timeout on resume, but KEEP each entry (handler/duration/originalRequest)
|
|
// so rearmOrSweepPendingRequests re-arms the in-flight request on foreground
|
|
// instead of sweeping it as an orphan. Wiping this map was the cause of
|
|
// "Request interrupted by app state change" on a background→foreground send.
|
|
Object.values(activeTimeoutsRef.current).forEach(t => {
|
|
clearTimeout(t.timeoutId);
|
|
t.timeoutId = null;
|
|
});
|
|
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
} else if (appState === 'active') {
|
|
console.log('App returned to foreground');
|
|
// clear any active timeouts to prevent timeout from switching to rn
|
|
Object.values(activeTimeoutsRef.current).forEach(t =>
|
|
clearTimeout(t.timeoutId),
|
|
);
|
|
|
|
// The loop above cleared every timer but left the pending promises intact.
|
|
// Re-arm each request (or sweep orphans whose bookkeeping was wiped on
|
|
// background) so nothing hangs forever. This runs before the offline return
|
|
// and the reset paths below, covering all of them uniformly.
|
|
rearmOrSweepPendingRequests();
|
|
|
|
// Wait for internet connection before proceeding
|
|
if (!isConnectedToTheInternet) {
|
|
console.log('Waiting for internet connection before processing...');
|
|
// Update refs so we can detect when connection comes back
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
|
|
return;
|
|
}
|
|
|
|
// Only execute if we actually transitioned to active OR connection just came back
|
|
const justBecameActive =
|
|
appStateChanged &&
|
|
(previousAppState.current === 'background' ||
|
|
previousAppState.current === 'inactive');
|
|
const connectionJustRestored =
|
|
connectionChanged && isConnectedToTheInternet;
|
|
|
|
if (justBecameActive || connectionJustRestored) {
|
|
if (!nonceVerified.current && !isResetting.current) {
|
|
console.log(
|
|
'App became active or connection restored and webview is not varified and not resetting - reloading WebView',
|
|
);
|
|
if (didGetToHomepageRef.current) {
|
|
blockAndResetWebview();
|
|
}
|
|
} else {
|
|
// sometimes the webview becomes stale, if internet connection goes away make sure to reset webview but dont clear pending events so they are handled once webview is active again
|
|
if (connectionJustRestored) {
|
|
blockAndResetWebview(false);
|
|
} else {
|
|
// Make sure to handle any events that happen during background and are within the three minute refresh timeout
|
|
if (!didGetToHomepageRef.current) {
|
|
// we need to make sure this doesn't double run before getting to the hompage otherwise we will send multiple init requests
|
|
console.log(
|
|
'Did not get to homepage yet, blocking duplicate request created by biometric login popup',
|
|
);
|
|
} else {
|
|
setTimeout(() => {
|
|
processQueuedRequests(connectionJustRestored);
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
} else {
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
}
|
|
}, [
|
|
appState,
|
|
isConnectedToTheInternet,
|
|
blockAndResetWebview,
|
|
processQueuedRequests,
|
|
rearmOrSweepPendingRequests,
|
|
]);
|
|
|
|
const initHandshake = useCallback(async () => {
|
|
try {
|
|
const privN = randomBytes(32);
|
|
const pubN = getPublicKey(privN, true); // compressed
|
|
const pubNHex = Buffer.from(pubN).toString('hex');
|
|
|
|
sessionKeyRef.current = {
|
|
privateKey: privN,
|
|
publicKey: pubNHex,
|
|
};
|
|
|
|
await sendWebViewRequestInternal('handshake:init', {
|
|
pubN: pubNHex,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Handshake failed or timed out:', error.message);
|
|
forceNativeMode('handshake failed');
|
|
const blockReset = isOnStartupRoute();
|
|
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: blockReset ? null : true,
|
|
count: prev.count + 1,
|
|
}));
|
|
// Snapshot and clear BEFORE iterating (re-entrancy safety). This site also
|
|
// previously never cleared the queue, leaving zombie requests behind.
|
|
const requestsToReject = queuedRequests.current;
|
|
queuedRequests.current = [];
|
|
requestsToReject.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Failed to process method, try again',
|
|
});
|
|
});
|
|
}
|
|
}, [sendWebViewRequestInternal]);
|
|
|
|
useEffect(() => {
|
|
async function startHandshake() {
|
|
if (!webViewRef.current) return;
|
|
if (!isWebViewReady) return;
|
|
if (!verifiedPath) return;
|
|
// blocking background init event from firing
|
|
if (appState === 'background') return;
|
|
if (didRunInit.current) return;
|
|
didRunInit.current = true;
|
|
|
|
// const androidAPI = DeviceInfo.getApiLevelSync();
|
|
// if (androidAPI == 33 || androidAPI == 34) {
|
|
// console.warn(`Skipping handshake on Android API ${androidAPI}`);
|
|
// forceReactNativeUse = true;
|
|
// return;
|
|
// }
|
|
|
|
const savedVariable = await getLocalStorageItem('FORCE_REACT_NATIVE');
|
|
|
|
if (savedVariable === 'true') {
|
|
console.log('FORCE_REACT_NATIVE is set, skipping handshake');
|
|
forceNativeMode('FORCE_REACT_NATIVE localStorage flag');
|
|
didRunHandshakeRef.current = true;
|
|
return;
|
|
}
|
|
transitionWvState(WV_STATES.HANDSHAKING, 'handshake init');
|
|
await initHandshake();
|
|
didRunHandshakeRef.current = true;
|
|
}
|
|
|
|
const debouceID = setTimeout(() => {
|
|
// forceReactNativeUse = true;
|
|
// didRunHandshakeRef.current = true
|
|
startHandshake(); //remove this and app fully uses RN
|
|
}, 250);
|
|
|
|
return () => {
|
|
if (debouceID) {
|
|
clearTimeout(debouceID);
|
|
}
|
|
};
|
|
}, [isWebViewReady, verifiedPath, initHandshake, appState]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
transitionWvState(WV_STATES.VERIFYING, 'initial verification');
|
|
try {
|
|
const { htmlPath, nonceHex, hashHex } = await verifyAndPrepareWebView(
|
|
Platform.OS === 'ios'
|
|
? require('spark-web-context')
|
|
: 'file:///android_asset/sparkContext.html',
|
|
);
|
|
|
|
expectedNonceRef.current = nonceHex;
|
|
setVerifiedPath(htmlPath);
|
|
} catch (err) {
|
|
didRunHandshakeRef.current = true;
|
|
forceNativeMode('bundle verification failed');
|
|
console.log(
|
|
'WebView bundle verification failed. Using react-native bundle',
|
|
err,
|
|
);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
globalSendWebViewRequest = sendWebViewRequestInternal;
|
|
globalPendingRequests = pendingRequests;
|
|
}, [sendWebViewRequestInternal]);
|
|
|
|
const getCustomUserAgent = useCallback(() => {
|
|
const deviceModel = getModel();
|
|
const systemVersion = getSystemVersion();
|
|
|
|
// For Android
|
|
if (Platform.OS === 'android') {
|
|
return `Mozilla/5.0 (Linux; Android ${systemVersion}; ${deviceModel}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36`;
|
|
}
|
|
|
|
// For iOS
|
|
return `Mozilla/5.0 (iPhone; CPU iPhone OS ${systemVersion.replace(
|
|
/\./g,
|
|
'_',
|
|
)} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1`;
|
|
}, []);
|
|
|
|
const handleWebViewTermination = useCallback(
|
|
reason => {
|
|
transitionWvState(WV_STATES.ERROR, reason);
|
|
|
|
if (AppState.currentState !== 'active') {
|
|
// App is backgrounded — do NOT reload now (no events should fire in background).
|
|
// Invalidate session so the foreground app-state effect sees !nonceVerified
|
|
// and triggers blockAndResetWebview() cleanly when the user returns.
|
|
// Critically: do NOT set isResetting.current = true here, as that would
|
|
// block the foreground handler's !isResetting.current guard.
|
|
console.warn(
|
|
`[WebView] Crash in background (${reason}) — deferring reload to foreground`,
|
|
);
|
|
nonceVerified.current = false;
|
|
aesKeyRef.current = null;
|
|
sessionKeyRef.current = null;
|
|
walletInitialized.current = false;
|
|
return;
|
|
}
|
|
|
|
console.warn(`[WebView] Crash while active (${reason}) — reloading now`);
|
|
blockAndResetWebview();
|
|
},
|
|
[blockAndResetWebview],
|
|
);
|
|
|
|
const providerValues = useMemo(() => {
|
|
return {
|
|
webViewRef,
|
|
sendWebViewRequest: sendWebViewRequestInternal,
|
|
fileHash,
|
|
changeSparkConnectionState,
|
|
didRunHandshakeRef,
|
|
};
|
|
}, [
|
|
webViewRef,
|
|
sendWebViewRequestInternal,
|
|
fileHash,
|
|
changeSparkConnectionState,
|
|
didRunHandshakeRef,
|
|
]);
|
|
|
|
return (
|
|
<WebViewContext.Provider value={providerValues}>
|
|
{children}
|
|
{verifiedPath && (
|
|
<WebView
|
|
key={reloadKey}
|
|
domStorageEnabled={true}
|
|
allowFileAccess={true}
|
|
allowFileAccessFromFileURLs={false}
|
|
allowUniversalAccessFromFileURLs={false}
|
|
thirdPartyCookiesEnabled={false}
|
|
sharedCookiesEnabled={false}
|
|
incognito={false}
|
|
userAgent={getCustomUserAgent()}
|
|
webviewDebuggingEnabled={false}
|
|
cacheEnabled={false}
|
|
mixedContentMode="never"
|
|
javaScriptEnabled
|
|
ref={webViewRef}
|
|
containerStyle={{ position: 'absolute', top: 1000, left: 1000 }}
|
|
source={{ uri: verifiedPath }}
|
|
originWhitelist={['file://']}
|
|
onShouldStartLoadWithRequest={request => {
|
|
return request.url === verifiedPath;
|
|
}}
|
|
onMessage={handleWebViewResponse}
|
|
onLoadStart={() => {
|
|
transitionWvState(WV_STATES.LOADING, 'onLoadStart');
|
|
didRunHandshakeRef.current = false;
|
|
}}
|
|
onLoadProgress={({ nativeEvent }) => {
|
|
if (
|
|
nativeEvent.progress === 1 &&
|
|
wvState.current === WV_STATES.LOADING
|
|
) {
|
|
transitionWvState(WV_STATES.LOADED, 'progress 100%');
|
|
}
|
|
}}
|
|
onLoadEnd={() => {
|
|
// Only transition if still in LOADING state
|
|
// (onLoadProgress might have already handled it)
|
|
if (wvState.current === WV_STATES.LOADING) {
|
|
transitionWvState(WV_STATES.LOADED, 'onLoadEnd');
|
|
}
|
|
}}
|
|
onContentProcessDidTerminate={() =>
|
|
handleWebViewTermination('iOS process terminated')
|
|
}
|
|
onRenderProcessGone={({ nativeEvent }) =>
|
|
handleWebViewTermination(
|
|
`Android renderer gone (didCrash=${nativeEvent.didCrash})`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</WebViewContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useWebView = () => React.useContext(WebViewContext);
|