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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 44 additions & 68 deletions class/deeplink-schema-match.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import bip21, { TOptions } from 'bip21';
import * as bitcoin from 'bitcoinjs-lib';
import URL from 'url';
import { Chain } from '../models/bitcoinUnits';
import { ElectrumServerItem, parseElectrumServerString } from '../modules/Electrum';
import { getActiveNetwork } from '../modules/network';
import type { TWallet } from './wallets/types';
import { getActiveNetwork, getAllNetworks } from '../modules/network';
import { isSilentPaymentAddress } from '../helpers/silent-payments';

type TCompletionHandlerParams = [string, object];
type TContext = {
wallets: TWallet[];
saveToDisk: () => void;
addWallet: (wallet: TWallet) => void;
};
export type TScanError = 'wrong_network' | 'unrecognized';
type TScanDestination = { send: string } | { electrumServer: ElectrumServerItem } | { error: TScanError };

class DeeplinkSchemaMatch {
static hasSchema(schemaString: string): boolean {
Expand All @@ -21,61 +15,34 @@ class DeeplinkSchemaMatch {
}

/**
* Examines the URL of the event and dispatches the appropriate navigation route.
* Maps scanned or pasted input to where it should open, or to the reason it can't.
*
* Supported schemes:
* bitcoin: — standard on-chain payment URI (BIP21)
* bitcoin: — payment URI (BIP21), including silent-payment addresses
* shroud:bitcoin: — same, wrapped in the app scheme
* shroud://widget?action= — home-screen widget actions
* shroud:setelectrumserver — configure the Electrum server
*
* @param event {{url: string}}
* @param completionHandler {function} Callback that receives [routeName, params]
*/
static navigationRouteFor(
event: { url: string },
completionHandler: (args: TCompletionHandlerParams) => void,
context: TContext = { wallets: [], saveToDisk: () => {}, addWallet: () => {} },
) {
if (!event.url || typeof event.url !== 'string') return;

const lower = event.url.toLowerCase();

// rip out the shroud: wrapper from shroud:bitcoin: (or shroud:BITCOIN:) URIs.
if (lower.startsWith('shroud:bitcoin:')) {
event.url = event.url.substring('shroud:'.length);
} else if (lower.startsWith('shroud://widget?action=')) {
event.url = event.url.substring('shroud://'.length);
}
static destinationFor(input: string): TScanDestination {
const uri = DeeplinkSchemaMatch.canonicalise(input);
const address = DeeplinkSchemaMatch.extractAddress(uri);

if (DeeplinkSchemaMatch.isWidgetAction(event.url)) {
const wallet = context.wallets[0];
if (wallet?.chain === Chain.ONCHAIN) {
const action = event.url.split('widget?action=')[1];
if (action === 'openSend') {
completionHandler(['SendDetailsRoot', { screen: 'SendDetails', params: { walletID: wallet.getID() } }]);
} else if (action === 'openReceive') {
completionHandler(['DetailViewStackScreensStack', { screen: 'ReceiveDetails', params: { walletID: wallet.getID() } }]);
}
}
return;
if (DeeplinkSchemaMatch.isValidAddress(address, getActiveNetwork().bitcoinjs)) {
return { send: uri };
}

if (DeeplinkSchemaMatch.isBitcoinAddress(event.url)) {
completionHandler(['SendDetailsRoot', { screen: 'SendDetails', params: { uri: event.url.replace('://', ':') } }]);
return;
if (uri.toLowerCase().startsWith('shroud:setelectrumserver')) {
const server = DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(uri);
return server ? { electrumServer: server } : { error: 'unrecognized' };
}

const urlObject = URL.parse(event.url, true); // eslint-disable-line n/no-deprecated-api
if (urlObject.protocol === 'shroud:') {
switch (urlObject.host) {
case 'setelectrumserver': {
const server = DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(event.url);
completionHandler(['ElectrumServerSettings', { server: server || undefined }]);
break;
}
}
// The active network already failed above, so any match is another chain. Disabled networks
// count too: their addresses are just as unpayable from here.
if (getAllNetworks().some(n => DeeplinkSchemaMatch.isValidAddress(address, n.bitcoinjs))) {
return { error: 'wrong_network' };
}

// TODO: BIP-321 `sp=` query param (e.g. `bitcoin:?sp=sp1q…`) is not routed yet.
Comment thread
chaitika marked this conversation as resolved.
return { error: 'unrecognized' };
}

/**
Expand All @@ -89,33 +56,42 @@ class DeeplinkSchemaMatch {
}
const parts = url.split('server=');
if (!parts[1]) return false;
return parseElectrumServerString(decodeURIComponent(parts[1])) ?? false;
let server: string;
try {
server = decodeURIComponent(parts[1]);
} catch {
// Malformed `%` escape.
return false;
}
return parseElectrumServerString(server) ?? false;
}

/** Trims, unwraps `shroud:bitcoin:` and rewrites a leading `bitcoin:` / `bitcoin://` in any case to `bitcoin:`. */
private static canonicalise(input: string): string {
return input.trim().replace(/^(?:shroud:)?bitcoin:(?:\/\/)?/i, 'bitcoin:');
}

/** Takes a canonical URI or a bare address. */
private static extractAddress(uri: string): string {
return uri.replace(/^bitcoin:/, '').split('?')[0];
}

static isBitcoinAddress(address: string): boolean {
address = address.replace('://', ':').replace('bitcoin:', '').replace('BITCOIN:', '').replace('bitcoin=', '').split('?')[0];
/** Includes silent-payment addresses. */
private static isValidAddress(address: string, network: bitcoin.Network): boolean {
if (isSilentPaymentAddress(address, network)) return true;
try {
bitcoin.address.toOutputScript(address, getActiveNetwork().bitcoinjs);
bitcoin.address.toOutputScript(address, network);
return true;
} catch {
return false;
}
}

static isWidgetAction(text: string): boolean {
return text.startsWith('widget?action=');
}

static bip21decode(uri?: string) {
if (!uri) {
throw new Error('No URI provided');
}
let replacedUri = uri;
for (const replaceMe of ['BITCOIN://', 'bitcoin://', 'BITCOIN:']) {
replacedUri = replacedUri.replace(replaceMe, 'bitcoin:');
}

return bip21.decode(replacedUri);
return bip21.decode(DeeplinkSchemaMatch.canonicalise(uri));
}

static bip21encode(address: string, options?: TOptions): string {
Expand Down
2 changes: 2 additions & 0 deletions loc/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@
"paste_from_clipboard": "Paste",
"import_file": "Import File",
"list_long_scan": "Scan QR Code",
"scan_wrong_network": "This address is for a different Bitcoin network. Your wallet is on {network}.",
"scan_unrecognized": "Shroud doesn't recognize this code or text.",
"list_title": "Wallets",
"list_tryagain": "Try again",
"single_wallet_limit": "Only one wallet is supported. Please delete the existing wallet first.",
Expand Down
45 changes: 33 additions & 12 deletions screen/wallets/WalletsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { useFocusEffect } from '@react-navigation/native';
import useAppState from '../../hooks/useAppState';
import ScanProgressBar from '../../components/ScanProgressBar';
import { useScanActions, useScannableWallet } from '../../hooks/useScannableWallet';
import { Alert, Animated, InteractionManager, Modal, SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Animated, InteractionManager, Modal, SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import A from '../../modules/analytics';
import { getClipboardContent } from '../../modules/clipboard';
import { isDesktop } from '../../modules/environment';
import { getActiveNetwork } from '../../modules/network';
import * as fs from '../../modules/fs';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../modules/hapticFeedback';
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
import DeeplinkSchemaMatch, { TScanError } from '../../class/deeplink-schema-match';
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
import { useTheme } from '../../components/themes';
Expand Down Expand Up @@ -125,6 +126,15 @@ function reducer(state: WalletListState, action: WalletListAction) {

type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'WalletsList'>;

const scanErrorMessage = (error: TScanError): string => {
switch (error) {
case 'wrong_network':
return loc.formatString(loc.wallets.scan_wrong_network, { network: getActiveNetwork().displayName });
case 'unrecognized':
return loc.wallets.scan_unrecognized;
}
};

const WalletsList: React.FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { isLoading } = state;
Expand Down Expand Up @@ -329,19 +339,30 @@ const WalletsList: React.FC = () => {
}, [currentAppState, scanState.status, scanWallet]);

const onBarScanned = useCallback(
(value: any) => {
(value?: string) => {
// Empty when the image picker is cancelled.
Comment thread
chaitika marked this conversation as resolved.
if (!value) return;
try {
DeeplinkSchemaMatch.navigationRouteFor({ url: value }, completionValue => {
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
// @ts-ignore: for now
navigation.navigate(...completionValue);
const destination = DeeplinkSchemaMatch.destinationFor(value);
if ('error' in destination) {
presentAlert({
title: loc.errors.error,
message: scanErrorMessage(destination.error),
hapticFeedback: HapticFeedbackTypes.NotificationError,
});
return;
}
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
if ('send' in destination) {
if (wallets.length === 0) return;
navigation.navigate('SendDetailsRoot', {
screen: 'SendDetails',
params: { walletID: wallets[0].getID(), uri: destination.send },
});
} catch (e: any) {
Alert.alert(loc.errors.error, e.message);
} else {
navigation.navigate('ElectrumServerSettings', { server: destination.electrumServer });
}
},
[navigation],
[navigation, wallets],
);

const openAddContact = useCallback(() => {
Expand Down Expand Up @@ -470,7 +491,7 @@ const WalletsList: React.FC = () => {
}, [onBarScanned]);

const sendButtonLongPress = useCallback(async () => {
const isClipboardEmpty = (await getClipboardContent())?.trim().length === 0;
const isClipboardEmpty = !(await getClipboardContent())?.trim();

const options = [loc._.cancel, loc.wallets.list_long_choose, loc.wallets.list_long_scan];
if (!isClipboardEmpty) {
Expand Down
19 changes: 18 additions & 1 deletion tests/e2e/05-send.spec.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { dismissBackupReminderIfPresent, helperCreateWallet, launchAppForE2E } from './helperz';
import { SP_MAINNET } from '../fixtures/addresses';
import { dismissBackupReminderIfPresent, helperCreateWallet, launchAppForE2E, scanFromHome, waitForId } from './helperz';

describe('Send', () => {
beforeAll(async () => {
await launchAppForE2E({ delete: true });
await helperCreateWallet();
});

it('scanning a silent payment address on home opens Send prefilled', async () => {
Comment thread
chaitika marked this conversation as resolved.
// SendDetails keeps detox busy, as in 07-contacts' send-flow tests.
await device.disableSynchronization();
try {
await scanFromHome(SP_MAINNET);
await waitFor(element(by.id('AddressInput')))
.toBeVisible()
.withTimeout(30_000);
await expect(element(by.id('AddressInput'))).toHaveText(SP_MAINNET);
} finally {
await device.enableSynchronization();
}
await device.pressBack();
await waitForId('HomeScreenReceiveButton');
});

it('shows zero-balance toast when tapping Send with no funds', async () => {
await element(by.id('HomeScreenSendButton')).tap();
await waitFor(element(by.id('ZeroBalanceToast')))
Expand Down
14 changes: 7 additions & 7 deletions tests/e2e/07-contacts.spec.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import en from '../../loc/en.json';
import { SP_MAINNET, SP_MAINNET_2 } from '../fixtures/addresses';
import { expectToBeVisible, helperAddContact, helperCreateWallet, launchAppForE2E, scrollIntoView, waitForId } from './helperz';

const ADDR_A = 'sp1qqfqnnv8czppwysafq3uwgwvsc638hc8rx3hscuddh0xa2yd746s7xqh6yy9ncjnqhqxazct0fzh98w7lpkm5fvlepqec2yy0sxlq4j6ccc3h6t0g';
const ADDR_B = 'sp1qqvchcnrcqpdutxhpf57ptn3wajj0ymqxwzu9g6vj9uxx3wuvlykhyqh99hyh33y5593802pzw5rtw040zrw9f8re52tgcwngc5974w5evuufdy0m';
const ADDR_A = SP_MAINNET;
const ADDR_B = SP_MAINNET_2;

// Copied from loc/en.json rather than imported: pulling loc/ into the detox runner would drag the
// whole react-native module graph into a plain node process.
const ADDRESS_INVALID = "That doesn't look like a valid address";
const ADDRESS_VALID = 'Valid silent payment address';
const REMOVE_CONFIRM = 'Remove';
const ADDRESS_INVALID = en.contacts.address_invalid;
const ADDRESS_VALID = en.contacts.address_valid;
const REMOVE_CONFIRM = en.contacts.remove_confirm_action;

const returnToHome = async () => {
for (let i = 0; i < 4; i++) {
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/helperz.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,8 @@ export async function scanText(text) {
await element(by.id('scanQrBackdoorInput')).replaceText(text);
await element(by.id('scanQrBackdoorOkButton')).tap();
}

export async function scanFromHome(text) {
await element(by.id('HomeScreenScanButton')).tap();
await scanText(text);
}
7 changes: 7 additions & 0 deletions tests/fixtures/addresses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Plain data so both the unit and the detox runners can import it.
export const SP_MAINNET =
'sp1qqfqnnv8czppwysafq3uwgwvsc638hc8rx3hscuddh0xa2yd746s7xqh6yy9ncjnqhqxazct0fzh98w7lpkm5fvlepqec2yy0sxlq4j6ccc3h6t0g';
export const SP_MAINNET_2 =
'sp1qqvchcnrcqpdutxhpf57ptn3wajj0ymqxwzu9g6vj9uxx3wuvlykhyqh99hyh33y5593802pzw5rtw040zrw9f8re52tgcwngc5974w5evuufdy0m';
export const SP_SIGNET =
'tsp1qqwv4j5d3ks6a8zs63lpfd6ydxqm4dlwz4z0stj0hdtjqmh0ky2pnkq5nqu25hnuzw42wfpv0zu404n4x2yr29e6kr4rgyt3m87mjwf9acua9metd';
Loading
Loading