diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d78edfcd66..75c187f67c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: WalletConnect support for Bitcoin (bip122), so proof-of-ownership signature requests from on-ramp partners work with existing BTC wallets + ## 4.51.0 (staging) - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. diff --git a/eslint.config.mjs b/eslint.config.mjs index eb5c9ca5d92..c540f54abc8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -309,8 +309,7 @@ export default [ 'src/components/scenes/SwapSuccessScene.tsx', 'src/components/scenes/WalletRestoreScene.tsx', - 'src/components/scenes/WcConnectionsScene.tsx', - 'src/components/scenes/WcConnectScene.tsx', + 'src/components/scenes/WcDisconnectScene.tsx', 'src/components/scenes/WebViewScene.tsx', 'src/components/services/AccountCallbackManager.tsx', @@ -330,7 +329,7 @@ export default [ 'src/components/services/SortedWalletList.ts', 'src/components/services/StatusBarManager.tsx', - 'src/components/services/WalletConnectService.tsx', + 'src/components/services/WalletLifecycle.ts', 'src/components/services/WipeLogsService.tsx', @@ -433,7 +432,7 @@ export default [ 'src/hooks/useTokenDisplayData.ts', 'src/hooks/useTransactionList.ts', 'src/hooks/useUnmount.ts', - 'src/hooks/useWalletConnect.tsx', + 'src/hooks/useWalletsSubscriber.ts', 'src/hooks/useWhyDidYouUpdate.ts', 'src/locales/intl.ts', diff --git a/src/components/modals/WcSignMessageModal.tsx b/src/components/modals/WcSignMessageModal.tsx new file mode 100644 index 00000000000..7fec9b2b90a --- /dev/null +++ b/src/components/modals/WcSignMessageModal.tsx @@ -0,0 +1,187 @@ +import type { EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' +import { Image, ScrollView, View } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import WalletConnectLogo from '../../assets/images/walletconnect-logo.png' +import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' +import { useHandler } from '../../hooks/useHandler' +import { useWalletConnect } from '../../hooks/useWalletConnect' +import { lstrings } from '../../locales/strings' +import { getCurrencyIconUris } from '../../util/CdnUris' +import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { ModalButtons } from '../buttons/ModalButtons' +import { EdgeCard } from '../cards/EdgeCard' +import { FlashNotification } from '../navigation/FlashNotification' +import { EdgeRow } from '../rows/EdgeRow' +import { Airship, showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { Alert } from '../themed/Alert' +import { ModalFooter, ModalTitle } from '../themed/ModalParts' +import { EdgeModal } from './EdgeModal' + +interface Props { + bridge: AirshipBridge + dAppIcon: string + dAppName: string + message: string + /** The address the session advertised, which is the one the dapp verifies + * the signature against. */ + publicAddress: string + requestId: number + topic: string + wallet: EdgeCurrencyWallet +} + +/** + * Approval prompt for a WalletConnect `signMessage` request, used by chains + * whose signing proves address ownership rather than moving funds (bip122 + * proof of ownership for on-ramp partners). Signing is free and spends + * nothing, so it confirms with buttons rather than the slider the + * smart-contract modal uses for value transfers. + */ +export const WcSignMessageModal: React.FC = props => { + const { + bridge, + dAppIcon, + dAppName, + message, + publicAddress, + requestId, + topic, + wallet + } = props + + const theme = useTheme() + const styles = getStyles(theme) + const walletConnect = useWalletConnect() + + const [isSigning, setIsSigning] = React.useState(false) + + const walletName = getWalletName(wallet) + const walletImageUri = getCurrencyIconUris( + wallet.currencyInfo.pluginId, + null + ).symbolImage + + const handleApprove = useHandler(async (): Promise => { + setIsSigning(true) + try { + // `signMessage` signs the literal UTF-8 message, which is what the dapp + // verifies. `signBytes` would base64-re-encode first and sign the wrong + // data. BIP137 encodes the signing address' script type in the header + // byte, which SegWit verifiers require and which collapses to the legacy + // encoding for non-SegWit addresses. + // eslint-disable-next-line @typescript-eslint/no-deprecated + const signature = await wallet.signMessage(message, { + otherParams: { publicAddress, signatureFormat: 'bip137' } + }) + await walletConnect.approveRequest(topic, requestId, { + address: publicAddress, + signature + }) + Airship.show(bridge => ( + {}} + /> + )).catch((err: unknown) => { + showError(err) + }) + bridge.resolve() + } catch (error: unknown) { + await walletConnect.rejectRequest(topic, requestId) + showError(error) + bridge.resolve() + } + }) + + const handleReject = useHandler((): void => { + walletConnect.rejectRequest(topic, requestId).catch((err: unknown) => { + showError(err) + }) + bridge.resolve() + }) + + return ( + + + {lstrings.wc_sign_message_title} + + } + > + + + + + + + + + + + + {/* The message is what the user is authorizing, so it is never + truncated: an ellipsized tail would be signed unseen. */} + + + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + title: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: theme.rem(1), + paddingTop: theme.rem(1) + }, + logo: { + height: theme.rem(2), + width: theme.rem(2), + resizeMode: 'contain', + padding: theme.rem(0.5) + }, + scrollPadding: { + paddingBottom: theme.rem(ModalFooter.bottomRem) + } +})) diff --git a/src/components/scenes/CreateWalletEditNameScene.tsx b/src/components/scenes/CreateWalletEditNameScene.tsx index 456227d9b68..277c1f20009 100644 --- a/src/components/scenes/CreateWalletEditNameScene.tsx +++ b/src/components/scenes/CreateWalletEditNameScene.tsx @@ -67,7 +67,8 @@ const CreateWalletEditNameComponent: React.FC = props => { const specialInfo = getSpecialCurrencyInfo(pluginId) const namespace = specialInfo.walletConnectV2ChainId?.namespace if (namespace === 'eip155') return lstrings.split_description_evm - if (namespace == null) return lstrings.split_description_utxo + if (namespace == null || namespace === 'bip122') + return lstrings.split_description_utxo return lstrings.split_description }, [splitSourceWalletId, currencyWallets]) diff --git a/src/components/scenes/WcConnectScene.tsx b/src/components/scenes/WcConnectScene.tsx index 53b3d9bd7cb..0c0069cf7aa 100644 --- a/src/components/scenes/WcConnectScene.tsx +++ b/src/components/scenes/WcConnectScene.tsx @@ -10,7 +10,10 @@ import { MAX_ADDRESS_CHARACTERS } from '../../constants/WalletAndCurrencyConstan import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useHandler } from '../../hooks/useHandler' import { useUnmount } from '../../hooks/useUnmount' -import { useWalletConnect } from '../../hooks/useWalletConnect' +import { + getWalletConnectAddress, + useWalletConnect +} from '../../hooks/useWalletConnect' import { useWalletName } from '../../hooks/useWalletName' import { lstrings } from '../../locales/strings' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' @@ -70,14 +73,14 @@ export const WcConnectScene = withWallet((props: Props) => { useAsyncEffect( async () => { - const r = await wallet.getReceiveAddress({ tokenId: null }) - setWalletAddress(r.publicAddress) + const address = await getWalletConnectAddress(wallet) + if (address != null) setWalletAddress(address) }, [wallet], 'WcConnectScene' ) - const handleConnect = async () => { + const handleConnect = async (): Promise => { try { await walletConnect.approveSession(proposal, wallet.id) connected.current = true @@ -87,7 +90,7 @@ export const WcConnectScene = withWallet((props: Props) => { message={lstrings.wc_confirm_return_to_browser} onPress={() => {}} /> - )).catch(e => { + )).catch((e: unknown) => { showError(e) }) navigation.navigate('wcConnections', {}) @@ -121,7 +124,7 @@ export const WcConnectScene = withWallet((props: Props) => { } }) - const renderWalletSelect = () => { + const renderWalletSelect = (): React.ReactElement => { const walletNameStr = truncateString(walletName, MAX_ADDRESS_CHARACTERS) const walletImage = ( diff --git a/src/components/scenes/WcConnectionsScene.tsx b/src/components/scenes/WcConnectionsScene.tsx index b4178d4762f..eceda0cf8eb 100644 --- a/src/components/scenes/WcConnectionsScene.tsx +++ b/src/components/scenes/WcConnectionsScene.tsx @@ -41,7 +41,7 @@ export interface WcConnectionsParams { uri?: string } -export const WcConnectionsScene = (props: Props) => { +export const WcConnectionsScene: React.FC = props => { const { navigation, route } = props const { uri } = route.params ?? {} const theme = useTheme() @@ -57,7 +57,7 @@ export const WcConnectionsScene = (props: Props) => { useMount(() => { if (uri != null) - onScanSuccess(uri).catch(err => { + onScanSuccess(uri).catch((err: unknown) => { showError(err) }) }) @@ -72,7 +72,7 @@ export const WcConnectionsScene = (props: Props) => { 'WcConnectionsScene' ) - const onScanSuccess = async (qrResult: string) => { + const onScanSuccess = async (qrResult: string): Promise => { setConnecting(true) try { let proposal = sessionProposal.get(qrResult) @@ -119,11 +119,13 @@ export const WcConnectionsScene = (props: Props) => { setConnecting(false) } - const handleActiveConnectionPress = (wcConnectionInfo: WcConnectionInfo) => { + const handleActiveConnectionPress = ( + wcConnectionInfo: WcConnectionInfo + ): void => { navigation.navigate('wcDisconnect', { wcConnectionInfo }) } - const handleNewConnectionPress = async () => { + const handleNewConnectionPress = async (): Promise => { if (checkAndShowLightBackupModal(account, navigation as NavigationBase)) { await Promise.resolve() } else { @@ -323,5 +325,14 @@ const getProposalNamespaceCompatibleEdgeTokenIds = ( throw new Error(NO_WALLETS_DAPP_REQUIREMENTS) } + // A dapp that lists its chains as optional never trips the check above, so an + // unsupported chain reaches here as an empty match set. Handing that to the + // wallet picker as `allowedAssets` would filter every wallet out and leave + // only the create-wallet rows, which reads as "Edge wants me to make a new + // wallet" instead of "Edge cannot serve this dapp". + if (edgeTokenIdMap.size === 0) { + throw new Error(NO_WALLETS_DAPP_REQUIREMENTS) + } + return [...edgeTokenIdMap.values()] } diff --git a/src/components/services/WalletConnectService.tsx b/src/components/services/WalletConnectService.tsx index 21d0037435f..18abefe8926 100644 --- a/src/components/services/WalletConnectService.tsx +++ b/src/components/services/WalletConnectService.tsx @@ -1,9 +1,10 @@ import '@walletconnect/react-native-compat' import { Core } from '@walletconnect/core' +import type { SessionTypes } from '@walletconnect/types' import Web3Wallet, { type Web3WalletTypes } from '@walletconnect/web3wallet' -import { asNumber, asObject, asString, asUnknown } from 'cleaners' -import type { EdgeAccount } from 'edge-core-js' +import { asNumber, asObject, asOptional, asString, asUnknown } from 'cleaners' +import type { EdgeAccount, EdgeCurrencyWallet } from 'edge-core-js' import * as React from 'react' import { ENV } from '../../env' @@ -11,12 +12,14 @@ import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { getAccounts, getClient, - getWalletIdFromSessionNamespace, + resolveSessionWalletId, + useWalletConnect, waitingClients, walletConnectClient } from '../../hooks/useWalletConnect' import { asLegacyTokenId } from '../../types/types' import { snooze } from '../../util/utils' +import { WcSignMessageModal } from '../modals/WcSignMessageModal' import { WcSmartContractModal } from '../modals/WcSmartContractModal' import { Airship, showError } from '../services/AirshipInstance' @@ -26,10 +29,81 @@ interface Props { account: EdgeAccount } -export const WalletConnectService = (props: Props) => { +export const WalletConnectService: React.FC = props => { const { account } = props - const handleSessionRequest = async (event: any) => { + const walletConnect = useWalletConnect() + + /** + * Serves the bip122 methods this wallet advertises when it approves a + * session. bip122 signing is not a transaction, so it never reaches the + * plugin's WalletConnect payload parser the way an EVM or Cosmos request + * does. + */ + const handleBip122Request = async ( + request: ReturnType, + session: SessionTypes.Struct, + wallet: EdgeCurrencyWallet + ): Promise => { + const { id: requestId, topic } = request + const payload = asBip122Payload(request.params.request) + + // The address this wallet advertised when the session was approved. The + // dapp verifies the signature against that exact string, so deriving a + // fresh address here would hand it a signature it cannot verify. + const sessionAccount = session.namespaces.bip122?.accounts[0] + const publicAddress = sessionAccount?.split(':')[2] + if (publicAddress == null) { + await walletConnect.rejectRequest(topic, requestId) + return + } + + switch (payload.method) { + case 'getAccountAddresses': { + // Approving the session already disclosed this address, so handing it + // back needs no second approval. + await walletConnect.approveRequest(topic, requestId, [ + { address: publicAddress } + ]) + return + } + case 'signMessage': { + const { account: requestedAccount, message } = + asBip122SignMessageParams(payload.params) + // A dapp may name the account it wants signed for. Edge holds one + // address per session, so anything else is unservable. + if (requestedAccount != null && requestedAccount !== publicAddress) { + await walletConnect.rejectRequest(topic, requestId) + return + } + + const iconUri = session.peer.metadata.icons[0] ?? '.svg' + const dAppIcon = iconUri.endsWith('.svg') + ? 'https://content.edge.app/walletConnectLogo.png' + : iconUri + await Airship.show(bridge => ( + + )) + return + } + default: { + await walletConnect.rejectRequest(topic, requestId) + } + } + } + + const handleSessionRequest = async ( + event: Web3WalletTypes.SessionRequest + ): Promise => { const client = await getClient() const request = asSessionRequest(event) @@ -38,16 +112,29 @@ export const WalletConnectService = (props: Props) => { if (session == null) return const { currencyWallets } = account const accounts = await getAccounts(currencyWallets) - const walletId = getWalletIdFromSessionNamespace( - session.namespaces, - accounts - ) - if (walletId == null) { + const walletId = await resolveSessionWalletId(account, session, accounts) + const wallet = walletId == null ? undefined : currencyWallets[walletId] + if (wallet == null) { + // Leaving the request unanswered would hang the dapp until its own + // timeout, so say no rather than dropping it. console.log('walletConnect unrecognized session request') + await walletConnect.rejectRequest(request.topic, request.id) + return + } + + const [namespace] = request.params.chainId.split(':') + if (namespace === 'bip122') { + // A malformed payload throws out of the cleaners below. Reject the + // request before surfacing the error, so the dapp is not left waiting. + await handleBip122Request(request, session, wallet).catch( + async (error: unknown) => { + await walletConnect.rejectRequest(request.topic, request.id) + throw error + } + ) return } - const wallet = currencyWallets[walletId] if (wallet.otherMethods.parseWalletConnectV2Payload == null) return try { const parsedPayload = @@ -116,8 +203,8 @@ export const WalletConnectService = (props: Props) => { } const handleSessionRequestSync = ( event: Web3WalletTypes.SessionRequest - ) => { - handleSessionRequest(event).catch(err => { + ): void => { + handleSessionRequest(event).catch((err: unknown) => { showError(err) }) } @@ -164,3 +251,11 @@ const asSessionRequest = asObject({ chainId: asString }) }) +const asBip122Payload = asObject({ + method: asString, + params: asUnknown +}) +const asBip122SignMessageParams = asObject({ + message: asString, + account: asOptional(asString) +}) diff --git a/src/constants/WalletAndCurrencyConstants.ts b/src/constants/WalletAndCurrencyConstants.ts index 4e43c3d4191..b7d2c69ec0c 100644 --- a/src/constants/WalletAndCurrencyConstants.ts +++ b/src/constants/WalletAndCurrencyConstants.ts @@ -314,7 +314,13 @@ export const SPECIAL_CURRENCY_INFO: Record = { displayIoniaRewards: true, isImportKeySupported: true, isStakingSupported: true, - unstoppableDomainsTicker: 'BTC' + unstoppableDomainsTicker: 'BTC', + walletConnectV2ChainId: { + namespace: 'bip122', + // CAIP-2 identifies a bip122 chain by the first 32 characters of its + // genesis block hash: + reference: '000000000019d6689c085ae165831e93' + } }, bitcointestnet: { hasSegwit: true, diff --git a/src/hooks/useWalletConnect.tsx b/src/hooks/useWalletConnect.tsx index f80fe78b546..e03d401722b 100644 --- a/src/hooks/useWalletConnect.tsx +++ b/src/hooks/useWalletConnect.tsx @@ -8,7 +8,7 @@ import { } from '@walletconnect/utils' import type { Web3WalletTypes } from '@walletconnect/web3wallet' import type { Web3Wallet } from '@walletconnect/web3wallet/dist/types/client' -import type { EdgeCurrencyWallet, JsonObject } from 'edge-core-js' +import type { EdgeAccount, EdgeCurrencyWallet, JsonObject } from 'edge-core-js' import * as React from 'react' import { sprintf } from 'sprintf-js' @@ -20,6 +20,11 @@ import { useSelector } from '../types/reactRedux' import type { WalletConnectChainId, WcConnectionInfo } from '../types/types' import { getWalletName } from '../util/CurrencyWalletHelpers' import { runWithTimeout, unixToLocaleDateTime } from '../util/utils' +import { + forgetSessionWallet, + lookupSessionWallet, + rememberSessionWallet +} from '../util/walletConnectSessionStore' import { useHandler } from './useHandler' import { useWatch } from './useWatch' @@ -91,11 +96,8 @@ export function useWalletConnect(): WalletConnect { const accounts = await getAccounts(currencyWallets) for (const sessionName of Object.keys(sessions)) { const session = sessions[sessionName] - const walletId = getWalletIdFromSessionNamespace( - session.namespaces, - accounts - ) - if (walletId == null) continue + const walletId = await resolveSessionWalletId(account, session, accounts) + if (walletId == null || currencyWallets[walletId] == null) continue const connection = parseConnection(session, walletId) connections.push(connection) @@ -123,9 +125,11 @@ export function useWalletConnect(): WalletConnect { resolve(proposal) }) - client.core.pairing.pair({ uri, activatePairing: true }).catch(e => { - reject(e) - }) + client.core.pairing + .pair({ uri, activatePairing: true }) + .catch((e: unknown) => { + reject(e) + }) }), 20000 ) @@ -143,17 +147,19 @@ export function useWalletConnect(): WalletConnect { .walletConnectV2ChainId if (chainId == null) return - const address = await wallet.getReceiveAddress({ tokenId: null }) - const supportedNamespaces = getSupportedNamespaces( - chainId, - address.publicAddress - ) + const address = await getWalletConnectAddress(wallet) + if (address == null) return + + const supportedNamespaces = getSupportedNamespaces(chainId, address) - // Check that we support all required methods - if (Object.keys(proposal.params.requiredNamespaces).length > 0) { - const unsupportedMethods = proposal.params.requiredNamespaces[ - chainId.namespace - ].methods.filter(method => { + // Check that we support all required methods. A dapp can require a + // namespace this wallet does not serve at all, in which case there is + // nothing to compare here and `buildApprovedNamespaces` below rejects + // the proposal on its own. + const requiredNamespace = + proposal.params.requiredNamespaces[chainId.namespace] + if (requiredNamespace != null) { + const unsupportedMethods = requiredNamespace.methods.filter(method => { return !supportedNamespaces[chainId.namespace].methods.includes( method ) @@ -165,7 +171,7 @@ export function useWalletConnect(): WalletConnect { } } - await runWithTimeout( + const session = await runWithTimeout( client.approveSession({ id: proposal.id, namespaces: buildApprovedNamespaces({ @@ -175,6 +181,7 @@ export function useWalletConnect(): WalletConnect { }), 20000 ) + await rememberSessionWallet(account, session.topic, walletId) } ) @@ -186,7 +193,7 @@ export function useWalletConnect(): WalletConnect { id: proposal.id, reason: getSdkError('USER_REJECTED') }) - .catch(e => { + .catch((e: unknown) => { console.log('walletConnect rejectSession error', String(e)) }) } @@ -207,13 +214,14 @@ export function useWalletConnect(): WalletConnect { }), 10000 ) + await forgetSessionWallet(account, topic) Airship.show(bridge => ( {}} /> - )).catch(e => { + )).catch((e: unknown) => { console.log(e) }) }) @@ -226,7 +234,7 @@ export function useWalletConnect(): WalletConnect { topic, response: { id, jsonrpc: '2.0', result } }) - .catch(e => { + .catch((e: unknown) => { console.log('walletConnect approveRequest error', String(e)) }) } @@ -243,7 +251,7 @@ export function useWalletConnect(): WalletConnect { error: getSdkError('USER_REJECTED_METHODS') } }) - .catch(e => { + .catch((e: unknown) => { console.log('walletConnect rejectRequest error', String(e)) }) }) @@ -271,13 +279,26 @@ export function useWalletConnect(): WalletConnect { } // Utilities + +/** The shape `buildApprovedNamespaces` expects, keyed by CAIP-2 namespace. */ +type SupportedNamespaces = Record< + string, + { + chains: string[] + methods: string[] + events: string[] + accounts: string[] + } +> + const getSupportedNamespaces = ( chainId: WalletConnectChainId, addr: string -) => { +): SupportedNamespaces => { const { namespace, reference } = chainId let methods: string[] + let events: string[] = ['chainChanged', 'accountsChanged'] switch (namespace) { case 'eip155': methods = [ @@ -294,6 +315,13 @@ const getSupportedNamespaces = ( case 'algorand': methods = ['algo_signTxn'] break + case 'bip122': + // Proof of ownership only. `sendTransfer` and `signPsbt` are left out + // deliberately: the UTXO plugin has no WalletConnect payload parser, so + // advertising them would accept spend requests Edge cannot serve. + methods = ['getAccountAddresses', 'signMessage'] + events = ['bip122_addressesChanged'] + break case 'cosmos': methods = ['cosmos_getAccounts', 'cosmos_signDirect', 'cosmos_signAmino'] } @@ -302,7 +330,7 @@ const getSupportedNamespaces = ( [namespace]: { chains: [`${namespace}:${reference}`], methods, - events: ['chainChanged', 'accountsChanged'], + events, accounts: [`${namespace}:${reference}:${addr}`] } } @@ -310,7 +338,7 @@ const getSupportedNamespaces = ( export const getAccounts = async ( currencyWallets: Record -) => { +): Promise> => { const map = new Map() for (const walletId of Object.keys(currencyWallets)) { const wallet = currencyWallets[walletId] @@ -318,15 +346,50 @@ export const getAccounts = async ( SPECIAL_CURRENCY_INFO[wallet.currencyInfo.pluginId].walletConnectV2ChainId if (chainId == null) continue - const address = await currencyWallets[walletId].getReceiveAddress({ - tokenId: null - }) - const account = `${chainId.namespace}:${chainId.reference}:${address.publicAddress}` + const address = await getWalletConnectAddress(wallet) + if (address == null) continue + + const account = `${chainId.namespace}:${chainId.reference}:${address}` map.set(account, walletId) } return map } +/** + * The address a WalletConnect session advertises for a wallet. Native SegWit is + * preferred where a chain offers it, since that is the address Edge shows the + * user as their receive address and therefore the one a verifier expects to see + * a proof-of-ownership signature against. Every other chain reports a single + * address and is unaffected. + */ +export const getWalletConnectAddress = async ( + wallet: EdgeCurrencyWallet +): Promise => { + const addresses = await wallet.getAddresses({ tokenId: null }) + const address = + addresses.find(address => address.addressType === 'segwitAddress') ?? + addresses.find(address => address.addressType === 'publicAddress') ?? + addresses[0] + // A wallet that reports no address cannot take part in a session. Returning + // undefined keeps it out of the account map instead of throwing, which would + // take down session listing and request routing for every other wallet. + return address?.publicAddress +} + +/** + * The wallet that approved a session: the remembered mapping first, since it + * survives receive-address rotation, then the address carried on the session. + */ +export const resolveSessionWalletId = async ( + account: EdgeAccount, + session: SessionTypes.Struct, + accounts: Map +): Promise => { + const remembered = await lookupSessionWallet(account, session.topic) + if (remembered != null) return remembered + return getWalletIdFromSessionNamespace(session.namespaces, accounts) +} + export const getWalletIdFromSessionNamespace = ( namespaces: SessionTypes.Namespaces, accounts: Map diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 54ab6b83562..e95c752befd 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1718,6 +1718,16 @@ const strings = { wc_scan_modal_text_modal_hint: 'Wallet Connect URI', wc_unsupported_version: 'Unsupported WalletConnect version', wc_no_wallets_dapp_requirements: 'No wallets meet dapp requirements', + wc_sign_message_title: 'Signature Request', + wc_sign_message_warning_text: + 'Signing proves you control this address. It moves no funds, but only approve it for an application you trust.', + wc_sign_message_dapp: 'DApp', + wc_sign_message_wallet: 'Wallet', + wc_sign_message_address: 'Address', + wc_sign_message_message: 'Message', + wc_sign_message_approve_button: 'Sign', + wc_sign_message_reject_button: 'Reject', + wc_sign_message_confirmed: 'Signature sent', // New Token TermsAgreement Modal token_agreement_modal_title: '%s Needed to Send Tokens', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2f05699d6e4..cbd1e41f8e6 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1347,6 +1347,15 @@ "wc_scan_modal_text_modal_hint": "Wallet Connect URI", "wc_unsupported_version": "Unsupported WalletConnect version", "wc_no_wallets_dapp_requirements": "No wallets meet dapp requirements", + "wc_sign_message_title": "Signature Request", + "wc_sign_message_warning_text": "Signing proves you control this address. It moves no funds, but only approve it for an application you trust.", + "wc_sign_message_dapp": "DApp", + "wc_sign_message_wallet": "Wallet", + "wc_sign_message_address": "Address", + "wc_sign_message_message": "Message", + "wc_sign_message_approve_button": "Sign", + "wc_sign_message_reject_button": "Reject", + "wc_sign_message_confirmed": "Signature sent", "token_agreement_modal_title": "%s Needed to Send Tokens", "token_agreement_modal_message": "%1$s is required to pay the mining fees when sending tokens. The associated %1$s wallet must contain a sufficient amount of funds.\n\nIf you do not have %1$s, you can acquire it within %2$s using the Buy or Exchange function.", "confirm_continue_modal_body": "Please confirm your understanding below:", diff --git a/src/types/types.ts b/src/types/types.ts index 2ec12aa32be..98aa9b29c73 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -336,7 +336,7 @@ export interface WcConnectionInfo { icon: string } export interface WalletConnectChainId { - namespace: 'algorand' | 'cosmos' | 'eip155' + namespace: 'algorand' | 'bip122' | 'cosmos' | 'eip155' reference: string } export interface wcGetConnection { diff --git a/src/util/walletConnectSessionStore.ts b/src/util/walletConnectSessionStore.ts new file mode 100644 index 00000000000..5e795b53e69 --- /dev/null +++ b/src/util/walletConnectSessionStore.ts @@ -0,0 +1,43 @@ +import type { EdgeAccount } from 'edge-core-js' + +const STORE_ID = 'walletConnectSessions' + +/** + * Remembers which wallet approved a WalletConnect session. + * + * A session's namespace carries the wallet's address, and that is the only + * identifier the dapp ever sees. Resolving an incoming request back to a wallet + * by that address alone is fragile on chains whose receive address rotates: + * once a UTXO wallet's fresh address advances, the address on the session no + * longer matches anything the account currently reports, and the request would + * be dropped. This store keeps the topic-to-wallet mapping we already knew at + * approval time, so the lookup survives rotation. + */ +export const rememberSessionWallet = async ( + account: EdgeAccount, + topic: string, + walletId: string +): Promise => { + await account.dataStore.setItem(STORE_ID, topic, walletId) +} + +export const lookupSessionWallet = async ( + account: EdgeAccount, + topic: string +): Promise => { + try { + return await account.dataStore.getItem(STORE_ID, topic) + } catch { + // A topic we never stored, or a store that has not been created yet. + return undefined + } +} + +export const forgetSessionWallet = async ( + account: EdgeAccount, + topic: string +): Promise => { + await account.dataStore.deleteItem(STORE_ID, topic).catch(() => { + // Nothing to forget. + }) +}