1233 lines
41 KiB
JavaScript
1233 lines
41 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',
|
|
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',
|
|
|
|
// 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,
|
|
];
|
|
|
|
const mediumOperations = [
|
|
OPERATION_TYPES.getBalance,
|
|
OPERATION_TYPES.queryStaticL1Address,
|
|
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;
|
|
|
|
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 => {
|
|
forceReactNativeUse = value;
|
|
};
|
|
|
|
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 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 = 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.
|
|
};
|
|
|
|
const getNextSequence = useCallback(() => {
|
|
const current = expectedSequenceRef.current;
|
|
expectedSequenceRef.current = current + 1;
|
|
return current;
|
|
}, []);
|
|
|
|
const resetWebViewState = useCallback(
|
|
(
|
|
clearHandshake = false,
|
|
sparkConnectionState,
|
|
shouldClearPending = true,
|
|
) => {
|
|
if (forceReactNativeUse) return;
|
|
console.log('Resetting WebView state', {
|
|
clearHandshake,
|
|
sparkConnectionState,
|
|
shouldClearPending,
|
|
});
|
|
isResetting.current = true;
|
|
setIsWebViewReady(false);
|
|
// setVerifiedPath('');
|
|
|
|
Object.values(activeTimeoutsRef.current).forEach(t =>
|
|
clearTimeout(t.timeoutId),
|
|
);
|
|
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 = {};
|
|
}
|
|
|
|
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;
|
|
|
|
// 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
|
|
forceReactNativeUse = true;
|
|
setHandshakeComplete(false);
|
|
const currentRoutes = navigationRef
|
|
.getRootState()
|
|
.routes?.map(r => r.name);
|
|
|
|
const blockReset =
|
|
currentRoutes?.includes('Splash') ||
|
|
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');
|
|
// Clear everything to prevent timeout issues on background
|
|
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 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 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);
|
|
forceReactNativeUse = true;
|
|
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).toString('hex'),
|
|
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;
|
|
console.log('Handshake complete. Got backend public key.');
|
|
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);
|
|
forceReactNativeUse = true;
|
|
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);
|
|
forceReactNativeUse = true;
|
|
|
|
setLocalStorageItem('FORCE_REACT_NATIVE', 'true');
|
|
}
|
|
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;
|
|
resetWebViewState(true, true);
|
|
forceReactNativeUse = true;
|
|
}
|
|
},
|
|
[decryptMessage, resetWebViewState],
|
|
);
|
|
|
|
const sendWebViewRequestInternal = useCallback(
|
|
async (action, args = {}, encrypt = true) => {
|
|
return new Promise(async (resolve, reject) => {
|
|
let timeoutId = null;
|
|
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)',
|
|
});
|
|
}
|
|
const id = customUUID();
|
|
|
|
// 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 2000;
|
|
|
|
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 !== 'active') {
|
|
console.log(
|
|
`Skipping timeout for ${action} because app is not active (${appState})`,
|
|
);
|
|
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];
|
|
|
|
resetWebViewState(true, true);
|
|
forceReactNativeUse = 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,
|
|
};
|
|
|
|
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);
|
|
forceReactNativeUse = true;
|
|
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);
|
|
forceReactNativeUse = true;
|
|
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);
|
|
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];
|
|
reject(err);
|
|
}
|
|
} catch (err) {
|
|
// Clean up timeout on error
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
console.log(
|
|
'Error sending webview request from internal function',
|
|
err,
|
|
);
|
|
reject(err);
|
|
}
|
|
});
|
|
},
|
|
[
|
|
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);
|
|
forceReactNativeUse = true;
|
|
const currentRoutes = navigationRef
|
|
.getRootState()
|
|
.routes?.map(r => r.name);
|
|
|
|
const blockReset =
|
|
currentRoutes?.includes('Splash') ||
|
|
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');
|
|
forceReactNativeUse = true;
|
|
didRunHandshakeRef.current = true;
|
|
return;
|
|
}
|
|
await initHandshake();
|
|
didRunHandshakeRef.current = true;
|
|
}
|
|
|
|
const debouceID = setTimeout(() => {
|
|
// forceReactNativeUse = true;
|
|
startHandshake(); //remove this and app fully uses RN
|
|
}, 250);
|
|
|
|
return () => {
|
|
if (debouceID) {
|
|
clearTimeout(debouceID);
|
|
}
|
|
};
|
|
}, [isWebViewReady, verifiedPath, initHandshake, appState]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
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) {
|
|
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 = [];
|
|
await Promise.allSettled(
|
|
requests.map(({ id, action, args, encrypt, resolve, reject }) =>
|
|
sendWebViewRequestInternal(action, args, encrypt)
|
|
.then(resolve)
|
|
.catch(reject)
|
|
.finally(() => {
|
|
queuedRequests.current = queuedRequests.current.filter(
|
|
req => req.id !== id,
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
|
|
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`;
|
|
}, []);
|
|
|
|
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={() => {
|
|
webViewLoadState.current = 'loading';
|
|
onLoadEndCalledRef.current = false;
|
|
setIsWebViewReady(false);
|
|
}}
|
|
onLoadProgress={({ nativeEvent }) => {
|
|
if (
|
|
nativeEvent.progress === 1 &&
|
|
webViewLoadState.current === 'loading'
|
|
) {
|
|
webViewLoadState.current = 'loaded';
|
|
setIsWebViewReady(true);
|
|
console.log('WebView loaded and ready (progress: 100%)');
|
|
}
|
|
}}
|
|
onLoadEnd={() => {
|
|
setIsWebViewReady(true);
|
|
// Only set ready if we're still in loading state
|
|
// (onLoadProgress might have already handled it)
|
|
if (webViewLoadState.current === 'loading') {
|
|
webViewLoadState.current = 'loaded';
|
|
setIsWebViewReady(true);
|
|
console.log('WebView loaded and ready (onLoadEnd)');
|
|
} else if (webViewLoadState.current === 'loaded') {
|
|
console.log(
|
|
'onLoadEnd called but WebView already marked ready - ignoring',
|
|
);
|
|
}
|
|
}}
|
|
onContentProcessDidTerminate={() => {
|
|
console.warn('WebView content process terminated — reloading...');
|
|
blockAndResetWebview();
|
|
}}
|
|
/>
|
|
)}
|
|
</WebViewContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useWebView = () => React.useContext(WebViewContext);
|