BlueWallet/screen/lnd/ScanLNDInvoice.tsx
Marcos Rodriguez Velez 0aa6b96e4b w
2025-03-07 19:07:15 -04:00

472 lines
15 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
import { ActivityIndicator, I18nManager, Keyboard, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Icon } from '@rneui/themed';
import { btcToSatoshi, fiatToBTC } from '../../blue_modules/currency';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import { BlueCard, BlueLoading } from '../../BlueComponents';
import Lnurl from '../../class/lnurl';
import AddressInput from '../../components/AddressInput';
import presentAlert from '../../components/Alert';
import AmountInput from '../../components/AmountInput';
import Button from '../../components/Button';
import SafeArea from '../../components/SafeArea';
import { useTheme } from '../../components/themes';
import { useBiometrics, unlockWithBiometrics } from '../../hooks/useBiometrics';
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
import { useStorage } from '../../hooks/context/useStorage';
import { DismissKeyboardInputAccessory, DismissKeyboardInputAccessoryViewID } from '../../components/DismissKeyboardInputAccessory';
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
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';
type RouteProps = RouteProp<LNDStackParamsList, 'ScanLNDInvoice'>;
type NavigationProps = NativeStackNavigationProp<LNDStackParamsList, 'ScanLNDInvoice'>;
const ScanLNDInvoice = () => {
const { wallets, fetchAndSaveWalletTransactions } = useStorage();
const { isBiometricUseCapableAndEnabled } = useBiometrics();
const { colors } = useTheme();
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),
);
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>();
const stylesHook = StyleSheet.create({
walletWrapLabel: {
color: colors.buttonAlternativeTextColor,
},
walletWrapBalance: {
color: colors.buttonAlternativeTextColor,
},
walletWrapSats: {
color: colors.buttonAlternativeTextColor,
},
root: {
backgroundColor: colors.elevated,
},
});
useEffect(() => {
if (walletID && wallet?.getID() !== walletID) {
const newWallet = wallets.find(w => w.getID() === walletID) as LightningCustodianWallet;
if (newWallet) {
setWallet(newWallet);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
useFocusEffect(
useCallback(() => {
if (!wallet) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
goBack();
setTimeout(
() => presentAlert({ message: loc.wallets.no_ln_wallet_error, hapticFeedback: HapticFeedbackTypes.NotificationError }),
500,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]),
);
useEffect(() => {
if (wallet && uri) {
if (Lnurl.isLnurl(uri)) return processLnurlPay(uri);
if (Lnurl.isLightningAddress(uri)) return processLnurlPay(uri);
let data = uri;
// handling BIP21 w/BOLT11 support
const ind = data.indexOf('lightning=');
if (ind !== -1) {
data = data.substring(ind + 10).split('&')[0];
}
data = data.replace('LIGHTNING:', '').replace('lightning:', '');
console.log(data);
let newDecoded: DecodedInvoice;
try {
newDecoded = wallet.decodeInvoice(data);
const expiryTimeMs = (newDecoded.timestamp * 1 + newDecoded.expiry * 1) * 1000; // ms
let newExpiresIn: string;
if (+new Date() > expiryTimeMs) {
newExpiresIn = loc.lnd.expired;
} else {
const time = Math.round((expiryTimeMs - +new Date()) / (60 * 1000));
newExpiresIn = loc.formatString(loc.lnd.expiresIn, { time });
}
Keyboard.dismiss();
setParams({ uri: undefined, invoice: data });
setIsAmountInitiallyEmpty(newDecoded.num_satoshis === 0);
setDestination(data);
setIsLoading(false);
setAmount(newDecoded.num_satoshis.toString());
setExpiresIn(newExpiresIn);
setDecoded(newDecoded);
} catch (Err: any) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
Keyboard.dismiss();
setParams({ uri: undefined });
setTimeout(() => presentAlert({ message: Err.message, hapticFeedback: HapticFeedbackTypes.NotificationError }), 10);
setIsLoading(false);
setAmount(undefined);
setDestination('');
setExpiresIn(undefined);
setDecoded(undefined);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri]);
const _keyboardDidShow = (): void => {
setRenderWalletSelectionButtonHidden(true);
};
const _keyboardDidHide = (): void => {
setRenderWalletSelectionButtonHidden(false);
};
useKeyboard({ onKeyboardDidShow: _keyboardDidShow, onKeyboardDidHide: _keyboardDidHide });
const processInvoice = (data: string): void => {
if (Lnurl.isLnurl(data)) return processLnurlPay(data);
if (Lnurl.isLightningAddress(data)) return processLnurlPay(data);
setParams({ uri: data });
};
const processLnurlPay = (data: string): void => {
navigate('LnurlPay', {
lnurl: data,
walletID: walletID || wallet?.getID() || '',
});
};
const pay = async () => {
if (!decoded || !wallet || !amount || !invoice) {
return null;
}
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (isBiometricsEnabled) {
if (!(await unlockWithBiometrics())) {
return;
}
}
let amountSats: number = parseInt(amount, 10);
switch (unit) {
case BitcoinUnit.SATS:
// amount is already in sats
break;
case BitcoinUnit.BTC:
amountSats = btcToSatoshi(amount);
break;
case BitcoinUnit.LOCAL_CURRENCY:
amountSats = btcToSatoshi(fiatToBTC(Number(amount)));
break;
}
setIsLoading(true);
const expiryTimeMs = (decoded.timestamp * 1 + decoded.expiry * 1) * 1000; // ms
if (+new Date() > expiryTimeMs) {
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: loc.lnd.errorInvoiceExpired });
}
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)) {
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: loc.lnd.sameWalletAsInvoiceError });
}
try {
await wallet.payInvoice(invoice, amountSats);
} catch (Err: any) {
console.log(Err.message);
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
return presentAlert({ message: Err.message });
}
navigate('Success', {
amount: amountSats,
amountUnit: BitcoinUnit.SATS,
invoiceDescription: decoded.description,
});
fetchAndSaveWalletTransactions(wallet.getID());
};
const processTextForInvoice = (text: string): void => {
if (
(text && text.toLowerCase().startsWith('lnb')) ||
text.toLowerCase().startsWith('lightning:lnb') ||
Lnurl.isLnurl(text) ||
Lnurl.isLightningAddress(text)
) {
processInvoice(text);
} else {
setDecoded(undefined);
setExpiresIn(undefined);
setDestination(text);
}
};
const shouldDisablePayButton = (): boolean => {
if (!decoded) {
return true;
} else {
if (!amount) {
return true;
}
}
return !(parseInt(amount, 10) > 0);
};
const naviageToSelectWallet = (): void => {
navigate('SelectWallet', { onWalletSelect, chainType: Chain.OFFCHAIN });
};
const renderWalletSelectionButton = (): JSX.Element | undefined => {
if (renderWalletSelectionButtonHidden || !wallet) return;
const walletLabel = wallet.getLabel();
return (
<View style={styles.walletSelectRoot}>
{!isLoading && (
<TouchableOpacity accessibilityRole="button" style={styles.walletSelectTouch} onPress={naviageToSelectWallet}>
<Text style={styles.walletSelectText}>{loc.wallets.select_wallet.toLowerCase()}</Text>
<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}>
<Text style={[styles.walletWrapLabel, stylesHook.walletWrapLabel]}>{walletLabel}</Text>
<Text style={[styles.walletWrapBalance, stylesHook.walletWrapBalance]}>
{formatBalanceWithoutSuffix(wallet.getBalance(), BitcoinUnit.SATS, false)}
</Text>
<Text style={[styles.walletWrapSats, stylesHook.walletWrapSats]}>{BitcoinUnit.SATS}</Text>
</TouchableOpacity>
</View>
</View>
);
};
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;
return `${min} ${BitcoinUnit.SATS} - ${max} ${BitcoinUnit.SATS}`;
};
const onBlur = (): void => {
processTextForInvoice(destination);
};
const onWalletSelect = (selectedWallet: TWallet): void => {
setParams({ walletID: selectedWallet.getID() });
pop();
};
const onBarScanned = useCallback(
(value: string): void => {
if (!value) return;
DeeplinkSchemaMatch.navigationRouteFor({ url: value }, (completionValue: any) => {
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]);
const onChangeText = (text: string): void => {
const trimmedText = text.trim();
setDestination(trimmedText);
processTextForInvoice(trimmedText);
};
if (wallet === undefined || !wallet) {
return (
<View style={[styles.loadingIndicator, stylesHook.root]}>
<BlueLoading />
</View>
);
}
return (
<SafeArea style={stylesHook.root}>
<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}
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
/>
</View>
<BlueCard>
<AddressInput
onChangeText={onChangeText}
address={destination}
isLoading={isLoading}
placeholder={loc.lnd.placeholder}
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
onBlur={onBlur}
keyboardType="email-address"
style={styles.addressInput}
/>
<View style={styles.description}>
<Text numberOfLines={0} style={styles.descriptionText}>
{decoded !== undefined ? decoded.description : ''}
</Text>
</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 ? (
<View>
<ActivityIndicator />
</View>
) : (
<View>
<Button title={loc.lnd.payButton} onPress={pay} disabled={shouldDisablePayButton()} />
</View>
)}
</BlueCard>
</BlueCard>
{renderWalletSelectionButton()}
</ScrollView>
</View>
<DismissKeyboardInputAccessory />
</SafeArea>
);
};
export default ScanLNDInvoice;
const styles = StyleSheet.create({
walletSelectRoot: {
marginBottom: 16,
alignItems: 'center',
justifyContent: 'flex-end',
},
loadingIndicator: {
flex: 1,
justifyContent: 'center',
},
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',
marginHorizontal: 16,
alignItems: 'center',
marginVertical: 0,
borderRadius: 4,
},
descriptionText: {
color: '#81868e',
fontWeight: '500',
fontSize: 14,
},
expiresIn: {
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
color: '#81868e',
fontSize: 12,
left: 20,
top: 10,
},
addressInput: {
marginHorizontal: 16,
},
});