Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions wallets/rn_cli_wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"react-native-svg": "15.14.0",
"react-native-toast-message": "2.3.3",
"react-native-vision-camera": "4.7.2",
"react-native-webview": "^13.16.0",
"react-native-worklets": "^0.7.1",
"stream-browserify": "3.0.0",
"tronweb": "^6.1.1",
Expand Down
2 changes: 2 additions & 0 deletions wallets/rn_cli_wallet/src/hooks/usePairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function usePairing() {
includePaymentInfo: true,
});

console.log('[Pay] getPaymentOptions response:', JSON.stringify(paymentOptions, null, 2));

ModalStore.open('PaymentOptionsModal', { paymentOptions });
} catch (error: any) {
ModalStore.open('PaymentOptionsModal', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import { View, StyleSheet, ActivityIndicator } from 'react-native';
import { WebView, WebViewNavigation } from 'react-native-webview';

import { useTheme } from '@/hooks/useTheme';
import { Text } from '@/components/Text';
import { Spacing } from '@/utils/ThemeUtil';
import LogStore from '@/store/LogStore';

interface CollectDataWebViewProps {
url: string;
onComplete: () => void;
onError: (error: string) => void;
}

export function CollectDataWebView({
url,
onComplete,
onError,
}: CollectDataWebViewProps) {
const Theme = useTheme();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const isMountedRef = useRef(true);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
LogStore.log(
'WebView unmounted',
'CollectDataWebView',
'cleanup',
);
};
}, []);

const handleNavigationStateChange = useCallback(
(navState: WebViewNavigation) => {
if (!isMountedRef.current) return;

LogStore.log(
'WebView navigation',
'CollectDataWebView',
'handleNavigationStateChange',
{ url: navState.url },
);

if (
navState.url.includes('/success') ||
navState.url.includes('/complete')
) {
LogStore.log(
'Data collection completed',
'CollectDataWebView',
'handleNavigationStateChange',
);
onComplete();
}
},
[onComplete],
);

const handleError = useCallback(
(syntheticEvent: { nativeEvent: { description: string } }) => {
if (!isMountedRef.current) return;

const { description } = syntheticEvent.nativeEvent;
LogStore.error('WebView error', 'CollectDataWebView', 'handleError', {
error: description,
});
onError(description || 'Failed to load the form');
},
[onError],
);

const handleMessage = useCallback(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: postMessage validation insufficient

Severity: MEDIUM
Category: security
Tool: Claude Auto Review

Context:

  • Pattern: handleMessage only validates message structure, not origin
  • Risk: Any script in WebView can send IC_COMPLETE/IC_ERROR messages
  • Impact: Malicious scripts could bypass form completion, trigger false errors
  • Trigger: XSS in hosted form or compromised CDN serving form assets

Recommendation: Validate message origin before processing:

const handleMessage = useCallback((event: { nativeEvent: { data: string; url: string } }) => {
  if (!isMountedRef.current) return;
  
  const messageOrigin = new URL(event.nativeEvent.url).origin;
  const allowedOrigins = ['https://pay.walletconnect.com', 'https://pay.reown.com'];
  if (!allowedOrigins.includes(messageOrigin)) {
    LogStore.warn('Blocked postMessage from untrusted origin', 'CollectDataWebView', 'handleMessage', { origin: messageOrigin });
    return;
  }
  
  // ... rest of handler
}, [onComplete, onError]);

(event: { nativeEvent: { data: string } }) => {
if (!isMountedRef.current) return;

try {
const message = JSON.parse(event.nativeEvent.data) as {
type: 'IC_COMPLETE' | 'IC_ERROR';
success: boolean;
error?: string;
};
LogStore.log(
'WebView message received',
'CollectDataWebView',
'handleMessage',
{ message },
);

if (message.type === 'IC_COMPLETE' && message.success) {
onComplete();
} else if (message.type === 'IC_ERROR' || !message.success) {
onError(message.error || 'Form submission failed');
}
} catch {
LogStore.log(
'WebView message (non-JSON)',
'CollectDataWebView',
'handleMessage',
{ data: event.nativeEvent.data },
);
}
},
[onComplete, onError],
);

return (
<View style={styles.container}>
{isLoading && (
<View
style={[
styles.loadingOverlay,
{ backgroundColor: Theme['bg-primary'] },
]}
>
<ActivityIndicator size="large" color={Theme['bg-accent-primary']} />
<Text variant="md-400" color="text-secondary" style={styles.loadingText}>
Loading form...
</Text>
</View>
)}
<WebView

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: WebView security - missing origin whitelist

Severity: HIGH
Category: security
Tool: Claude Auto Review

Context:

  • Pattern: WebView accepts any URL without origin validation or whitelist
  • Risk: Malicious URL in collectData.url could load arbitrary external content
  • Impact: Phishing attacks, credential theft, or malicious script execution in payment flow
  • Trigger: If API response is compromised or MITM attack modifies collectData.url

Recommendation: Add origin whitelist to restrict allowed domains:

<WebView
  ref={webViewRef}
  source={{ uri: url }}
  originWhitelist={['https://pay.walletconnect.com', 'https://pay.reown.com']}
  // ... rest of props
/>

ref={webViewRef}
source={{ uri: url }}
style={[styles.webView, { backgroundColor: Theme['bg-primary'] }]}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)}
onNavigationStateChange={handleNavigationStateChange}
onError={handleError}
onMessage={handleMessage}
javaScriptEnabled
domStorageEnabled
startInLoadingState
scalesPageToFit
showsVerticalScrollIndicator={false}
/>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
},
webView: {
flex: 1,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
loadingText: {
marginTop: Spacing[4],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Step =
| 'loading'
| 'intro'
| 'collectData'
| 'collectDataWebView'
| 'confirm'
| 'confirming'
| 'result';
Expand Down Expand Up @@ -38,17 +39,30 @@ export function ViewWrapper({

// Determine if we should show step pills
const showStepPills =
hasCollectData && (step === 'collectData' || step === 'confirm');
hasCollectData &&
(step === 'collectData' || step === 'collectDataWebView' || step === 'confirm');
const currentPillIndex =
step === 'collectData' ? 0 : step === 'confirm' ? 1 : -1;
step === 'collectData' || step === 'collectDataWebView'
? 0
: step === 'confirm'
? 1
: -1;

const isWebViewStep = step === 'collectDataWebView';

return (
<View style={[styles.container, { backgroundColor: Theme['bg-primary'] }]}>
<View
style={[
styles.container,
isWebViewStep && styles.fullscreenContainer,
{ backgroundColor: Theme['bg-primary'] },
]}
>
{/* Header */}
<View style={styles.header}>
{/* Back Button */}
{/* Back Button - hidden in WebView step since X handles back */}
<View style={styles.headerLeft}>
{showBackButton && (
{showBackButton && !isWebViewStep && (
<Button
onPress={onBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
Expand Down Expand Up @@ -92,15 +106,16 @@ export function ViewWrapper({
)}
</View>

{/* Close Button */}
{/* Close Button - in WebView step, X goes back instead of closing */}
<View style={styles.headerRight}>
<ModalCloseButton onPress={onClose} />
<ModalCloseButton onPress={isWebViewStep ? onBack : onClose} />
</View>
</View>

{/* Animated Content */}
<Animated.View
key={step}
style={step === 'collectDataWebView' ? styles.webViewContent : undefined}
entering={FadeIn.duration(ANIMATION_DURATION)}
exiting={FadeOut.duration(ANIMATION_DURATION)}
>
Expand All @@ -118,6 +133,15 @@ const styles = StyleSheet.create({
paddingBottom: Spacing[8],
maxHeight: '90%',
},
fullscreenContainer: {
flex: 1,
maxHeight: '100%',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
webViewContent: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
Expand Down
72 changes: 63 additions & 9 deletions wallets/rn_cli_wallet/src/modals/PaymentOptionsModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import { LoadingView } from './LoadingView';
import { IntroView } from './IntroView';
import { CollectDataView } from './CollectDataView';
import { CollectDataWebView } from './CollectDataWebView';
import { ConfirmPaymentView } from './ConfirmPaymentView';
import { ResultView } from './ResultView';
import { ViewWrapper } from './ViewWrapper';
Expand All @@ -40,6 +41,10 @@ export default function PaymentOptionsModal() {
// Derived values
const hasCollectData =
paymentData?.collectData && paymentData.collectData.fields.length > 0;
// The `url` property is a new addition to the API - cast to access it
const collectDataUrl = (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: Type safety - unsafe type casting

Severity: LOW
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: Type cast to access undocumented url property: (paymentData?.collectData as { url?: string } | undefined)?.url
  • Risk: Runtime errors if API shape changes, no compile-time safety
  • Impact: Silent failures, hard-to-debug issues when property added to types
  • Trigger: When @walletconnect/pay package adds url to CollectData type

Recommendation: Extend type properly or add runtime check:

// Option 1: Type extension (preferred)
interface CollectDataWithUrl extends CollectData {
  url?: string;
}
const collectDataUrl = (paymentData?.collectData as CollectDataWithUrl | undefined)?.url;

// Option 2: Add comment explaining temporary nature
const collectDataUrl = (
  // TEMP: url not yet in @walletconnect/pay types v1.x
  paymentData?.collectData as { url?: string } | undefined
)?.url;

Summary

Functional implementation with good error handling and UX flow. Critical security concern: WebView lacks origin restrictions allowing arbitrary URLs. Add originWhitelist and message origin validation before merging.

paymentData?.collectData as { url?: string } | undefined
)?.url;

// Transition from loading to intro when data is available
useEffect(() => {
Expand Down Expand Up @@ -96,18 +101,50 @@ export default function PaymentOptionsModal() {
case 'collectData':
dispatch({ type: 'SET_STEP', payload: 'intro' });
break;
case 'collectDataWebView':
// Closing WebView means form was completed, move to confirm step
dispatch({ type: 'SET_STEP', payload: 'confirm' });
break;
case 'confirm':
dispatch({ type: 'CLEAR_SELECTED_OPTION' });
dispatch({
type: 'SET_STEP',
payload: hasCollectData ? 'collectData' : 'intro',
});
if (hasCollectData) {
// When going back from confirm, go to intro (not back to WebView)
dispatch({ type: 'SET_STEP', payload: 'intro' });
} else {
dispatch({ type: 'SET_STEP', payload: 'intro' });
}
break;
default:
onClose();
}
}, [state.step, hasCollectData, onClose]);

const handleWebViewComplete = useCallback(() => {
LogStore.log(
'WebView data collection completed',
'PaymentOptionsModal',
'handleWebViewComplete',
);
dispatch({ type: 'SET_STEP', payload: 'confirm' });
}, []);

const handleWebViewError = useCallback((error: string) => {
LogStore.error(
'WebView data collection error',
'PaymentOptionsModal',
'handleWebViewError',
{ error },
);
dispatch({
type: 'SET_RESULT',
payload: {
status: 'error',
message: error || 'Failed to complete data collection',
},
});
dispatch({ type: 'SET_STEP', payload: 'result' });
}, []);

const updateCollectedField = useCallback(
(fieldId: string, value: string, fieldType?: string) => {
const formattedValue =
Expand Down Expand Up @@ -206,11 +243,16 @@ export default function PaymentOptionsModal() {
return;
}

dispatch({
type: 'SET_STEP',
payload: hasCollectData ? 'collectData' : 'confirm',
});
}, [hasCollectData, paymentData?.options]);
if (hasCollectData) {
// Use WebView if URL is available, otherwise use native form
dispatch({
type: 'SET_STEP',
payload: collectDataUrl ? 'collectDataWebView' : 'collectData',
});
} else {
dispatch({ type: 'SET_STEP', payload: 'confirm' });
}
}, [hasCollectData, collectDataUrl, paymentData?.options]);

const handleCollectDataNext = useCallback(() => {
if (!paymentData?.collectData) {
Expand Down Expand Up @@ -522,6 +564,15 @@ export default function PaymentOptionsModal() {
/>
);

case 'collectDataWebView':
return (
<CollectDataWebView
url={collectDataUrl!}
onComplete={handleWebViewComplete}
onError={handleWebViewError}
/>
);

case 'confirm':
return (
<ConfirmPaymentView
Expand Down Expand Up @@ -567,6 +618,9 @@ export default function PaymentOptionsModal() {
handleIntroNext,
updateCollectedField,
handleCollectDataNext,
collectDataUrl,
handleWebViewComplete,
handleWebViewError,
onSelectOption,
onApprovePayment,
onClose,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type Step =
| 'loading'
| 'intro'
| 'collectData'
| 'collectDataWebView'
| 'confirm'
| 'confirming'
| 'result';
Expand Down
Loading