Files
com.blitzwallet/context-store/webViewContext.js
T

1434 lines
49 KiB
JavaScript

import React, {
createContext,
useCallback,
useEffect,
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',
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',
setPrivacyEnabled: 'setPrivacyEnabled',
getSingleTxDetails: 'getSingleTxDetails',
createSatsInvoice: 'createSatsInvoice',
createTokensInvoice: 'createTokensInvoice',
claimSparkHodlLightningPayment: 'claimSparkHodlLightningPayment',
receiveSparkHodlLightningPayment: 'receiveSparkHodlLightningPayment',
querySparkHodlLightningPayments: 'querySparkHodlLightningPayments',
// 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 = [
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,
];
const mediumOperations = [
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,
];
const rejectIfNotConnectedToInternet = [
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,
OPERATION_TYPES.getSparkAddress,
];
export const INCOMING_SPARK_TX_NAME = 'RECEIVED_CONTACTS EVENT';
export const incomingSparkTransaction = 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 webviewFailureCount = 0;
const MAX_WEBVIEW_FAILURES = 2;
const WV_STATES = {
UNLOADED: 'unloaded',
VERIFYING: 'verifying',
LOADING: 'loading',
LOADED: 'loaded',
HANDSHAKING: 'handshaking',
READY: 'ready',
ERROR: 'error',
};
const VALID_TRANSITIONS = {
[WV_STATES.UNLOADED]: [WV_STATES.VERIFYING],
[WV_STATES.VERIFYING]: [WV_STATES.LOADING, WV_STATES.ERROR],
[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;
};
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;
};
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 webViewLoadState = useRef('unloaded');
const onLoadEndCalledRef = useRef(false);
const lastLoadEndTimeRef = useRef(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');
queuedRequests.current.forEach(({ reject }) => {
reject({
error: 'Wallet initialization failed, using React Native',
});
});
queuedRequests.current = [];
}
blockAndResetWebview();
}, [authResetkey]);
useEffect(() => {
didGetToHomepageRef.current = didGetToHomepage;
}, [didGetToHomepage]);
useEffect(() => {
internetConnectionRef.current = isConnectedToTheInternet;
}, [isConnectedToTheInternet]);
const messageRateLimiter = useRef({
count: 0,
windowStart: Date.now(),
maxPerSecond: 20,
});
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 getNextSequence = useCallback(() => {
const current = expectedSequenceRef.current;
expectedSequenceRef.current = current + 1;
return current;
}, []);
const isDuplicate = (queue, action, args) => {
return queue.some(
req =>
![
OPERATION_TYPES.getSingleTxDetails,
OPERATION_TYPES.getUserSwapHistory,
].includes(req.action) &&
req.action === action &&
JSON.stringify(req.args) === JSON.stringify(args),
);
};
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.
// Allow transition from any state during reset.
wvState.current = WV_STATES.UNLOADED;
isResetting.current = true;
setIsWebViewReady(false);
// 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}`);
// Add to queue if not already there
// IMPORTANT: We pass the original resolve function so the promise chain is preserved
if (!isDuplicate(queuedRequests.current, action, args)) {
queuedRequests.current.push({
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,
}));
if (shouldClearPending) {
Object.entries(pendingRequests.current).forEach(([id, resolve]) => {
// Call the resolve to trigger timeout cleanup
if (typeof resolve === 'function') {
resolve({
error: 'Unable to finish action, request got cleaned up.',
});
}
});
pendingRequests.current = {};
}
// Note: We already removed re-queued items from pendingRequests in the loop above,
// so no additional cleanup needed in the else branch
sessionKeyRef.current = null;
expectedSequenceRef.current = 0;
aesKeyRef.current = null;
nonceVerified.current = false;
},
[],
);
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 currentRoutes = navigationRef
.getRootState()
.routes?.map(r => r.name);
const blockReset =
currentRoutes?.includes('Splash') ||
currentRoutes?.includes('SplashReload') ||
currentRoutes?.includes('Home') ||
currentRoutes?.includes('ConnectingToNodeLoadingScreen');
setChangeSparkConnectionState(prev => ({
state: blockReset ? null : true,
count: prev.count + 1,
}));
}
}, []);
// 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 - clearing all timeouts and pending requests',
);
Object.values(activeTimeoutsRef.current).forEach(t =>
clearTimeout(t.timeoutId),
);
activeTimeoutsRef.current = {};
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),
);
// 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,
]);
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 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) {
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');
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;
}
}
console.log('receiving message from webview', content);
if (content.type === 'security:csp-violation') {
console.error('CSP VIOLATION DETECTED:', content);
resetWebViewState(true, true);
forceNativeMode('CSP violation');
return;
}
if (content.error) throw new Error(content.error);
if (content.incomingPayment) {
const data = JSON.parse(content.result);
incomingSparkTransaction.emit(
INCOMING_SPARK_TX_NAME,
data.transferId,
data.balance,
);
}
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' &&
err.message === 'Rejected stale message'
)
return;
webviewFailureCount++;
if (webviewFailureCount >= MAX_WEBVIEW_FAILURES) {
forceNativeMode('repeated WebView errors');
}
resetWebViewState(true, true);
}
},
[decryptMessage, resetWebViewState],
);
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,
);
if (!isDuplicate(queuedRequests.current, action, args)) {
queuedRequests.current.push({
id,
action,
args,
encrypt,
resolve,
reject,
});
} else {
console.log('Duplicate request ignored:', action, args);
reject('Duplicate request ignored');
}
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.some(op =>
action.toLowerCase().includes(op.toLowerCase()),
)
) {
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
if (!isDuplicate(queuedRequests.current, action, args)) {
queuedRequests.current.push({
id,
action,
args,
encrypt,
resolve,
reject,
});
} else {
console.log('Duplicate request ignored:', action, args);
reject('Duplicate request ignored');
}
}
return;
}
if (!webViewRef.current || !isWebviewReadyRef.current) {
console.log(
'WebView not ready or internet is not connected, queueing message:',
action,
webViewRef.current,
isWebviewReadyRef.current,
);
if (!isDuplicate(queuedRequests.current, action, args)) {
queuedRequests.current.push({
id,
action,
args,
encrypt,
resolve,
reject,
});
} else {
console.log('Duplicate request ignored:', action, args);
reject('Duplicate request ignored');
}
return;
}
const getTimeoutDuration = action => {
if (action === 'handshake:init') return 4000;
if (
longOperations.some(op =>
action.toLowerCase().includes(op.toLowerCase()),
)
) {
return 90000; // 90 seconds for payment operations
}
if (
mediumOperations.some(op =>
action.toLowerCase().includes(op.toLowerCase()),
)
) {
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})`,
);
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);
};
if (args.mnemonic && action !== 'initializeSparkWallet') {
args.mnemonic = sha256Hash(args.mnemonic);
}
// 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,
// }));
queuedRequests.current.forEach(({ reject }) => {
reject({
error: 'Wallet initialization failed, using React Native',
});
});
queuedRequests.current = [];
} 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
if (!isDuplicate(queuedRequests.current, action, args)) {
queuedRequests.current.push({
id,
action,
args,
encrypt,
resolve,
reject,
});
// Clean up timeout since we're queueing
if (timeoutId) clearTimeout(timeoutId);
delete activeTimeoutsRef.current[id];
return;
} else {
console.log('Duplicate request ignored:', action);
if (timeoutId) clearTimeout(timeoutId);
delete pendingRequests.current[id];
delete activeTimeoutsRef.current[id];
return reject(new Error('Duplicate request ignored'));
}
}
}
const sequence = getNextSequence();
const timestamp = Date.now();
let payload = {
id,
action,
args,
sequence,
timestamp,
};
console.log('sending message to webview', action, payload);
try {
if (encrypt && aesKeyRef.current) {
const encrypted = encryptMessage(JSON.stringify(payload));
payload = { type: 'secure:msg', encrypted };
}
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,
appState,
isConnectedToTheInternet,
],
);
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 currentRoutes = navigationRef
.getRootState()
.routes?.map(r => r.name);
const blockReset =
currentRoutes?.includes('Splash') ||
currentRoutes?.includes('SplashReload') ||
currentRoutes?.includes('Home') ||
currentRoutes?.includes('ConnectingToNodeLoadingScreen');
setChangeSparkConnectionState(prev => ({
state: blockReset ? null : true,
count: prev.count + 1,
}));
queuedRequests.current.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;
}, [sendWebViewRequestInternal]);
useEffect(() => {
webViewLoadState.current = 'unloaded';
onLoadEndCalledRef.current = false;
lastLoadEndTimeRef.current = 0;
}, [reloadKey, verifiedPath]);
const processQueuedRequests = useCallback(
async connectionJustRestored => {
// 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
queuedRequests.current.forEach(({ reject }) => {
reject({
error: 'Wallet initialization failed, using React Native',
});
});
queuedRequests.current = [];
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 (20 msgs/sec).
// Parallel dispatch via Promise.allSettled could fire 21+ 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],
);
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],
);
return (
<WebViewContext.Provider
value={{
webViewRef,
sendWebViewRequest: sendWebViewRequestInternal,
fileHash,
changeSparkConnectionState,
didRunHandshakeRef,
}}
>
{children}
{verifiedPath && (
<WebView
key={reloadKey}
domStorageEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
thirdPartyCookiesEnabled={true}
sharedCookiesEnabled={true}
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');
webViewLoadState.current = 'loading';
onLoadEndCalledRef.current = false;
didRunHandshakeRef.current = false;
}}
onLoadProgress={({ nativeEvent }) => {
if (
nativeEvent.progress === 1 &&
wvState.current === WV_STATES.LOADING
) {
transitionWvState(WV_STATES.LOADED, 'progress 100%');
webViewLoadState.current = 'loaded';
}
}}
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');
webViewLoadState.current = 'loaded';
}
}}
onContentProcessDidTerminate={() =>
handleWebViewTermination('iOS process terminated')
}
onRenderProcessGone={({ nativeEvent }) =>
handleWebViewTermination(
`Android renderer gone (didCrash=${nativeEvent.didCrash})`,
)
}
/>
)}
</WebViewContext.Provider>
);
};
export const useWebView = () => React.useContext(WebViewContext);