diff --git a/wallets/rn_cli_wallet/.env.example b/wallets/rn_cli_wallet/.env.example index 7eaf1a90d..18d734ad7 100644 --- a/wallets/rn_cli_wallet/.env.example +++ b/wallets/rn_cli_wallet/.env.example @@ -15,4 +15,12 @@ EXPO_PUBLIC_PAY_API_BASE_URL='' SENTRY_DISABLE_AUTO_UPLOAD=true # Sentry auth token -SENTRY_AUTH_TOKEN="" \ No newline at end of file +SENTRY_AUTH_TOKEN="" +# Dapp Picker POC (H2b) — all optional, defaults are hardcoded demo values +# Fee recipients declared in wc_feeTerms at session approval: +EXPO_PUBLIC_FEE_RECIPIENT_SOLANA= +EXPO_PUBLIC_FEE_RECIPIENT_EVM= +EXPO_PUBLIC_FEE_BPS= +# Base URL of the fee-demo dapp opened by Explore tiles: +EXPO_PUBLIC_PICKER_DAPP_URL= +EXPO_PUBLIC_STAKE_DAPP_URL= diff --git a/wallets/rn_cli_wallet/DAPP-PICKER-POC.md b/wallets/rn_cli_wallet/DAPP-PICKER-POC.md new file mode 100644 index 000000000..5c1430608 --- /dev/null +++ b/wallets/rn_cli_wallet/DAPP-PICKER-POC.md @@ -0,0 +1,128 @@ +# Dapp Picker POC (H2b) — wallet side + +The wallet ships an **Explore** tab: a curated directory of fee-honoring +dapps. Tapping a tile opens the dapp in a webview with a **monetized +WalletConnect session pre-established** — the user lands already connected, +`wc_feeTerms` attached, and every swap pays the wallet via the session-fees +mechanism (see `web-examples` branch `session-fees-poc`, PR #1042). + +## How it works + +1. **Explore tab** (`src/screens/Explore`) — four tiles (Jupiter, 1inch, + KyberSwap, Uniswap). All open the same Session Fees POC dapp with a + different `?aggregator=` default (the "picker illusion"); tile data is + shaped like a future registry entry (`PICKER_DAPPS` in + `src/utils/PickerUtil.ts`). First tap shows a **one-time consent alert** + ("Auto-connect to dapps opened from Explore?"), persisted in MMKV. +2. **Webview host** (`src/screens/DappBrowser`) — the dapp page acquires a + WC pairing URI and posts `{type:'wc_session_offer', uri}` via + `window.ReactNativeWebView.postMessage`; a `wc:` navigation intercept + (`onShouldStartLoadWithRequest`) is the fallback. The host registers the + pairing topic as picker-initiated and calls `walletKit.pair({uri})`. +3. **Auto-approve** (`src/hooks/useWalletKitEventsManager.ts`) — proposals + whose `pairingTopic` was registered by the webview are approved directly + (full supported namespaces + sessionProperties) when consent is granted; + any failure falls back to the normal proposal modal. **All other + proposals are untouched** — the normal modal flow applies. +4. **Fee terms** (`src/utils/PickerUtil.ts`) — every approval (modal or + auto) now carries + `wc_feeTerms = {"version":1,"feeRecipient":"9zYt…","feeRecipientEip155":"0x879d…","feeBps":50}` + — the same demo recipients the Session Fees POC collects on (Solana USDC + ATA already initialized; EVM EOA holds the KyberSwap/Uniswap fees). +5. **Connect-variant toggle** — Settings → "Explore: headless connect" + switches tiles between `?connect=headless` (AppKit Headless) and + `?connect=provider` (provider-direct). Default: headless. + +## Setup / run + +```bash +cd wallets/rn_cli_wallet +cp .env.example .env # set EXPO_PUBLIC_PROJECT_ID +yarn install +yarn ios # or: yarn android +``` + +Env (all optional, hardcoded demo defaults): `EXPO_PUBLIC_FEE_RECIPIENT_SOLANA`, +`EXPO_PUBLIC_FEE_RECIPIENT_EVM`, `EXPO_PUBLIC_FEE_BPS`, +`EXPO_PUBLIC_PICKER_DAPP_URL` (defaults to the `session-fees-poc` Vercel +preview of react-dapp-v2). + +Machine gotchas found while building this POC: +- **iOS**: if the Android NDK toolchain is in your `PATH`, its `clang` + shadows Apple's and an Expo pod script fails with + `ld64.lld: error: library not found for -lSystem`. Strip it for iOS builds: + `PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "ndk/" | paste -sd: -) yarn ios`. +- Run `pod install` (`ios/`) if xcodebuild complains the sandbox is out of + sync with Podfile.lock. + +## Demo script (~60 s) + +1. Open the wallet → **Explore** tab: four fee-sharing dapps. +2. Tap **KyberSwap** (or Jupiter for Solana) → consent alert (first time + only) → **Allow**. +3. The dapp loads **already connected**: "✓ Connected via rn_cli_wallet — + fee sharing active", aggregator preselected, fee terms card showing + 0.50% / recipient. +4. Enter an amount → Swap → the wallet's normal sign sheet appears over the + webview → sign once. +5. Fee lands on-chain in the same transaction (Solscan ATA / Arbiscan EOA + linked from the dapp's live fee-balance card). + +Tap count to connected: **1 tap** (tile) after the one-time consent — +the H2b claim holds. + +## Integration steps — for a wallet adopting this + +Generalized from this POC (≈300 lines in rn_cli_wallet; the five touched +files below are the map): + +1. **Attach fee terms at session approval** — add `wc_feeTerms` (JSON + string: `{version, feeRecipient, feeRecipientEip155, feeBps}`) to + `sessionProperties` in your `approveSession` call, for every session. + (Target architecture: skip this — terms live in the WCN registry, zero + wallet code; see web-examples `docs/session-fees/fee-splitter.md`.) + *Here: `src/utils/PickerUtil.ts` (`buildSessionProperties`) + + `src/modals/SessionProposalModal.tsx`.* +2. **Ship the directory UI** — a screen listing fee-honoring dapps. Each + entry needs only `{name, icon, url}` where `url` carries the contract + (`?wc_auto=1&connect=…` + dapp-specific presets). Hardcode for a pilot; + fetch from a registry later — the entry shape is registry-ready. + *Here: `src/screens/Explore/index.tsx`.* +3. **Webview host** — open the tile URL in a webview with two hooks: + `onMessage` parsing `{type:'wc_session_offer', uri}`, and + `onShouldStartLoadWithRequest` intercepting `wc:` URLs (return false) as + the fallback channel. On either: **record the pairing topic** (parse + `wc:@`) as picker-initiated, then `walletKit.pair({ uri })` + silently. *Here: `src/screens/DappBrowser/index.tsx`.* +4. **Scoped auto-approval** — in your `session_proposal` handler, if + `proposal.params.pairingTopic` is in the picker set AND the user granted + consent: approve directly with your full supported namespaces + the fee + terms; on any error fall back to your normal approval modal. **Never + auto-approve proposals from other sources** (QR, deep links). *Here: + `src/hooks/useWalletKitEventsManager.ts` + `PickerUtil.ts` + (`autoApprovePickerProposal`, `isPickerPairing`).* +5. **Consent UX** — a one-time "Auto-connect to dapps opened from Explore?" + prompt before the first auto-approval, persisted only when granted + ("Not now" re-asks next app start), plus a settings switch to + grant/revoke any time. Transactions keep the normal sign flow — consent + covers connections only. *Here: `Explore/index.tsx` (alert), + `src/store/SettingsStore.ts`, `src/screens/Settings/index.tsx`.* +6. **Leave signing untouched** — session requests arrive on the same + session events as always; your existing sign sheets render over the + webview. + +Security invariants to preserve when adapting: auto-approval keyed strictly +to pairing topics your own webview created; consent explicit and revocable; +fallback to the manual modal on every error path. + +## Corners cut (POC) + +- Explore tiles are a hardcoded array, not a registry; icons are colored + glyphs, not brand assets; Android tab icon reuses the connections svg. +- Auto-approval trusts the pairing topic registered by the webview — no + origin verification of the page inside the webview beyond the tile URL. +- One-time consent is an OS alert, not a designed sheet. +- Fee recipients are hardcoded demo addresses (env-overridable). +- The modal's supportedNamespaces map is duplicated in PickerUtil (kept in + sync manually) rather than refactored to one source. +- Web build (`HomeTabNavigator.web.tsx`) does not get the Explore tab. diff --git a/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts b/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts index d3a66965d..d5f840dda 100644 --- a/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts +++ b/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts @@ -14,6 +14,10 @@ import { TON_SIGNING_METHODS } from '@/constants/Ton'; import { CANTON_SIGNING_METHODS } from '@/constants/Canton'; import { approveCantonRequest } from '@/utils/CantonRequestHandlerUtil'; import { getRequestConfig } from '@/modals/requestConfig'; +import { + autoApprovePickerProposal, + isPickerPairing, +} from '@/utils/PickerUtil'; export default function useWalletKitEventsManager(initialized: boolean) { /****************************************************************************** @@ -33,6 +37,25 @@ export default function useWalletKitEventsManager(initialized: boolean) { // set the verify context so it can be displayed in the projectInfoCard SettingsStore.setCurrentRequestVerifyContext(proposal.verifyContext); + // Dapp Picker POC: proposals arriving on a pairing initiated from the + // Explore webview are auto-approved (with wc_feeTerms) when the user + // has granted the one-time consent. Everything else keeps the modal. + if ( + isPickerPairing(proposal.params.pairingTopic) && + SettingsStore.state.pickerAutoConnect + ) { + autoApprovePickerProposal(proposal).catch(e => { + LogStore.error( + (e as Error).message, + 'WalletKitEvents', + 'pickerAutoApprove', + ); + // Fall back to the normal consent modal. + ModalStore.open('SessionProposalModal', { proposal }); + }); + return; + } + const chains = getSupportedChains( proposal.params.requiredNamespaces, proposal.params.optionalNamespaces, diff --git a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx index 63dfb4c0a..1db24452b 100644 --- a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx +++ b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx @@ -14,10 +14,11 @@ import { handleRedirect } from '@/utils/LinkingUtils'; import { RequestModal } from './RequestModal'; import { getSupportedChains } from '@/utils/HelperUtil'; import { suiAddresses } from '@/utils/SuiWalletUtil'; +import { buildSessionProperties } from '@/utils/PickerUtil'; import { EIP155_CHAINS, EIP155_SIGNING_METHODS } from '@/constants/Eip155'; import { SUI_CHAINS, SUI_EVENTS, SUI_SIGNING_METHODS } from '@/constants/Sui'; import { TON_CHAINS, TON_SIGNING_METHODS } from '@/constants/Ton'; -import { getWallet, tonAddresses } from '@/utils/TonWalletUtil'; +import { tonAddresses } from '@/utils/TonWalletUtil'; import { tronAddresses } from '@/utils/TronWalletUtil'; import { TRON_CHAINS, TRON_SIGNING_METHODS } from '@/constants/Tron'; import { @@ -240,22 +241,13 @@ export default function SessionProposalModal() { }); try { - // Build session properties for TON - const sessionProperties: Record = {}; - - if (namespaces.ton) { - const tonWallet = await getWallet(); - sessionProperties.ton_getPublicKey = tonWallet.getPublicKey(); - sessionProperties.ton_getStateInit = tonWallet.getStateInit(); - } + // TON props + wc_feeTerms (Session Fees / Dapp Picker POC) + const sessionProperties = await buildSessionProperties(namespaces); const session = await walletKit.approveSession({ id: proposal.id, namespaces, - sessionProperties: - Object.keys(sessionProperties).length > 0 - ? sessionProperties - : undefined, + sessionProperties, }); haptics.requestResponse(); SettingsStore.setSessions(Object.values(walletKit.getActiveSessions())); diff --git a/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.tsx b/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.tsx index c1145fda3..b496d5f9a 100644 --- a/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.tsx +++ b/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.tsx @@ -4,6 +4,7 @@ import { Platform } from 'react-native'; import { HomeTabParamList } from '@/utils/TypesUtil'; import Wallets from '@/screens/Wallets'; import Connections from '@/screens/Connections'; +import Explore from '@/screens/Explore'; import Settings from '@/screens/Settings'; import { useTheme } from '@/hooks/useTheme'; @@ -19,6 +20,12 @@ const tabConnectionsIcon = Platform.select({ default: require('@/assets/icons/tab-connections.svg'), }); +const tabExploreIcon = Platform.select({ + ios: { sfSymbol: 'safari.fill' }, + // POC corner cut: reuse the connections svg on Android + default: require('@/assets/icons/tab-connections.svg'), +}); + const tabSettingsIcon = Platform.select({ ios: { sfSymbol: 'gearshape.fill' }, default: require('@/assets/icons/tab-settings.svg'), @@ -62,6 +69,15 @@ export function HomeTabNavigator() { sceneStyle, }} /> + tabExploreIcon, + sceneStyle, + }} + /> + ({ + headerShown: true, + title: route.params.name, + headerBackButtonDisplayMode: 'minimal', + headerTintColor: Theme['text-primary'], + headerTitleStyle: { + ...headerTitleStyle, + fontWeight: '400', + }, + })} + /> ; + +/** + * H2b bridge wrapper, injected at document start on every page load (per the + * technical design, "How wallets expose the bridge"). It gives the dapp: + * - autoConnect: the wallet-originated launch signal. Always true here + * because this webview only hosts Explore launches; a generic in-wallet + * browser must NOT set it. User consent still gates auto-approval on the + * wallet side (SettingsStore.pickerAutoConnect) — without it the dapp still + * connects, but through the normal proposal modal. + * - postMessage: one wallet-agnostic channel the dapp uses to hand back the + * pairing URI as {type:'wc_session_offer', uri}. + * The flag is a trigger, not proof of origin: pairing topics are recorded and + * only picker-initiated proposals are auto-approved (PickerUtil). + */ +const WALLET_CONNECT_HOST_BRIDGE = ` + window.walletConnectHost = { + autoConnect: true, + postMessage: function (message) { + window.ReactNativeWebView.postMessage(JSON.stringify(message)); + } + }; + true; +`; + +/** + * Dapp Picker POC (H2b): webview host for Explore-launched dapps. The dapp + * posts {type:'wc_session_offer', uri} via window.ReactNativeWebView; we pair + * silently and the proposal is auto-approved (see useWalletKitEventsManager). + * A wc: navigation intercept covers hosts/pages where postMessage fails. + */ +export default function DappBrowser({ route }: Props) { + const Theme = useTheme(); + const { url } = route.params; + const [isLoading, setIsLoading] = useState(true); + const pairedUris = useRef(new Set()); + + const pairFromDapp = useCallback(async (uri: string) => { + if (!uri.startsWith('wc:') || pairedUris.current.has(uri)) { + return; + } + pairedUris.current.add(uri); + // Mark this pairing as picker-initiated BEFORE pairing so the proposal + // handler can recognize it. + registerPickerPairing(uri); + try { + await SettingsStore.state.initPromise; + await walletKit.pair({ uri }); + } catch (e) { + LogStore.error((e as Error).message, 'DappBrowser', 'pair'); + } + }, []); + + const onMessage = useCallback( + (event: WebViewMessageEvent) => { + try { + const message = JSON.parse(event.nativeEvent.data); + if (message?.type === 'wc_session_offer' && message.uri) { + LogStore.info('wc_session_offer received', 'DappBrowser', 'onMessage'); + pairFromDapp(message.uri); + } + } catch { + // Non-JSON messages from the page are ignored. + } + }, + [pairFromDapp], + ); + + const onShouldStartLoadWithRequest = useCallback( + (request: ShouldStartLoadRequest) => { + // Fallback URI handoff: the dapp navigates to wc:… when the + // postMessage bridge is unavailable. + if (request.url.startsWith('wc:')) { + pairFromDapp(request.url); + return false; + } + return true; + }, + [pairFromDapp], + ); + + return ( + + setIsLoading(false)} + javaScriptEnabled + domStorageEnabled + style={styles.webview} + /> + {isLoading && ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + webview: { + flex: 1, + }, + loading: { + ...StyleSheet.absoluteFill as object, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/wallets/rn_cli_wallet/src/screens/Explore/index.tsx b/wallets/rn_cli_wallet/src/screens/Explore/index.tsx new file mode 100644 index 000000000..d60495a52 --- /dev/null +++ b/wallets/rn_cli_wallet/src/screens/Explore/index.tsx @@ -0,0 +1,160 @@ +import { + Alert, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { useSnapshot } from 'valtio'; + +import { Text } from '@/components/Text'; +import { useTheme } from '@/hooks/useTheme'; +import SettingsStore from '@/store/SettingsStore'; +import { + buildPickerDappUrl, + PICKER_DAPPS, + PickerDapp, +} from '@/utils/PickerUtil'; +import { HomeTabScreenProps } from '@/utils/TypesUtil'; +import { Spacing, BorderRadius } from '@/utils/ThemeUtil'; + +type Props = HomeTabScreenProps<'Explore'>; + +/** + * Dapp Picker POC (H2b): a curated directory of fee-honoring dapps. Tapping a + * tile opens the dapp in a webview with a monetized WC session + * pre-established — the user lands already connected. + */ +export default function Explore({ navigation }: Props) { + const Theme = useTheme(); + const { pickerHeadless } = useSnapshot(SettingsStore.state); + + const openDapp = (dapp: PickerDapp) => { + navigation.navigate('DappBrowser', { + url: buildPickerDappUrl(dapp), + name: dapp.name, + }); + }; + + const onTilePress = (dapp: PickerDapp) => { + if (!SettingsStore.state.pickerConsentAsked) { + Alert.alert( + 'Auto-connect to dapps opened from Explore?', + 'Dapps opened from this screen will be connected to your wallet automatically, with fee sharing enabled. You still confirm every transaction.', + [ + { + text: 'Not now', + style: 'cancel', + onPress: () => { + SettingsStore.setPickerConsent(false); + openDapp(dapp); + }, + }, + { + text: 'Allow', + onPress: () => { + SettingsStore.setPickerConsent(true); + openDapp(dapp); + }, + }, + ], + ); + return; + } + openDapp(dapp); + }; + + return ( + + + Explore + + + Fee-sharing dapps — tap to open connected ({pickerHeadless ? 'headless' : 'provider'} mode) + + + {PICKER_DAPPS.map(dapp => ( + onTilePress(dapp)} + > + + + {dapp.glyph} + + + + {dapp.name} + + + {dapp.description} + + + + Fee-sharing · {dapp.chainLabel} + + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: Spacing[4], + }, + subtitle: { + marginTop: Spacing[1], + marginBottom: Spacing[4], + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing[3], + }, + tile: { + width: '47%', + borderRadius: BorderRadius[4], + borderWidth: 1, + padding: Spacing[4], + gap: Spacing[1], + }, + glyph: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing[2], + }, + glyphText: { + color: '#FFFFFF', + }, + badge: { + alignSelf: 'flex-start', + borderRadius: BorderRadius.full, + paddingHorizontal: Spacing[2], + paddingVertical: 2, + marginTop: Spacing[2], + }, +}); diff --git a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx index 8e7f428e7..f884dc11f 100644 --- a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx @@ -19,7 +19,8 @@ import { RootStackParamList } from '@/utils/TypesUtil'; import { Button } from '@/components/Button'; export default function Settings() { - const { socketStatus, themeMode } = useSnapshot(SettingsStore.state); + const { socketStatus, themeMode, pickerHeadless, pickerAutoConnect } = + useSnapshot(SettingsStore.state); const [clientId, setClientId] = useState(''); const navigation = useNavigation>(); const Theme = useTheme(); @@ -107,6 +108,70 @@ export default function Settings() { )} + + navigation.navigate('SecretPhrase')} diff --git a/wallets/rn_cli_wallet/src/store/SettingsStore.ts b/wallets/rn_cli_wallet/src/store/SettingsStore.ts index 12e282537..e642326de 100644 --- a/wallets/rn_cli_wallet/src/store/SettingsStore.ts +++ b/wallets/rn_cli_wallet/src/store/SettingsStore.ts @@ -58,6 +58,10 @@ interface State { logs: string[]; isLinkModeRequest: boolean; themeMode: 'light' | 'dark'; + // Dapp Picker POC + pickerAutoConnect: boolean; + pickerConsentAsked: boolean; + pickerHeadless: boolean; } /** @@ -87,6 +91,9 @@ const state = proxy({ logs: [], isLinkModeRequest: false, themeMode: getInitialThemeMode(), + pickerAutoConnect: new MMKV().getBoolean('PICKER_AUTO_CONNECT') ?? false, + pickerConsentAsked: new MMKV().getBoolean('PICKER_CONSENT_ASKED') ?? false, + pickerHeadless: new MMKV().getBoolean('PICKER_HEADLESS') ?? true, }); /** @@ -140,6 +147,25 @@ const SettingsStore = { state.isLinkModeRequest = value; }, + setPickerConsent(granted: boolean) { + state.pickerAutoConnect = granted; + state.pickerConsentAsked = true; + const mmkv = new MMKV(); + mmkv.set('PICKER_AUTO_CONNECT', granted); + if (granted) { + mmkv.set('PICKER_CONSENT_ASKED', true); + } else { + // "Not now" applies to the current app run only — the consent + // alert shows again on next app start. + mmkv.delete('PICKER_CONSENT_ASKED'); + } + }, + + togglePickerHeadless() { + state.pickerHeadless = !state.pickerHeadless; + new MMKV().set('PICKER_HEADLESS', state.pickerHeadless); + }, + toggleTestNets() { state.testNets = !state.testNets; if (state.testNets) { diff --git a/wallets/rn_cli_wallet/src/utils/PickerUtil.ts b/wallets/rn_cli_wallet/src/utils/PickerUtil.ts new file mode 100644 index 000000000..4ed18e95f --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/PickerUtil.ts @@ -0,0 +1,296 @@ +import { SignClientTypes } from '@walletconnect/types'; +import { buildApprovedNamespaces } from '@walletconnect/utils'; + +import LogStore from '@/store/LogStore'; +import SettingsStore from '@/store/SettingsStore'; +import { walletKit } from '@/utils/WalletKitUtil'; +import { eip155Addresses } from '@/utils/EIP155WalletUtil'; +import { suiAddresses } from '@/utils/SuiWalletUtil'; +import { getWallet, tonAddresses } from '@/utils/TonWalletUtil'; +import { tronAddresses } from '@/utils/TronWalletUtil'; +import { cantonAddresses } from '@/utils/CantonWalletUtil'; +import { solanaAddresses } from '@/utils/SolanaWalletUtil'; +import { bitcoinAddresses } from '@/utils/BitcoinWalletUtil'; +import { EIP155_CHAINS, EIP155_SIGNING_METHODS } from '@/constants/Eip155'; +import { SUI_CHAINS, SUI_EVENTS, SUI_SIGNING_METHODS } from '@/constants/Sui'; +import { TON_CHAINS, TON_SIGNING_METHODS } from '@/constants/Ton'; +import { TRON_CHAINS, TRON_SIGNING_METHODS } from '@/constants/Tron'; +import { + CANTON_CHAINS, + CANTON_SIGNING_METHODS, + CANTON_EVENTS, +} from '@/constants/Canton'; +import { + SOLANA_CHAINS, + SOLANA_EVENTS, + SOLANA_SIGNING_METHODS, +} from '@/constants/Solana'; +import { + BIP122_CHAINS, + BIP122_EVENTS, + BIP122_SIGNING_METHODS, +} from '@/constants/Bitcoin'; +import { ENV } from '@/utils/env'; + +/** + * Dapp Picker POC (H2b): a curated Explore directory of fee-honoring dapps. + * Every tile opens the Session Fees POC dapp in a webview with a different + * default aggregator; the dapp hands back a WC pairing URI which we pair and + * auto-approve (with wc_feeTerms attached) — the user lands connected. + */ + +// Session-fees demo recipients (see web-examples SESSION-FEES-POC.md): +// - Solana: USDC ATA already initialized (Jupiter silently skips fees otherwise) +// - EVM: the address KyberSwap/Uniswap fees already accumulate on +const FEE_RECIPIENT_SOLANA = + ENV.FEE_RECIPIENT_SOLANA || '9zYtGz2nuUMe8yb9EJNNWdh2MNgMjAoWFuNgzjDm2nua'; +const FEE_RECIPIENT_EVM = + ENV.FEE_RECIPIENT_EVM || '0x879d5d9f48391b07525453F00e6690F851048E46'; +const FEE_BPS = Number(ENV.FEE_BPS || 50); + +const PICKER_DAPP_BASE_URL = + ENV.PICKER_DAPP_URL || + 'https://react-dapp-v2-git-session-fees-poc-reown-com.vercel.app'; + +// The stake dapp carries the dapp-side auto-connect changes (walletconnect-apps +// apps/portal). Until they ship to production, the default is the portal +// preview (Vercel share link — tracks the PR branch's latest deployment), so +// CI builds work without EXPO_PUBLIC_STAKE_DAPP_URL in the env file. Override +// via the env var for a local dev server. +const STAKE_DAPP_URL = + ENV.STAKE_DAPP_URL || + 'https://portal-git-feat-h2b-stake-auto-connect-poc-walletconnect.vercel.app/stake?_vercel_share=esDVgpyqZ03Gg6obtfqgsY154bMY7zZh'; + +/** + * Explore tile data — shaped like a future registry entry: a fee-honoring + * dapp per aggregator. All four point at the same POC dapp with a different + * default aggregator (the "picker illusion"). + */ +export interface PickerDapp { + id: string; + name: string; + chainLabel: string; + description: string; + color: string; + glyph: string; + /** Legacy POC tiles: default aggregator of the shared Session Fees dapp. */ + aggregator?: string; + /** + * Real dapps: opened as-is. No wc_auto/aggregator params — the + * auto-connect signal is the injected walletConnectHost.autoConnect bridge + * flag (see DappBrowser), per the H2b technical design. + */ + url?: string; +} + +export const PICKER_DAPPS: PickerDapp[] = [ + { + id: 'jupiter', + name: 'Jupiter', + chainLabel: 'Solana', + description: 'Swap SOL → USDC', + color: '#14F195', + glyph: '◎', + aggregator: 'jupiter', + }, + { + id: 'oneinch', + name: '1inch', + chainLabel: 'Arbitrum', + description: 'Swap ETH → USDC', + color: '#627EEA', + glyph: '🦄', + aggregator: 'oneinch', + }, + { + id: 'kyberswap', + name: 'KyberSwap', + chainLabel: 'Arbitrum', + description: 'Swap ETH → USDC', + color: '#31CB9E', + glyph: 'K', + aggregator: 'kyberswap', + }, + { + id: 'wc-stake', + name: 'WalletConnect', + chainLabel: 'Optimism', + description: 'Stake WCT', + color: '#0988F0', + glyph: 'W', + url: STAKE_DAPP_URL, + }, + { + id: 'uniswap', + name: 'Uniswap', + chainLabel: 'Arbitrum', + description: 'Swap ETH → USDC', + color: '#FC72FF', + glyph: '🦄', + aggregator: 'uniswap', + }, +]; + +export function buildPickerDappUrl(dapp: PickerDapp): string { + // Real dapps (e.g. WalletConnect Stake) implement the production + // auto-connect approach: the wallet injects the walletConnectHost bridge + // flag before the page loads, so the URL stays untouched. + if (dapp.url) { + return dapp.url; + } + // Legacy POC tiles keep the wc_auto=1 URL signal + aggregator/variant params. + const variant = SettingsStore.state.pickerHeadless ? 'headless' : 'provider'; + return `${PICKER_DAPP_BASE_URL}/?wc_auto=1&aggregator=${dapp.aggregator}&connect=${variant}`; +} + +/** + * Fee terms attached to every session this wallet approves — same shape the + * Session Fees POC dapp already parses (helpers/feeTerms.ts). + */ +export function buildFeeTermsProperties(): Record { + return { + wc_feeTerms: JSON.stringify({ + version: 1, + feeRecipient: FEE_RECIPIENT_SOLANA, + feeRecipientEip155: FEE_RECIPIENT_EVM, + feeBps: FEE_BPS, + }), + }; +} + +// -------- picker-initiated pairing tracking -------- +// Auto-approval applies ONLY to proposals arriving on pairings the Explore +// webview initiated; everything else keeps the normal consent modal. +const pickerPairingTopics = new Set(); + +export function parsePairingTopic(uri: string): string | undefined { + const match = uri.match(/^wc:([0-9a-fA-F]+)@/); + return match?.[1]; +} + +export function registerPickerPairing(uri: string): void { + const topic = parsePairingTopic(uri); + if (topic) { + pickerPairingTopics.add(topic); + LogStore.info('Picker pairing registered', 'PickerUtil', 'register', { + topic, + }); + } +} + +export function isPickerPairing(pairingTopic?: string): boolean { + return !!pairingTopic && pickerPairingTopics.has(pairingTopic); +} + +/** + * The wallet's full supported-namespaces map — extracted from + * SessionProposalModal so the picker auto-approve path approves exactly what + * the modal would. + */ +export function buildSupportedNamespaces() { + return { + eip155: { + chains: Object.keys(EIP155_CHAINS), + methods: Object.values(EIP155_SIGNING_METHODS), + events: ['accountsChanged', 'chainChanged'], + accounts: Object.keys(EIP155_CHAINS).map( + chain => `${chain}:${eip155Addresses[0]}`, + ), + }, + sui: { + chains: Object.keys(SUI_CHAINS), + methods: Object.values(SUI_SIGNING_METHODS), + events: Object.values(SUI_EVENTS), + accounts: Object.keys(SUI_CHAINS).map( + chain => `${chain}:${suiAddresses[0]}`, + ), + }, + ton: { + chains: Object.keys(TON_CHAINS), + methods: Object.values(TON_SIGNING_METHODS), + events: [] as string[], + accounts: Object.keys(TON_CHAINS).map( + chain => `${chain}:${tonAddresses[0]}`, + ), + }, + tron: { + chains: Object.keys(TRON_CHAINS), + methods: Object.values(TRON_SIGNING_METHODS), + events: [] as string[], + accounts: Object.keys(TRON_CHAINS).map( + chain => `${chain}:${tronAddresses[0]}`, + ), + }, + canton: { + chains: Object.keys(CANTON_CHAINS), + methods: Object.values(CANTON_SIGNING_METHODS), + events: Object.values(CANTON_EVENTS), + accounts: Object.keys(CANTON_CHAINS).map( + chain => `${chain}:${cantonAddresses[0]}`, + ), + }, + solana: { + chains: Object.keys(SOLANA_CHAINS), + methods: Object.values(SOLANA_SIGNING_METHODS), + events: Object.values(SOLANA_EVENTS), + accounts: solanaAddresses?.[0] + ? Object.keys(SOLANA_CHAINS).map( + chain => `${chain}:${solanaAddresses[0]}`, + ) + : [], + }, + bip122: { + chains: Object.keys(BIP122_CHAINS), + methods: Object.values(BIP122_SIGNING_METHODS), + events: Object.values(BIP122_EVENTS), + accounts: bitcoinAddresses?.[0] + ? Object.keys(BIP122_CHAINS).flatMap(chain => + bitcoinAddresses.map(address => `${chain}:${address}`), + ) + : [], + }, + }; +} + +/** + * Builds the sessionProperties every approval carries: TON props (existing + * behavior) + wc_feeTerms (Session Fees / Dapp Picker POC). + */ +export async function buildSessionProperties(namespaces: { + ton?: unknown; +}): Promise> { + const sessionProperties: Record = { + ...buildFeeTermsProperties(), + }; + if (namespaces.ton) { + const tonWallet = await getWallet(); + sessionProperties.ton_getPublicKey = tonWallet.getPublicKey(); + sessionProperties.ton_getStateInit = tonWallet.getStateInit(); + } + return sessionProperties; +} + +/** + * Auto-approves a picker-initiated proposal with the wallet's full supported + * namespaces + fee terms. Throws on failure — the caller falls back to the + * normal proposal modal. + */ +export async function autoApprovePickerProposal( + proposal: SignClientTypes.EventArguments['session_proposal'], +): Promise { + const namespaces = buildApprovedNamespaces({ + proposal: proposal.params, + supportedNamespaces: buildSupportedNamespaces(), + }); + const sessionProperties = await buildSessionProperties(namespaces); + await walletKit.approveSession({ + id: proposal.id, + namespaces, + sessionProperties, + }); + SettingsStore.setSessions(Object.values(walletKit.getActiveSessions())); + LogStore.info('Picker session auto-approved', 'PickerUtil', 'autoApprove', { + proposalId: proposal.id, + proposer: proposal.params.proposer?.metadata?.name, + }); +} diff --git a/wallets/rn_cli_wallet/src/utils/TypesUtil.ts b/wallets/rn_cli_wallet/src/utils/TypesUtil.ts index fc7efceee..aad4ddd55 100644 --- a/wallets/rn_cli_wallet/src/utils/TypesUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/TypesUtil.ts @@ -66,11 +66,13 @@ export type RootStackParamList = { Scan: undefined; Logs: undefined; SecretPhrase: undefined; + DappBrowser: { url: string; name: string }; }; export type HomeTabParamList = { Wallets: undefined; Connections?: { uri: string }; + Explore: undefined; Settings: undefined; }; diff --git a/wallets/rn_cli_wallet/src/utils/env.ts b/wallets/rn_cli_wallet/src/utils/env.ts index 2fb3e043e..f2534dfb5 100644 --- a/wallets/rn_cli_wallet/src/utils/env.ts +++ b/wallets/rn_cli_wallet/src/utils/env.ts @@ -17,4 +17,10 @@ export const ENV = { TEST_PRIVATE_KEY: process.env.EXPO_PUBLIC_TEST_PRIVATE_KEY, TEST_MODE: process.env.EXPO_PUBLIC_TEST_MODE, PAY_API_BASE_URL: process.env.EXPO_PUBLIC_PAY_API_BASE_URL, + // Dapp Picker POC (H2b) + FEE_RECIPIENT_SOLANA: process.env.EXPO_PUBLIC_FEE_RECIPIENT_SOLANA, + FEE_RECIPIENT_EVM: process.env.EXPO_PUBLIC_FEE_RECIPIENT_EVM, + FEE_BPS: process.env.EXPO_PUBLIC_FEE_BPS, + PICKER_DAPP_URL: process.env.EXPO_PUBLIC_PICKER_DAPP_URL, + STAKE_DAPP_URL: process.env.EXPO_PUBLIC_STAKE_DAPP_URL, };