From e8696698fffbb4b27f7e0f69a5df1dc3f375b0a2 Mon Sep 17 00:00:00 2001 From: jorbuedo Date: Sat, 8 Nov 2025 14:58:38 +0100 Subject: [PATCH 001/335] initial implementation --- mobile/packages/p2p-communication/README.md | 526 +++++++++++++++ .../packages/p2p-communication/constants.ts | 34 + .../core/connection-manager.test.ts | 74 +++ .../core/connection-manager.ts | 140 ++++ .../core/multi-connection-manager.ts | 161 +++++ .../p2p-communication/core/peer-connection.ts | 620 ++++++++++++++++++ .../core/signaling-client.ts | 123 ++++ .../core/wallet-communication.test.ts | 84 +++ .../core/wallet-communication.ts | 374 +++++++++++ .../hooks/use-peer-connection.ts | 142 ++++ .../hooks/use-wallet-connection.ts | 115 ++++ .../hooks/use-wallet-messages.ts | 163 +++++ mobile/packages/p2p-communication/index.ts | 68 ++ mobile/packages/p2p-communication/types.ts | 102 +++ .../p2p-communication/utils/id-utils.test.ts | 77 +++ .../p2p-communication/utils/id-utils.ts | 90 +++ .../p2p-communication/utils/logger.ts | 23 + .../utils/message-utils.test.ts | 125 ++++ .../p2p-communication/utils/message-utils.ts | 56 ++ mobile/tsconfig.json | 3 + scripts/build-packages.sh | 2 +- scripts/cleanup-packages.sh | 1 + scripts/create-src-symlinks.sh | 1 + scripts/move-package-configs.sh | 1 + .../p2p-communication/.dependency-cruiser.js | 410 ++++++++++++ .../packages/p2p-communication/.eslintignore | 11 + .../packages/p2p-communication/.eslintrc.json | 14 + scripts/packages/p2p-communication/.gitignore | 72 ++ .../packages/p2p-communication/.prettierrc | 10 + .../p2p-communication/.release-it.json | 18 + .../p2p-communication/babel.config.js | 4 + .../packages/p2p-communication/bob.config.js | 16 + .../p2p-communication/commitlint.config.js | 4 + .../packages/p2p-communication/jest.config.js | 39 ++ .../packages/p2p-communication/jest.setup.js | 6 + .../packages/p2p-communication/package.json | 113 ++++ .../p2p-communication/scripts/flowgen.sh | 4 + scripts/packages/p2p-communication/src | 1 + .../p2p-communication/tsconfig.build.json | 6 + .../packages/p2p-communication/tsconfig.json | 29 + scripts/prune-pkgs.sh | 1 + scripts/standardize-versions.js | 2 + 42 files changed, 3864 insertions(+), 1 deletion(-) create mode 100644 mobile/packages/p2p-communication/README.md create mode 100644 mobile/packages/p2p-communication/constants.ts create mode 100644 mobile/packages/p2p-communication/core/connection-manager.test.ts create mode 100644 mobile/packages/p2p-communication/core/connection-manager.ts create mode 100644 mobile/packages/p2p-communication/core/multi-connection-manager.ts create mode 100644 mobile/packages/p2p-communication/core/peer-connection.ts create mode 100644 mobile/packages/p2p-communication/core/signaling-client.ts create mode 100644 mobile/packages/p2p-communication/core/wallet-communication.test.ts create mode 100644 mobile/packages/p2p-communication/core/wallet-communication.ts create mode 100644 mobile/packages/p2p-communication/hooks/use-peer-connection.ts create mode 100644 mobile/packages/p2p-communication/hooks/use-wallet-connection.ts create mode 100644 mobile/packages/p2p-communication/hooks/use-wallet-messages.ts create mode 100644 mobile/packages/p2p-communication/index.ts create mode 100644 mobile/packages/p2p-communication/types.ts create mode 100644 mobile/packages/p2p-communication/utils/id-utils.test.ts create mode 100644 mobile/packages/p2p-communication/utils/id-utils.ts create mode 100644 mobile/packages/p2p-communication/utils/logger.ts create mode 100644 mobile/packages/p2p-communication/utils/message-utils.test.ts create mode 100644 mobile/packages/p2p-communication/utils/message-utils.ts create mode 100644 scripts/packages/p2p-communication/.dependency-cruiser.js create mode 100644 scripts/packages/p2p-communication/.eslintignore create mode 100644 scripts/packages/p2p-communication/.eslintrc.json create mode 100644 scripts/packages/p2p-communication/.gitignore create mode 100644 scripts/packages/p2p-communication/.prettierrc create mode 100644 scripts/packages/p2p-communication/.release-it.json create mode 100644 scripts/packages/p2p-communication/babel.config.js create mode 100644 scripts/packages/p2p-communication/bob.config.js create mode 100644 scripts/packages/p2p-communication/commitlint.config.js create mode 100644 scripts/packages/p2p-communication/jest.config.js create mode 100644 scripts/packages/p2p-communication/jest.setup.js create mode 100644 scripts/packages/p2p-communication/package.json create mode 100644 scripts/packages/p2p-communication/scripts/flowgen.sh create mode 120000 scripts/packages/p2p-communication/src create mode 100644 scripts/packages/p2p-communication/tsconfig.build.json create mode 100644 scripts/packages/p2p-communication/tsconfig.json diff --git a/mobile/packages/p2p-communication/README.md b/mobile/packages/p2p-communication/README.md new file mode 100644 index 0000000000..9e9b163ec1 --- /dev/null +++ b/mobile/packages/p2p-communication/README.md @@ -0,0 +1,526 @@ +# @yoroi/p2p-communication + +P2P communication package for Cardano wallet connections using WebRTC. Supports both React Native and browser environments. Enables dApp-to-wallet and wallet-to-wallet communication for complex multi-party transactions. + +## Features + +- WebRTC-based peer-to-peer communication +- Cross-platform support (React Native and Browser) +- Storage abstraction via dependency injection +- Heartbeat system for connection monitoring +- TypeScript with strict typing +- Functional programming approach (no classes) +- **Wallet-to-wallet connections** for multi-party transactions +- **Multiple simultaneous connections** support + +## Installation + +For React Native: +```bash +npm install react-native-webrtc +``` + +The package uses `react-native-webrtc` as a peer dependency for React Native environments. In browser environments, native WebRTC APIs are used. + +## Usage + +### dApp to Wallet Connection (Traditional) + +#### React Native Example + +```typescript +import {connectionManagerMaker, WebRTCAdapter} from '@yoroi/p2p-communication' +import {BaseStorage} from '@yoroi/types' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} from 'react-native-webrtc' + +// Create BaseStorage adapter (apps should provide their own) +const storage: BaseStorage = { + getItem: async (key: string) => await AsyncStorage.getItem(key), + setItem: async (key: string, value: string) => await AsyncStorage.setItem(key, value), + removeItem: async (key: string) => await AsyncStorage.removeItem(key), +} + +// Create WebRTC adapter from react-native-webrtc +const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} + +// Create connection manager (dApp side - isWallet defaults to false) +const connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: { + signalingUrl: 'wss://signaling-server.com', + }, +}) +``` + +#### Browser Example + +```typescript +import {connectionManagerMaker, WebRTCAdapter} from '@yoroi/p2p-communication' +import {BaseStorage} from '@yoroi/types' + +// Create BaseStorage adapter from localStorage +const storage: BaseStorage = { + getItem: async (key: string) => localStorage.getItem(key), + setItem: async (key: string, value: string) => localStorage.setItem(key, value), + removeItem: async (key: string) => localStorage.removeItem(key), +} + +// Create WebRTC adapter from native browser APIs +const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection: window.RTCPeerConnection, + RTCSessionDescription: window.RTCSessionDescription, + RTCIceCandidate: window.RTCIceCandidate, +} + +// Create connection manager (dApp side) +const connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: { + signalingUrl: 'wss://signaling-server.com', + }, +}) + +// Initialize +await connectionManager.initialize() + +// Get peer connection and wallet communication +const peerConnection = connectionManager.getPeerConnection() +const walletCommunication = connectionManager.getWalletCommunication() +``` + +### Wallet to Wallet Connection (New) + +```typescript +import {connectionManagerMaker, WebRTCAdapter} from '@yoroi/p2p-communication' +import {BaseStorage} from '@yoroi/types' +import { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} from 'react-native-webrtc' + +// Create storage adapter (example for React Native) +const storage: BaseStorage = { + getItem: async (key: string) => await AsyncStorage.getItem(key), + setItem: async (key: string, value: string) => await AsyncStorage.setItem(key, value), + removeItem: async (key: string) => await AsyncStorage.removeItem(key), +} + +// Create WebRTC adapter from react-native-webrtc +const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} + +// Create connection manager (wallet side) +const connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: { + signalingUrl: 'wss://signaling-server.com', + }, + isWallet: true, // Important: mark as wallet +}) + +// Initialize +await connectionManager.initialize() + +// Get peer connection +const peerConnection = connectionManager.getPeerConnection() + +// Connect to another wallet using their peer ID (from QR code/deeplink) +const targetPeerId = 'wallet-abc123-xyz789' +await peerConnection?.connectToPeer(targetPeerId) + +// Listen for connection established +peerConnection?.on('peerConnected', (peerId) => { + console.log('Connected to wallet:', peerId) +}) + +// Send messages +peerConnection?.send({type: 'request', method: 'signTx', data: txData}) +``` + +### Multiple Wallet Connections + +For scenarios requiring multiple wallets to connect simultaneously (e.g., multi-party transactions): + +```typescript +import {multiConnectionManagerMaker} from '@yoroi/p2p-communication' +import {BaseStorage, WebRTCAdapter} from '@yoroi/types' +import { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} from 'react-native-webrtc' + +// Create storage adapter +const storage: BaseStorage = { + getItem: async (key: string) => await AsyncStorage.getItem(key), + setItem: async (key: string, value: string) => await AsyncStorage.setItem(key, value), + removeItem: async (key: string) => await AsyncStorage.removeItem(key), +} + +// Create WebRTC adapter from react-native-webrtc +const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} + +// Create multi-connection manager +const multiManager = multiConnectionManagerMaker(storage, webrtcAdapter, { + signalingUrl: 'wss://signaling-server.com', +}) + +// Initialize and get your peer ID +const myPeerId = await multiManager.initialize() +console.log('My peer ID:', myPeerId) + +// Connect to multiple wallets +const wallet1 = await multiManager.connectToPeer('wallet-abc123') +const wallet2 = await multiManager.connectToPeer('wallet-xyz789') +const wallet3 = await multiManager.connectToPeer('wallet-def456') + +// Send to specific wallet +wallet1.send({type: 'request', method: 'signTx', data: txData}) + +// Get all active connections +const allConnections = multiManager.getAllConnections() +allConnections.forEach(({peerId, connection}) => { + console.log(`Connected to: ${peerId}`) +}) + +// Disconnect from a specific wallet +multiManager.disconnectFromPeer('wallet-abc123') +``` + +### Browser Extension Example + +```typescript +import {connectionManagerMaker} from '@yoroi/p2p-communication' +import {BaseStorage} from '@yoroi/types' +import {WebRTCAdapter} from '@yoroi/p2p-communication' + +// Create BaseStorage adapter from chrome.storage (apps should provide their own) +const storage: BaseStorage = { + getItem: async (key: string) => { + const result = await chrome.storage.local.get(key) + return typeof result[key] === 'string' ? result[key] : null + }, + setItem: async (key: string, value: string) => { + await chrome.storage.local.set({[key]: value}) + }, + removeItem: async (key: string) => { + await chrome.storage.local.remove(key) + }, +} + +// Create WebRTC adapter from native browser APIs +const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection: window.RTCPeerConnection, + RTCSessionDescription: window.RTCSessionDescription, + RTCIceCandidate: window.RTCIceCandidate, +} + +// Create connection manager +const connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: { + signalingUrl: 'wss://signaling-server.com', + }, + isWallet: true, // For wallet extension +}) + +// Initialize +await connectionManager.initialize() +``` + +### Using React Hooks + +```typescript +import {usePeerConnection, useWalletConnection, useWalletMessages} from '@yoroi/p2p-communication' +import {BaseStorage, WebRTCAdapter} from '@yoroi/types' +import {useEffect} from 'react' +import { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, +} from 'react-native-webrtc' + +function MyComponent() { + const storage: BaseStorage = { + // ... storage implementation + } + + const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, + } + + const connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: {signalingUrl: 'wss://...'}, + isWallet: true, + }) + const peerConnection = connectionManager.getPeerConnection() + const walletCommunication = connectionManager.getWalletCommunication() + + const peerState = usePeerConnection(peerConnection) + const walletState = useWalletConnection(peerState, walletCommunication) + const messages = useWalletMessages( + walletState.connected, + walletState.setStatus, + walletCommunication, + ) + + useEffect(() => { + connectionManager.initialize() + return () => { + connectionManager.cleanup() + } + }, []) + + return ( +
+

Peer ID: {peerState.peerId}

+

Status: {peerState.status}

+

Connected: {walletState.connected ? 'Yes' : 'No'}

+ {peerConnection && ( +

Connected to: {peerConnection.getConnectedPeerId() ?? 'None'}

+ )} +
+ ) +} +``` + +## Storage and WebRTC Adapters + +The package requires two adapters to be provided by the app: + +### Storage Adapter + +Apps should provide a `BaseStorage` implementation from `@yoroi/types` based on their environment: + +- **React Native**: Create an adapter from `@react-native-async-storage/async-storage` +- **Browser**: Create an adapter from `localStorage` +- **Browser Extensions**: Create an adapter from `chrome.storage.local` + +### WebRTC Adapter + +Apps should provide a `WebRTCAdapter` implementation from `@yoroi/p2p-communication` based on their environment: + +- **React Native**: Pass exports from `react-native-webrtc`: + ```typescript + import {RTCPeerConnection, RTCSessionDescription, RTCIceCandidate} from 'react-native-webrtc' + + const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, + } + ``` + +- **Browser**: Pass native WebRTC APIs: + ```typescript + const webrtcAdapter: WebRTCAdapter = { + RTCPeerConnection: window.RTCPeerConnection, + RTCSessionDescription: window.RTCSessionDescription, + RTCIceCandidate: window.RTCIceCandidate, + } + ``` + +Both adapters use dependency injection, allowing the package to work across different environments without hardcoded dependencies. + +## API Reference + +### ConnectionManager + +Manages the lifecycle of peer connections and wallet communication. + +```typescript +type ConnectionManager = { + initialize: () => Promise + cleanup: () => void + getPeerConnection: () => PeerConnection | null + getWalletCommunication: () => WalletCommunication | null + isInitialized: () => boolean +} +``` + +### MultiConnectionManager + +Manages multiple simultaneous peer connections for wallet-to-wallet scenarios. + +```typescript +type MultiConnectionManager = { + initialize: () => Promise // Returns your peer ID + connectToPeer: (targetPeerId: string) => Promise + getConnection: (peerId: string) => PeerConnection | null + getAllConnections: () => ReadonlyArray<{ + readonly peerId: string + readonly connection: PeerConnection + }> + disconnectFromPeer: (peerId: string) => void + cleanup: () => void + getMyPeerId: () => string +} +``` + +### PeerConnection + +Manages WebRTC peer connections. + +```typescript +type PeerConnection = { + init: () => Promise // Returns peer ID + connectToPeer: (targetPeerId: string) => Promise // NEW: Initiate wallet-to-wallet connection + send: (data: unknown) => boolean + reconnect: () => void + destroy: () => void + on: (event: keyof EventListener, callback: EventCallback) => void + off: (event: keyof EventListener, callback: EventCallback) => void + getPeerId: () => string + getStatus: () => ConnectionStatus + isReady: () => boolean + getConnectedPeerId: () => string | null // NEW: Get currently connected peer ID +} +``` + +**Events:** +- `open`: Fired when peer connection is ready +- `connection`: Fired when data channel is established +- `peerConnected`: Fired when a specific peer connects (wallet-to-wallet) +- `data`: Fired when data is received +- `error`: Fired on errors +- `close`: Fired when connection closes +- `disconnected`: Fired when connection is disconnected +- `connectionClosed`: Fired when data channel closes + +### WalletCommunication + +High-level wallet communication service. + +```typescript +type WalletCommunication = { + sendMessage: (message: string) => boolean + callWalletFunction: (method: string, data?: unknown) => boolean + signTransaction: (txData?: unknown) => boolean + disconnect: () => boolean + on: (event: 'message' | 'connect' | 'disconnect' | 'error', callback: EventCallback) => void + off: (event: 'message' | 'connect' | 'disconnect' | 'error', callback: EventCallback) => void + isConnected: () => boolean + getWalletId: () => string | null +} +``` + +## Message Protocol + +The package uses a standardized message protocol: + +### Request +```typescript +{ + type: 'request', + method: string, + data?: unknown, + id: number +} +``` + +### Response +```typescript +{ + type: 'response', + method: string, + data?: unknown, + error?: string, + id: number +} +``` + +### Heartbeat +```typescript +{ + type: 'heartbeat', + action: 'ping' | 'pong', + timestamp: number, + received?: number +} +``` + +## Connection Modes + +### dApp → Wallet (Traditional) + +- **dApp side**: Sets `isWallet: false` (or omits it, defaults to false) +- **Wallet side**: Sets `isWallet: true` +- dApp creates data channel and initiates connection +- Wallet waits for incoming connection +- **Backward compatible** with existing implementations + +### Wallet → Wallet (New) + +- Both sides set `isWallet: true` +- Initiating wallet calls `connectToPeer(targetPeerId)` +- Target wallet receives connection automatically +- Supports multiple simultaneous connections via `MultiConnectionManager` + +## Signaling Server Requirements + +The signaling server must support: + +1. **Peer ID registration**: Receives `peer-id` messages when peers connect +2. **Message routing**: Routes messages to specific peers using `targetPeerId` when present +3. **Broadcast support**: Routes messages without `targetPeerId` to all connected peers (backward compatibility) + +Signaling message format: +```typescript +{ + type: 'offer' | 'answer' | 'ice-candidate' | 'peer-id', + peerId: string, + targetPeerId?: string, // Optional: for wallet-to-wallet connections + sdp?: string, // For offer/answer + candidate?: unknown // For ice-candidate +} +``` + +## Platform Support + +The package works in any environment where you can provide: +- A `BaseStorage` implementation (for persistent ID storage) +- A `WebRTCAdapter` implementation (for WebRTC APIs) + +This includes: +- **React Native**: Provide `react-native-webrtc` exports as the adapter +- **Browser**: Provide native WebRTC APIs as the adapter +- **Browser Extension**: Provide native WebRTC APIs as the adapter + +## Peer ID Sharing + +For wallet-to-wallet connections, peer IDs can be shared via: +- QR codes +- Deep links +- Manual entry +- Any other mechanism your app supports + +The peer ID format is: `wallet-{deviceHash}-{installTime}` or `dapp-{deviceHash}-{timestamp}` + +## License + +See main project license. diff --git a/mobile/packages/p2p-communication/constants.ts b/mobile/packages/p2p-communication/constants.ts new file mode 100644 index 0000000000..5499b649b5 --- /dev/null +++ b/mobile/packages/p2p-communication/constants.ts @@ -0,0 +1,34 @@ +/** + * P2P Communication Constants + */ + +export const MESSAGE_TYPES = { + REQUEST: 'request', + RESPONSE: 'response', +} as const + +export const WALLET_METHODS = { + SIGN_TX: 'signTx', +} as const + +export const STORAGE_KEYS = { + DAPP_PEER_ID: 'dapp-peer-id', + WALLET_PEER_ID: 'wallet-peer-id', + WALLET_INSTALL_TIME: 'wallet-install-time', +} as const + +export const CONNECTION_CONSTANTS = { + MAX_RECONNECT_ATTEMPTS: 100, + RECONNECT_INTERVAL: 200, + CONNECTION_TIMEOUT: 20000, + HEARTBEAT_INTERVAL: 5000, + HEARTBEAT_TIMEOUT: 3000, +} as const + +export const PEER_CONFIG = { + debug: 1, + iceServers: [ + {urls: 'stun:stun.l.google.com:19302'}, + {urls: 'stun:stun1.l.google.com:19302'}, + ], +} as const diff --git a/mobile/packages/p2p-communication/core/connection-manager.test.ts b/mobile/packages/p2p-communication/core/connection-manager.test.ts new file mode 100644 index 0000000000..beed30e108 --- /dev/null +++ b/mobile/packages/p2p-communication/core/connection-manager.test.ts @@ -0,0 +1,74 @@ +import {BaseStorage} from '@yoroi/types' + +import {WebRTCAdapter} from '../types' +import {connectionManagerMaker} from './connection-manager' + +describe('connectionManagerMaker', () => { + const createMockStorage = (): BaseStorage => { + const storage: Record = {} + + return { + getItem: async (key: string): Promise => { + return storage[key] ?? null + }, + setItem: async (key: string, value: string): Promise => { + storage[key] = value + }, + removeItem: async (key: string): Promise => { + delete storage[key] + }, + } + } + + const createMockWebRTCAdapter = (): WebRTCAdapter => { + // Mock WebRTC adapter for testing + return { + RTCPeerConnection: class { + constructor() {} + } as unknown as new ( + configuration?: RTCConfiguration, + ) => RTCPeerConnection, + RTCSessionDescription: class { + constructor() {} + } as unknown as new ( + descriptionInitDict?: RTCSessionDescriptionInit, + ) => RTCSessionDescription, + RTCIceCandidate: class { + constructor() {} + } as unknown as new ( + candidateInitDict?: RTCIceCandidateInit, + ) => RTCIceCandidate, + } + } + + it('should create a connection manager', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const manager = connectionManagerMaker({storage, webrtcAdapter}) + + expect(manager).toBeDefined() + expect(manager.initialize).toBeDefined() + expect(manager.cleanup).toBeDefined() + expect(manager.getPeerConnection).toBeDefined() + expect(manager.getWalletCommunication).toBeDefined() + expect(manager.isInitialized).toBeDefined() + }) + + it('should start uninitialized', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const manager = connectionManagerMaker({storage, webrtcAdapter}) + + expect(manager.isInitialized()).toBe(false) + expect(manager.getPeerConnection()).toBeNull() + expect(manager.getWalletCommunication()).toBeNull() + }) + + it('should cleanup without errors when not initialized', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const manager = connectionManagerMaker({storage, webrtcAdapter}) + + expect(() => manager.cleanup()).not.toThrow() + }) +}) diff --git a/mobile/packages/p2p-communication/core/connection-manager.ts b/mobile/packages/p2p-communication/core/connection-manager.ts new file mode 100644 index 0000000000..cdb195b879 --- /dev/null +++ b/mobile/packages/p2p-communication/core/connection-manager.ts @@ -0,0 +1,140 @@ +import {freeze} from 'immer' + +import {ConnectionManagerConfig} from '../types' +import {getLogger} from '../utils/logger' +import {PeerConnection, peerConnectionMaker} from './peer-connection' +import { + WalletCommunication, + walletCommunicationMaker, +} from './wallet-communication' + +/** + * Connection Manager State + */ +type ConnectionManagerState = { + readonly initialized: boolean + readonly initPromise: Promise | null + readonly peerConnection: PeerConnection | null + readonly walletCommunication: WalletCommunication | null +} + +/** + * Connection Manager API + */ +export type ConnectionManager = { + readonly initialize: () => Promise + readonly cleanup: () => void + readonly getPeerConnection: () => PeerConnection | null + readonly getWalletCommunication: () => WalletCommunication | null + readonly isInitialized: () => boolean +} + +const createInitialState = (): ConnectionManagerState => + freeze({ + initialized: false, + initPromise: null, + peerConnection: null, + walletCommunication: null, + } as const) + +export const connectionManagerMaker = ( + config: ConnectionManagerConfig, +): ConnectionManager => { + let state = createInitialState() + const logger = getLogger(config.logger) + + const updateState = (updates: Partial): void => { + state = freeze({...state, ...updates} as const) + } + + const initialize = async (): Promise => { + if (state.initialized) { + logger.debug('Services already initialized', { + origin: 'p2p-communication', + }) + return + } + + if (state.initPromise) { + logger.debug('Services initialization in progress', { + origin: 'p2p-communication', + }) + return state.initPromise + } + + logger.log('Initializing all services centrally', { + origin: 'p2p-communication', + }) + + const initPromise = (async (): Promise => { + try { + const peerConnection = peerConnectionMaker({ + storage: config.storage, + webrtcAdapter: config.webrtcAdapter, + config: config.peerConfig, + isWallet: config.isWallet, + logger: config.logger, + }) + + await peerConnection.init() + + const walletCommunication = walletCommunicationMaker({ + peerConnection, + logger: config.logger, + }) + + updateState({ + initialized: true, + peerConnection, + walletCommunication, + }) + + logger.log('All services initialized successfully', { + origin: 'p2p-communication', + }) + } catch (error) { + logger.error( + error instanceof Error ? error : new Error(String(error)), + {origin: 'p2p-communication', operation: 'initialize'}, + ) + updateState({initPromise: null}) + throw error + } + })() + + updateState({initPromise}) + return initPromise + } + + const cleanup = (): void => { + if (!state.initialized) { + return + } + + logger.log('Cleaning up all services', {origin: 'p2p-communication'}) + + if (state.peerConnection) { + state.peerConnection.destroy() + } + + updateState(createInitialState()) + } + + const getPeerConnection = (): PeerConnection | null => state.peerConnection + + const getWalletCommunication = (): WalletCommunication | null => + state.walletCommunication + + const isInitialized = (): boolean => state.initialized + + return freeze( + { + initialize, + cleanup, + getPeerConnection, + getWalletCommunication, + isInitialized, + } as const, + true, + ) +} diff --git a/mobile/packages/p2p-communication/core/multi-connection-manager.ts b/mobile/packages/p2p-communication/core/multi-connection-manager.ts new file mode 100644 index 0000000000..33090fca75 --- /dev/null +++ b/mobile/packages/p2p-communication/core/multi-connection-manager.ts @@ -0,0 +1,161 @@ +import {App, BaseStorage} from '@yoroi/types' + +import {freeze} from 'immer' + +import {PeerConnectionConfig, WebRTCAdapter} from '../types' +import {PeerConnection, peerConnectionMaker} from './peer-connection' + +/** + * Multi-Connection Manager State + */ +type MultiConnectionManagerState = { + readonly myPeerId: string + readonly connections: ReadonlyMap + readonly initialized: boolean +} + +/** + * Multi-Connection Manager API + * Manages multiple simultaneous peer connections for wallet-to-wallet scenarios + */ +export type MultiConnectionManager = { + readonly initialize: () => Promise + readonly connectToPeer: (targetPeerId: string) => Promise + readonly getConnection: (peerId: string) => PeerConnection | null + readonly getAllConnections: () => ReadonlyArray<{ + readonly peerId: string + readonly connection: PeerConnection + }> + readonly disconnectFromPeer: (peerId: string) => void + readonly cleanup: () => void + readonly getMyPeerId: () => string +} + +export const multiConnectionManagerMaker = ( + storage: BaseStorage, + webrtcAdapter: WebRTCAdapter, + peerConfig?: PeerConnectionConfig, + logger?: App.Logger.Manager, +): MultiConnectionManager => { + let state: MultiConnectionManagerState = freeze({ + myPeerId: '', + connections: new Map(), + initialized: false, + } as const) + + const updateState = (updates: Partial): void => { + state = freeze({...state, ...updates} as const) + } + + const initialize = async (): Promise => { + if (state.initialized) { + return state.myPeerId + } + + // Create a base peer connection for receiving connections + const baseConnection = peerConnectionMaker({ + storage, + webrtcAdapter, + config: peerConfig, + isWallet: true, // Assume wallet for multi-wallet scenario + logger, + }) + + const peerId = await baseConnection.init() + + updateState({ + myPeerId: peerId, + initialized: true, + connections: new Map([['base', baseConnection]]), + }) + + return peerId + } + + const connectToPeer = async ( + targetPeerId: string, + ): Promise => { + if (!state.initialized) { + await initialize() + } + + // Check if connection already exists + const existing = Array.from(state.connections.values()).find( + (conn) => conn.getConnectedPeerId() === targetPeerId, + ) + if (existing) { + return existing + } + + // Create new connection for this specific peer + const connection = peerConnectionMaker({ + storage, + webrtcAdapter, + config: { + ...peerConfig, + targetPeerId, + }, + isWallet: true, + logger, + }) + + await connection.init() + await connection.connectToPeer(targetPeerId) + + const newConnections = new Map(state.connections) + newConnections.set(targetPeerId, connection) + updateState({connections: newConnections}) + + return connection + } + + const getConnection = (peerId: string): PeerConnection | null => { + return state.connections.get(peerId) ?? null + } + + const getAllConnections = (): ReadonlyArray<{ + readonly peerId: string + readonly connection: PeerConnection + }> => { + return Array.from(state.connections.entries()).map( + ([peerId, connection]) => ({ + peerId, + connection, + }), + ) + } + + const disconnectFromPeer = (peerId: string): void => { + const connection = state.connections.get(peerId) + if (connection) { + connection.destroy() + const newConnections = new Map(state.connections) + newConnections.delete(peerId) + updateState({connections: newConnections}) + } + } + + const cleanup = (): void => { + state.connections.forEach((conn) => conn.destroy()) + updateState({ + connections: new Map(), + initialized: false, + myPeerId: '', + }) + } + + const getMyPeerId = (): string => state.myPeerId + + return freeze( + { + initialize, + connectToPeer, + getConnection, + getAllConnections, + disconnectFromPeer, + cleanup, + getMyPeerId, + } as const, + true, + ) +} diff --git a/mobile/packages/p2p-communication/core/peer-connection.ts b/mobile/packages/p2p-communication/core/peer-connection.ts new file mode 100644 index 0000000000..dfa94f127d --- /dev/null +++ b/mobile/packages/p2p-communication/core/peer-connection.ts @@ -0,0 +1,620 @@ +import {App, BaseStorage} from '@yoroi/types' + +import {freeze} from 'immer' + +import {PEER_CONFIG} from '../constants' +import { + ConnectionStatus, + EventCallback, + EventListener, + PeerConnectionConfig, + WebRTCAdapter, +} from '../types' +import {getPersistentDappId, getPersistentWalletId} from '../utils/id-utils' +import {getLogger} from '../utils/logger' +import { + type SignalingClient, + type SignalingMessage, + signalingClientMaker, +} from './signaling-client' + +/** + * Peer Connection State + */ +type PeerConnectionState = { + readonly peer: RTCPeerConnection | null + readonly dataChannel: RTCDataChannel | null + readonly signaling: SignalingClient | null + readonly connection: RTCDataChannel | null + readonly isInitializing: boolean + readonly initPromise: Promise | null + readonly listeners: EventListener + readonly peerId: string + readonly status: ConnectionStatus + readonly connectedPeerId: string | null // Track which peer we're connected to + readonly isInitiator: boolean // Track if we initiated this connection +} + +/** + * Peer Connection API + */ +export type PeerConnection = { + readonly init: () => Promise + readonly connectToPeer: (targetPeerId: string) => Promise + readonly send: (data: unknown) => boolean + readonly reconnect: () => void + readonly destroy: () => void + readonly on: (event: keyof EventListener, callback: EventCallback) => void + readonly off: (event: keyof EventListener, callback: EventCallback) => void + readonly getPeerId: () => string + readonly getStatus: () => ConnectionStatus + readonly isReady: () => boolean + readonly getConnectedPeerId: () => string | null +} + +type PeerConnectionDeps = { + readonly storage: BaseStorage + readonly webrtcAdapter: WebRTCAdapter + readonly config?: PeerConnectionConfig + readonly isWallet?: boolean + readonly logger?: App.Logger.Manager +} + +const createInitialState = (peerId: string): PeerConnectionState => + freeze({ + peer: null, + dataChannel: null, + signaling: null, + connection: null, + isInitializing: false, + initPromise: null, + listeners: { + open: [], + error: [], + close: [], + disconnected: [], + connection: [], + data: [], + connectionClosed: [], + peerConnected: [], + }, + peerId, + status: 'initializing', + connectedPeerId: null, + isInitiator: false, + } as const) + +export const peerConnectionMaker = ( + deps: PeerConnectionDeps, +): PeerConnection => { + let state = createInitialState('') + const logger = getLogger(deps.logger) + + const updateState = (updates: Partial): void => { + state = freeze({...state, ...updates} as const) + } + + const notifyListeners = (event: keyof EventListener, data: T): void => { + state.listeners[event].forEach((callback) => { + try { + ;(callback as any)(data) + } catch (error) { + logger.error( + error instanceof Error ? error : new Error(String(error)), + {origin: 'p2p-communication', event: String(event)}, + ) + } + }) + } + + const createPeerConnection = (): RTCPeerConnection | null => { + try { + const iceServers = + deps.config?.iceServers ?? + PEER_CONFIG.iceServers.map((server) => ({ + urls: server.urls, + })) + + return new deps.webrtcAdapter.RTCPeerConnection({ + iceServers: [...iceServers] as RTCIceServer[], + }) + } catch (error) { + logger.error(error instanceof Error ? error : new Error(String(error)), { + origin: 'p2p-communication', + operation: 'createPeerConnection', + }) + notifyListeners('error', new Error(`Failed to create peer: ${error}`)) + return null + } + } + + const setupDataChannel = (channel: RTCDataChannel): void => { + channel.onopen = () => { + logger.log('Data channel opened', {origin: 'p2p-communication'}) + updateState({connection: channel}) + notifyListeners('connection', channel) + } + + channel.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + notifyListeners('data', data) + } catch (error) { + notifyListeners('data', event.data) + } + } + + channel.onclose = () => { + logger.log('Data channel closed', {origin: 'p2p-communication'}) + updateState({connection: null}) + notifyListeners('connectionClosed', undefined) + } + + channel.onerror = (error) => { + logger.error(error instanceof Error ? error : new Error(String(error)), { + origin: 'p2p-communication', + operation: 'dataChannel', + }) + notifyListeners('error', new Error(`Data channel error: ${error}`)) + } + } + + const init = async (): Promise => { + // Check if peer is already connected and ready + if ( + state.peer && + (state.peer.connectionState === 'connected' || + state.dataChannel?.readyState === 'open' || + state.connection?.readyState === 'open') + ) { + return state.peerId + } + + if (state.isInitializing && state.initPromise) { + return state.initPromise + } + + updateState({isInitializing: true, status: 'initializing'}) + + const initPromise = (async (): Promise => { + try { + // Clean up existing connection + if (state.peer) { + state.peer.close() + updateState({peer: null}) + } + + // Get persistent ID + const persistentId = deps.isWallet + ? await getPersistentWalletId(deps.storage) + : await getPersistentDappId(deps.storage) + + updateState({peerId: persistentId}) + + // Create peer connection + const peer = createPeerConnection() + if (!peer) { + throw new Error('Failed to create RTCPeerConnection') + } + + // Store signaling reference for ICE candidate callback + let signalingRef: SignalingClient | null = null + + // Setup ICE candidate handling + peer.onicecandidate = (event) => { + if (event.candidate && signalingRef) { + signalingRef.send({ + type: 'ice-candidate', + candidate: event.candidate.toJSON(), + peerId: persistentId, + targetPeerId: state.connectedPeerId ?? undefined, + }) + } + } + + // Setup connection state changes + peer.onconnectionstatechange = () => { + const connectionState = peer.connectionState + if (connectionState === 'connected') { + updateState({status: 'connected'}) + notifyListeners('open', persistentId) + } else if (connectionState === 'disconnected') { + updateState({status: 'disconnected'}) + notifyListeners('disconnected', undefined) + } else if (connectionState === 'failed') { + updateState({status: 'error'}) + notifyListeners('error', new Error('Connection failed')) + } else if (connectionState === 'closed') { + updateState({status: 'closed'}) + notifyListeners('close', undefined) + } else if (connectionState === 'connecting') { + updateState({status: 'connecting'}) + } + } + + // Create data channel (for dApp side OR wallet-initiated connections) + // For backward compatibility: dApp always creates, wallet waits UNLESS connecting to specific peer + if (!deps.isWallet || deps.config?.targetPeerId) { + // dApp or wallet initiating connection - create data channel + const dataChannel = peer.createDataChannel('messages', { + ordered: true, + }) + setupDataChannel(dataChannel) + updateState({dataChannel}) + } else { + // Wallet side: wait for data channel from remote (backward compatible) + peer.ondatachannel = (event) => { + const channel = event.channel + setupDataChannel(channel) + } + } + + // Setup signaling if URL provided + if (deps.config?.signalingUrl) { + const signaling = signalingClientMaker({ + signalingUrl: deps.config.signalingUrl, + peerId: persistentId, + onMessage: (message) => { + handleSignalingMessage(message, peer) + }, + onError: (error) => { + notifyListeners('error', error) + }, + onOpen: () => { + logger.log('Signaling connected', {origin: 'p2p-communication'}) + updateState({signaling}) + }, + onClose: () => { + logger.log('Signaling disconnected', { + origin: 'p2p-communication', + }) + }, + }) + // Store reference for ICE candidate handler + signalingRef = signaling + updateState({signaling}) + } + + updateState({peer, isInitializing: false, status: 'ready'}) + + return persistentId + } catch (error) { + updateState({isInitializing: false, status: 'error'}) + notifyListeners('error', error as Error) + throw error + } + })() + + updateState({initPromise}) + return initPromise + } + + const connectToPeer = async (targetPeerId: string): Promise => { + if (!state.signaling || !state.signaling.isConnected()) { + throw new Error('Signaling not connected. Call init() first.') + } + + if (!state.peer) { + throw new Error('Peer connection not initialized. Call init() first.') + } + + if (state.connectedPeerId === targetPeerId) { + logger.log('Already connected to peer', { + origin: 'p2p-communication', + peerId: targetPeerId, + }) + return + } + + // If we're a wallet and want to initiate, we need to create a data channel + // and create an offer + if (deps.isWallet) { + try { + updateState({ + isInitiator: true, + connectedPeerId: targetPeerId, + status: 'connecting', + }) + + // Create data channel for wallet-initiated connection + const dataChannel = state.peer.createDataChannel('messages', { + ordered: true, + }) + setupDataChannel(dataChannel) + updateState({dataChannel}) + + // Create offer + const offer = await state.peer.createOffer() + await state.peer.setLocalDescription(offer) + + // Send offer through signaling + if (state.signaling && state.peer.localDescription) { + state.signaling.send({ + type: 'offer', + sdp: state.peer.localDescription.sdp, + peerId: state.peerId, + targetPeerId: targetPeerId, + }) + } + } catch (error) { + updateState({ + status: 'error', + isInitiator: false, + connectedPeerId: null, + }) + notifyListeners( + 'error', + new Error(`Failed to connect to peer: ${error}`), + ) + throw error + } + } else { + // dApp side - similar logic but already has data channel + try { + updateState({ + isInitiator: true, + connectedPeerId: targetPeerId, + status: 'connecting', + }) + + const offer = await state.peer.createOffer() + await state.peer.setLocalDescription(offer) + + if (state.signaling && state.peer.localDescription) { + state.signaling.send({ + type: 'offer', + sdp: state.peer.localDescription.sdp, + peerId: state.peerId, + targetPeerId: targetPeerId, + }) + } + } catch (error) { + updateState({ + status: 'error', + isInitiator: false, + connectedPeerId: null, + }) + notifyListeners( + 'error', + new Error(`Failed to connect to peer: ${error}`), + ) + throw error + } + } + } + + const handleSignalingMessage = ( + message: SignalingMessage, + peer: RTCPeerConnection, + ): void => { + // Filter messages: if targetPeerId is set, only process if it matches our peerId + // If no targetPeerId, it's a broadcast (backward compatible) + const targetPeerId = + 'targetPeerId' in message ? message.targetPeerId : undefined + if (targetPeerId && targetPeerId !== state.peerId) { + // Message not for us, ignore + return + } + + if (message.type === 'offer') { + // Only handle offer if we're not already connected or if it's from the peer we're waiting for + if (state.connectedPeerId && state.connectedPeerId !== message.peerId) { + logger.debug('Ignoring offer from different peer', { + origin: 'p2p-communication', + expectedPeerId: state.connectedPeerId, + receivedPeerId: message.peerId, + }) + return + } + + peer + .setRemoteDescription( + new deps.webrtcAdapter.RTCSessionDescription({ + type: 'offer', + sdp: message.sdp, + }), + ) + .then(() => peer.createAnswer()) + .then((answer) => peer.setLocalDescription(answer)) + .then(() => { + if (state.signaling && peer.localDescription) { + updateState({connectedPeerId: message.peerId, isInitiator: false}) + state.signaling.send({ + type: 'answer', + sdp: peer.localDescription.sdp, + peerId: state.peerId, + targetPeerId: message.peerId, // Send answer back to the offerer + }) + notifyListeners('peerConnected', message.peerId) + } + }) + .catch((error) => { + notifyListeners( + 'error', + new Error(`Failed to handle offer: ${error}`), + ) + }) + } else if (message.type === 'answer') { + // Only handle answer if we initiated and it's from the peer we're connecting to + if (!state.isInitiator || state.connectedPeerId !== message.peerId) { + logger.debug( + 'Ignoring answer - not from expected peer or not initiator', + { + origin: 'p2p-communication', + isInitiator: state.isInitiator, + expectedPeerId: state.connectedPeerId, + receivedPeerId: message.peerId, + }, + ) + return + } + + peer + .setRemoteDescription( + new deps.webrtcAdapter.RTCSessionDescription({ + type: 'answer', + sdp: message.sdp, + }), + ) + .then(() => { + updateState({status: 'connected'}) + notifyListeners('peerConnected', message.peerId) + }) + .catch((error) => { + notifyListeners( + 'error', + new Error(`Failed to handle answer: ${error}`), + ) + }) + } else if (message.type === 'ice-candidate') { + // Filter ICE candidates by targetPeerId if set + const candidateTargetPeerId = + 'targetPeerId' in message ? message.targetPeerId : undefined + if (candidateTargetPeerId && candidateTargetPeerId !== state.peerId) { + return + } + + if (message.candidate) { + peer + .addIceCandidate( + new deps.webrtcAdapter.RTCIceCandidate( + message.candidate as RTCIceCandidateInit, + ), + ) + .catch((error) => { + notifyListeners( + 'error', + new Error(`Failed to add ICE candidate: ${error}`), + ) + }) + } + } + } + + const send = (data: unknown): boolean => { + const channel = state.connection ?? state.dataChannel + if (!channel || channel.readyState !== 'open') { + logger.warn('Cannot send: No open data channel', { + origin: 'p2p-communication', + }) + return false + } + + try { + const serializedData = + typeof data === 'string' ? data : JSON.stringify(data) + channel.send(serializedData) + return true + } catch (error) { + logger.error(error instanceof Error ? error : new Error(String(error)), { + origin: 'p2p-communication', + operation: 'send', + }) + return false + } + } + + const reconnect = (): void => { + if (state.peer && state.peer.connectionState !== 'closed') { + logger.log('Reconnecting peer connection...', { + origin: 'p2p-communication', + }) + updateState({status: 'reconnecting'}) + // Restart ICE + try { + state.peer.restartIce() + } catch (error) { + logger.error( + error instanceof Error ? error : new Error(String(error)), + {origin: 'p2p-communication', operation: 'restartIce'}, + ) + // If restart fails, create new connection + destroy() + init().catch((err) => { + notifyListeners('error', err) + }) + } + } else { + logger.log('Creating new peer connection...', { + origin: 'p2p-communication', + }) + updateState({status: 'reconnecting'}) + init().catch((error) => { + notifyListeners('error', error) + }) + } + } + + const destroy = (): void => { + if (state.connection) { + state.connection.close() + } + if (state.dataChannel) { + state.dataChannel.close() + } + if (state.signaling) { + state.signaling.close() + } + if (state.peer) { + state.peer.close() + } + + updateState(createInitialState('')) + } + + const on = (event: keyof EventListener, callback: EventCallback): void => { + const currentListeners = state.listeners[event] + updateState({ + listeners: { + ...state.listeners, + + [event]: [...currentListeners, callback as any], + }, + }) + } + + const off = (event: keyof EventListener, callback: EventCallback): void => { + const currentListeners = state.listeners[event] + updateState({ + listeners: { + ...state.listeners, + + [event]: currentListeners.filter((cb) => cb !== (callback as any)), + }, + }) + } + + const getPeerId = (): string => state.peerId + + const getStatus = (): ConnectionStatus => state.status + + const getConnectedPeerId = (): string | null => state.connectedPeerId + + const isReady = (): boolean => { + return ( + (state.status === 'ready' || state.status === 'connected') && + state.peer !== null && + (state.peer.connectionState === 'connected' || + state.peer.connectionState === 'connecting' || + state.dataChannel?.readyState === 'open' || + state.connection?.readyState === 'open') + ) + } + + return freeze( + { + init, + connectToPeer, + send, + reconnect, + destroy, + on, + off, + getPeerId, + getStatus, + isReady, + getConnectedPeerId, + } as const, + true, + ) +} diff --git a/mobile/packages/p2p-communication/core/signaling-client.ts b/mobile/packages/p2p-communication/core/signaling-client.ts new file mode 100644 index 0000000000..417f86ca90 --- /dev/null +++ b/mobile/packages/p2p-communication/core/signaling-client.ts @@ -0,0 +1,123 @@ +/** + * WebRTC Signaling Client + * Handles WebSocket-based signaling for WebRTC peer connections + */ + +export type SignalingMessage = + | { + readonly type: 'offer' + readonly sdp: string + readonly peerId: string + readonly targetPeerId?: string + } + | { + readonly type: 'answer' + readonly sdp: string + readonly peerId: string + readonly targetPeerId?: string + } + | { + readonly type: 'ice-candidate' + readonly candidate: unknown + readonly peerId: string + readonly targetPeerId?: string + } + | {readonly type: 'peer-id'; readonly peerId: string} + +export type SignalingClientConfig = { + readonly signalingUrl: string + readonly peerId: string + readonly onMessage: (message: SignalingMessage) => void + readonly onError: (error: Error) => void + readonly onOpen: () => void + readonly onClose: () => void +} + +export type SignalingClient = { + readonly send: (message: SignalingMessage) => void + readonly close: () => void + readonly isConnected: () => boolean +} + +export const signalingClientMaker = ( + config: SignalingClientConfig, +): SignalingClient => { + let ws: WebSocket | null = null + let connected = false + + const connect = (): void => { + try { + // WebSocket is available in both browser and React Native + if (typeof WebSocket === 'undefined') { + config.onError( + new Error('WebSocket is not available in this environment'), + ) + return + } + ws = new WebSocket(config.signalingUrl) + + ws.onopen = () => { + connected = true + config.onOpen() + // Send peer ID to signaling server + if (ws) { + ws.send( + JSON.stringify({ + type: 'peer-id', + peerId: config.peerId, + }), + ) + } + } + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as SignalingMessage + config.onMessage(message) + } catch (error) { + config.onError( + new Error(`Failed to parse signaling message: ${error}`), + ) + } + } + + ws.onerror = (error) => { + config.onError(new Error(`WebSocket error: ${error}`)) + } + + ws.onclose = () => { + connected = false + config.onClose() + } + } catch (error) { + config.onError(new Error(`Failed to create WebSocket: ${error}`)) + } + } + + const send = (message: SignalingMessage): void => { + if (!ws || !connected) { + config.onError(new Error('WebSocket not connected')) + return + } + ws.send(JSON.stringify(message)) + } + + const close = (): void => { + if (ws) { + ws.close() + ws = null + } + connected = false + } + + const isConnected = (): boolean => connected + + // Auto-connect on creation + connect() + + return { + send, + close, + isConnected, + } +} diff --git a/mobile/packages/p2p-communication/core/wallet-communication.test.ts b/mobile/packages/p2p-communication/core/wallet-communication.test.ts new file mode 100644 index 0000000000..67554f8e60 --- /dev/null +++ b/mobile/packages/p2p-communication/core/wallet-communication.test.ts @@ -0,0 +1,84 @@ +import {BaseStorage} from '@yoroi/types' + +import {WebRTCAdapter} from '../types' +import {peerConnectionMaker} from './peer-connection' +import {walletCommunicationMaker} from './wallet-communication' + +describe('walletCommunicationMaker', () => { + const createMockStorage = (): BaseStorage => { + const storage: Record = {} + + return { + getItem: async (key: string): Promise => { + return storage[key] ?? null + }, + setItem: async (key: string, value: string): Promise => { + storage[key] = value + }, + removeItem: async (key: string): Promise => { + delete storage[key] + }, + } + } + + const createMockWebRTCAdapter = (): WebRTCAdapter => { + // Mock WebRTC adapter for testing + return { + RTCPeerConnection: class { + constructor() {} + } as unknown as new ( + configuration?: RTCConfiguration, + ) => RTCPeerConnection, + RTCSessionDescription: class { + constructor() {} + } as unknown as new ( + descriptionInitDict?: RTCSessionDescriptionInit, + ) => RTCSessionDescription, + RTCIceCandidate: class { + constructor() {} + } as unknown as new ( + candidateInitDict?: RTCIceCandidateInit, + ) => RTCIceCandidate, + } + } + + it('should create wallet communication', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const peerConnection = peerConnectionMaker({storage, webrtcAdapter}) + const walletCommunication = walletCommunicationMaker({peerConnection}) + + expect(walletCommunication).toBeDefined() + expect(walletCommunication.sendMessage).toBeDefined() + expect(walletCommunication.callWalletFunction).toBeDefined() + expect(walletCommunication.signTransaction).toBeDefined() + expect(walletCommunication.disconnect).toBeDefined() + expect(walletCommunication.on).toBeDefined() + expect(walletCommunication.off).toBeDefined() + expect(walletCommunication.isConnected).toBeDefined() + }) + + it('should start disconnected', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const peerConnection = peerConnectionMaker({storage, webrtcAdapter}) + const walletCommunication = walletCommunicationMaker({peerConnection}) + + expect(walletCommunication.isConnected()).toBe(false) + }) + + it('should handle event listeners', () => { + const storage = createMockStorage() + const webrtcAdapter = createMockWebRTCAdapter() + const peerConnection = peerConnectionMaker({storage, webrtcAdapter}) + const walletCommunication = walletCommunicationMaker({peerConnection}) + + const connectCallback = jest.fn() + walletCommunication.on('connect', connectCallback) + + // Remove listener + walletCommunication.off('connect', connectCallback) + + expect(connectCallback).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/packages/p2p-communication/core/wallet-communication.ts b/mobile/packages/p2p-communication/core/wallet-communication.ts new file mode 100644 index 0000000000..e724f22950 --- /dev/null +++ b/mobile/packages/p2p-communication/core/wallet-communication.ts @@ -0,0 +1,374 @@ +import {App} from '@yoroi/types' + +import {freeze} from 'immer' + +import {CONNECTION_CONSTANTS} from '../constants' +import {EventCallback, HeartbeatMessage, WalletMessage} from '../types' +import {getLogger} from '../utils/logger' +import {createWalletRequest, isHeartbeatMessage} from '../utils/message-utils' +import {PeerConnection} from './peer-connection' + +/** + * Wallet Communication State + */ +type WalletCommunicationState = { + readonly walletId: string | null + readonly connected: boolean + readonly heartbeatInterval: ReturnType | null + readonly heartbeatTimeout: ReturnType | null + readonly listeners: { + readonly message: ReadonlyArray> + readonly connect: ReadonlyArray> + readonly disconnect: ReadonlyArray> + readonly error: ReadonlyArray> + } +} + +/** + * Wallet Communication API + */ +export type WalletCommunication = { + readonly sendMessage: (message: string) => boolean + readonly callWalletFunction: (method: string, data?: unknown) => boolean + readonly signTransaction: (txData?: unknown) => boolean + readonly disconnect: () => boolean + readonly on: ( + event: 'message' | 'connect' | 'disconnect' | 'error', + callback: EventCallback, + ) => void + readonly off: ( + event: 'message' | 'connect' | 'disconnect' | 'error', + callback: EventCallback, + ) => void + readonly isConnected: () => boolean +} + +type WalletCommunicationDeps = { + readonly peerConnection: PeerConnection + readonly logger?: App.Logger.Manager +} + +const createInitialState = (): WalletCommunicationState => + freeze({ + walletId: null, + connected: false, + heartbeatInterval: null, + heartbeatTimeout: null, + listeners: { + message: [], + connect: [], + disconnect: [], + error: [], + }, + } as const) + +export const walletCommunicationMaker = ( + deps: WalletCommunicationDeps, +): WalletCommunication => { + let state = createInitialState() + const logger = getLogger(deps.logger) + + const updateState = (updates: Partial): void => { + state = freeze({...state, ...updates} as const) + } + + const notifyListeners = ( + event: keyof WalletCommunicationState['listeners'], + data: T, + ): void => { + state.listeners[event].forEach((callback) => { + try { + ;(callback as any)(data) + } catch (error) { + logger.error( + error instanceof Error ? error : new Error(String(error)), + {origin: 'p2p-communication', event: String(event)}, + ) + } + }) + } + + const startHeartbeat = (): void => { + stopHeartbeat() + + if (!state.connected || !deps.peerConnection.isReady()) { + logger.debug('Cannot start heartbeat - no wallet connection', { + origin: 'p2p-communication', + }) + return + } + + logger.log('Starting wallet heartbeat monitoring', { + origin: 'p2p-communication', + }) + + const interval = setInterval(() => { + if (!deps.peerConnection.isReady()) { + stopHeartbeat() + return + } + + const pingTime = Date.now() + + // Clear any existing timeout before sending new ping + if (state.heartbeatTimeout) { + clearTimeout(state.heartbeatTimeout) + } + + const success = deps.peerConnection.send({ + type: 'heartbeat', + action: 'ping', + timestamp: pingTime, + }) + + if (!success) { + logger.error(new Error('Failed to send heartbeat'), { + origin: 'p2p-communication', + operation: 'heartbeat', + }) + stopHeartbeat() + if (state.connected) { + updateState({connected: false}) + notifyListeners('disconnect', undefined) + } + return + } + + logger.debug('Sent heartbeat ping to wallet', { + origin: 'p2p-communication', + }) + + // Set timeout for waiting for pong response + const timeout = setTimeout(() => { + logger.warn('Wallet heartbeat timeout - connection may be dead', { + origin: 'p2p-communication', + operation: 'heartbeat', + }) + if (state.connected) { + updateState({connected: false}) + notifyListeners('disconnect', undefined) + } + }, CONNECTION_CONSTANTS.HEARTBEAT_TIMEOUT) + + updateState({heartbeatTimeout: timeout}) + }, CONNECTION_CONSTANTS.HEARTBEAT_INTERVAL) + + updateState({heartbeatInterval: interval}) + } + + const stopHeartbeat = (): void => { + if (state.heartbeatInterval) { + clearInterval(state.heartbeatInterval) + } + if (state.heartbeatTimeout) { + clearTimeout(state.heartbeatTimeout) + } + updateState({ + heartbeatInterval: null, + heartbeatTimeout: null, + }) + } + + const processHeartbeatMessage = (message: HeartbeatMessage): boolean => { + if (message.action === 'ping') { + deps.peerConnection.send({ + type: 'heartbeat', + action: 'pong', + timestamp: message.timestamp, + received: Date.now(), + }) + logger.debug('Received heartbeat ping, sent pong', { + origin: 'p2p-communication', + }) + return true + } + + if (message.action === 'pong') { + if (state.heartbeatTimeout) { + clearTimeout(state.heartbeatTimeout) + updateState({heartbeatTimeout: null}) + } + const latency = Date.now() - message.timestamp + logger.debug(`Wallet connection confirmed (${latency}ms latency)`, { + origin: 'p2p-communication', + latency, + }) + + if (!state.connected) { + updateState({connected: true}) + notifyListeners('connect', deps.peerConnection.getPeerId()) + } + return true + } + + return false + } + + const handleIncomingData = (data: unknown): void => { + try { + let message: WalletMessage + if (typeof data === 'string') { + message = JSON.parse(data) as WalletMessage + } else if (typeof data === 'object' && data !== null) { + message = data as WalletMessage + } else { + throw new Error('Invalid message format') + } + + if (isHeartbeatMessage(message)) { + processHeartbeatMessage(message) + return + } + + notifyListeners('message', message) + } catch (error) { + logger.error(error instanceof Error ? error : new Error(String(error)), { + origin: 'p2p-communication', + operation: 'parseMessage', + }) + notifyListeners('error', new Error(`Failed to parse message: ${error}`)) + } + } + + // Setup peer connection listeners + const setupListeners = (): void => { + deps.peerConnection.on('connection', () => { + const walletId = deps.peerConnection.getPeerId() + updateState({connected: true, walletId}) + notifyListeners('connect', walletId) + startHeartbeat() + }) + + deps.peerConnection.on('connectionClosed', () => { + updateState({connected: false}) + notifyListeners('disconnect', undefined) + stopHeartbeat() + }) + + deps.peerConnection.on('data', handleIncomingData) + } + + setupListeners() + + // Check if already connected + if (deps.peerConnection.isReady()) { + const walletId = deps.peerConnection.getPeerId() + updateState({connected: true, walletId}) + notifyListeners('connect', walletId) + startHeartbeat() + } + + const sendMessage = (message: string): boolean => { + if (!state.connected) { + logger.warn('Cannot send: Not connected to wallet', { + origin: 'p2p-communication', + }) + return false + } + + const messageObj = {message} + return deps.peerConnection.send(messageObj) + } + + const callWalletFunction = ( + method: string, + data: unknown = null, + ): boolean => { + if (!state.connected) { + logger.warn(`Cannot call ${method}: Not connected to wallet`, { + origin: 'p2p-communication', + method, + }) + return false + } + + const request = createWalletRequest(method, data) + return deps.peerConnection.send(request) + } + + const signTransaction = (txData: unknown = null): boolean => { + if (!state.connected) { + logger.warn('Cannot sign transaction: Not connected to wallet', { + origin: 'p2p-communication', + }) + return false + } + + logger.log('Creating transaction signing request...', { + origin: 'p2p-communication', + }) + + const defaultTxData = txData ?? { + type: 'Payment', + amount: '2.5 ADA', + recipient: 'addr1qxyz...abc123', + fee: '0.17 ADA', + metadata: `dApp Payment #${Date.now()}`, + } + + return callWalletFunction('signTx', defaultTxData) + } + + const disconnect = (): boolean => { + if (!state.connected) { + logger.debug('Not connected to a wallet', { + origin: 'p2p-communication', + }) + return false + } + + stopHeartbeat() + + // Close the P2P connection if peer connection is available + // Note: In our implementation, the peer connection manages its own lifecycle + // The wallet communication just tracks the connection state + + updateState({connected: false}) + notifyListeners('disconnect', undefined) + + return true + } + + const on = ( + event: 'message' | 'connect' | 'disconnect' | 'error', + callback: EventCallback, + ): void => { + const currentListeners = state.listeners[event] + updateState({ + listeners: { + ...state.listeners, + + [event]: [...currentListeners, callback as any], + }, + }) + } + + const off = ( + event: 'message' | 'connect' | 'disconnect' | 'error', + callback: EventCallback, + ): void => { + const currentListeners = state.listeners[event] + updateState({ + listeners: { + ...state.listeners, + + [event]: currentListeners.filter((cb) => cb !== (callback as any)), + }, + }) + } + + const isConnected = (): boolean => state.connected + + return freeze( + { + sendMessage, + callWalletFunction, + signTransaction, + disconnect, + on, + off, + isConnected, + } as const, + true, + ) +} diff --git a/mobile/packages/p2p-communication/hooks/use-peer-connection.ts b/mobile/packages/p2p-communication/hooks/use-peer-connection.ts new file mode 100644 index 0000000000..a5e44420ee --- /dev/null +++ b/mobile/packages/p2p-communication/hooks/use-peer-connection.ts @@ -0,0 +1,142 @@ +import {useCallback, useEffect, useRef, useState} from 'react' + +import {PeerConnection} from '../core/peer-connection' +import {ConnectionStatus} from '../types' + +type UsePeerConnectionResult = { + readonly peerId: string + readonly status: ConnectionStatus + readonly isReady: boolean + readonly error: Error | null + readonly reconnect: () => void +} + +export const usePeerConnection = ( + peerConnection: PeerConnection | null, +): UsePeerConnectionResult => { + const [peerId, setPeerId] = useState('') + const [status, setStatus] = useState('initializing') + const [isReady, setIsReady] = useState(false) + const [error, setError] = useState(null) + const listenerSetupRef = useRef(false) + + const handleReconnect = useCallback(() => { + if (!peerConnection) { + return + } + + if (isReady) { + console.log('Peer connection already connected') + return + } + + console.log('Attempting to reconnect peer connection...') + setStatus('initializing') + peerConnection.reconnect() + }, [isReady, peerConnection]) + + useEffect(() => { + if (!peerConnection) { + return + } + + if (listenerSetupRef.current) { + console.log( + 'Peer connection listeners already set up, skipping duplicate setup', + ) + return + } + + console.log('Setting up peer connection event listeners') + listenerSetupRef.current = true + + if (peerConnection.isReady()) { + console.log('Peer connection already connected, syncing state') + setPeerId(peerConnection.getPeerId()) + setStatus(peerConnection.getStatus()) + setIsReady(true) + } + + const onOpen = (id: string): void => { + console.log('Peer connection open event - ready for connections:', id) + setPeerId(id) + setStatus('ready') + setIsReady(true) + setError(null) + } + + const onError = (err: Error): void => { + console.error('Peer connection error in hook:', err) + setError(err) + setStatus('error') + setIsReady(false) + } + + const onDisconnected = (): void => { + console.log('Peer connection disconnected from server') + setStatus('disconnected') + setIsReady(false) + } + + const onClose = (): void => { + console.log('Peer connection closed') + setStatus('closed') + setIsReady(false) + } + + peerConnection.on('open', onOpen as any) + + peerConnection.on('error', onError as any) + + peerConnection.on('disconnected', onDisconnected as any) + + peerConnection.on('close', onClose as any) + + return () => { + console.log('Cleaning up peer connection event listeners') + + peerConnection.off('open', onOpen as any) + + peerConnection.off('error', onError as any) + + peerConnection.off('disconnected', onDisconnected as any) + + peerConnection.off('close', onClose as any) + + listenerSetupRef.current = false + } + }, [peerConnection]) + + useEffect(() => { + if (typeof document === 'undefined') { + return + } + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible' && status === 'disconnected') { + console.log('Tab became visible, attempting reconnect') + handleReconnect() + } + } + + const handleFocus = (): void => { + handleReconnect() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('focus', handleFocus) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('focus', handleFocus) + } + }, [status, handleReconnect]) + + return { + peerId, + status, + isReady, + error, + reconnect: handleReconnect, + } +} diff --git a/mobile/packages/p2p-communication/hooks/use-wallet-connection.ts b/mobile/packages/p2p-communication/hooks/use-wallet-connection.ts new file mode 100644 index 0000000000..660a93a566 --- /dev/null +++ b/mobile/packages/p2p-communication/hooks/use-wallet-connection.ts @@ -0,0 +1,115 @@ +import {useCallback, useEffect, useState} from 'react' + +import {WalletCommunication} from '../core/wallet-communication' +import {ConnectionStatus} from '../types' + +type PeerConnectionState = { + readonly peerId: string + readonly isReady: boolean +} + +type UseWalletConnectionResult = { + readonly walletId: string + readonly connected: boolean + readonly status: ConnectionStatus + readonly error: Error | null + readonly disconnectWallet: () => void + readonly setStatus: (status: ConnectionStatus) => void +} + +export const useWalletConnection = ( + peerConnection: PeerConnectionState, + walletCommunication: WalletCommunication | null, +): UseWalletConnectionResult => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {peerId: _peerId, isReady: _isReady} = peerConnection + + const [walletId, setWalletId] = useState('') + const [connected, setConnected] = useState( + walletCommunication?.isConnected() ?? false, + ) + const [status, setStatus] = useState( + walletCommunication?.isConnected() ? 'connected' : 'initializing', + ) + const [error, setError] = useState(null) + + useEffect(() => { + if (!walletCommunication) { + return + } + + console.log( + 'Initializing wallet connection, current status:', + walletCommunication.isConnected() ? 'connected' : 'disconnected', + ) + + if (walletCommunication.isConnected()) { + console.log('Wallet is already connected!') + setConnected(true) + setStatus('connected') + } + + const onConnect = (id: string): void => { + console.log('Wallet connection established in hook') + setConnected(true) + setStatus('connected') + setWalletId(id) + setError(null) + } + + const onDisconnect = (): void => { + console.log('Wallet disconnected') + setConnected(false) + setStatus('disconnected') + } + + const onError = (err: Error): void => { + console.error('Wallet connection error:', err) + setError(err) + setStatus('error') + } + + walletCommunication.on('connect', onConnect as any) + + walletCommunication.on('disconnect', onDisconnect as any) + + walletCommunication.on('error', onError as any) + + return () => { + walletCommunication.off('connect', onConnect as any) + + walletCommunication.off('disconnect', onDisconnect as any) + + walletCommunication.off('error', onError as any) + } + }, [walletCommunication]) + + const disconnectWallet = useCallback(() => { + if (!walletCommunication) { + return + } + + console.log('Disconnecting from wallet...') + if (walletCommunication.isConnected()) { + try { + walletCommunication.disconnect() + setConnected(false) + setStatus('disconnected') + setWalletId('') + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + setError(error) + setStatus('error') + } + } + }, [walletCommunication]) + + return { + walletId, + connected, + status, + error, + disconnectWallet, + setStatus, + } +} diff --git a/mobile/packages/p2p-communication/hooks/use-wallet-messages.ts b/mobile/packages/p2p-communication/hooks/use-wallet-messages.ts new file mode 100644 index 0000000000..e86a96b96c --- /dev/null +++ b/mobile/packages/p2p-communication/hooks/use-wallet-messages.ts @@ -0,0 +1,163 @@ +import {useCallback, useEffect, useState} from 'react' + +import {WALLET_METHODS} from '../constants' +import {WalletCommunication} from '../core/wallet-communication' +import {WalletMessage, WalletResponse} from '../types' +import {ConnectionStatus} from '../types' + +type Message = { + readonly from: string + readonly text: string + readonly time: string +} + +type UseWalletMessagesResult = { + readonly messages: ReadonlyArray + readonly sendMessage: (text: string) => boolean + readonly callWalletFunction: (method: string, data?: unknown) => boolean + readonly signTransaction: (txData?: unknown) => boolean + readonly addMessage: (from: string, text: string) => void + readonly addSystemMessage: (text: string) => void +} + +export const useWalletMessages = ( + connected: boolean, + setStatus: (status: ConnectionStatus) => void, + walletCommunication: WalletCommunication | null, +): UseWalletMessagesResult => { + const [messages, setMessages] = useState>([]) + + const addMessage = useCallback((from: string, text: string): void => { + setMessages((prev) => [ + ...prev, + { + from, + text, + time: new Date().toLocaleTimeString(), + }, + ]) + }, []) + + const addSystemMessage = useCallback( + (text: string): void => { + addMessage('System', text) + }, + [addMessage], + ) + + useEffect(() => { + if (!walletCommunication) { + return + } + + const handleMessage = (data: WalletMessage): void => { + try { + if (data.type === 'response') { + const response = data as WalletResponse + + if (response.method === WALLET_METHODS.SIGN_TX) { + if (response.error) { + addMessage('Wallet', `Transaction REJECTED: ${response.error}`) + } else { + addMessage('Wallet', 'Transaction SIGNED!') + if (response.data && typeof response.data === 'object') { + const dataObj = response.data as Record + if (dataObj.signature) { + addMessage( + 'Wallet', + `Signature: ${String(dataObj.signature)}`, + ) + } + if (dataObj.status) { + addMessage('Wallet', `Status: ${String(dataObj.status)}`) + } + } + } + } else { + addMessage( + 'Wallet', + `${response.method}: ${JSON.stringify(response.data ?? {})}`, + ) + } + } else if ('message' in data && typeof data.message === 'string') { + addMessage('Wallet', data.message) + } else { + addMessage('Wallet', JSON.stringify(data)) + } + } catch (error) { + addMessage('Wallet', String(data)) + } + } + + walletCommunication.on('message', handleMessage as any) + + return () => { + walletCommunication.off('message', handleMessage as any) + } + }, [walletCommunication, addMessage]) + + const sendMessage = useCallback( + (text: string): boolean => { + if (!connected || !walletCommunication) { + return false + } + + const success = walletCommunication.sendMessage(text) + + if (success) { + addMessage('dApp', text) + } + + return success + }, + [connected, walletCommunication, addMessage], + ) + + const callWalletFunction = useCallback( + (method: string, data: unknown = null): boolean => { + if (!connected || !walletCommunication) { + return false + } + + const success = walletCommunication.callWalletFunction(method, data) + + if (success) { + addMessage('dApp', `Called: ${method}`) + } + + return success + }, + [connected, walletCommunication, addMessage], + ) + + const signTransaction = useCallback( + (txData: unknown = null): boolean => { + if (!connected || !walletCommunication) { + console.log('Cannot sign tx: Not connected to wallet') + return false + } + + console.log('Sending sign transaction request') + const success = walletCommunication.signTransaction(txData) + + if (success) { + addMessage('dApp', 'Requesting transaction signature...') + setStatus('initializing') + } else { + console.error('Failed to send transaction signing request') + } + + return success + }, + [connected, walletCommunication, addMessage, setStatus], + ) + + return { + messages, + sendMessage, + callWalletFunction, + signTransaction, + addMessage, + addSystemMessage, + } +} diff --git a/mobile/packages/p2p-communication/index.ts b/mobile/packages/p2p-communication/index.ts new file mode 100644 index 0000000000..cbcd84c485 --- /dev/null +++ b/mobile/packages/p2p-communication/index.ts @@ -0,0 +1,68 @@ +/** + * P2P Communication Package + * Main exports + */ + +// Core +export {connectionManagerMaker} from './core/connection-manager' +export type {ConnectionManager} from './core/connection-manager' + +export {peerConnectionMaker} from './core/peer-connection' +export type {PeerConnection} from './core/peer-connection' + +export {multiConnectionManagerMaker} from './core/multi-connection-manager' +export type {MultiConnectionManager} from './core/multi-connection-manager' + +export {walletCommunicationMaker} from './core/wallet-communication' +export type {WalletCommunication} from './core/wallet-communication' + +export {signalingClientMaker} from './core/signaling-client' +export type { + SignalingClient, + SignalingClientConfig, + SignalingMessage, +} from './core/signaling-client' + +// Hooks +export {usePeerConnection} from './hooks/use-peer-connection' +export {useWalletConnection} from './hooks/use-wallet-connection' +export {useWalletMessages} from './hooks/use-wallet-messages' + +// Utils +export { + createWalletRequest, + createTextMessage, + parseWalletMessage, + isWalletRequest, + isWalletResponse, + isHeartbeatMessage, +} from './utils/message-utils' + +export {getPersistentDappId, getPersistentWalletId} from './utils/id-utils' + +// Types +export type { + MessageType, + WalletRequest, + WalletResponse, + HeartbeatMessage, + WalletMessage, + ConnectionStatus, + PeerConnectionConfig, + ConnectionManagerConfig, + EventCallback, + EventListener, + WalletConnectionState, + PeerConnectionState, +} from './types' +// Re-export WebRTCAdapter for convenience +export type {WebRTCAdapter} from './types' + +// Constants +export { + MESSAGE_TYPES, + WALLET_METHODS, + STORAGE_KEYS, + CONNECTION_CONSTANTS, + PEER_CONFIG, +} from './constants' diff --git a/mobile/packages/p2p-communication/types.ts b/mobile/packages/p2p-communication/types.ts new file mode 100644 index 0000000000..7ecb82e709 --- /dev/null +++ b/mobile/packages/p2p-communication/types.ts @@ -0,0 +1,102 @@ +import {App, BaseStorage} from '@yoroi/types' + +/** + * P2P Communication Types + */ + +/** + * WebRTC Adapter interface + * Provides platform-agnostic WebRTC API access + * Apps should provide their own implementation based on their environment: + * - Browser: Pass native WebRTC APIs (RTCPeerConnection, RTCSessionDescription, RTCIceCandidate) + * - React Native: Pass exports from react-native-webrtc + */ +export type WebRTCAdapter = { + readonly RTCPeerConnection: new ( + configuration?: RTCConfiguration, + ) => RTCPeerConnection + readonly RTCSessionDescription: new ( + descriptionInitDict?: RTCSessionDescriptionInit, + ) => RTCSessionDescription + readonly RTCIceCandidate: new ( + candidateInitDict?: RTCIceCandidateInit, + ) => RTCIceCandidate +} + +export type MessageType = 'request' | 'response' | 'heartbeat' + +export type WalletRequest = { + readonly type: 'request' + readonly method: string + readonly data?: unknown + readonly id: number +} + +export type WalletResponse = { + readonly type: 'response' + readonly method: string + readonly data?: unknown + readonly error?: string + readonly id: number +} + +export type HeartbeatMessage = { + readonly type: 'heartbeat' + readonly action: 'ping' | 'pong' + readonly timestamp: number + readonly received?: number +} + +export type WalletMessage = WalletRequest | WalletResponse | HeartbeatMessage + +export type ConnectionStatus = + | 'initializing' + | 'ready' + | 'connecting' + | 'connected' + | 'disconnected' + | 'error' + | 'closed' + | 'reconnecting' + +export type PeerConnectionConfig = { + readonly signalingUrl?: string + readonly iceServers?: ReadonlyArray<{readonly urls: string}> + readonly debug?: number + readonly targetPeerId?: string // For wallet-to-wallet connections +} + +export type ConnectionManagerConfig = { + readonly storage: BaseStorage + readonly webrtcAdapter: WebRTCAdapter + readonly peerConfig?: PeerConnectionConfig + readonly isWallet?: boolean // Whether this is a wallet (true) or dApp (false/undefined) + readonly logger?: App.Logger.Manager // Optional logger for debugging +} + +export type EventCallback = (data?: T) => void + +export type EventListener = { + readonly open: ReadonlyArray> + readonly error: ReadonlyArray> + readonly close: ReadonlyArray> + readonly disconnected: ReadonlyArray> + readonly connection: ReadonlyArray> + readonly data: ReadonlyArray> + readonly connectionClosed: ReadonlyArray> + readonly peerConnected: ReadonlyArray> // When a specific peer connects +} + +export type WalletConnectionState = { + readonly walletId: string + readonly connected: boolean + readonly status: ConnectionStatus + readonly error: Error | null +} + +export type PeerConnectionState = { + readonly peerId: string + readonly status: ConnectionStatus + readonly isReady: boolean + readonly error: Error | null +} diff --git a/mobile/packages/p2p-communication/utils/id-utils.test.ts b/mobile/packages/p2p-communication/utils/id-utils.test.ts new file mode 100644 index 0000000000..5ba1f7f23b --- /dev/null +++ b/mobile/packages/p2p-communication/utils/id-utils.test.ts @@ -0,0 +1,77 @@ +import {BaseStorage} from '@yoroi/types' + +import {getPersistentDappId, getPersistentWalletId} from './id-utils' + +describe('id-utils', () => { + const createMockStorage = (): BaseStorage => { + const storage: Record = {} + + return { + getItem: async (key: string): Promise => { + return storage[key] ?? null + }, + setItem: async (key: string, value: string): Promise => { + storage[key] = value + }, + removeItem: async (key: string): Promise => { + delete storage[key] + }, + } + } + + describe('getPersistentDappId', () => { + it('should generate a new ID if none exists', async () => { + const storage = createMockStorage() + const id = await getPersistentDappId(storage) + + expect(id).toMatch(/^dapp-/) + expect(id.length).toBeGreaterThan(5) + }) + + it('should return existing ID if stored', async () => { + const storage = createMockStorage() + const existingId = 'dapp-existing-id-123' + await storage.setItem('dapp-peer-id', existingId) + + const id = await getPersistentDappId(storage) + + expect(id).toBe(existingId) + }) + + it('should persist generated ID', async () => { + const storage = createMockStorage() + const id1 = await getPersistentDappId(storage) + const id2 = await getPersistentDappId(storage) + + expect(id1).toBe(id2) + }) + }) + + describe('getPersistentWalletId', () => { + it('should generate a new ID if none exists', async () => { + const storage = createMockStorage() + const id = await getPersistentWalletId(storage) + + expect(id).toMatch(/^wallet-/) + expect(id.length).toBeGreaterThan(5) + }) + + it('should return existing ID if stored', async () => { + const storage = createMockStorage() + const existingId = 'wallet-existing-id-123' + await storage.setItem('wallet-peer-id', existingId) + + const id = await getPersistentWalletId(storage) + + expect(id).toBe(existingId) + }) + + it('should persist generated ID', async () => { + const storage = createMockStorage() + const id1 = await getPersistentWalletId(storage) + const id2 = await getPersistentWalletId(storage) + + expect(id1).toBe(id2) + }) + }) +}) diff --git a/mobile/packages/p2p-communication/utils/id-utils.ts b/mobile/packages/p2p-communication/utils/id-utils.ts new file mode 100644 index 0000000000..385e34b097 --- /dev/null +++ b/mobile/packages/p2p-communication/utils/id-utils.ts @@ -0,0 +1,90 @@ +import {BaseStorage} from '@yoroi/types' + +import {STORAGE_KEYS} from '../constants' + +/** + * ID Utilities with Storage Injection + */ + +export const getPersistentDappId = async ( + storage: BaseStorage, +): Promise => { + const existingId = await storage.getItem(STORAGE_KEYS.DAPP_PEER_ID) + if (existingId) { + return existingId + } + + const deviceId = generateDeviceId('dapp') + const id = `dapp-${deviceId}` + await storage.setItem(STORAGE_KEYS.DAPP_PEER_ID, id) + return id +} + +export const getPersistentWalletId = async ( + storage: BaseStorage, +): Promise => { + const existingId = await storage.getItem(STORAGE_KEYS.WALLET_PEER_ID) + if (existingId) { + return existingId + } + + const deviceId = await generateWalletDeviceId(storage) + const id = `wallet-${deviceId}` + await storage.setItem(STORAGE_KEYS.WALLET_PEER_ID, id) + return id +} + +const generateDeviceId = (_prefix: string): string => { + const fingerprint = [ + typeof navigator !== 'undefined' ? navigator.userAgent : '', + typeof navigator !== 'undefined' ? navigator.language : '', + typeof screen !== 'undefined' ? `${screen.width}x${screen.height}` : '', + new Date().getTimezoneOffset().toString(), + typeof navigator !== 'undefined' + ? (navigator.hardwareConcurrency ?? 'unknown').toString() + : 'unknown', + ].join('|') + + let hash = 0 + for (let i = 0; i < fingerprint.length; i++) { + const char = fingerprint.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + + const deviceHash = Math.abs(hash).toString(36) + const timestamp = Date.now().toString(24) + + return `${deviceHash}-${timestamp}` +} + +const generateWalletDeviceId = async ( + storage: BaseStorage, +): Promise => { + const fingerprint = [ + typeof navigator !== 'undefined' ? navigator.userAgent : '', + typeof screen !== 'undefined' ? `${screen.width}x${screen.height}` : '', + typeof navigator !== 'undefined' + ? (navigator.hardwareConcurrency ?? 'unknown').toString() + : 'unknown', + typeof navigator !== 'undefined' ? navigator.language : '', + new Date().getTimezoneOffset().toString(), + ].join('|') + + let hash = 0 + for (let i = 0; i < fingerprint.length; i++) { + const char = fingerprint.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + + const deviceHash = Math.abs(hash).toString(36) + + let installTime = await storage.getItem(STORAGE_KEYS.WALLET_INSTALL_TIME) + if (!installTime) { + installTime = Date.now().toString(36) + await storage.setItem(STORAGE_KEYS.WALLET_INSTALL_TIME, installTime) + } + + return `${deviceHash}-${installTime}` +} diff --git a/mobile/packages/p2p-communication/utils/logger.ts b/mobile/packages/p2p-communication/utils/logger.ts new file mode 100644 index 0000000000..6fd61f904d --- /dev/null +++ b/mobile/packages/p2p-communication/utils/logger.ts @@ -0,0 +1,23 @@ +import {App} from '@yoroi/types' + +/** + * No-op logger that does nothing + * Used as default when no logger is provided + */ +export const noOpLogger: App.Logger.Manager = { + level: App.Logger.Level.Debug, + debug: () => {}, + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + addTransport: () => () => {}, + disable: () => {}, + enable: () => {}, +} + +/** + * Get logger or return no-op logger + */ +export const getLogger = (logger?: App.Logger.Manager): App.Logger.Manager => + logger ?? noOpLogger diff --git a/mobile/packages/p2p-communication/utils/message-utils.test.ts b/mobile/packages/p2p-communication/utils/message-utils.test.ts new file mode 100644 index 0000000000..3d10ef7eaf --- /dev/null +++ b/mobile/packages/p2p-communication/utils/message-utils.test.ts @@ -0,0 +1,125 @@ +import {HeartbeatMessage, WalletRequest, WalletResponse} from '../types' +import { + createTextMessage, + createWalletRequest, + isHeartbeatMessage, + isWalletRequest, + isWalletResponse, + parseWalletMessage, +} from './message-utils' + +describe('message-utils', () => { + describe('createWalletRequest', () => { + it('should create a wallet request with method and data', () => { + const request = createWalletRequest('signTx', {amount: '100'}) + + expect(request.type).toBe('request') + expect(request.method).toBe('signTx') + expect(request.data).toEqual({amount: '100'}) + expect(typeof request.id).toBe('number') + }) + + it('should create a wallet request with null data when not provided', () => { + const request = createWalletRequest('signTx') + + expect(request.type).toBe('request') + expect(request.method).toBe('signTx') + expect(request.data).toBeNull() + }) + }) + + describe('createTextMessage', () => { + it('should create a text message', () => { + const message = createTextMessage('Hello') + + expect(message.message).toBe('Hello') + }) + }) + + describe('parseWalletMessage', () => { + it('should parse a JSON string message', () => { + const jsonString = JSON.stringify({ + type: 'request', + method: 'signTx', + id: 123, + }) + const message = parseWalletMessage(jsonString) + + expect(message.type).toBe('request') + expect((message as {method: string}).method).toBe('signTx') + }) + + it('should return object as-is if already parsed', () => { + const messageObj = {type: 'request', method: 'signTx', id: 123} + const message = parseWalletMessage(messageObj) + + expect(message).toEqual(messageObj) + }) + + it('should throw error for invalid JSON', () => { + expect(() => parseWalletMessage('invalid json')).toThrow( + 'Invalid message format', + ) + }) + }) + + describe('isWalletRequest', () => { + it('should return true for wallet request', () => { + const request: WalletRequest = { + type: 'request', + method: 'signTx', + id: 123, + } + expect(isWalletRequest(request)).toBe(true) + }) + + it('should return false for non-request messages', () => { + const response: WalletResponse = { + type: 'response', + method: 'signTx', + id: 123, + } + expect(isWalletRequest(response)).toBe(false) + }) + }) + + describe('isWalletResponse', () => { + it('should return true for wallet response', () => { + const response: WalletResponse = { + type: 'response', + method: 'signTx', + id: 123, + } + expect(isWalletResponse(response)).toBe(true) + }) + + it('should return false for non-response messages', () => { + const request: WalletRequest = { + type: 'request', + method: 'signTx', + id: 123, + } + expect(isWalletResponse(request)).toBe(false) + }) + }) + + describe('isHeartbeatMessage', () => { + it('should return true for heartbeat message', () => { + const heartbeat: HeartbeatMessage = { + type: 'heartbeat', + action: 'ping', + timestamp: 123456, + } + expect(isHeartbeatMessage(heartbeat)).toBe(true) + }) + + it('should return false for non-heartbeat messages', () => { + const request: WalletRequest = { + type: 'request', + method: 'signTx', + id: 123, + } + expect(isHeartbeatMessage(request)).toBe(false) + }) + }) +}) diff --git a/mobile/packages/p2p-communication/utils/message-utils.ts b/mobile/packages/p2p-communication/utils/message-utils.ts new file mode 100644 index 0000000000..4d4bd0bd9c --- /dev/null +++ b/mobile/packages/p2p-communication/utils/message-utils.ts @@ -0,0 +1,56 @@ +import { + HeartbeatMessage, + WalletMessage, + WalletRequest, + WalletResponse, +} from '../types' + +/** + * Message Utilities + */ + +export const createWalletRequest = ( + method: string, + data: unknown = null, +): WalletRequest => ({ + type: 'request', + method, + data: data ?? null, + id: Date.now(), +}) + +export const createTextMessage = ( + message: string, +): {readonly message: string} => ({ + message, +}) + +export const parseWalletMessage = (data: unknown): WalletMessage => { + try { + if (typeof data === 'string') { + return JSON.parse(data) as WalletMessage + } + return data as WalletMessage + } catch (error) { + // Silently fail - let caller handle error + throw new Error('Invalid message format') + } +} + +export const isWalletRequest = ( + message: WalletMessage, +): message is WalletRequest => { + return message.type === 'request' +} + +export const isWalletResponse = ( + message: WalletMessage, +): message is WalletResponse => { + return message.type === 'response' +} + +export const isHeartbeatMessage = ( + message: WalletMessage, +): message is HeartbeatMessage => { + return message.type === 'heartbeat' +} diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 98f6eab87f..72faa8f1d0 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -96,6 +96,9 @@ ], "@yoroi/types": [ "./packages/types" + ], + "@yoroi/p2p-communication": [ + "./packages/p2p-communication" ] } } diff --git a/scripts/build-packages.sh b/scripts/build-packages.sh index 4487a3ecf5..7ff5d155a5 100755 --- a/scripts/build-packages.sh +++ b/scripts/build-packages.sh @@ -31,7 +31,7 @@ LEVEL_0=("types" "identicon") LEVEL_1=("common") # Level 2: Depends on Level 1 -LEVEL_2=("theme" "api" "explorers" "portfolio" "notifications" "links" "dapp-connector") +LEVEL_2=("theme" "api" "explorers" "portfolio" "notifications" "links" "dapp-connector" "p2p-communication") # Level 3: Depends on Level 2 LEVEL_3=("exchange" "resolver" "claim" "setup-wallet") diff --git a/scripts/cleanup-packages.sh b/scripts/cleanup-packages.sh index d85177f4f0..ce87af5110 100755 --- a/scripts/cleanup-packages.sh +++ b/scripts/cleanup-packages.sh @@ -13,6 +13,7 @@ PACKAGES=( "identicon" "links" "notifications" + "p2p-communication" "portfolio" "resolver" "setup-wallet" diff --git a/scripts/create-src-symlinks.sh b/scripts/create-src-symlinks.sh index 479bf0297f..07a90b1f33 100755 --- a/scripts/create-src-symlinks.sh +++ b/scripts/create-src-symlinks.sh @@ -13,6 +13,7 @@ PACKAGES=( "identicon" "links" "notifications" + "p2p-communication" "portfolio" "resolver" "setup-wallet" diff --git a/scripts/move-package-configs.sh b/scripts/move-package-configs.sh index 11159b7674..f2a349fcde 100755 --- a/scripts/move-package-configs.sh +++ b/scripts/move-package-configs.sh @@ -13,6 +13,7 @@ PACKAGES=( "identicon" "links" "notifications" + "p2p-communication" "portfolio" "resolver" "setup-wallet" diff --git a/scripts/packages/p2p-communication/.dependency-cruiser.js b/scripts/packages/p2p-communication/.dependency-cruiser.js new file mode 100644 index 0000000000..3fd328c033 --- /dev/null +++ b/scripts/packages/p2p-communication/.dependency-cruiser.js @@ -0,0 +1,410 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + /* rules from the 'recommended' preset: */ + { + name: 'fix-circular', + severity: 'error', + comment: + 'This dependency is part of a circular relationship. You might want to revise ' + + 'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', + from: {}, + to: { + circular: true + } + }, + { + name: 'no-orphans', + comment: + "This is an orphan module - it's likely not used (anymore?). Either use it or " + + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + + "add an exception for it in your dependency-cruiser configuration. By default " + + "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " + + "files (.d.ts), tsconfig.json and some of the babel and webpack configs.", + severity: 'warn', + from: { + orphan: true, + pathNot: [ + '(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$', // dot files + '\\.d\\.ts$', // TypeScript declaration files + '(^|/)tsconfig\\.json$', // TypeScript config + '(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$' // other configs + ] + }, + to: {}, + }, + { + name: 'no-deprecated-core', + comment: + 'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + + "bound to exist - node doesn't deprecate lightly.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'core' + ], + path: [ + '^(v8\/tools\/codemap)$', + '^(v8\/tools\/consarray)$', + '^(v8\/tools\/csvparser)$', + '^(v8\/tools\/logreader)$', + '^(v8\/tools\/profile_view)$', + '^(v8\/tools\/profile)$', + '^(v8\/tools\/SourceMap)$', + '^(v8\/tools\/splaytree)$', + '^(v8\/tools\/tickprocessor-driver)$', + '^(v8\/tools\/tickprocessor)$', + '^(node-inspect\/lib\/_inspect)$', + '^(node-inspect\/lib\/internal\/inspect_client)$', + '^(node-inspect\/lib\/internal\/inspect_repl)$', + '^(async_hooks)$', + '^(punycode)$', + '^(domain)$', + '^(constants)$', + '^(sys)$', + '^(_linklist)$', + '^(_stream_wrap)$' + ], + } + }, + { + name: 'not-to-deprecated', + comment: + 'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + + 'version of that module, or find an alternative. Deprecated modules are a security risk.', + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'deprecated' + ] + } + }, + { + name: 'no-non-package-json', + severity: 'error', + comment: + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + + "available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " + + "in your package.json.", + from: {}, + to: { + dependencyTypes: [ + 'npm-no-pkg', + 'npm-unknown' + ] + } + }, + { + name: 'not-to-unresolvable', + comment: + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + + 'module: add it to your package.json. In all other cases you likely already know what to do.', + severity: 'error', + from: {}, + to: { + couldNotResolve: true + } + }, + { + name: 'no-duplicate-dep-types', + comment: + "Likely this module depends on an external ('npm') package that occurs more than once " + + "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " + + "maintenance problems later on.", + severity: 'warn', + from: {}, + to: { + moreThanOneDependencyType: true, + // as it's pretty common to have a type import be a type only import + // _and_ (e.g.) a devDependency - don't consider type-only dependency + // types for this rule + dependencyTypesNot: ["type-only"] + } + }, + + /* rules you might want to tweak for your specific situation: */ + { + name: 'not-to-spec', + comment: + 'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + + "If there's something in a spec that's of use to other modules, it doesn't have that single " + + 'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', + severity: 'error', + from: {}, + to: { + path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$' + } + }, + { + name: 'not-to-dev-dep', + severity: 'error', + comment: + "This module depends on an npm package from the 'devDependencies' section of your " + + 'package.json. It looks like something that ships to production, though. To prevent problems ' + + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + + 'section of your package.json. If this module is development only - add it to the ' + + 'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', + from: { + path: '^(src)', + pathNot: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$' + }, + to: { + dependencyTypes: [ + 'npm-dev' + ] + } + }, + { + name: 'optional-deps-used', + severity: 'info', + comment: + "This module depends on an npm package that is declared as an optional dependency " + + "in your package.json. As this makes sense in limited situations only, it's flagged here. " + + "If you're using an optional dependency here by design - add an exception to your" + + "dependency-cruiser configuration.", + from: {}, + to: { + dependencyTypes: [ + 'npm-optional' + ] + } + }, + { + name: 'peer-deps-used', + comment: + "This module depends on an npm package that is declared as a peer dependency " + + "in your package.json. This makes sense if your package is e.g. a plugin, but in " + + "other cases - maybe not so much. If the use of a peer dependency is intentional " + + "add an exception to your dependency-cruiser configuration.", + severity: 'warn', + from: {}, + to: { + dependencyTypes: [ + 'npm-peer' + ] + } + } + ], + options: { + + /* conditions specifying which files not to follow further when encountered: + - path: a regular expression to match + - dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/master/doc/rules-reference.md#dependencytypes-and-dependencytypesnot + for a complete list + */ + doNotFollow: { + path: 'node_modules' + }, + + /* conditions specifying which dependencies to exclude + - path: a regular expression to match + - dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies. + leave out if you want to exclude neither (recommended!) + */ + // exclude : { + // path: '', + // dynamic: true + // }, + + /* pattern specifying which files to include (regular expression) + dependency-cruiser will skip everything not matching this pattern + */ + // includeOnly : '', + + /* dependency-cruiser will include modules matching against the focus + regular expression in its output, as well as their neighbours (direct + dependencies and dependents) + */ + // focus : '', + + /* list of module systems to cruise */ + // moduleSystems: ['amd', 'cjs', 'es6', 'tsd'], + + /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/' + to open it on your online repo or `vscode://file/${process.cwd()}/` to + open it in visual studio code), + */ + // prefix: '', + + /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation + true: also detect dependencies that only exist before typescript-to-javascript compilation + "specify": for each dependency identify whether it only exists before compilation or also after + */ + tsPreCompilationDeps: true, + + /* + list of extensions to scan that aren't javascript or compile-to-javascript. + Empty by default. Only put extensions in here that you want to take into + account that are _not_ parsable. + */ + // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], + + /* if true combines the package.jsons found from the module up to the base + folder the cruise is initiated from. Useful for how (some) mono-repos + manage dependencies & dependency definitions. + */ + // combinedDependencies: false, + + /* if true leave symlinks untouched, otherwise use the realpath */ + // preserveSymlinks: false, + + /* TypeScript project file ('tsconfig.json') to use for + (1) compilation and + (2) resolution (e.g. with the paths property) + + The (optional) fileName attribute specifies which file to take (relative to + dependency-cruiser's current working directory). When not provided + defaults to './tsconfig.json'. + */ + tsConfig: { + fileName: 'tsconfig.json' + }, + + /* Webpack configuration to use to get resolve options from. + + The (optional) fileName attribute specifies which file to take (relative + to dependency-cruiser's current working directory. When not provided defaults + to './webpack.conf.js'. + + The (optional) `env` and `args` attributes contain the parameters to be passed if + your webpack config is a function and takes them (see webpack documentation + for details) + */ + // webpackConfig: { + // fileName: './webpack.config.js', + // env: {}, + // args: {}, + // }, + + /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use + for compilation (and whatever other naughty things babel plugins do to + source code). This feature is well tested and usable, but might change + behavior a bit over time (e.g. more precise results for used module + systems) without dependency-cruiser getting a major version bump. + */ + // babelConfig: { + // fileName: './.babelrc' + // }, + + /* List of strings you have in use in addition to cjs/ es6 requires + & imports to declare module dependencies. Use this e.g. if you've + re-declared require, use a require-wrapper or use window.require as + a hack. + */ + // exoticRequireStrings: [], + /* options to pass on to enhanced-resolve, the package dependency-cruiser + uses to resolve module references to disk. You can set most of these + options in a webpack.conf.js - this section is here for those + projects that don't have a separate webpack config file. + + Note: settings in webpack.conf.js override the ones specified here. + */ + enhancedResolveOptions: { + /* List of strings to consider as 'exports' fields in package.json. Use + ['exports'] when you use packages that use such a field and your environment + supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack). + + If you have an `exportsFields` attribute in your webpack config, that one + will have precedence over the one specified here. + */ + exportsFields: ["exports"], + /* List of conditions to check for in the exports field. e.g. use ['imports'] + if you're only interested in exposed es6 modules, ['require'] for commonjs, + or all conditions at once `(['import', 'require', 'node', 'default']`) + if anything goes for you. Only works when the 'exportsFields' array is + non-empty. + + If you have a 'conditionNames' attribute in your webpack config, that one will + have precedence over the one specified here. + */ + conditionNames: ["import", "require", "node", "default"], + /* + The extensions, by default are the same as the ones dependency-cruiser + can access (run `npx depcruise --info` to see which ones that are in + _your_ environment. If that list is larger than what you need (e.g. + it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use + TypeScript you can pass just the extensions you actually use (e.g. + [".js", ".jsx"]). This can speed up the most expensive step in + dependency cruising (module resolution) quite a bit. + */ + // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + /* + If your TypeScript project makes use of types specified in 'types' + fields in package.jsons of external dependencies, specify "types" + in addition to "main" in here, so enhanced-resolve (the resolver + dependency-cruiser uses) knows to also look there. You can also do + this if you're not sure, but still use TypeScript. In a future version + of dependency-cruiser this will likely become the default. + */ + mainFields: ["main", "types"], + }, + reporterOptions: { + dot: { + /* pattern of modules that can be consolidated in the detailed + graphical dependency graph. The default pattern in this configuration + collapses everything in node_modules to one folder deep so you see + the external modules, but not the innards your app depends upon. + */ + collapsePattern: 'node_modules/[^/]+', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/master/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + don't worry - dependency-cruiser will fall back to the default one. + */ + // theme: { + // graph: { + // /* use splines: "ortho" for straight lines. Be aware though + // graphviz might take a long time calculating ortho(gonal) + // routings. + // */ + // splines: "true" + // }, + // modules: [ + // { + // criteria: { matchesFocus: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + // }, + // }, + // { + // criteria: { matchesFocus: false }, + // attributes: { + // fillcolor: "lightgrey", + // }, + // }, + // { + // criteria: { matchesReaches: true }, + // attributes: { + // fillcolor: "lime", + // penwidth: 2, + }, + archi: { + /* pattern of modules that can be consolidated in the high level + graphical dependency graph. If you use the high level graphical + dependency graph reporter (`archi`) you probably want to tweak + this collapsePattern to your situation. + */ + collapsePattern: '^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/[^/]+', + + /* Options to tweak the appearance of your graph.See + https://github.com/sverweij/dependency-cruiser/blob/master/doc/options-reference.md#reporteroptions + for details and some examples. If you don't specify a theme + for 'archi' dependency-cruiser will use the one specified in the + dot section (see above), if any, and otherwise use the default one. + */ + // theme: { + // }, + }, + "text": { + "highlightFocused": true + }, + } + } +}; +// generated: dependency-cruiser@12.10.0 on 2023-03-08T01:53:10.874Z + diff --git a/scripts/packages/p2p-communication/.eslintignore b/scripts/packages/p2p-communication/.eslintignore new file mode 100644 index 0000000000..44cfda4ae3 --- /dev/null +++ b/scripts/packages/p2p-communication/.eslintignore @@ -0,0 +1,11 @@ +node_modules/ +lib/ +babel.config.js +jest.config.js +jest.setup.js +coverage/ +commitlint.config.js +bob.config.js +.release-it.json +__mocks__/ + diff --git a/scripts/packages/p2p-communication/.eslintrc.json b/scripts/packages/p2p-communication/.eslintrc.json new file mode 100644 index 0000000000..dd032603b2 --- /dev/null +++ b/scripts/packages/p2p-communication/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "extends": ["@react-native", "prettier"], + "plugins": ["prettier"], + "rules": { + "@babel/no-invalid-this": "off", + "react-native/no-inline-styles": "off", + "@typescript-eslint/no-shadow": "off", + "react/no-unstable-nested-components": ["error", {"allowAsProps": true}], + "eqeqeq": "off", + "prettier/prettier": "error" + }, + "root": true +} + diff --git a/scripts/packages/p2p-communication/.gitignore b/scripts/packages/p2p-communication/.gitignore new file mode 100644 index 0000000000..019c809e96 --- /dev/null +++ b/scripts/packages/p2p-communication/.gitignore @@ -0,0 +1,72 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +!.vscode/launch.json +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ + diff --git a/scripts/packages/p2p-communication/.prettierrc b/scripts/packages/p2p-communication/.prettierrc new file mode 100644 index 0000000000..f1c66b20db --- /dev/null +++ b/scripts/packages/p2p-communication/.prettierrc @@ -0,0 +1,10 @@ +{ + "bracketSpacing": false, + "quoteProps": "consistent", + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} + diff --git a/scripts/packages/p2p-communication/.release-it.json b/scripts/packages/p2p-communication/.release-it.json new file mode 100644 index 0000000000..c99e2977fb --- /dev/null +++ b/scripts/packages/p2p-communication/.release-it.json @@ -0,0 +1,18 @@ +{ + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": false + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": "angular" + } + } +} + diff --git a/scripts/packages/p2p-communication/babel.config.js b/scripts/packages/p2p-communication/babel.config.js new file mode 100644 index 0000000000..f7e79fdba6 --- /dev/null +++ b/scripts/packages/p2p-communication/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +} + diff --git a/scripts/packages/p2p-communication/bob.config.js b/scripts/packages/p2p-communication/bob.config.js new file mode 100644 index 0000000000..1d0e495cf9 --- /dev/null +++ b/scripts/packages/p2p-communication/bob.config.js @@ -0,0 +1,16 @@ +module.exports = { + source: 'src', + output: 'lib', + targets: [ + 'commonjs', + 'module', + [ + 'typescript', + { + project: 'tsconfig.build.json', + tsc: './node_modules/.bin/tsc', + }, + ], + ], +} + diff --git a/scripts/packages/p2p-communication/commitlint.config.js b/scripts/packages/p2p-communication/commitlint.config.js new file mode 100644 index 0000000000..8d36a01c48 --- /dev/null +++ b/scripts/packages/p2p-communication/commitlint.config.js @@ -0,0 +1,4 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +} + diff --git a/scripts/packages/p2p-communication/jest.config.js b/scripts/packages/p2p-communication/jest.config.js new file mode 100644 index 0000000000..d93ff14f13 --- /dev/null +++ b/scripts/packages/p2p-communication/jest.config.js @@ -0,0 +1,39 @@ +module.exports = { + preset: 'react-native', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transformIgnorePatterns: [ + 'node_modules/(?!(react-native|@react-native|@react-native-async-storage/async-storage|@testing-library/react-native|react-native-webrtc)/)', + ], + moduleNameMapper: { + '^react-native$': '/node_modules/react-native', + '@react-native-async-storage/async-storage': + '/node_modules/@react-native-async-storage/async-storage/jest/async-storage-mock', + }, + setupFiles: ['/jest.setup.js'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', + collectCoverage: true, + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!src/**/*.d.ts', + '!src/fixtures/**', + '!src/**/*.mocks.ts', + ], + coverageReporters: ['text-summary', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + modulePathIgnorePatterns: [ + '/example/node_modules', + '/lib/', + ], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', + }, + testEnvironment: 'jsdom', +} + diff --git a/scripts/packages/p2p-communication/jest.setup.js b/scripts/packages/p2p-communication/jest.setup.js new file mode 100644 index 0000000000..9d5315e165 --- /dev/null +++ b/scripts/packages/p2p-communication/jest.setup.js @@ -0,0 +1,6 @@ +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +) + +jest.setTimeout(5000) + diff --git a/scripts/packages/p2p-communication/package.json b/scripts/packages/p2p-communication/package.json new file mode 100644 index 0000000000..998cf12d1d --- /dev/null +++ b/scripts/packages/p2p-communication/package.json @@ -0,0 +1,113 @@ +{ + "name": "@yoroi/p2p-communication", + "version": "0.1.0", + "description": "P2P communication package for Cardano wallet connections using WebRTC", + "keywords": [ + "yoroi", + "cardano", + "p2p", + "webrtc", + "wallet", + "communication", + "browser", + "react", + "react-native", + "typescript" + ], + "homepage": "https://github.com/Emurgo/yoroi/tree/develop/packages/p2p-communication/README.md", + "bugs": { + "url": "https://github.com/Emurgo/yoroi/issues" + }, + "repository": { + "type": "github", + "url": "https://github.com/Emurgo/yoroi.git", + "directory": "packages/p2p-communication" + }, + "license": "Apache-2.0", + "author": "EMURGO Fintech (https://github.com/Emurgo/yoroi)", + "main": "lib/commonjs/index", + "module": "lib/module/index", + "source": "src/index", + "browser": "lib/module/index", + "types": "lib/typescript/index.d.ts", + "files": [ + "src", + "lib", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "scripts": { + "build": "npm run tsc && npm run lint && npm run test --ci --silent && npm run clean && bob build", + "build:dev": "npm run tsc && npm run clean && bob build", + "build:release": "npm run build && npm run flow", + "clean": "del-cli lib", + "dev": "npm run clean && bob build", + "dgraph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", + "flow": ". ./scripts/flowgen.sh", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "prepack": "npm run build:release", + "prepublish:beta": "npm run build:release", + "publish:beta": "npm publish --scope yoroi --tag beta --access beta", + "prepublish:prod": "npm run build:release", + "publish:prod": "npm publish --scope yoroi --access public", + "release": "release-it", + "test": "jest --maxWorkers=1 --passWithNoTests", + "test:watch": "jest --watch --maxWorkers=1", + "tsc": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@babel/core": "7.26.0", + "@babel/preset-env": "7.25.3", + "@babel/runtime": "7.25.0", + "@commitlint/config-conventional": "17.0.2", + "@react-native-community/cli": "18.0.0", + "@react-native-community/cli-platform-android": "18.0.0", + "@react-native-community/cli-platform-ios": "18.0.0", + "@react-native/babel-preset": "0.79.2", + "@react-native/eslint-config": "0.79.2", + "@react-native/metro-config": "0.79.2", + "@react-native/typescript-config": "0.79.2", + "@release-it/conventional-changelog": "10.0.1", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "16.3.0", + "@testing-library/react-native": "13.2.0", + "@tsconfig/react-native": "3.0.3", + "@types/jest": "29.5.12", + "@types/react": "19.0.10", + "@types/react-test-renderer": "19.0.0", + "@yoroi/types": "file:../types", + "babel-jest": "29.7.0", + "commitlint": "17.0.2", + "del-cli": "6.0.0", + "dependency-cruiser": "16.10.2", + "eslint": "8.57.0", + "eslint-config-prettier": "10.1.5", + "eslint-plugin-ft-flow": "3.0.11", + "eslint-plugin-prettier": "5.4.0", + "flowgen": "1.21.0", + "jest": "29.7.0", + "jest-expo": "53.0.9", + "pod-install": "0.1.0", + "prettier": "3.5.3", + "react-native-builder-bob": "0.40.11", + "react-test-renderer": "19.0.0", + "release-it": "19.0.2", + "typescript": "5.8.3" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "2.1.2", + "@yoroi/types": "file:../types", + "immer": "10.1.1", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-native": "0.79.5", + "react-native-webrtc": "^111.0.0" + }, + "packageManager": "npm@10.9.0", + "engines": { + "node": ">= 22.12.0" + } +} + diff --git a/scripts/packages/p2p-communication/scripts/flowgen.sh b/scripts/packages/p2p-communication/scripts/flowgen.sh new file mode 100644 index 0000000000..177738eb10 --- /dev/null +++ b/scripts/packages/p2p-communication/scripts/flowgen.sh @@ -0,0 +1,4 @@ +for i in $(find lib -type f -name "*.d.ts"); + do sh -c "npx flowgen $i -o ${i%.*.*}.js.flow"; +done; + diff --git a/scripts/packages/p2p-communication/src b/scripts/packages/p2p-communication/src new file mode 120000 index 0000000000..13caf6e1b2 --- /dev/null +++ b/scripts/packages/p2p-communication/src @@ -0,0 +1 @@ +../../../mobile/packages/p2p-communication \ No newline at end of file diff --git a/scripts/packages/p2p-communication/tsconfig.build.json b/scripts/packages/p2p-communication/tsconfig.build.json new file mode 100644 index 0000000000..ffe89a6433 --- /dev/null +++ b/scripts/packages/p2p-communication/tsconfig.build.json @@ -0,0 +1,6 @@ + +{ + "extends": "./tsconfig.json", + "exclude": ["example", "src/**/*.spec.ts", "src/**/*.test.ts"] +} + diff --git a/scripts/packages/p2p-communication/tsconfig.json b/scripts/packages/p2p-communication/tsconfig.json new file mode 100644 index 0000000000..aeafa031dc --- /dev/null +++ b/scripts/packages/p2p-communication/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "declaration": true, + "baseUrl": ".", + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "paths": { + "src/*": ["./src/*"] + } + } +} + diff --git a/scripts/prune-pkgs.sh b/scripts/prune-pkgs.sh index 9c86d3b4ce..2c2cf1708e 100755 --- a/scripts/prune-pkgs.sh +++ b/scripts/prune-pkgs.sh @@ -13,6 +13,7 @@ PACKAGES=( "notifications" "links" "dapp-connector" + "p2p-communication" "exchange" "resolver" "claim" diff --git a/scripts/standardize-versions.js b/scripts/standardize-versions.js index 2368108a45..24091eab5f 100755 --- a/scripts/standardize-versions.js +++ b/scripts/standardize-versions.js @@ -38,6 +38,7 @@ const STANDARD_VERSIONS = { "@yoroi/links": "file:../links", "@yoroi/notifications": "file:../notifications", "@yoroi/dapp-connector": "file:../dapp-connector", + "@yoroi/p2p-communication": "file:../p2p-communication", // Dev dependencies "@babel/core": "7.26.0", @@ -96,6 +97,7 @@ const PACKAGES = [ "scripts/packages/identicon", "scripts/packages/links", "scripts/packages/notifications", + "scripts/packages/p2p-communication", "scripts/packages/portfolio", "scripts/packages/resolver", "scripts/packages/setup-wallet", From 26eec2bf103381e9f9fd0d63abe9f7d04c5403b0 Mon Sep 17 00:00:00 2001 From: jorbuedo Date: Sat, 8 Nov 2025 16:07:28 +0100 Subject: [PATCH 002/335] improve links --- mobile/packages/links/cardano/constants.ts | 137 ++++++++++++- mobile/packages/links/cardano/helpers.ts | 180 +++++++++++++++++- mobile/packages/links/cardano/module.ts | 170 +++++++++++++++-- mobile/packages/links/cardano/params.ts | 90 ++++++++- mobile/packages/links/cardano/types.ts | 115 +++++++++++ mobile/packages/links/cardano/validators.ts | 56 ++++++ mobile/packages/p2p-communication/README.md | 65 +++++++ mobile/packages/p2p-communication/index.ts | 7 + .../utils/deeplink-utils.test.ts | 117 ++++++++++++ .../p2p-communication/utils/deeplink-utils.ts | 90 +++++++++ 10 files changed, 1011 insertions(+), 16 deletions(-) create mode 100644 mobile/packages/links/cardano/validators.ts create mode 100644 mobile/packages/p2p-communication/utils/deeplink-utils.test.ts create mode 100644 mobile/packages/p2p-communication/utils/deeplink-utils.ts diff --git a/mobile/packages/links/cardano/constants.ts b/mobile/packages/links/cardano/constants.ts index 7b05ca7d14..fd0e5972ae 100644 --- a/mobile/packages/links/cardano/constants.ts +++ b/mobile/packages/links/cardano/constants.ts @@ -2,9 +2,21 @@ import {Links} from '@yoroi/types' import {freeze} from 'immer' -import {LinksCardanoClaimV1, LinksCardanoLegacyTransfer} from './types' +import { + LinksCardanoAddressV1, + LinksCardanoBlockV1, + LinksCardanoBrowseV1, + LinksCardanoClaimV1, + LinksCardanoConnectV1, + LinksCardanoLegacyTransfer, + LinksCardanoPayV1, + LinksCardanoPaymentV1, + LinksCardanoStakeV1, + LinksCardanoTransactionV1, +} from './types' export const cardanoScheme: Links.WebCardanoUriConfig['scheme'] = 'web+cardano' + export const configCardanoClaimV1: Readonly = freeze( { scheme: cardanoScheme, @@ -20,6 +32,8 @@ export const configCardanoClaimV1: Readonly = freeze( true, ) +// @deprecated Use configCardanoPayV1 instead +// LEGACY COMPATIBILITY: Kept for backward compatibility export const configCardanoLegacyTransfer: Readonly = freeze( { @@ -35,3 +49,124 @@ export const configCardanoLegacyTransfer: Readonly = }, true, ) + +export const configCardanoBrowseV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'browse', + version: 'v1', + rules: { + requiredParams: ['scheme', 'namespaced_domain'], + optionalParams: ['app_path', 'url'], + forbiddenParams: [], + extraParams: 'include', + }, + }, + true, +) + +export const configCardanoPayV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'pay', + version: 'v1', + rules: { + requiredParams: ['address'], + optionalParams: ['amount', 'asset', 'memo'], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) + +export const configCardanoPaymentV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'payment', + version: 'v1', + rules: { + requiredParams: ['address'], + optionalParams: ['amount', 'asset', 'memo'], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) + +export const configCardanoStakeV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'stake', + version: 'v1', + rules: { + requiredParams: ['pool'], + optionalParams: [], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) + +export const configCardanoTransactionV1: Readonly = + freeze( + { + scheme: cardanoScheme, + authority: 'transaction', + version: 'v1', + rules: { + requiredParams: ['hash'], + optionalParams: [], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, + ) + +export const configCardanoBlockV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'block', + version: 'v1', + rules: { + requiredParams: [], + optionalParams: ['hash', 'height'], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) + +export const configCardanoAddressV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'address', + version: 'v1', + rules: { + requiredParams: ['address'], + optionalParams: [], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) + +export const configCardanoConnectV1: Readonly = freeze( + { + scheme: cardanoScheme, + authority: 'connect', + version: 'v1', + rules: { + requiredParams: ['peerId'], + optionalParams: ['signalingUrl'], + forbiddenParams: [], + extraParams: 'drop', + }, + }, + true, +) diff --git a/mobile/packages/links/cardano/helpers.ts b/mobile/packages/links/cardano/helpers.ts index a98121e943..c462975485 100644 --- a/mobile/packages/links/cardano/helpers.ts +++ b/mobile/packages/links/cardano/helpers.ts @@ -1,6 +1,182 @@ import {isString} from '@yoroi/common' +import {Links} from '@yoroi/types' -// TODO: validate address with headless -// NOTE: simple test for now +import { + configCardanoAddressV1, + configCardanoBlockV1, + configCardanoBrowseV1, + configCardanoClaimV1, + configCardanoConnectV1, + configCardanoPayV1, + configCardanoPaymentV1, + configCardanoStakeV1, + configCardanoTransactionV1, +} from './constants' + +/** + * Classifies and validates a Cardano address + * Based on cardano-uri-parser classifyCardanoAddress logic + * @param address - The address string to validate + * @returns Object with valid flag, type, and is_testnet flag + */ +export const classifyCardanoAddress = ( + address: string, +): {valid: boolean; type?: string; is_testnet?: boolean} => { + const is_testnet = address.includes('_test') + + if (address.startsWith('stake1') || address.startsWith('stake_test1')) { + return { + valid: address.length === 59 || address.length === 64, + type: 'stake', + is_testnet, + } + } + + if (address.startsWith('addr1')) { + return { + valid: address.length === 103 || address.length === 58, + type: 'shelley', + is_testnet, + } + } + + if (address.startsWith('addr_test1')) { + return { + valid: address.length === 108 || address.length === 63, + type: 'shelley', + is_testnet, + } + } + + if (address.startsWith('Ae2')) { + return { + valid: address.length >= 59 && address.length <= 64, + type: 'byron_icarus', + is_testnet, + } + } + + if (address.startsWith('DdzFF')) { + return { + valid: address.length >= 104 && address.length <= 128, + type: 'byron_daedalus', + is_testnet, + } + } + + const cip105Prefixes = [ + 'drep_vk', + 'drep_script', + 'drep', + 'cc_cold_vk', + 'cc_cold_script', + 'cc_cold', + 'cc_hot_vk', + 'cc_hot_script', + 'cc_hot', + ] + + for (const prefix of cip105Prefixes) { + if (address.startsWith(prefix)) { + return {valid: true, type: prefix, is_testnet} + } + } + + return {valid: false} +} + +/** + * Validates if a string is a valid Cardano address + * Uses classifyCardanoAddress for comprehensive validation + * @param address - The address string to validate + * @returns true if the address is valid, false otherwise + */ +export const validateCardanoAddress = (address: string): boolean => { + if (!isString(address)) { + return false + } + return classifyCardanoAddress(address).valid +} + +/** + * @deprecated Use validateCardanoAddress instead + * LEGACY COMPATIBILITY: Simple regex-based validation kept for backward compatibility + * NOTE: This is a simple test and may not catch all invalid addresses + */ export const isCardanoAddress = (address: string) => isString(address) && /^[A-Za-z_0-9]+$/.test(address) + +/** + * Helper functions to detect Cardano URI authorities + */ +export const isCardanoClaimV1 = (url: URL): boolean => { + if (url.hostname === configCardanoClaimV1.authority) { + if (url.pathname === `/${configCardanoClaimV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoBrowseV1 = (url: URL): boolean => { + if (url.hostname === configCardanoBrowseV1.authority) { + if (url.pathname.startsWith(`/${configCardanoBrowseV1.version}/`)) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoPayV1 = (url: URL): boolean => { + if (url.hostname === configCardanoPayV1.authority) { + if (url.pathname === `/${configCardanoPayV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoPaymentV1 = (url: URL): boolean => { + if (url.hostname === configCardanoPaymentV1.authority) { + if (url.pathname === `/${configCardanoPaymentV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoStakeV1 = (url: URL): boolean => { + if (url.hostname === configCardanoStakeV1.authority) { + if (url.pathname === `/${configCardanoStakeV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoTransactionV1 = (url: URL): boolean => { + if (url.hostname === configCardanoTransactionV1.authority) { + if (url.pathname.startsWith(`/${configCardanoTransactionV1.version}/`)) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoBlockV1 = (url: URL): boolean => { + if (url.hostname === configCardanoBlockV1.authority) { + if (url.pathname === `/${configCardanoBlockV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoAddressV1 = (url: URL): boolean => { + if (url.hostname === configCardanoAddressV1.authority) { + if (url.pathname.startsWith(`/${configCardanoAddressV1.version}/`)) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} + +export const isCardanoConnectV1 = (url: URL): boolean => { + if (url.hostname === configCardanoConnectV1.authority) { + if (url.pathname === `/${configCardanoConnectV1.version}`) return true + throw new Links.Errors.UnsupportedVersion() + } + return false +} diff --git a/mobile/packages/links/cardano/module.ts b/mobile/packages/links/cardano/module.ts index a5bd8e240d..7e60e1ea2e 100644 --- a/mobile/packages/links/cardano/module.ts +++ b/mobile/packages/links/cardano/module.ts @@ -5,12 +5,40 @@ import {freeze} from 'immer' import { cardanoScheme, + configCardanoAddressV1, + configCardanoBlockV1, + configCardanoBrowseV1, configCardanoClaimV1, + configCardanoConnectV1, configCardanoLegacyTransfer, + configCardanoPayV1, + configCardanoPaymentV1, + configCardanoStakeV1, + configCardanoTransactionV1, } from './constants' -import {isCardanoAddress} from './helpers' +import { + isCardanoAddress, + isCardanoAddressV1, + isCardanoBlockV1, + isCardanoBrowseV1, + isCardanoClaimV1, + isCardanoConnectV1, + isCardanoPayV1, + isCardanoPaymentV1, + isCardanoStakeV1, + isCardanoTransactionV1, + validateCardanoAddress, +} from './helpers' import {preapareParams} from './params' import {LinksCardanoUriConfig} from './types' +import { + isValidBlockHeight, + isValidHex64, + validateBlockHash, + validateNamespacedDomain, + validateScheme, + validateTransactionHash, +} from './validators' export const linksCardanoModuleMaker = (): Links.Module => { @@ -52,7 +80,27 @@ export const linksCardanoModuleMaker = } url = new URL(config.scheme + ':' + address) addSearchParams(url, restParams) + } else if (config.authority === 'browse') { + // CIP-158 Browse: path-based authority + const {scheme, namespaced_domain, app_path, url: reconstructedUrl, ...queryParams} = sanitizedParams + const pathSegments = [config.version, scheme, namespaced_domain] + if (app_path) { + pathSegments.push(...app_path.split('/').filter(Boolean)) + } + url = new URL(config.scheme + '://' + config.authority + '/' + pathSegments.join('/')) + addSearchParams(url, queryParams) + } else if (config.authority === 'transaction') { + // CIP-107 Transaction: path-based authority + const {hash, ...restParams} = sanitizedParams + url = new URL(config.scheme + '://' + config.authority + '/' + config.version + '/' + hash) + addSearchParams(url, restParams) + } else if (config.authority === 'address') { + // CIP-134 Address: path-based authority + const {address, ...restParams} = sanitizedParams + url = new URL(config.scheme + '://' + config.authority + '/' + config.version + '/' + address) + addSearchParams(url, restParams) } else { + // Query-based authorities (claim, pay, payment, stake, block, connect) url = new URL(config.scheme + '://' + config.authority + '/') addSearchParams(url, sanitizedParams) url.pathname = config.version @@ -73,6 +121,7 @@ export const linksCardanoModuleMaker = let config: LinksCardanoUriConfig | undefined const params: Record = {} + // Extract query params url.searchParams.forEach((value, key) => { // TODO: add support for records if (params[key]) { @@ -86,11 +135,116 @@ export const linksCardanoModuleMaker = } }) - // NOTE: order matters - if (isCardanoClaimV1(url)) { + // NOTE: order matters - check path-based authorities first + if (isCardanoBrowseV1(url)) { + // CIP-158 Browse: extract from path segments + const pathParts = url.pathname.split('/').filter(Boolean) + if (pathParts.length < 3) { + throw new Links.Errors.ParamsValidationFailed( + 'Browse URI must have at least version, scheme, and namespaced_domain', + ) + } + const [, scheme, namespacedDomain, ...appPathParts] = pathParts + if (!validateScheme(scheme)) { + throw new Links.Errors.ParamsValidationFailed('Invalid scheme format') + } + if (!validateNamespacedDomain(namespacedDomain)) { + throw new Links.Errors.ParamsValidationFailed( + 'Invalid namespaced domain format', + ) + } + const appPath = appPathParts.join('/') + params.scheme = scheme + params.namespaced_domain = namespacedDomain + if (appPath) { + params.app_path = appPath + } + // Reconstruct URL + const reversedDomain = namespacedDomain.split('.').reverse().join('.') + const queryString = url.searchParams.toString() + ? `?${url.searchParams.toString()}` + : '' + params.url = `${scheme}://${reversedDomain}/${appPath}${queryString}` + config = configCardanoBrowseV1 + } else if (isCardanoTransactionV1(url)) { + // CIP-107 Transaction: extract hash from path + const pathParts = url.pathname.split('/').filter(Boolean) + if (pathParts.length < 2) { + throw new Links.Errors.ParamsValidationFailed( + 'Transaction URI must have version and hash', + ) + } + const [, hash] = pathParts + if (!validateTransactionHash(hash)) { + throw new Links.Errors.ParamsValidationFailed( + 'Invalid transaction hash format', + ) + } + params.hash = hash + // Handle fragment for output_index if present + if (url.hash) { + const outputIndex = Number(url.hash.slice(1)) + if (Number.isInteger(outputIndex) && outputIndex >= 0) { + params.output_index = outputIndex + } + } + config = configCardanoTransactionV1 + } else if (isCardanoAddressV1(url)) { + // CIP-134 Address: extract address from path + const pathParts = url.pathname.split('/').filter(Boolean) + if (pathParts.length < 2) { + throw new Links.Errors.ParamsValidationFailed( + 'Address URI must have version and address', + ) + } + const [, address] = pathParts + if (!validateCardanoAddress(address)) { + throw new Links.Errors.ParamsValidationFailed( + 'Invalid Cardano address format', + ) + } + params.address = address + config = configCardanoAddressV1 + } else if (isCardanoClaimV1(url)) { config = configCardanoClaimV1 + } else if (isCardanoPayV1(url)) { + config = configCardanoPayV1 + } else if (isCardanoPaymentV1(url)) { + config = configCardanoPaymentV1 + } else if (isCardanoStakeV1(url)) { + config = configCardanoStakeV1 + } else if (isCardanoBlockV1(url)) { + // CIP-107 Block: validate either hash or height is present + const hash = params.hash + const height = params.height + if (!hash && !height) { + throw new Links.Errors.ParamsValidationFailed( + 'Block URI must have either hash or height', + ) + } + if (hash && height) { + throw new Links.Errors.ParamsValidationFailed( + 'Cannot provide both block hash and height', + ) + } + if (hash && !validateBlockHash(hash)) { + throw new Links.Errors.ParamsValidationFailed( + 'Invalid block hash format', + ) + } + if (height && !isValidBlockHeight(height)) { + throw new Links.Errors.ParamsValidationFailed( + 'Invalid block height format', + ) + } + if (height) { + params.height = Number(height) + } + config = configCardanoBlockV1 + } else if (isCardanoConnectV1(url)) { + config = configCardanoConnectV1 } else if (url.pathname !== '' && url.hostname === '') { - // legacy transfer address is the authority but should be handled as a param + // LEGACY COMPATIBILITY: legacy transfer address is the authority but should be handled as a param if (!isCardanoAddress(url.pathname)) throw new Links.Errors.ParamsValidationFailed( `The param address is an invalid cardano address`, @@ -114,11 +268,3 @@ export const linksCardanoModuleMaker = true, ) } - -const isCardanoClaimV1 = (url: URL) => { - if (url.hostname === configCardanoClaimV1.authority) { - if (url.pathname === `/${configCardanoClaimV1.version}`) return true - throw new Links.Errors.UnsupportedVersion() - } - return false -} diff --git a/mobile/packages/links/cardano/params.ts b/mobile/packages/links/cardano/params.ts index 6cb5987cbe..ba3127de9f 100644 --- a/mobile/packages/links/cardano/params.ts +++ b/mobile/packages/links/cardano/params.ts @@ -1,7 +1,15 @@ import {isArrayOfString, isString, isUrl} from '@yoroi/common' import {Links, Writable} from '@yoroi/types' +import {validateCardanoAddress} from './helpers' import {LinksCardanoUriConfig} from './types' +import { + isValidBlockHeight, + validateBlockHash, + validateNamespacedDomain, + validateScheme, + validateTransactionHash, +} from './validators' /** * Prepares and validates parameters for a Cardano URI link based on a given configuration. @@ -127,7 +135,13 @@ export const getParamValidator = `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a number without thousand separators and using dot as decimal separator`, ) } - case 'address': + case 'address': { + // Validate Cardano address format + if (isString(value) && validateCardanoAddress(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid Cardano address`, + ) + } case 'code': { // if other check besides `claim` authority is needed it should be added here conditionally if (isString(value)) break @@ -135,6 +149,80 @@ export const getParamValidator = `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a string`, ) } + case 'peerId': { + if (isString(value) && value.length > 0) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a non-empty string`, + ) + } + case 'signalingUrl': { + if (isUrl(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid URL`, + ) + } + case 'scheme': { + if (isString(value) && validateScheme(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid URI scheme`, + ) + } + case 'namespaced_domain': { + if (isString(value) && validateNamespacedDomain(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid namespaced domain`, + ) + } + case 'app_path': { + if (isString(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a string`, + ) + } + case 'url': { + // Reconstructed URL for browse authority + if (isString(value) && isUrl(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid URL`, + ) + } + case 'hash': { + // Transaction or block hash validation depends on authority + if (config.authority === 'transaction') { + if (isString(value) && validateTransactionHash(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid transaction hash`, + ) + } else if (config.authority === 'block') { + if (isString(value) && validateBlockHash(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid block hash`, + ) + } + // Fallback for other authorities + if (isString(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a string`, + ) + } + case 'height': { + if (isString(value) && isValidBlockHeight(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a valid block height (non-negative integer)`, + ) + } + case 'pool': { + if (isString(value) && value.length > 0) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a non-empty string`, + ) + } + case 'asset': { + if (isString(value)) break + throw new Links.Errors.ParamsValidationFailed( + `The param ${key} on ${config.scheme} ${config.authority} ${config.version} must be a string`, + ) + } case 'faucet_url': { if (isUrl(value)) break throw new Links.Errors.ParamsValidationFailed( diff --git a/mobile/packages/links/cardano/types.ts b/mobile/packages/links/cardano/types.ts index df5f1c580f..228c0f99c1 100644 --- a/mobile/packages/links/cardano/types.ts +++ b/mobile/packages/links/cardano/types.ts @@ -14,6 +14,8 @@ export interface LinksCardanoClaimV1 extends Links.WebCardanoUriConfig { } // CIP13 - initial version +// @deprecated Use LinksCardanoPayV1 instead +// LEGACY COMPATIBILITY: Kept for backward compatibility export interface LinksCardanoLegacyTransfer extends Links.WebCardanoUriConfig { readonly scheme: 'web+cardano' readonly authority: '' // is the wallet address @@ -26,6 +28,119 @@ export interface LinksCardanoLegacyTransfer extends Links.WebCardanoUriConfig { } } +// CIP-158 Browse +export interface LinksCardanoBrowseV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'browse' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['scheme', 'namespaced_domain']> + readonly optionalParams: Readonly<['app_path', 'url']> // url is reconstructed + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'include' // Query params passed through + } +} + +// CIP-PR843 Pay (replaces legacy transfer) +export interface LinksCardanoPayV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'pay' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['address']> + readonly optionalParams: Readonly<['amount', 'asset', 'memo']> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// CIP-13 Payment +export interface LinksCardanoPaymentV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'payment' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['address']> + readonly optionalParams: Readonly<['amount', 'asset', 'memo']> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// CIP-13 Stake +export interface LinksCardanoStakeV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'stake' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['pool']> + readonly optionalParams: Readonly<[]> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// CIP-107 Transaction +export interface LinksCardanoTransactionV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'transaction' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['hash']> + readonly optionalParams: Readonly<[]> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// CIP-107 Block +// Note: Requires either 'hash' or 'height' (validated at runtime) +export interface LinksCardanoBlockV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'block' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<[]> + readonly optionalParams: Readonly<['hash', 'height']> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// CIP-134 Address +export interface LinksCardanoAddressV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'address' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['address']> + readonly optionalParams: Readonly<[]> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + +// P2P Connect (new, follows CIP-158 pattern) +export interface LinksCardanoConnectV1 extends Links.WebCardanoUriConfig { + readonly scheme: 'web+cardano' + readonly authority: 'connect' + readonly version: 'v1' + readonly rules: { + readonly requiredParams: Readonly<['peerId']> + readonly optionalParams: Readonly<['signalingUrl']> + readonly forbiddenParams: Readonly<[]> + readonly extraParams: 'drop' + } +} + export type LinksCardanoUriConfig = | LinksCardanoClaimV1 | LinksCardanoLegacyTransfer + | LinksCardanoBrowseV1 + | LinksCardanoPayV1 + | LinksCardanoPaymentV1 + | LinksCardanoStakeV1 + | LinksCardanoTransactionV1 + | LinksCardanoBlockV1 + | LinksCardanoAddressV1 + | LinksCardanoConnectV1 diff --git a/mobile/packages/links/cardano/validators.ts b/mobile/packages/links/cardano/validators.ts new file mode 100644 index 0000000000..851c9a8f70 --- /dev/null +++ b/mobile/packages/links/cardano/validators.ts @@ -0,0 +1,56 @@ +/** + * Validation utilities for Cardano URI parameters + * Based on cardano-uri-parser validation logic + */ + +/** + * Validates if a string is a valid 64-character hexadecimal value + */ +export const isValidHex64 = (value: string): boolean => { + return /^[0-9a-fA-F]{64}$/.test(value) +} + +/** + * Validates if a string is a valid block height (non-negative integer) + */ +export const isValidBlockHeight = (value: string): boolean => { + return /^[0-9]+$/.test(value) && Number(value) >= 0 +} + +/** + * Validates if a string is a valid metadata label (numeric string) + */ +export const isValidMetadataLabel = (value: string): boolean => { + return /^[0-9]+$/.test(value) +} + +/** + * Validates if a string is a valid URI scheme + * Scheme must start with a letter and contain only alphanumeric, +, ., or - characters + */ +export const validateScheme = (scheme: string): boolean => { + return /^[a-zA-Z][a-zA-Z0-9+.-]*$/.test(scheme) +} + +/** + * Validates if a string is a valid namespaced domain + * Must contain at least one dot (.) + */ +export const validateNamespacedDomain = (domain: string): boolean => { + return domain.includes('.') +} + +/** + * Validates if a string is a valid transaction hash (64-char hex or "self") + */ +export const validateTransactionHash = (hash: string): boolean => { + return hash === 'self' || isValidHex64(hash) +} + +/** + * Validates if a string is a valid block hash (64-char hex) + */ +export const validateBlockHash = (hash: string): boolean => { + return isValidHex64(hash) +} + diff --git a/mobile/packages/p2p-communication/README.md b/mobile/packages/p2p-communication/README.md index 9e9b163ec1..b6279efc8a 100644 --- a/mobile/packages/p2p-communication/README.md +++ b/mobile/packages/p2p-communication/README.md @@ -521,6 +521,71 @@ For wallet-to-wallet connections, peer IDs can be shared via: The peer ID format is: `wallet-{deviceHash}-{installTime}` or `dapp-{deviceHash}-{timestamp}` +### Deep Link Format + +The package provides utilities for generating and parsing P2P connection deeplinks: + +#### Standard Format (wallet://) + +``` +wallet://connect?peerId=xyz&signalingUrl=wss://signaling-server.com +``` + +#### CIP-158 Compatible Format (web+cardano://) + +``` +web+cardano://connect/v1?peerId=xyz&signalingUrl=wss://signaling-server.com +``` + +**Parameters:** +- `peerId` (required): The peer ID to connect to +- `signalingUrl` (optional): The WebSocket URL of the signaling server. If omitted, the connection will use the signalingUrl from the connection configuration. + +#### Usage Example + +```typescript +import { + generateP2PDeeplink, + parseP2PDeeplink, + generateCIP158P2PDeeplink, +} from '@yoroi/p2p-communication' + +// Generate deeplink for QR code +const deeplink = generateP2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: 'wss://signaling-server.com', +}) + +// Or generate CIP-158 compatible deeplink +const cip158Deeplink = generateCIP158P2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: 'wss://signaling-server.com', +}) + +// Parse deeplink from QR scan +const parsed = parseP2PDeeplink(scannedQrCode) +if (parsed) { + // Use parsed.signalingUrl if provided, otherwise use default from config + const signalingUrl = parsed.signalingUrl || defaultSignalingUrl + + // Update connection config with signaling URL + connectionManager = connectionManagerMaker({ + storage, + webrtcAdapter, + peerConfig: { + signalingUrl, + }, + }) + + // Connect to peer + await connectionManager.initialize() + const peerConnection = connectionManager.getPeerConnection() + if (peerConnection) { + await peerConnection.connectToPeer(parsed.peerId) + } +} +``` + ## License See main project license. diff --git a/mobile/packages/p2p-communication/index.ts b/mobile/packages/p2p-communication/index.ts index cbcd84c485..92360ff0b3 100644 --- a/mobile/packages/p2p-communication/index.ts +++ b/mobile/packages/p2p-communication/index.ts @@ -40,6 +40,13 @@ export { export {getPersistentDappId, getPersistentWalletId} from './utils/id-utils' +export { + generateP2PDeeplink, + parseP2PDeeplink, + generateCIP158P2PDeeplink, +} from './utils/deeplink-utils' +export type {P2PConnectionDeeplink} from './utils/deeplink-utils' + // Types export type { MessageType, diff --git a/mobile/packages/p2p-communication/utils/deeplink-utils.test.ts b/mobile/packages/p2p-communication/utils/deeplink-utils.test.ts new file mode 100644 index 0000000000..45b6d8d4e1 --- /dev/null +++ b/mobile/packages/p2p-communication/utils/deeplink-utils.test.ts @@ -0,0 +1,117 @@ +import { + generateCIP158P2PDeeplink, + generateP2PDeeplink, + parseP2PDeeplink, +} from './deeplink-utils' + +describe('deeplink-utils', () => { + describe('generateP2PDeeplink', () => { + it('should generate deeplink with peerId only', () => { + const deeplink = generateP2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + }) + expect(deeplink).toBe('wallet://connect?peerId=dapp-abc123-xyz789') + }) + + it('should generate deeplink with peerId and signalingUrl', () => { + const deeplink = generateP2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: 'wss://signaling-server.com', + }) + expect(deeplink).toBe( + 'wallet://connect?peerId=dapp-abc123-xyz789&signalingUrl=wss%3A%2F%2Fsignaling-server.com', + ) + }) + + it('should URL encode signalingUrl', () => { + const deeplink = generateP2PDeeplink({ + peerId: 'wallet-xyz', + signalingUrl: 'wss://server.com:8080/path?query=value', + }) + expect(deeplink).toContain('peerId=wallet-xyz') + expect(deeplink).toContain('signalingUrl=wss%3A%2F%2Fserver.com%3A8080') + }) + }) + + describe('generateCIP158P2PDeeplink', () => { + it('should generate CIP-158 deeplink with peerId only', () => { + const deeplink = generateCIP158P2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + }) + expect(deeplink).toBe( + 'web+cardano://connect/v1?peerId=dapp-abc123-xyz789', + ) + }) + + it('should generate CIP-158 deeplink with peerId and signalingUrl', () => { + const deeplink = generateCIP158P2PDeeplink({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: 'wss://signaling-server.com', + }) + expect(deeplink).toBe( + 'web+cardano://connect/v1?peerId=dapp-abc123-xyz789&signalingUrl=wss%3A%2F%2Fsignaling-server.com', + ) + }) + }) + + describe('parseP2PDeeplink', () => { + it('should parse wallet:// deeplink with peerId only', () => { + const parsed = parseP2PDeeplink( + 'wallet://connect?peerId=dapp-abc123-xyz789', + ) + expect(parsed).toEqual({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: undefined, + }) + }) + + it('should parse wallet:// deeplink with peerId and signalingUrl', () => { + const parsed = parseP2PDeeplink( + 'wallet://connect?peerId=dapp-abc123-xyz789&signalingUrl=wss%3A%2F%2Fsignaling-server.com', + ) + expect(parsed).toEqual({ + peerId: 'dapp-abc123-xyz789', + signalingUrl: 'wss://signaling-server.com', + }) + }) + + it('should parse web+cardano:// deeplink with v1 path', () => { + const parsed = parseP2PDeeplink( + 'web+cardano://connect/v1?peerId=wallet-xyz&signalingUrl=wss%3A%2F%2Fserver.com', + ) + expect(parsed).toEqual({ + peerId: 'wallet-xyz', + signalingUrl: 'wss://server.com', + }) + }) + + it('should return null for invalid scheme', () => { + const parsed = parseP2PDeeplink('https://example.com?peerId=xyz') + expect(parsed).toBeNull() + }) + + it('should return null for web+cardano:// without /v1 path', () => { + const parsed = parseP2PDeeplink('web+cardano://connect?peerId=xyz') + expect(parsed).toBeNull() + }) + + it('should return null for missing peerId', () => { + const parsed = parseP2PDeeplink( + 'wallet://connect?signalingUrl=wss://server.com', + ) + expect(parsed).toBeNull() + }) + + it('should return null for invalid URL', () => { + const parsed = parseP2PDeeplink('not-a-valid-url') + expect(parsed).toBeNull() + }) + + it('should handle URL-encoded parameters correctly', () => { + const parsed = parseP2PDeeplink( + 'wallet://connect?peerId=wallet-abc&signalingUrl=wss%3A%2F%2Fserver.com%3A8080%2Fpath', + ) + expect(parsed?.signalingUrl).toBe('wss://server.com:8080/path') + }) + }) +}) diff --git a/mobile/packages/p2p-communication/utils/deeplink-utils.ts b/mobile/packages/p2p-communication/utils/deeplink-utils.ts new file mode 100644 index 0000000000..a2504a20d5 --- /dev/null +++ b/mobile/packages/p2p-communication/utils/deeplink-utils.ts @@ -0,0 +1,90 @@ +/** + * Deep Link Utilities for P2P Communication + * Handles generation and parsing of deeplinks for peer connections + */ + +export type P2PConnectionDeeplink = { + readonly peerId: string + readonly signalingUrl?: string +} + +/** + * Generate a deeplink for P2P connection + * Format: wallet://connect?peerId=xyz&signalingUrl=wss://... + * + * @param params Connection parameters + * @returns Deep link URL string + */ +export const generateP2PDeeplink = (params: P2PConnectionDeeplink): string => { + const url = new URL('wallet://connect') + url.searchParams.set('peerId', params.peerId) + + if (params.signalingUrl) { + url.searchParams.set('signalingUrl', params.signalingUrl) + } + + return url.toString() +} + +/** + * Parse a P2P connection deeplink + * Supports both wallet://connect and web+cardano://connect formats + * + * @param deeplink Deep link URL string + * @returns Parsed connection parameters or null if invalid + */ +export const parseP2PDeeplink = ( + deeplink: string, +): P2PConnectionDeeplink | null => { + try { + const url = new URL(deeplink) + + // Support both wallet:// and web+cardano:// schemes + const isWalletScheme = url.protocol === 'wallet:' + const isCardanoScheme = + url.protocol === 'web+cardano:' && url.hostname === 'connect' + + if (!isWalletScheme && !isCardanoScheme) { + return null + } + + // For web+cardano://connect, check path is /v1 + if (isCardanoScheme && !url.pathname.startsWith('/v1')) { + return null + } + + const peerId = url.searchParams.get('peerId') + if (!peerId) { + return null + } + + const signalingUrl = url.searchParams.get('signalingUrl') || undefined + + return { + peerId, + signalingUrl, + } + } catch (error) { + return null + } +} + +/** + * Generate a CIP-158 compatible deeplink for P2P connection + * Format: web+cardano://connect/v1?peerId=xyz&signalingUrl=wss://... + * + * @param params Connection parameters + * @returns CIP-158 compatible deep link URL string + */ +export const generateCIP158P2PDeeplink = ( + params: P2PConnectionDeeplink, +): string => { + const url = new URL('web+cardano://connect/v1') + url.searchParams.set('peerId', params.peerId) + + if (params.signalingUrl) { + url.searchParams.set('signalingUrl', params.signalingUrl) + } + + return url.toString() +} From 8285abf3b3731cf2d40447c31fba714643366c13 Mon Sep 17 00:00:00 2001 From: jorbuedo Date: Sat, 8 Nov 2025 17:25:25 +0100 Subject: [PATCH 003/335] feat: Add web+cardano:// deep link support - Add web+cardano scheme registration in app.json (iOS & Android) - Update useDeepLinkWatcher to parse web+cardano:// links - Create ScanActionHandler component to trigger scan actions from deeplinks - Add isWebCardanoLink helper function - Integrate ScanActionHandler into AppNavigator - Support all CIP link types via external app deeplinks (browse, pay, payment, stake, transaction, block, address, connect) All web+cardano:// links from external apps are now automatically handled. --- mobile/app.json | 12 + mobile/packages/links/cardano/helpers.ts | 9 +- mobile/packages/links/cardano/module.ts | 53 ++- mobile/packages/links/cardano/validators.ts | 1 - mobile/packages/types/index.ts | 14 + mobile/packages/types/links/cardano.ts | 13 +- mobile/packages/types/scan/actions.ts | 51 +++ .../Links/components/ScanActionHandler.tsx | 26 ++ .../Links/hooks/useDeepLinkWatcher.tsx | 48 ++- .../P2PConnectionScreen.tsx | 322 ++++++++++++++++++ .../useCases/RequestSpecificAmountScreen.tsx | 6 +- .../features/Scan/common/modals/InfoModal.tsx | 52 +++ .../modals/TransactionNotFoundModal.tsx | 83 +++++ mobile/src/features/Scan/common/parsers.ts | 116 ++++++- .../Scan/common/triggerScanActionHelper.ts | 12 + .../Scan/common/useTriggerScanAction.tsx | 102 +++++- .../Transactions/TxHistoryNavigator.tsx | 27 ++ .../AddressDetails/AddressDetails.tsx | 92 +++++ .../useCases/BlockDetails/BlockDetails.tsx | 111 ++++++ .../WalletManager/hooks/useSelectedWallet.tsx | 61 +++- .../ui/modals/SelectWalletModal.tsx | 76 +++++ .../ui/shared/WithWalletOpened.tsx | 52 ++- mobile/src/kernel/i18n/messages/scan.ts | 28 ++ .../src/kernel/i18n/messages/transactions.ts | 12 + mobile/src/kernel/i18n/useStrings.ts | 10 + mobile/src/kernel/navigation/AppNavigator.tsx | 5 +- .../navigation/hooks/useWalletNavigation.ts | 48 +++ mobile/src/kernel/navigation/types.tsx | 11 + 28 files changed, 1406 insertions(+), 47 deletions(-) create mode 100644 mobile/src/features/Links/components/ScanActionHandler.tsx create mode 100644 mobile/src/features/P2P/useCases/P2PConnectionScreen/P2PConnectionScreen.tsx create mode 100644 mobile/src/features/Scan/common/modals/InfoModal.tsx create mode 100644 mobile/src/features/Scan/common/modals/TransactionNotFoundModal.tsx create mode 100644 mobile/src/features/Scan/common/triggerScanActionHelper.ts create mode 100644 mobile/src/features/Transactions/useCases/AddressDetails/AddressDetails.tsx create mode 100644 mobile/src/features/Transactions/useCases/BlockDetails/BlockDetails.tsx create mode 100644 mobile/src/features/WalletManager/ui/modals/SelectWalletModal.tsx diff --git a/mobile/app.json b/mobile/app.json index 9bf8503306..7f5ee25c7b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -21,6 +21,11 @@ "bundleIdentifier": "com.emurgo.yoroi", "associatedDomains": ["applinks:yoroi-wallet.com"], "infoPlist": { + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": ["yoroi", "web+cardano"] + } + ], "NSCameraUsageDescription": "Allow $(PRODUCT_NAME) to access your camera to scan QR codes", "NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photo library to import wallet data and QR codes", "NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location for Bluetooth scanning", @@ -88,6 +93,13 @@ "pathPrefix": "/w1" }, "autoVerify": true + }, + { + "action": "VIEW", + "category": ["DEFAULT", "BROWSABLE"], + "data": { + "scheme": "web+cardano" + } } ], "splash": { diff --git a/mobile/packages/links/cardano/helpers.ts b/mobile/packages/links/cardano/helpers.ts index c462975485..abd824b64b 100644 --- a/mobile/packages/links/cardano/helpers.ts +++ b/mobile/packages/links/cardano/helpers.ts @@ -119,7 +119,8 @@ export const isCardanoClaimV1 = (url: URL): boolean => { export const isCardanoBrowseV1 = (url: URL): boolean => { if (url.hostname === configCardanoBrowseV1.authority) { - if (url.pathname.startsWith(`/${configCardanoBrowseV1.version}/`)) return true + if (url.pathname.startsWith(`/${configCardanoBrowseV1.version}/`)) + return true throw new Links.Errors.UnsupportedVersion() } return false @@ -151,7 +152,8 @@ export const isCardanoStakeV1 = (url: URL): boolean => { export const isCardanoTransactionV1 = (url: URL): boolean => { if (url.hostname === configCardanoTransactionV1.authority) { - if (url.pathname.startsWith(`/${configCardanoTransactionV1.version}/`)) return true + if (url.pathname.startsWith(`/${configCardanoTransactionV1.version}/`)) + return true throw new Links.Errors.UnsupportedVersion() } return false @@ -167,7 +169,8 @@ export const isCardanoBlockV1 = (url: URL): boolean => { export const isCardanoAddressV1 = (url: URL): boolean => { if (url.hostname === configCardanoAddressV1.authority) { - if (url.pathname.startsWith(`/${configCardanoAddressV1.version}/`)) return true + if (url.pathname.startsWith(`/${configCardanoAddressV1.version}/`)) + return true throw new Links.Errors.UnsupportedVersion() } return false diff --git a/mobile/packages/links/cardano/module.ts b/mobile/packages/links/cardano/module.ts index 7e60e1ea2e..08e3846841 100644 --- a/mobile/packages/links/cardano/module.ts +++ b/mobile/packages/links/cardano/module.ts @@ -33,7 +33,6 @@ import {preapareParams} from './params' import {LinksCardanoUriConfig} from './types' import { isValidBlockHeight, - isValidHex64, validateBlockHash, validateNamespacedDomain, validateScheme, @@ -82,22 +81,50 @@ export const linksCardanoModuleMaker = addSearchParams(url, restParams) } else if (config.authority === 'browse') { // CIP-158 Browse: path-based authority - const {scheme, namespaced_domain, app_path, url: reconstructedUrl, ...queryParams} = sanitizedParams + const { + scheme, + namespaced_domain, + app_path, + url: reconstructedUrl, + ...queryParams + } = sanitizedParams const pathSegments = [config.version, scheme, namespaced_domain] if (app_path) { pathSegments.push(...app_path.split('/').filter(Boolean)) } - url = new URL(config.scheme + '://' + config.authority + '/' + pathSegments.join('/')) + url = new URL( + config.scheme + + '://' + + config.authority + + '/' + + pathSegments.join('/'), + ) addSearchParams(url, queryParams) } else if (config.authority === 'transaction') { // CIP-107 Transaction: path-based authority const {hash, ...restParams} = sanitizedParams - url = new URL(config.scheme + '://' + config.authority + '/' + config.version + '/' + hash) + url = new URL( + config.scheme + + '://' + + config.authority + + '/' + + config.version + + '/' + + hash, + ) addSearchParams(url, restParams) } else if (config.authority === 'address') { // CIP-134 Address: path-based authority const {address, ...restParams} = sanitizedParams - url = new URL(config.scheme + '://' + config.authority + '/' + config.version + '/' + address) + url = new URL( + config.scheme + + '://' + + config.authority + + '/' + + config.version + + '/' + + address, + ) addSearchParams(url, restParams) } else { // Query-based authorities (claim, pay, payment, stake, block, connect) @@ -145,10 +172,10 @@ export const linksCardanoModuleMaker = ) } const [, scheme, namespacedDomain, ...appPathParts] = pathParts - if (!validateScheme(scheme)) { + if (!scheme || !validateScheme(scheme)) { throw new Links.Errors.ParamsValidationFailed('Invalid scheme format') } - if (!validateNamespacedDomain(namespacedDomain)) { + if (!namespacedDomain || !validateNamespacedDomain(namespacedDomain)) { throw new Links.Errors.ParamsValidationFailed( 'Invalid namespaced domain format', ) @@ -175,7 +202,7 @@ export const linksCardanoModuleMaker = ) } const [, hash] = pathParts - if (!validateTransactionHash(hash)) { + if (!hash || !validateTransactionHash(hash)) { throw new Links.Errors.ParamsValidationFailed( 'Invalid transaction hash format', ) @@ -198,7 +225,7 @@ export const linksCardanoModuleMaker = ) } const [, address] = pathParts - if (!validateCardanoAddress(address)) { + if (!address || !validateCardanoAddress(address)) { throw new Links.Errors.ParamsValidationFailed( 'Invalid Cardano address format', ) @@ -227,12 +254,16 @@ export const linksCardanoModuleMaker = 'Cannot provide both block hash and height', ) } - if (hash && !validateBlockHash(hash)) { + if (hash && typeof hash === 'string' && !validateBlockHash(hash)) { throw new Links.Errors.ParamsValidationFailed( 'Invalid block hash format', ) } - if (height && !isValidBlockHeight(height)) { + if ( + height && + typeof height === 'string' && + !isValidBlockHeight(height) + ) { throw new Links.Errors.ParamsValidationFailed( 'Invalid block height format', ) diff --git a/mobile/packages/links/cardano/validators.ts b/mobile/packages/links/cardano/validators.ts index 851c9a8f70..5bb3c982d0 100644 --- a/mobile/packages/links/cardano/validators.ts +++ b/mobile/packages/links/cardano/validators.ts @@ -53,4 +53,3 @@ export const validateTransactionHash = (hash: string): boolean => { export const validateBlockHash = (hash: string): boolean => { return isValidHex64(hash) } - diff --git a/mobile/packages/types/index.ts b/mobile/packages/types/index.ts index 7020cc7b86..901304e291 100644 --- a/mobile/packages/types/index.ts +++ b/mobile/packages/types/index.ts @@ -262,10 +262,17 @@ import {ResolverReceiver} from './resolver/receiver' import {ResolverStorage} from './resolver/storage' import { ScanAction, + ScanActionBrowseDapp, ScanActionClaim, ScanActionLaunchUrl, + ScanActionP2PConnect, + ScanActionPayRequest, ScanActionSendOnlyReceiver, ScanActionSendSinglePt, + ScanActionStakePool, + ScanActionViewAddress, + ScanActionViewBlock, + ScanActionViewTransaction, ScanFeature, } from './scan/actions' import {ScanErrorUnknown, ScanErrorUnknownContent} from './scan/errors' @@ -726,6 +733,13 @@ export namespace Scan { export type ActionSendOnlyReceiver = ScanActionSendOnlyReceiver export type ActionSendSinglePt = ScanActionSendSinglePt export type ActionScanLaunchUrl = ScanActionLaunchUrl + export type ActionBrowseDapp = ScanActionBrowseDapp + export type ActionPayRequest = ScanActionPayRequest + export type ActionStakePool = ScanActionStakePool + export type ActionViewTransaction = ScanActionViewTransaction + export type ActionViewBlock = ScanActionViewBlock + export type ActionViewAddress = ScanActionViewAddress + export type ActionP2PConnect = ScanActionP2PConnect } export namespace Claim { diff --git a/mobile/packages/types/links/cardano.ts b/mobile/packages/types/links/cardano.ts index c648eabcb8..0fcac3d21a 100644 --- a/mobile/packages/types/links/cardano.ts +++ b/mobile/packages/types/links/cardano.ts @@ -16,7 +16,18 @@ export interface LinksUriConfig { export interface LinksWebCardanoUriConfig extends LinksUriConfig { readonly scheme: 'web+cardano' - readonly authority: '' | 'transfer' | 'claim' + readonly authority: + | '' + | 'transfer' + | 'claim' + | 'browse' + | 'pay' + | 'payment' + | 'stake' + | 'transaction' + | 'block' + | 'address' + | 'connect' readonly version: 'v1' | '' } diff --git a/mobile/packages/types/scan/actions.ts b/mobile/packages/types/scan/actions.ts index 704265bdfa..51a67a71bf 100644 --- a/mobile/packages/types/scan/actions.ts +++ b/mobile/packages/types/scan/actions.ts @@ -27,10 +27,61 @@ export type ScanActionLaunchUrl = Readonly<{ url: string }> +export type ScanActionBrowseDapp = Readonly<{ + action: 'browse-dapp' + scheme: string + domain: string + path?: string + url: string + query?: string +}> + +export type ScanActionPayRequest = Readonly<{ + action: 'pay-request' + address: string + amount?: string + asset?: string + memo?: string +}> + +export type ScanActionStakePool = Readonly<{ + action: 'stake-pool' + pool: string +}> + +export type ScanActionViewTransaction = Readonly<{ + action: 'view-transaction' + hash: string +}> + +export type ScanActionViewBlock = Readonly<{ + action: 'view-block' + hash?: string + height?: string +}> + +export type ScanActionViewAddress = Readonly<{ + action: 'view-address' + address: string +}> + +export type ScanActionP2PConnect = Readonly<{ + action: 'p2p-connect' + peerId: string + signalingUrl?: string +}> + export type ScanAction = | ScanActionSendOnlyReceiver | ScanActionSendSinglePt | ScanActionClaim | ScanActionLaunchUrl + | ScanActionBrowseDapp + | ScanActionPayRequest + | ScanActionStakePool + | ScanActionViewTransaction + | ScanActionViewBlock + | ScanActionViewAddress + | ScanActionP2PConnect export type ScanFeature = 'send' | 'scan' diff --git a/mobile/src/features/Links/components/ScanActionHandler.tsx b/mobile/src/features/Links/components/ScanActionHandler.tsx new file mode 100644 index 0000000000..08fe9ff668 --- /dev/null +++ b/mobile/src/features/Links/components/ScanActionHandler.tsx @@ -0,0 +1,26 @@ +/** + * ScanActionHandler Component + * + * Handles scan actions from web+cardano:// deep links. + * Consumes pendingScanAction from useDeepLinkWatcher and triggers + * the appropriate action using useTriggerScanAction. + */ +import * as React from 'react' + +import {useTriggerScanAction} from '~/features/Scan/common/useTriggerScanAction' + +import {useDeepLinkWatcher} from '../hooks/useDeepLinkWatcher' + +export const ScanActionHandler = () => { + const {pendingScanAction, clearPendingScanAction} = useDeepLinkWatcher() + const triggerScanAction = useTriggerScanAction({insideFeature: 'scan'}) + + React.useEffect(() => { + if (pendingScanAction) { + triggerScanAction(pendingScanAction) + clearPendingScanAction() + } + }, [pendingScanAction, triggerScanAction, clearPendingScanAction]) + + return null +} diff --git a/mobile/src/features/Links/hooks/useDeepLinkWatcher.tsx b/mobile/src/features/Links/hooks/useDeepLinkWatcher.tsx index 60d122b1fe..d65790b83b 100644 --- a/mobile/src/features/Links/hooks/useDeepLinkWatcher.tsx +++ b/mobile/src/features/Links/hooks/useDeepLinkWatcher.tsx @@ -1,28 +1,55 @@ import {linksYoroiParser, useLinks} from '@yoroi/links' +import {Scan} from '@yoroi/types' import * as Linking from 'expo-linking' import * as React from 'react' -import {isDev} from '~/kernel/constants' +import {parseScanAction} from '~/features/Scan/common/parsers' +import {isWebCardanoLink} from '~/features/Scan/common/triggerScanActionHelper' import {logger} from '~/kernel/logger/logger' export const useDeepLinkWatcher = () => { const {actionStarted} = useLinks() + const [pendingScanAction, setPendingScanAction] = + React.useState(null) + + const clearPendingScanAction = React.useCallback(() => { + setPendingScanAction(null) + }, []) const processLink = React.useCallback( (url: string) => { + // Try Yoroi links first (yoroi://) const parsedAction = linksYoroiParser(url) - if (parsedAction == null) { - logger.debug('useDeepLinkWatcher: link is malformated, ignored') + if (parsedAction != null) { + if (parsedAction.params?.isSandbox === true && __DEV__ === false) { + logger.debug('useDeepLinkWatcher: link is sandboxed, ignored') + return + } + logger.debug('useDeepLinkWatcher: parsedAction', {parsedAction}) + actionStarted({info: parsedAction, isTrusted: false}) return } - if (parsedAction.params?.isSandbox === true && isDev === false) { - logger.debug('useDeepLinkWatcher: link is sandboxed, ignored') + + // Try web+cardano:// links + if (isWebCardanoLink(url)) { + try { + const scanAction = parseScanAction(url) + logger.debug('useDeepLinkWatcher: parsed web+cardano link', { + action: scanAction.action, + }) + // Store the action to be handled by a component that can use useTriggerScanAction + setPendingScanAction(scanAction) + } catch (error) { + logger.debug('useDeepLinkWatcher: web+cardano link parsing failed', { + error, + url, + }) + } return } - // TODO: implement isTrusted if signature was provided and doesn't match with authorization ignore it - logger.debug('useDeepLinkWatcher: parsedAction', {parsedAction}) - actionStarted({info: parsedAction, isTrusted: false}) + + logger.debug('useDeepLinkWatcher: link is malformated, ignored', {url}) }, [actionStarted], ) @@ -42,4 +69,9 @@ export const useDeepLinkWatcher = () => { } getInitialURL() }, [processLink]) + + return { + pendingScanAction, + clearPendingScanAction, + } } diff --git a/mobile/src/features/P2P/useCases/P2PConnectionScreen/P2PConnectionScreen.tsx b/mobile/src/features/P2P/useCases/P2PConnectionScreen/P2PConnectionScreen.tsx new file mode 100644 index 0000000000..d4b2df6f37 --- /dev/null +++ b/mobile/src/features/P2P/useCases/P2PConnectionScreen/P2PConnectionScreen.tsx @@ -0,0 +1,322 @@ +import { + WebRTCAdapter, + connectionManagerMaker, + generateCIP158P2PDeeplink, +} from '@yoroi/p2p-communication' +import {atoms as a, useTheme} from '@yoroi/theme' +import {BaseStorage} from '@yoroi/types' + +import {useRoute} from '@react-navigation/native' +import * as React from 'react' +import {GestureResponderEvent, Text, View} from 'react-native' + +import {useInfoModal} from '~/features/Scan/common/modals/InfoModal' +import {logger} from '~/kernel/logger/logger' +import {rootStorage} from '~/kernel/storage/storages' +import {Button, ButtonType} from '~/ui/Button/Button' +import {Copiable} from '~/ui/Copiable/Copiable' +import {SafeArea} from '~/ui/SafeArea/SafeArea' +import {ScrollView} from '~/ui/ScrollView/ScrollView' +import {useScrollView} from '~/ui/ScrollView/hooks/useScrollView' +import {ShareQRCodeCard} from '~/ui/ShareQRCodeCard/ShareQRCodeCard' + +type Params = { + peerId: string + signalingUrl?: string +} + +const Label = ({children}: {children: string}) => { + const {atoms: ta} = useTheme() + + return ( + + {children} + + ) +} + +// Try to import react-native-webrtc, fallback gracefully if not available +let RTCPeerConnection: any +let RTCSessionDescription: any +let RTCIceCandidate: any +let webrtcAvailable = false + +try { + const webrtc = require('react-native-webrtc') + RTCPeerConnection = webrtc.RTCPeerConnection + RTCSessionDescription = webrtc.RTCSessionDescription + RTCIceCandidate = webrtc.RTCIceCandidate + webrtcAvailable = true +} catch (error) { + logger.warn('react-native-webrtc not available', {error}) + webrtcAvailable = false +} + +const createWebRTCAdapter = (): WebRTCAdapter | null => { + if (!webrtcAvailable) { + return null + } + return { + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, + } +} + +const createStorageAdapter = (): BaseStorage => { + return { + getItem: async (key: string) => { + const value = await rootStorage.getItem(key) + return typeof value === 'string' ? value : null + }, + setItem: async (key: string, value: string) => { + await rootStorage.setItem(key, value) + }, + removeItem: async (key: string) => { + await rootStorage.removeItem(key) + }, + } +} + +export const P2PConnectionScreen = () => { + const {atoms: ta} = useTheme() + const {peerId: targetPeerId, signalingUrl} = useRoute().params as Params + const {scrollViewRef} = useScrollView() + const {openInfoModal} = useInfoModal() + + const [myPeerId, setMyPeerId] = React.useState('') + const [connectionStatus, setConnectionStatus] = + React.useState('Not connected') + const [connectedPeerId, setConnectedPeerId] = React.useState( + null, + ) + const [isConnecting, setIsConnecting] = React.useState(false) + const [connectionManager, setConnectionManager] = React.useState(null) + + const webrtcAdapter = React.useMemo(() => createWebRTCAdapter(), []) + const storageAdapter = React.useMemo(() => createStorageAdapter(), []) + + React.useEffect(() => { + if (!webrtcAdapter) { + openInfoModal({ + title: 'WebRTC Not Available', + message: + 'react-native-webrtc is not installed. Please install it to use P2P connections.', + }) + return + } + + const manager = connectionManagerMaker({ + storage: storageAdapter, + webrtcAdapter, + peerConfig: { + signalingUrl: signalingUrl || 'wss://signaling-server.example.com', // TODO: Get from config + targetPeerId, + }, + isWallet: true, + logger, + }) + + setConnectionManager(manager) + + const initializeConnection = async () => { + try { + setIsConnecting(true) + await manager.initialize() + const peerConnection = manager.getPeerConnection() + if (peerConnection) { + const id = peerConnection.getPeerId() + setMyPeerId(id) + + // Listen for connection events + peerConnection.on('open', (id: unknown) => { + setConnectionStatus('Ready') + setMyPeerId(id as string) + }) + + peerConnection.on('peerConnected', (peerId: unknown) => { + setConnectionStatus('Connected') + setConnectedPeerId(peerId as string) + setIsConnecting(false) + }) + + peerConnection.on('error', (error: unknown) => { + const err = + error instanceof Error ? error : new Error(String(error)) + setConnectionStatus(`Error: ${err.message}`) + setIsConnecting(false) + }) + + peerConnection.on('close', () => { + setConnectionStatus('Closed') + setConnectedPeerId(null) + }) + + // Connect to target peer if provided + if (targetPeerId) { + await peerConnection.connectToPeer(targetPeerId) + } + } + } catch (error) { + logger.error( + error instanceof Error ? error : new Error(String(error)), + { + origin: 'P2PConnectionScreen', + }, + ) + openInfoModal({ + title: 'Connection Error', + message: String(error), + }) + setIsConnecting(false) + } + } + + initializeConnection() + + return () => { + if (manager) { + manager.cleanup() + } + } + }, [webrtcAdapter, storageAdapter, targetPeerId, signalingUrl, openInfoModal]) + + const handleDisconnect = () => { + if (connectionManager) { + connectionManager.cleanup() + setConnectionStatus('Disconnected') + setConnectedPeerId(null) + setConnectionManager(null) + } + } + + const handleShareMyPeerId = () => { + if (!myPeerId) return + + const deeplink = generateCIP158P2PDeeplink({ + peerId: myPeerId, + signalingUrl: signalingUrl, + }) + // Share the deeplink (could use Share API or copy to clipboard) + openInfoModal({ + title: 'Share Peer ID', + message: `Deeplink: ${deeplink}`, + }) + } + + if (!webrtcAdapter) { + return ( + + + + WebRTC is not available. Please install react-native-webrtc to use + P2P connections. + + + + ) + } + + return ( + + + + {myPeerId ? ( + <> + + +