BlueWallet/screen/lnd/ScanLNDInvoice.tsx

473 lines
15 KiB
TypeScript
Raw Normal View History

2024-05-17 18:34:39 -04:00
import React, { useCallback, useEffect, useState } from 'react';
2025-03-05 21:43:02 -04:00
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
import { ActivityIndicator, I18nManager, Keyboard, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2024-06-12 12:46:44 -04:00
import { Icon } from '@rneui/themed';
2024-05-20 10:54:13 +01:00
import { btcToSatoshi, fiatToBTC } from '../../blue_modules/currency';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
2024-08-24 14:06:17 -04:00
import { BlueCard, BlueLoading } from '../../BlueComponents';
2020-07-23 19:06:13 +01:00
import Lnurl from '../../class/lnurl';
2024-05-20 10:54:13 +01:00
import AddressInput from '../../components/AddressInput';
import presentAlert from '../../components/Alert';
2024-05-20 10:54:13 +01:00
import AmountInput from '../../components/AmountInput';
2023-11-15 04:40:22 -04:00
import Button from '../../components/Button';
import SafeArea from '../../components/SafeArea';
2024-05-20 10:54:13 +01:00
import { useTheme } from '../../components/themes';
2024-06-03 21:54:32 -04:00
import { useBiometrics, unlockWithBiometrics } from '../../hooks/useBiometrics';
2024-05-20 10:54:13 +01:00
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
2024-05-31 13:22:22 -04:00
import { useStorage } from '../../hooks/context/useStorage';
2024-08-24 14:06:17 -04:00
import { DismissKeyboardInputAccessory, DismissKeyboardInputAccessoryViewID } from '../../components/DismissKeyboardInputAccessory';
2025-01-03 06:14:09 -04:00
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
2025-03-05 21:43:02 -04:00
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import { LNDStackParamsList } from '../../navigation/LNDStackParamsList';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
import { DecodedInvoice, TWallet } from '../../class/wallets/types';
import { useKeyboard } from '../../hooks/useKeyboard';
2025-03-05 21:43:02 -04:00
type RouteProps = RouteProp<LNDStackParamsList, 'ScanLNDInvoice'>;
type NavigationProps = NativeStackNavigationProp<LNDStackParamsList, 'ScanLNDInvoice'>;
const ScanLNDInvoice = () => {
2024-05-17 18:34:39 -04:00
const { wallets, fetchAndSaveWalletTransactions } = useStorage();
2024-06-03 21:54:32 -04:00
const { isBiometricUseCapableAndEnabled } = useBiometrics();
2020-12-05 00:17:51 -05:00
const { colors } = useTheme();
2025-03-05 21:43:02 -04:00
const route = useRoute<RouteProps>();
const { walletID, uri, invoice } = route.params || {};
const [wallet, setWallet] = useState<LightningCustodianWallet | undefined>(
(wallets.find(item => item.getID() === walletID) as LightningCustodianWallet) ||
(wallets.find(item => item.chain === Chain.OFFCHAIN) as LightningCustodianWallet),
2020-12-05 00:28:36 -05:00
);
2025-03-05 21:43:02 -04:00
const { navigate, setParams, goBack, pop } = useExtendedNavigation<NavigationProps>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [renderWalletSelectionButtonHidden, setRenderWalletSelectionButtonHidden] = useState<boolean>(false);
const [destination, setDestination] = useState<string>('');
const [unit, setUnit] = useState<BitcoinUnit>(BitcoinUnit.SATS);
const [decoded, setDecoded] = useState<DecodedInvoice | undefined>();
const [amount, setAmount] = useState<string | undefined>();
const [isAmountInitiallyEmpty, setIsAmountInitiallyEmpty] = useState<boolean | undefined>();
const [expiresIn, setExpiresIn] = useState<string | undefined>();
2020-12-05 00:17:51 -05:00
const stylesHook = StyleSheet.create({
walletWrapLabel: {
color: colors.buttonAlternativeTextColor,
},
walletWrapBalance: {
color: colors.buttonAlternativeTextColor,
},
walletWrapSats: {
color: colors.buttonAlternativeTextColor,
},
root: {
backgroundColor: colors.elevated,
},
});
2020-12-05 00:28:36 -05:00
useEffect(() => {
2020-12-10 22:56:01 -05:00
if (walletID && wallet?.getID() !== walletID) {
2025-03-05 21:43:02 -04:00
const newWallet = wallets.find(w => w.getID() === walletID) as LightningCustodianWallet;
if (newWallet) {
setWallet(newWallet);
}
2020-12-05 00:28:36 -05:00
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
2020-12-10 22:56:01 -05:00
useFocusEffect(
useCallback(() => {
if (!wallet) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
2020-12-10 22:56:01 -05:00
goBack();
2025-03-05 21:43:02 -04:00
setTimeout(
() => presentAlert({ message: loc.wallets.no_ln_wallet_error, hapticFeedback: HapticFeedbackTypes.NotificationError }),
500,
);
2020-12-10 22:56:01 -05:00
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]),
);
2020-12-05 00:17:51 -05:00
useEffect(() => {
2020-12-10 22:56:01 -05:00
if (wallet && uri) {
if (Lnurl.isLnurl(uri)) return processLnurlPay(uri);
if (Lnurl.isLightningAddress(uri)) return processLnurlPay(uri);
2020-12-05 00:17:51 -05:00
let data = uri;
// handling BIP21 w/BOLT11 support
const ind = data.indexOf('lightning=');
if (ind !== -1) {
data = data.substring(ind + 10).split('&')[0];
}
2019-01-06 15:21:04 -05:00
data = data.replace('LIGHTNING:', '').replace('lightning:', '');
console.log(data);
2025-03-05 21:43:02 -04:00
let newDecoded: DecodedInvoice;
2019-01-06 15:21:04 -05:00
try {
newDecoded = wallet.decodeInvoice(data);
2025-03-05 21:43:02 -04:00
const expiryTimeMs = (newDecoded.timestamp * 1 + newDecoded.expiry * 1) * 1000; // ms
let newExpiresIn: string;
if (+new Date() > expiryTimeMs) {
newExpiresIn = loc.lnd.expired;
2019-01-06 15:21:04 -05:00
} else {
2025-03-05 21:43:02 -04:00
const time = Math.round((expiryTimeMs - +new Date()) / (60 * 1000));
newExpiresIn = loc.formatString(loc.lnd.expiresIn, { time });
2019-01-06 15:21:04 -05:00
}
2025-03-05 21:43:02 -04:00
2019-01-06 15:21:04 -05:00
Keyboard.dismiss();
2020-12-05 00:17:51 -05:00
setParams({ uri: undefined, invoice: data });
2025-03-05 21:43:02 -04:00
setIsAmountInitiallyEmpty(newDecoded.num_satoshis === 0);
2020-12-05 00:17:51 -05:00
setDestination(data);
setIsLoading(false);
2025-03-05 21:43:02 -04:00
setAmount(newDecoded.num_satoshis.toString());
setExpiresIn(newExpiresIn);
setDecoded(newDecoded);
2025-03-05 21:43:02 -04:00
} catch (Err: any) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
2020-01-19 22:33:17 -05:00
Keyboard.dismiss();
2020-12-05 00:17:51 -05:00
setParams({ uri: undefined });
2025-03-05 21:43:02 -04:00
setTimeout(() => presentAlert({ message: Err.message, hapticFeedback: HapticFeedbackTypes.NotificationError }), 10);
2020-12-05 00:17:51 -05:00
setIsLoading(false);
2025-03-05 21:43:02 -04:00
setAmount(undefined);
setDestination('');
setExpiresIn(undefined);
setDecoded(undefined);
}
2020-01-19 22:33:17 -05:00
}
2020-12-05 00:17:51 -05:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri]);
2020-01-19 22:33:17 -05:00
2025-03-05 21:43:02 -04:00
const _keyboardDidShow = (): void => {
2020-12-05 00:17:51 -05:00
setRenderWalletSelectionButtonHidden(true);
2020-01-19 22:33:17 -05:00
};
2025-03-05 21:43:02 -04:00
const _keyboardDidHide = (): void => {
2020-12-05 00:17:51 -05:00
setRenderWalletSelectionButtonHidden(false);
2020-01-19 22:33:17 -05:00
};
2025-03-05 21:43:02 -04:00
useKeyboard({ onKeyboardDidShow: _keyboardDidShow, onKeyboardDidHide: _keyboardDidHide });
const processInvoice = (data: string): void => {
2020-12-05 00:17:51 -05:00
if (Lnurl.isLnurl(data)) return processLnurlPay(data);
if (Lnurl.isLightningAddress(data)) return processLnurlPay(data);
2020-12-05 00:17:51 -05:00
setParams({ uri: data });
2019-01-24 02:36:01 -05:00
};
2025-03-05 21:43:02 -04:00
const processLnurlPay = (data: string): void => {
navigate('LnurlPay', {
lnurl: data,
walletID: walletID || wallet?.getID() || '',
2020-07-23 19:06:13 +01:00
});
};
2020-12-05 00:17:51 -05:00
const pay = async () => {
2025-03-05 21:43:02 -04:00
if (!decoded || !wallet || !amount || !invoice) {
return null;
}
2024-05-17 18:34:39 -04:00
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (isBiometricsEnabled) {
2024-05-17 18:34:39 -04:00
if (!(await unlockWithBiometrics())) {
return;
}
}
2025-03-05 21:43:02 -04:00
let amountSats: number = parseInt(amount, 10);
2020-12-05 00:17:51 -05:00
switch (unit) {
2020-06-09 15:08:18 +01:00
case BitcoinUnit.SATS:
2025-03-05 21:43:02 -04:00
// amount is already in sats
2020-06-09 15:08:18 +01:00
break;
case BitcoinUnit.BTC:
2025-03-05 21:43:02 -04:00
amountSats = btcToSatoshi(amount);
2020-06-09 15:08:18 +01:00
break;
case BitcoinUnit.LOCAL_CURRENCY:
2025-03-05 21:43:02 -04:00
amountSats = btcToSatoshi(fiatToBTC(Number(amount)));
2020-06-09 15:08:18 +01:00
break;
}
2020-12-05 00:17:51 -05:00
setIsLoading(true);
2020-06-09 15:08:18 +01:00
2025-03-05 21:43:02 -04:00
const expiryTimeMs = (decoded.timestamp * 1 + decoded.expiry * 1) * 1000; // ms
if (+new Date() > expiryTimeMs) {
2020-12-05 00:17:51 -05:00
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: loc.lnd.errorInvoiceExpired });
2020-12-05 00:17:51 -05:00
}
const currentUserInvoices = wallet.user_invoices_raw; // not fetching invoices, as we assume they were loaded previously
if (currentUserInvoices.some(i => i.payment_hash === decoded.payment_hash)) {
2020-12-05 00:17:51 -05:00
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: loc.lnd.sameWalletAsInvoiceError });
2020-12-05 00:17:51 -05:00
}
2020-12-05 00:17:51 -05:00
try {
2020-12-07 10:58:28 -05:00
await wallet.payInvoice(invoice, amountSats);
2025-03-05 21:43:02 -04:00
} catch (Err: any) {
2020-12-05 00:17:51 -05:00
console.log(Err.message);
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: Err.message });
2020-12-05 00:17:51 -05:00
}
2020-12-05 00:17:51 -05:00
navigate('Success', {
2020-12-18 11:08:02 -05:00
amount: amountSats,
2020-12-05 00:17:51 -05:00
amountUnit: BitcoinUnit.SATS,
invoiceDescription: decoded.description,
});
fetchAndSaveWalletTransactions(wallet.getID());
2020-12-05 00:17:51 -05:00
};
2025-03-05 21:43:02 -04:00
const processTextForInvoice = (text: string): void => {
if (
2024-07-04 19:55:00 -04:00
(text && text.toLowerCase().startsWith('lnb')) ||
text.toLowerCase().startsWith('lightning:lnb') ||
Lnurl.isLnurl(text) ||
Lnurl.isLightningAddress(text)
) {
2020-12-05 00:17:51 -05:00
processInvoice(text);
2018-12-24 10:29:33 -05:00
} else {
2020-12-05 00:17:51 -05:00
setDecoded(undefined);
setExpiresIn(undefined);
setDestination(text);
2018-12-24 10:29:33 -05:00
}
};
2025-03-05 21:43:02 -04:00
const shouldDisablePayButton = (): boolean => {
2020-12-05 00:17:51 -05:00
if (!decoded) {
return true;
} else {
2020-12-05 00:17:51 -05:00
if (!amount) {
return true;
}
}
2025-03-05 21:43:02 -04:00
return !(parseInt(amount, 10) > 0);
};
2025-03-05 21:43:02 -04:00
const naviageToSelectWallet = (): void => {
2020-12-07 10:12:49 -05:00
navigate('SelectWallet', { onWalletSelect, chainType: Chain.OFFCHAIN });
};
2025-03-05 21:43:02 -04:00
const renderWalletSelectionButton = (): JSX.Element | undefined => {
if (renderWalletSelectionButtonHidden || !wallet) return;
2020-12-07 10:58:28 -05:00
const walletLabel = wallet.getLabel();
return (
<View style={styles.walletSelectRoot}>
2020-12-05 00:17:51 -05:00
{!isLoading && (
<TouchableOpacity accessibilityRole="button" style={styles.walletSelectTouch} onPress={naviageToSelectWallet}>
<Text style={styles.walletSelectText}>{loc.wallets.select_wallet.toLowerCase()}</Text>
2021-03-19 11:12:17 -04:00
<Icon name={I18nManager.isRTL ? 'angle-left' : 'angle-right'} size={18} type="font-awesome" color="#9aa0aa" />
</TouchableOpacity>
)}
<View style={styles.walletWrap}>
<TouchableOpacity accessibilityRole="button" disabled={isLoading} style={styles.walletWrapTouch} onPress={naviageToSelectWallet}>
2020-12-07 10:58:28 -05:00
<Text style={[styles.walletWrapLabel, stylesHook.walletWrapLabel]}>{walletLabel}</Text>
2020-12-05 00:17:51 -05:00
<Text style={[styles.walletWrapBalance, stylesHook.walletWrapBalance]}>
2020-12-07 10:58:28 -05:00
{formatBalanceWithoutSuffix(wallet.getBalance(), BitcoinUnit.SATS, false)}
</Text>
2020-12-05 00:17:51 -05:00
<Text style={[styles.walletWrapSats, stylesHook.walletWrapSats]}>{BitcoinUnit.SATS}</Text>
</TouchableOpacity>
</View>
</View>
);
};
2025-03-05 21:43:02 -04:00
const getFees = (): string => {
if (!decoded) return '';
const num_satoshis = parseInt(decoded.num_satoshis.toString(), 10);
const min = Math.floor(num_satoshis * 0.003);
const max = Math.floor(num_satoshis * 0.01) + 1;
2021-09-02 16:22:43 -04:00
return `${min} ${BitcoinUnit.SATS} - ${max} ${BitcoinUnit.SATS}`;
2020-12-05 00:17:51 -05:00
};
2020-06-04 14:07:33 +01:00
2025-03-05 21:43:02 -04:00
const onBlur = (): void => {
processTextForInvoice(destination);
};
2025-03-05 21:43:02 -04:00
const onWalletSelect = (selectedWallet: TWallet): void => {
2020-12-05 00:17:51 -05:00
setParams({ walletID: selectedWallet.getID() });
pop();
};
2025-01-03 06:14:09 -04:00
const onBarScanned = useCallback(
2025-03-05 21:43:02 -04:00
(value: string): void => {
2025-01-03 06:14:09 -04:00
if (!value) return;
2025-03-05 21:43:02 -04:00
DeeplinkSchemaMatch.navigationRouteFor({ url: value }, (completionValue: any) => {
2025-01-03 06:14:09 -04:00
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
navigate(...completionValue);
});
},
[navigate],
);
useEffect(() => {
const data = route.params?.onBarScanned;
if (data) {
onBarScanned(data);
setParams({ onBarScanned: undefined });
}
}, [navigate, onBarScanned, route.params?.onBarScanned, setParams]);
2025-03-05 21:43:02 -04:00
const onChangeText = (text: string): void => {
const trimmedText = text.trim();
setDestination(trimmedText);
processTextForInvoice(trimmedText);
};
2020-12-07 11:06:45 -05:00
if (wallet === undefined || !wallet) {
2020-12-10 22:56:01 -05:00
return (
<View style={[styles.loadingIndicator, stylesHook.root]}>
<BlueLoading />
</View>
);
2020-06-09 15:08:18 +01:00
}
2020-12-05 00:17:51 -05:00
return (
<SafeArea style={stylesHook.root}>
2020-12-05 00:17:51 -05:00
<View style={[styles.root, stylesHook.root]}>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
automaticallyAdjustContentInsets
automaticallyAdjustKeyboardInsets
contentInsetAdjustmentBehavior="automatic"
>
<View style={styles.scrollMargin}>
<AmountInput
pointerEvents={isAmountInitiallyEmpty ? 'auto' : 'none'}
isLoading={isLoading}
amount={amount}
onAmountUnitChange={setUnit}
onChangeText={setAmount}
disabled={!decoded || isLoading || decoded.num_satoshis > 0}
unit={unit}
2024-08-24 14:06:17 -04:00
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
/>
</View>
<BlueCard>
<AddressInput
2025-03-05 21:43:02 -04:00
onChangeText={onChangeText}
address={destination}
isLoading={isLoading}
placeholder={loc.lnd.placeholder}
2024-08-24 14:06:17 -04:00
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
onBlur={onBlur}
keyboardType="email-address"
2024-11-15 22:32:50 +00:00
style={styles.addressInput}
/>
<View style={styles.description}>
<Text numberOfLines={0} style={styles.descriptionText}>
{decoded !== undefined ? decoded.description : ''}
</Text>
2020-12-05 00:17:51 -05:00
</View>
{expiresIn !== undefined && (
<View>
<Text style={styles.expiresIn}>{expiresIn}</Text>
{decoded && decoded.num_satoshis > 0 && (
<Text style={styles.expiresIn}>{loc.formatString(loc.lnd.potentialFee, { fee: getFees() })}</Text>
)}
</View>
)}
<BlueCard>
{isLoading ? (
2020-12-05 00:17:51 -05:00
<View>
<ActivityIndicator />
</View>
) : (
<View>
<Button title={loc.lnd.payButton} onPress={pay} disabled={shouldDisablePayButton()} />
</View>
2020-12-05 00:17:51 -05:00
)}
</BlueCard>
</BlueCard>
2020-12-05 00:17:51 -05:00
{renderWalletSelectionButton()}
</ScrollView>
</View>
2024-08-24 14:06:17 -04:00
<DismissKeyboardInputAccessory />
</SafeArea>
2020-12-05 00:17:51 -05:00
);
2020-04-17 16:23:18 +01:00
};
2020-07-15 13:32:59 -04:00
2025-03-05 21:43:02 -04:00
export default ScanLNDInvoice;
2024-01-12 09:10:48 -04:00
2020-12-05 00:17:51 -05:00
const styles = StyleSheet.create({
walletSelectRoot: {
marginBottom: 16,
alignItems: 'center',
justifyContent: 'flex-end',
},
2020-12-10 22:56:01 -05:00
loadingIndicator: {
flex: 1,
justifyContent: 'center',
},
2020-12-05 00:17:51 -05:00
walletSelectTouch: {
flexDirection: 'row',
alignItems: 'center',
},
walletSelectText: {
color: '#9aa0aa',
fontSize: 14,
marginRight: 8,
},
walletWrap: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 4,
},
walletWrapTouch: {
flexDirection: 'row',
alignItems: 'center',
},
walletWrapLabel: {
fontSize: 14,
},
walletWrapBalance: {
fontSize: 14,
fontWeight: '600',
marginLeft: 4,
marginRight: 4,
},
walletWrapSats: {
fontSize: 11,
fontWeight: '600',
textAlignVertical: 'bottom',
marginTop: 2,
},
root: {
flex: 1,
},
scroll: {
flex: 1,
justifyContent: 'space-between',
},
scrollMargin: {
marginTop: 60,
},
description: {
flexDirection: 'row',
2024-11-15 22:32:50 +00:00
marginHorizontal: 16,
2020-12-05 00:17:51 -05:00
alignItems: 'center',
marginVertical: 0,
borderRadius: 4,
},
descriptionText: {
color: '#81868e',
fontWeight: '500',
fontSize: 14,
},
expiresIn: {
2021-08-26 14:00:23 -04:00
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
2020-12-05 00:17:51 -05:00
color: '#81868e',
fontSize: 12,
left: 20,
top: 10,
},
2024-11-15 22:32:50 +00:00
addressInput: {
marginHorizontal: 16,
},
2020-12-05 00:17:51 -05:00
});