Files
app.zeusln.zeus/views/Receive.tsx
T
2026-05-01 20:43:42 -04:00

3384 lines
174 KiB
TypeScript

import * as React from 'react';
import {
Dimensions,
NativeEventEmitter,
NativeModules,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
Platform
} from 'react-native';
import BigNumber from 'bignumber.js';
import { LNURLWithdrawParams } from 'js-lnurl';
import { ButtonGroup, Icon } from '@rneui/themed';
import { inject, observer } from 'mobx-react';
import _map from 'lodash/map';
import NfcManager from 'react-native-nfc-manager';
import { Route } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import handleAnything from '../utils/handleAnything';
import Wordmark from '../assets/images/SVG/wordmark-black.svg';
const ZIcon = require('../assets/images/icon_black.png');
const LightningIcon = require('../assets/images/lightning_black.png');
const OnChainIcon = require('../assets/images/onchain_black.png');
const ZPayIcon = require('../assets/images/pay_z_black.png');
const ZIconWhite = require('../assets/images/icon_white.png');
const LightningIconWhite = require('../assets/images/lightning_white.png');
const OnChainIconWhite = require('../assets/images/onchain_white.png');
const ZPayIconWhite = require('../assets/images/pay_z_white.png');
import Amount from '../components/Amount';
import AmountInput, { getSatAmount } from '../components/AmountInput';
import Button from '../components/Button';
import CollapsedQR from '../components/CollapsedQR';
import DropdownSetting from '../components/DropdownSetting';
import Header from '../components/Header';
import HopPicker from '../components/HopPicker';
import KeyValue from '../components/KeyValue';
import LoadingIndicator from '../components/LoadingIndicator';
import PaidIndicator from '../components/PaidIndicator';
import ModalBox from '../components/ModalBox';
import { Row } from '../components/layout/Row';
import Screen from '../components/Screen';
import SuccessAnimation from '../components/SuccessAnimation';
import {
SuccessMessage,
WarningMessage,
ErrorMessage
} from '../components/SuccessErrorMessage';
import Switch from '../components/Switch';
import Text from '../components/Text';
import TextInput from '../components/TextInput';
import { Spacer } from '../components/layout/Spacer';
import Invoice from '../models/Invoice';
import Channel from '../models/Channel';
import ChannelsStore from '../stores/ChannelsStore';
import ModalStore from '../stores/ModalStore';
import NodeInfoStore from '../stores/NodeInfoStore';
import InvoicesStore from '../stores/InvoicesStore';
import PosStore from '../stores/PosStore';
import SettingsStore, {
TIME_PERIOD_KEYS,
DefaultInvoiceType
} from '../stores/SettingsStore';
import LightningAddressStore from '../stores/LightningAddressStore';
import LSPStore from '../stores/LSPStore';
import UnitsStore from '../stores/UnitsStore';
import BalanceStore from '../stores/BalanceStore';
import { localeString } from '../utils/LocaleUtils';
import BackendUtils from '../utils/BackendUtils';
import Base64Utils from '../utils/Base64Utils';
import { scanNfcTag } from '../utils/NFCUtils';
import { themeColor } from '../utils/ThemeUtils';
import { SATS_PER_BTC } from '../utils/UnitsUtils';
import { getAmountFromSats } from '../utils/AmountUtils';
import {
ExpirationPreset,
TimePeriod,
expirationIndexFromSeconds,
expirySecondsFromInput
} from '../utils/ExpiryUtils';
import lndMobile from '../lndmobile/LndMobileInjection';
import { decodeSubscribeTransactionsResult } from '../lndmobile/onchain';
import {
checkLndStreamErrorResponse,
LndMobileEventEmitter
} from '../utils/LndMobileUtils';
import CaretDown from '../assets/images/SVG/Caret Down.svg';
import CaretRight from '../assets/images/SVG/Caret Right.svg';
import LockIcon from '../assets/images/SVG/Lock.svg';
import UnifiedSvg from '../components/SVG/UnifiedSvg';
import LightningSvg from '../components/SVG/LightningSvg';
import OnChainSvg from '../components/SVG/OnChainSvg';
import AddressSvg from '../components/SVG/AddressSvg';
import Gear from '../assets/images/SVG/Gear.svg';
import NfcIcon from '../assets/images/SVG/NFC-alt.svg';
interface ReceiveProps {
exitSetup: any;
navigation: NativeStackNavigationProp<any, any>;
ChannelsStore: ChannelsStore;
InvoicesStore: InvoicesStore;
PosStore: PosStore;
ModalStore: ModalStore;
NodeInfoStore: NodeInfoStore;
SettingsStore: SettingsStore;
UnitsStore: UnitsStore;
LSPStore: LSPStore;
LightningAddressStore: LightningAddressStore;
BalanceStore: BalanceStore;
route: Route<
'Receive',
{
lnurlParams: LNURLWithdrawParams | undefined;
amount: string;
autoGenerate: boolean;
autoGenerateOnChain: boolean;
autoGenerateChange?: boolean;
forceLn?: boolean; // when coming from the LN balance slider action
forceOnChain?: boolean; // when coming from the on-chain balance slider action
account: string;
addressType?: string;
selectedIndex: number;
memo: string;
order: any; // Full order, required for receipt printing
orderId: string;
orderTotal: string;
orderTip: string;
exchangeRate: string;
rate: number;
hideRightHeaderComponent: boolean;
}
>;
}
interface ReceiveState {
selectedIndex: number;
expirationIndex: ExpirationPreset | null;
addressType: string;
memo: string;
receiverName: string;
value: string;
satAmount: string | number;
expiry: string;
timePeriod: string;
expirySeconds: string;
customPreimage: string;
ampInvoice: boolean;
routeHints: boolean;
account: string;
blindedPaths: boolean;
nfcSupported: boolean;
advancedSettingsToggle: boolean;
durationSettingsToggle: boolean;
// POS
orderId: string;
orderTotal: string;
orderTip: string;
exchangeRate: string;
rate: number;
// LSP
needInbound: boolean;
enableLSP: boolean;
lspIsActive: boolean;
flowLspNotConfigured: boolean;
routeHintMode: RouteHintMode;
selectedRouteHintChannels?: Channel[];
hideRightHeaderComponent?: boolean;
showAdvanced: boolean;
}
enum RouteHintMode {
Automatic = 0,
Custom = 1
}
const LOCKED_EXPIRY = '1';
const LOCKED_TIME_PERIOD = 'Hours';
const LOCKED_EXPIRY_SECONDS = '3600';
@inject(
'ChannelsStore',
'InvoicesStore',
'ModalStore',
'SettingsStore',
'UnitsStore',
'PosStore',
'NodeInfoStore',
'LightningAddressStore',
'LSPStore',
'BalanceStore'
)
@observer
export default class Receive extends React.Component<
ReceiveProps,
ReceiveState
> {
constructor(props: ReceiveProps) {
super(props);
this.state = {
selectedIndex: props.route.params?.selectedIndex ?? 0,
expirationIndex: 1,
addressType: '0',
memo: '',
receiverName: '',
value: '',
satAmount: '',
expiry: '1',
timePeriod: 'Hours',
expirySeconds: '3600',
customPreimage: '',
ampInvoice: false,
routeHints: false,
account: 'default',
blindedPaths: false,
nfcSupported: false,
advancedSettingsToggle: false,
durationSettingsToggle: false,
// POS
orderId: '',
orderTip: '',
orderTotal: '',
exchangeRate: '',
rate: 0,
// LSP
needInbound: false,
enableLSP: true,
lspIsActive: false,
flowLspNotConfigured: true,
routeHintMode: RouteHintMode.Automatic,
selectedRouteHintChannels: undefined,
showAdvanced: false
};
}
listener: any;
listenerSecondary: any;
ldkUnsubscribe: (() => void) | null = null;
lnInterval: any;
onChainInterval: any;
hopPickerRef: HopPicker | null;
private getDefaultIndex = (): number => {
const { settings } = this.props.SettingsStore;
const defaultInvoiceType =
settings?.invoices?.defaultInvoiceType ||
DefaultInvoiceType.Lightning;
return defaultInvoiceType === DefaultInvoiceType.Lightning ? 1 : 0;
};
async componentDidMount() {
const {
InvoicesStore,
SettingsStore,
LightningAddressStore,
NodeInfoStore,
route
} = this.props;
const { reset } = InvoicesStore;
const { getSettings, posStatus } = SettingsStore;
const { status, lightningAddressHandle } = LightningAddressStore;
const settings = await getSettings();
if (settings?.lightningAddress?.enabled && !lightningAddressHandle) {
status();
}
const { flowLspNotConfigured } = NodeInfoStore.flowLspNotConfigured();
const newExpirySeconds =
Number(settings?.invoices?.expirySeconds) > 0
? settings!.invoices!.expirySeconds!
: '3600';
const expirationIndex = expirationIndexFromSeconds(newExpirySeconds);
const lspIsActive =
settings?.enableLSP &&
BackendUtils.supportsFlowLSP() &&
!flowLspNotConfigured;
// Calculate invoice options once to avoid duplication
const routeHints =
(settings?.invoices?.routeHints ||
!this.props.ChannelsStore.haveAnnouncedChannels) &&
!(
settings?.invoices?.blindedPaths &&
BackendUtils.supportsBolt11BlindedRoutes()
);
const ampInvoice =
(settings?.invoices?.ampInvoice && BackendUtils.supportsAMP()) ||
false;
const blindedPaths =
(settings?.invoices?.blindedPaths &&
BackendUtils.supportsBolt11BlindedRoutes()) ||
false;
this.setState({
addressType: settings?.invoices?.addressType || '0',
expirationIndex,
memo: settings?.invoices?.memo || '',
receiverName: settings?.invoices?.receiverName || '',
expiry: settings?.invoices?.expiry || '1',
timePeriod: settings?.invoices?.timePeriod || 'Hours',
expirySeconds: newExpirySeconds,
routeHints,
ampInvoice,
blindedPaths,
enableLSP: settings?.enableLSP,
lspIsActive,
flowLspNotConfigured
});
const lnOnly =
(settings &&
posStatus &&
posStatus === 'active' &&
settings.pos &&
settings.pos.confirmationPreference &&
settings.pos.confirmationPreference === 'lnOnly') ||
route.params?.forceLn;
const onChainOnly = route.params?.forceOnChain;
reset();
const {
lnurlParams: lnurl,
amount,
autoGenerate,
autoGenerateOnChain,
autoGenerateChange,
account,
selectedIndex,
hideRightHeaderComponent
} = route.params ?? {};
if (hideRightHeaderComponent) {
this.setState({ hideRightHeaderComponent });
}
if (account) {
this.setState({ account });
}
if (selectedIndex !== undefined) {
this.setState({ selectedIndex });
} else if (!lnOnly && !onChainOnly) {
this.setState({ selectedIndex: this.getDefaultIndex() });
}
const addressType =
route.params?.addressType || settings?.invoices?.addressType || '0';
// POS
const memo = route.params?.memo ?? this.state.memo;
const { orderId, orderTotal, orderTip, exchangeRate, rate } =
route.params ?? {};
if (orderId) {
this.setState({
orderId,
orderTotal,
orderTip,
exchangeRate,
rate
});
}
if (lnurl) {
this.props.UnitsStore.resetUnits();
let needInbound = false;
if (
this.state.lspIsActive &&
new BigNumber(getSatAmount(lnurl.maxWithdrawable / 1000)).gt(
this.props.ChannelsStore.totalInbound
)
) {
needInbound = true;
}
this.setState({
memo: lnurl.defaultDescription,
value: (lnurl.maxWithdrawable / 1000).toString(),
satAmount: getSatAmount(lnurl.maxWithdrawable / 1000),
needInbound
});
}
if (amount && amount != '0') {
let needInbound = false;
if (
this.state.lspIsActive &&
getSatAmount(amount) !== 0 &&
new BigNumber(getSatAmount(amount)).gt(
this.props.ChannelsStore.totalInbound
)
) {
needInbound = true;
}
this.setState({
value: amount,
satAmount: getSatAmount(amount),
needInbound
});
}
if (lnOnly) {
this.setState({
selectedIndex: 1
});
}
if (onChainOnly) {
this.setState({
selectedIndex: 2
});
}
if (autoGenerate) {
this.autoGenerateInvoice(
getSatAmount(amount).toString(),
memo,
newExpirySeconds,
routeHints,
ampInvoice,
blindedPaths,
addressType,
lspIsActive
);
}
if (autoGenerateChange) {
this.autoGenerateChange(account, addressType);
} else if (autoGenerateOnChain) {
this.autoGenerateOnChainAddress(account, addressType);
}
if (Platform.OS !== 'android') {
return;
}
try {
const nfcSupported = await NfcManager.isSupported();
this.setState({ nfcSupported });
} catch {
this.setState({ nfcSupported: false });
}
}
async componentDidUpdate(prevProps: ReceiveProps) {
const { route, InvoicesStore } = this.props;
const { route: prevRoute } = prevProps;
if (route.params?.amount !== prevRoute.params?.amount) {
const { reset } = InvoicesStore;
reset();
const { amount, lnurlParams: lnurl } = route.params ?? {};
if (amount && amount != '0') {
let needInbound = false;
if (
this.state.lspIsActive &&
getSatAmount(amount) !== 0 &&
new BigNumber(getSatAmount(amount)).gt(
this.props.ChannelsStore.totalInbound
)
) {
needInbound = true;
}
this.setState({
value: amount,
satAmount: getSatAmount(amount),
needInbound
});
}
if (lnurl) {
let needInbound = false;
if (
this.state.lspIsActive &&
new BigNumber(
getSatAmount(lnurl.maxWithdrawable / 1000)
).gt(this.props.ChannelsStore.totalInbound)
) {
needInbound = true;
}
this.setState({
memo: lnurl.defaultDescription,
value: (lnurl.maxWithdrawable / 1000).toString(),
satAmount: getSatAmount(lnurl.maxWithdrawable / 1000),
needInbound
});
}
}
}
componentWillUnmount() {
this.cleanup();
}
clearListeners = () => {
if (this.listener && this.listener.stop) this.listener.stop();
if (this.listenerSecondary && this.listenerSecondary.stop)
this.listenerSecondary.stop();
if (this.ldkUnsubscribe) {
this.ldkUnsubscribe();
this.ldkUnsubscribe = null;
}
};
clearIntervals = () => {
if (this.lnInterval) clearInterval(this.lnInterval);
if (this.onChainInterval) clearInterval(this.onChainInterval);
};
cleanup = () => {
// kill all listeners and pollers
if (this.listener && this.listener.stop) this.listener.stop();
if (this.listenerSecondary && this.listenerSecondary.stop)
this.listenerSecondary.stop();
this.clearIntervals();
// clear invoice
this.props.InvoicesStore.reset();
};
onBack = () => {
this.cleanup();
};
autoGenerateInvoice = (
amount?: string,
memo?: string,
expirySeconds?: string,
routeHints?: boolean,
ampInvoice?: boolean,
blindedPaths?: boolean,
addressType?: string,
lspIsActive?: boolean
) => {
const { InvoicesStore, PosStore, SettingsStore } = this.props;
const { receiverName, orderId, orderTip, exchangeRate } = this.state;
// Use passed lspIsActive parameter, fall back to state if not provided
const effectiveLspIsActive = lspIsActive ?? this.state.lspIsActive;
const { createUnifiedInvoice } = InvoicesStore;
const { settings } = SettingsStore;
const skipOnchain =
settings?.invoices?.defaultInvoiceType !==
DefaultInvoiceType.Unified;
// POS invoice reuse logic
const checkExistingInvoice = async () => {
if (orderId) {
try {
const existingInvoice = await PosStore.getOrderInvoiceById(
orderId
);
if (
existingInvoice &&
PosStore.canReuseInvoice(
existingInvoice,
amount || '0',
orderTip,
exchangeRate
)
) {
// Reuse existing invoice
InvoicesStore.payment_request =
existingInvoice.payment_request;
this.subscribeInvoice(existingInvoice.rHash);
return true;
}
} catch (error) {
console.warn('Error checking existing invoice:', error);
}
}
return false;
};
checkExistingInvoice().then((canReuseInvoice) => {
if (canReuseInvoice) return;
createUnifiedInvoice({
memo: effectiveLspIsActive
? ''
: receiverName
? `${receiverName}: ${memo}`
: memo || '',
value: amount || '0',
expirySeconds: effectiveLspIsActive
? LOCKED_EXPIRY_SECONDS
: expirySeconds || '3600',
ampInvoice: effectiveLspIsActive ? false : ampInvoice || false,
blindedPaths: effectiveLspIsActive
? false
: blindedPaths || false,
routeHints,
addressType: BackendUtils.supportsAddressTypeSelection()
? addressType || '1'
: undefined,
noLsp: !effectiveLspIsActive,
skipOnchain
}).then(
({
rHash,
onChainAddress
}: {
rHash: string;
onChainAddress?: string;
}) => {
// Store invoice for POS orders
if (orderId && InvoicesStore.payment_request) {
PosStore.recordInvoice({
orderId,
payment_request: InvoicesStore.payment_request,
rHash,
exchangeRate,
amount: amount || '0',
tip: orderTip,
createdAt: Math.floor(Date.now() / 1000),
expirySeconds: expirySeconds || '3600'
});
}
this.subscribeInvoice(rHash, onChainAddress);
}
);
});
};
autoGenerateOnChainAddress = (account?: string, address_type?: string) => {
const { InvoicesStore } = this.props;
const { getNewAddress } = InvoicesStore;
let request: any = {
type: address_type || this.state.addressType
};
if (account) {
request.account = account;
}
getNewAddress(request).then((onChainAddress: string) => {
this.subscribeInvoice(undefined, onChainAddress);
});
};
autoGenerateChange = (account?: string, address_type?: string) => {
const { InvoicesStore } = this.props;
const { getNewChangeAddress } = InvoicesStore;
let request: any = {
type: address_type || this.state.addressType
};
if (account) {
request.account = account;
}
getNewChangeAddress(request).then((onChainAddress: string) => {
this.subscribeInvoice(undefined, onChainAddress);
});
};
scanNfc = async () => {
const str = await scanNfcTag(this.props.ModalStore);
if (str) this.validateAddress(str);
};
validateAddress = (text: string) => {
const { navigation, InvoicesStore, SettingsStore, route } = this.props;
const { lspIsActive, receiverName } = this.state;
const { createUnifiedInvoice } = InvoicesStore;
const { settings } = SettingsStore;
const satAmount = getSatAmount(route.params?.amount);
const skipOnchain =
settings?.invoices?.defaultInvoiceType !==
DefaultInvoiceType.Unified;
handleAnything(text, satAmount.toString())
.then((response) => {
try {
const [route, props] = response;
const { lnurlParams } = props;
const { memo } = lnurlParams.defaultDescription;
// if an amount was entered on the keypad screen before scanning
// we will automatically create an invoice and attempt to withdraw
// otherwise we present the user with the create invoice screen
if (Number(satAmount) > 0) {
createUnifiedInvoice({
memo: lspIsActive
? ''
: receiverName
? `${receiverName}: ${memo}`
: memo,
value: satAmount.toString(),
expirySeconds: '3600',
lnurl: lnurlParams,
noLsp: !lspIsActive,
skipOnchain
})
.then(
({
rHash,
onChainAddress
}: {
rHash: string;
onChainAddress?: string;
}) => {
this.subscribeInvoice(
rHash,
onChainAddress
);
}
)
.catch(() => {
navigation.navigate(route, {
satAmount,
...props
});
});
} else {
navigation.navigate(route, props);
}
} catch (e) {}
})
.catch();
};
handlePaymentReceived = (
amount: number,
posPayment?: {
type: 'ln' | 'onchain';
tx: string;
preimage?: string;
}
) => {
const { InvoicesStore, PosStore, BalanceStore, ChannelsStore } =
this.props;
const { orderId, orderTotal, orderTip, exchangeRate, rate } =
this.state;
InvoicesStore.setWatchedInvoicePaid(amount);
BalanceStore.getCombinedBalance();
if (
posPayment?.type === 'ln' &&
BackendUtils.supportsChannelManagement()
) {
ChannelsStore.getChannels();
}
if (orderId && posPayment) {
PosStore.recordPayment({
orderId,
orderTotal,
orderTip,
exchangeRate,
rate,
...posPayment
});
// Clear stored invoice after successful payment
PosStore.clearOrderInvoice(orderId);
}
};
subscribeInvoice = async (rHash?: string, onChainAddress?: string) => {
const { SettingsStore, NodeInfoStore } = this.props;
const { value } = this.state;
const { implementation, settings } = SettingsStore;
const { nodeInfo } = NodeInfoStore;
const numConfPreference =
settings.pos && settings.pos.confirmationPreference === '1conf'
? 1
: 0;
if (implementation === 'embedded-lnd') {
if (rHash) {
this.listener = LndMobileEventEmitter.addListener(
'SubscribeInvoices',
(e: any) => {
try {
const error = checkLndStreamErrorResponse(
'SubscribeInvoices',
e
);
if (error === 'EOF') {
this.listener?.remove();
return;
} else if (error) {
console.error(
'Got error from SubscribeInvoices',
[error]
);
this.listener?.remove();
return;
}
const invoice =
lndMobile.wallet.decodeInvoiceResult(e.data);
if (
invoice.settled &&
// @ts-ignore:next-line
Base64Utils.bytesToHex(invoice.r_hash) === rHash
) {
this.handlePaymentReceived(
Number(invoice.amt_paid_sat),
{
type: 'ln',
tx: invoice.payment_request,
preimage: Base64Utils.bytesToHex(
// @ts-ignore:next-line
invoice.r_preimage
)
}
);
this.listener?.remove();
}
} catch (error) {
console.error(error);
this.listener?.remove();
}
}
);
}
if (onChainAddress) {
this.listenerSecondary = LndMobileEventEmitter.addListener(
'SubscribeTransactions',
(e: any) => {
try {
const error = checkLndStreamErrorResponse(
'SubscribeTransactions',
e
);
if (error === 'EOF') {
this.listenerSecondary?.remove();
return;
} else if (error) {
console.error(
'Got error from SubscribeTransactions',
[error]
);
this.listenerSecondary?.remove();
return;
}
const transaction =
decodeSubscribeTransactionsResult(e.data);
if (
onChainAddress &&
transaction.dest_addresses.includes(
onChainAddress
) &&
transaction.num_confirmations >
numConfPreference &&
Number(transaction.amount) >= Number(value)
) {
this.handlePaymentReceived(
Number(transaction.amount),
{
type: 'onchain',
tx: transaction.tx_hash
}
);
this.listenerSecondary?.remove();
}
} catch (error) {
console.error(error);
this.listenerSecondary?.remove();
}
}
);
}
await lndMobile.wallet.subscribeInvoices();
await lndMobile.onchain.subscribeTransactions();
}
if (implementation === 'lightning-node-connect') {
const { LncModule } = NativeModules;
if (rHash) {
const eventName = BackendUtils.subscribeInvoice(rHash);
const eventEmitter = new NativeEventEmitter(LncModule);
this.listener = eventEmitter.addListener(
eventName,
(event: any) => {
if (event.result) {
if (
typeof event.result === 'string' &&
event.result.includes(
'rpc error: code = Canceled'
)
) {
this.listener?.remove();
return;
}
try {
const result = JSON.parse(event.result);
if (result === 'EOF') {
this.listener?.remove();
return;
}
if (result.settled) {
this.handlePaymentReceived(
result.amt_paid_sat,
{
type: 'ln',
tx: result.payment_request,
preimage: result.r_preimage
}
);
this.listener?.remove();
}
} catch (error) {
console.error(error);
this.listener?.remove();
}
}
}
);
}
if (onChainAddress) {
const eventName2 = BackendUtils.subscribeTransactions();
const eventEmitter2 = new NativeEventEmitter(LncModule);
this.listenerSecondary = eventEmitter2.addListener(
eventName2,
(event: any) => {
if (event.result) {
if (
typeof event.result === 'string' &&
event.result.includes(
'rpc error: code = Canceled'
)
) {
this.listenerSecondary?.remove();
return;
}
try {
const result = JSON.parse(event.result);
if (result === 'EOF') {
this.listenerSecondary?.remove();
return;
}
if (
result.dest_addresses.includes(
onChainAddress
) &&
result.num_confirmations >=
numConfPreference &&
Number(result.amount) >= Number(value)
) {
this.handlePaymentReceived(result.amount, {
type: 'onchain',
tx: result.tx_hash
});
this.listenerSecondary?.remove();
}
} catch (error) {
console.error(error);
this.listenerSecondary?.remove();
}
}
}
);
}
}
if (implementation === 'ldk-node') {
const ldkBackend = BackendUtils.ldkNode;
this.ldkUnsubscribe = ldkBackend.subscribeToEvents((event) => {
if (event.type === 'paymentReceived') {
const amountSat = Math.floor(event.amountMsat / 1000);
// Check if this is the invoice we're watching
if (rHash && event.paymentHash === rHash) {
this.handlePaymentReceived(amountSat, {
type: 'ln',
tx: event.paymentHash
});
if (this.ldkUnsubscribe) {
this.ldkUnsubscribe();
this.ldkUnsubscribe = null;
}
}
}
});
}
if (implementation === 'lnd') {
if (rHash) {
this.lnInterval = setInterval(() => {
// only fetch the last 10 invoices
BackendUtils.getInvoices({ limit: 10 }).then(
(response: any) => {
const invoices = response.invoices;
for (let i = 0; i < invoices.length; i++) {
const result = invoices[i];
if (
result.r_hash
.replace(/\+/g, '-')
.replace(/\//g, '_') === rHash &&
Number(result.amt_paid_sat) >=
Number(value) &&
Number(result.amt_paid_sat) !== 0
) {
this.handlePaymentReceived(
result.amt_paid_sat,
{
type: 'ln',
tx: result.payment_request
}
);
this.clearIntervals();
break;
}
}
}
);
}, 5000);
}
// this is workaround that manually calls your transactions every 30 secs
if (onChainAddress) {
this.onChainInterval = setInterval(() => {
// only look for transactions in the last 3 blocks
BackendUtils.getTransactions(
nodeInfo && nodeInfo.block_height
? {
start_height: nodeInfo.block_height - 3
}
: null
).then((response: any) => {
const txs = response.transactions;
for (let i = 0; i < txs.length; i++) {
const result = txs[i];
if (
result.dest_addresses.includes(
onChainAddress
) &&
result.num_confirmations >= numConfPreference
) {
// loop through outputs since amount is negative if unconfirmed
const output_details = result.output_details;
for (
let j = 0;
j < output_details.length;
j++
) {
const output = output_details[j];
if (
Number(output.amount) >=
Number(value) &&
output.address === onChainAddress
) {
this.handlePaymentReceived(
output.amount,
{
type: 'onchain',
tx: result.tx_hash
}
);
this.clearIntervals();
// break parent loop
i = txs.length;
break;
}
}
}
}
});
}, 7000);
}
}
if (implementation === 'cln-rest') {
if (rHash) {
this.lnInterval = setInterval(() => {
// only fetch the last 10 invoices
BackendUtils.getInvoices({ limit: 10 }).then(
(response: any) => {
const invoices = response.invoices;
for (let i = 0; i < invoices.length; i++) {
const result = invoices[i];
if (
result.payment_hash
.replace(/\+/g, '-')
.replace(/\//g, '_') === rHash &&
Number(
result.amount_received_msat / 1000
) >= Number(value) &&
Number(result.amount_received_msat) !== 0
) {
this.handlePaymentReceived(
result.amount_received_msat / 1000,
{
type: 'ln',
tx: result.bolt11
}
);
this.clearIntervals();
break;
}
}
}
);
}, 5000);
}
}
if (implementation === 'lndhub') {
if (rHash) {
this.lnInterval = setInterval(() => {
BackendUtils.getInvoices().then((response: any) => {
const invoices = response.invoices;
for (let i = 0; i < invoices.length; i++) {
const result = new Invoice(invoices[i]);
if (
result.getFormattedRhash === rHash &&
result.ispaid &&
Number(result.amt) >= Number(value) &&
Number(result.amt) !== 0
) {
this.handlePaymentReceived(Number(result.amt), {
type: 'ln',
tx: result.payment_request,
preimage: result.r_preimage
});
this.clearIntervals();
break;
}
}
});
}, 5000);
}
}
};
getNewAddress = (params: any) => {
const { InvoicesStore } = this.props;
InvoicesStore.getNewAddress(params);
};
updateExpirationIndex = (expirationIndex: number) => {
if (expirationIndex === 0) {
this.setState({
expirySeconds: '600',
expiry: '10',
timePeriod: 'Minutes',
expirationIndex: 0
});
} else if (expirationIndex === 1) {
this.setState({
expirySeconds: '3600',
expiry: '1',
timePeriod: 'Hours',
expirationIndex: 1
});
} else if (expirationIndex === 2) {
this.setState({
expirySeconds: '86400',
expiry: '1',
timePeriod: 'Days',
expirationIndex: 2
});
} else if (expirationIndex === 3) {
this.setState({
expirySeconds: '604800',
expiry: '1',
timePeriod: 'Weeks',
expirationIndex: 3
});
}
};
updateIndex = async (selectedIndex: number) => {
const { InvoicesStore } = this.props;
const { onChainAddress, payment_request, getNewAddress } =
InvoicesStore;
const { addressType, account } = this.state;
this.setState({
selectedIndex
});
// If switching to unified or on-chain and there's a lightning invoice
// but no on-chain address, generate one
if (
(selectedIndex === 0 || selectedIndex === 2) &&
payment_request &&
!onChainAddress &&
BackendUtils.supportsOnchainReceiving()
) {
await getNewAddress({
type: addressType,
account: account !== 'default' ? account : 'default'
});
}
};
private modalBoxRef = React.createRef<ModalBox>();
getFormattedDuration = () => {
const { expiry, timePeriod, lspIsActive } = this.state;
const expiryDisplay = lspIsActive ? LOCKED_EXPIRY : expiry;
const periodDisplay = lspIsActive ? LOCKED_TIME_PERIOD : timePeriod;
const period =
expiryDisplay === '1'
? periodDisplay.toLowerCase().replace(/s$/, '')
: periodDisplay.toLowerCase();
return `${expiryDisplay} ${period}`;
};
render() {
const {
InvoicesStore,
SettingsStore,
UnitsStore,
LightningAddressStore,
LSPStore,
NodeInfoStore,
navigation,
route
} = this.props;
const {
selectedIndex,
expirationIndex,
addressType,
memo,
receiverName,
value,
satAmount,
expiry,
timePeriod,
expirySeconds,
customPreimage,
ampInvoice,
routeHints,
account,
needInbound,
enableLSP,
lspIsActive,
flowLspNotConfigured,
routeHintMode,
selectedRouteHintChannels,
blindedPaths,
hideRightHeaderComponent,
nfcSupported,
advancedSettingsToggle,
durationSettingsToggle,
showAdvanced
} = this.state;
const { zeroConfFee, showLspSettings } = LSPStore;
const {
createUnifiedInvoice,
onChainAddress,
payment_request,
payment_request_amt,
creatingInvoice,
creatingInvoiceError,
watchedInvoicePaid,
watchedInvoicePaidAmt,
clearUnified
} = InvoicesStore;
const { implementation, posStatus, settings, updateSettings } =
SettingsStore;
const loading = SettingsStore.loading || InvoicesStore.loading;
const address = onChainAddress;
const { lightningAddress } = LightningAddressStore;
const lightningAddressLoading = LightningAddressStore.loading;
const error_msg = LSPStore.flow_error_msg || InvoicesStore.error_msg;
const showCustomPreimageField =
settings?.invoices?.showCustomPreimageField;
const skipOnchain =
settings?.invoices?.defaultInvoiceType !==
DefaultInvoiceType.Unified;
const lnOnly =
(settings &&
posStatus &&
posStatus === 'active' &&
settings.pos &&
settings.pos.confirmationPreference &&
settings.pos.confirmationPreference === 'lnOnly') ||
route.params?.forceLn;
const onChainOnly = route.params?.forceOnChain;
const lnurl = route.params?.lnurlParams;
const ClearButton = () => (
<Icon
name="cancel"
onPress={() => {
if (route.params?.selectedIndex !== undefined) {
this.setState({
account: 'default',
selectedIndex: route.params.selectedIndex
});
} else if (!lnOnly && !onChainOnly) {
this.setState({
account: 'default',
selectedIndex: this.getDefaultIndex()
});
}
this.cleanup();
}}
color={themeColor('text')}
underlayColor="transparent"
size={30}
/>
);
const SettingsButton = () => (
<TouchableOpacity onPress={() => this.modalBoxRef.current?.open()}>
<Gear
style={{ alignSelf: 'center' }}
fill={themeColor('text')}
/>
</TouchableOpacity>
);
const ADDRESS_TYPES = [];
if (BackendUtils.supportsNestedSegWit()) {
ADDRESS_TYPES.push({
key: localeString('views.Receive.np2wkhKey'),
value: '1',
description: BackendUtils.supportsTaproot()
? localeString('views.Receive.np2wkhDescription')
: localeString('views.Receive.np2wkhDescriptionAlt')
});
}
ADDRESS_TYPES.push({
key: localeString('views.Receive.p2wkhKey'),
value: '0',
description: localeString('views.Receive.p2wkhDescription')
});
if (BackendUtils.supportsTaproot()) {
ADDRESS_TYPES.push({
key: localeString('views.Receive.p2trKey'),
value: '4',
description: localeString('views.Receive.p2trDescription')
});
}
const unifiedButton = () => (
<React.Fragment>
<UnifiedSvg
circle={false}
selected={selectedIndex === 0 ? true : false}
/>
<Text
style={{
color:
selectedIndex === 0
? themeColor('background')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString('general.unified')}
</Text>
</React.Fragment>
);
const lightningButton = () => (
<React.Fragment>
<LightningSvg
width={50}
height={50}
circle={false}
selected={selectedIndex === 1 ? true : false}
/>
<Text
style={{
color:
selectedIndex === 1
? themeColor('background')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString('general.lightning')}
</Text>
</React.Fragment>
);
const onChainButton = () => (
<React.Fragment>
<OnChainSvg
width={50}
height={50}
circle={false}
selected={selectedIndex === 2 ? true : false}
/>
<Text
style={{
color:
selectedIndex === 2
? themeColor('background')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString('general.onchain')}
</Text>
</React.Fragment>
);
const lightningAddressButton = () => (
<React.Fragment>
<AddressSvg
circle={false}
selected={selectedIndex === 3 ? true : false}
/>
<Text
style={{
color:
selectedIndex === 3
? themeColor('background')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString('general.lightningAddressCondensed')}
</Text>
</React.Fragment>
);
const buttons: any =
BackendUtils.supportsCustomPreimages() && !NodeInfoStore.testnet
? [
{ element: unifiedButton },
{ element: lightningButton },
{ element: onChainButton },
{ element: lightningAddressButton }
]
: [
{ element: unifiedButton },
{ element: lightningButton },
{ element: onChainButton }
];
const haveUnifiedInvoice = !!payment_request && !!address;
const haveInvoice = !!payment_request || !!address;
let unifiedInvoice,
lnInvoice,
lnInvoiceCopyValue,
btcAddress,
btcAddressCopyValue;
// if format is case insensitive, format as all caps to save QR space, otherwise present in original format
const onChainFormatted =
address && address === address.toLowerCase()
? address.toUpperCase()
: address;
if (haveUnifiedInvoice) {
unifiedInvoice = `bitcoin:${onChainFormatted}?${`lightning=${payment_request.toUpperCase()}`}${
Number(satAmount) > 0
? `&amount=${new BigNumber(satAmount)
.dividedBy(SATS_PER_BTC)
.toFormat()}`
: ''
}${memo ? `&message=${memo.replace(/ /g, '%20')}` : ''}`;
}
if (payment_request) {
lnInvoice = `lightning:${payment_request.toUpperCase()}`;
lnInvoiceCopyValue = payment_request;
}
if (address) {
btcAddress = `bitcoin:${onChainFormatted}${
(Number(satAmount) > 0 || memo) && '?'
}${
Number(satAmount) > 0
? `amount=${new BigNumber(satAmount)
.dividedBy(SATS_PER_BTC)
.toFormat()}`
: ''
}${
memo
? Number(satAmount) > 0
? `&message=${memo.replace(/ /g, '%20')}`
: `message=${memo.replace(/ /g, '%20')}`
: ''
}`;
if (Number(satAmount) > 0 || memo) {
btcAddressCopyValue = btcAddress;
} else {
btcAddressCopyValue = address;
}
}
const belowDustLimit: boolean =
Number(satAmount) !== 0 && Number(satAmount) < 546;
const windowSize = Dimensions.get('window');
const tenMButton = () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
expirationIndex === 0
? themeColor('background')
: themeColor('text')
}}
>
{localeString('time.10min')}
</Text>
);
const oneHButton = () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
expirationIndex === 1
? themeColor('background')
: themeColor('text')
}}
>
{localeString('time.1H')}
</Text>
);
const oneDButton = () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
expirationIndex === 2
? themeColor('background')
: themeColor('text')
}}
>
{localeString('time.1D')}
</Text>
);
const oneWButton = () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
expirationIndex === 3
? themeColor('background')
: themeColor('text')
}}
>
{localeString('time.1W')}
</Text>
);
const expirationButtons: any = [
{ element: tenMButton },
{ element: oneHButton },
{ element: oneDButton },
{ element: oneWButton }
];
const routeHintModeButtons: any = [
{
element: () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
routeHintMode === RouteHintMode.Automatic
? themeColor('background')
: themeColor('text')
}}
>
{localeString('general.automatic')}
</Text>
)
},
{
element: () => (
<Text
style={{
fontFamily: 'PPNeueMontreal-Book',
color:
routeHintMode === RouteHintMode.Custom
? themeColor('background')
: themeColor('text')
}}
>
{localeString('general.custom')}
</Text>
)
}
];
const setRouteHintMode = (mode: RouteHintMode) => {
if (this.state.routeHintMode === mode) {
return;
}
this.setState({
routeHintMode: mode
});
if (
mode === RouteHintMode.Custom &&
(!selectedRouteHintChannels ||
selectedRouteHintChannels.length === 0)
) {
this.hopPickerRef?.openPicker();
}
};
const enablePrinter: boolean = settings?.pos?.enablePrinter || false;
const showAdvancedSettingsToggle =
(BackendUtils.supportsCustomPreimages() &&
showCustomPreimageField) ||
(BackendUtils.isLNDBased() && !lspIsActive) ||
(BackendUtils.supportsAMP() && !lspIsActive) ||
(BackendUtils.supportsBolt11BlindedRoutes() && !lspIsActive);
const baseModalHeight = 300;
const itemHeight = ADDRESS_TYPES.length <= 2 ? 25 : 50;
const modalHeight = baseModalHeight + ADDRESS_TYPES.length * itemHeight;
const showButtonGroup =
!belowDustLimit &&
!lnOnly &&
!onChainOnly &&
!watchedInvoicePaid &&
haveInvoice;
const showNfcButton =
!(
selectedIndex === 3 &&
(!lightningAddress || lightningAddressLoading)
) && nfcSupported;
return (
<Screen>
<Header
leftComponent="Back"
onBack={this.onBack}
centerComponent={{
text:
posStatus === 'active'
? localeString('general.pay')
: localeString('views.Receive.title'),
style: {
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}
}}
rightComponent={
<Row>
{loading && (
<View style={{ marginRight: 10 }}>
<LoadingIndicator size={30} />
</View>
)}
{watchedInvoicePaid ||
posStatus === 'active' ||
hideRightHeaderComponent ? undefined : route.params
?.selectedIndex === 2 ? (
BackendUtils.supportsAddressTypeSelection() &&
account === 'default' ? (
<SettingsButton />
) : undefined
) : haveInvoice ? (
selectedIndex === 2 ? (
BackendUtils.supportsAddressTypeSelection() &&
account === 'default' ? (
<SettingsButton />
) : (
<ClearButton />
)
) : (
<ClearButton />
)
) : !creatingInvoice &&
BackendUtils.supportsAddressTypeSelection() &&
account === 'default' &&
!route.params?.forceLn &&
(selectedIndex === 2 || selectedIndex === 1) ? (
<SettingsButton />
) : undefined}
</Row>
}
navigation={navigation}
/>
<View style={{ flex: 1 }}>
{watchedInvoicePaid ? (
<View
style={{
alignItems: 'center',
justifyContent: 'space-evenly',
height: '100%'
}}
>
<PaidIndicator />
<Wordmark
height={windowSize.width * 0.25}
width={windowSize.width}
fill={themeColor('highlight')}
/>
<SuccessAnimation />
<View style={{ alignItems: 'center' }}>
<>
<Text
style={{
...styles.text,
fontSize:
windowSize.width *
windowSize.scale *
0.017,
alignSelf: 'center',
color: themeColor('text'),
textAlign: 'center'
}}
>
{posStatus === 'active'
? localeString(
'views.Wallet.Invoices.paid'
)
: `${localeString(
'views.Receive.youReceived'
)} ${getAmountFromSats(
watchedInvoicePaidAmt ||
payment_request_amt ||
''
)}`}
</Text>
</>
</View>
{posStatus === 'active' &&
Platform.OS === 'android' &&
enablePrinter && (
<Button
title={localeString(
'pos.views.Order.printReceipt'
)}
secondary
icon={{ name: 'print', size: 25 }}
onPress={() =>
navigation.navigate('Order', {
order: route?.params?.order,
orderId: this.state.orderId,
print: true
})
}
/>
)}
<Button
title={
posStatus === 'active'
? localeString('general.goBack')
: localeString(
'views.SendingLightning.goToWallet'
)
}
icon={{
name: 'list',
size: 25
}}
onPress={() => navigation.popTo('Wallet')}
containerStyle={{ width: '100%' }}
/>
</View>
) : (
<ScrollView
style={styles.content}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{creatingInvoiceError && (
<ErrorMessage
message={localeString(
'views.Receive.errorCreate'
)}
/>
)}
{error_msg && (
<ErrorMessage message={error_msg} dismissable />
)}
{showLspSettings && (
<View style={{ margin: 10 }}>
<Button
title={localeString(
'views.Receive.goToLspSettings'
)}
onPress={() =>
navigation.navigate('LSPSettings')
}
/>
</View>
)}
<View>
{!!payment_request && (
<>
{implementation === 'lndhub' &&
!!address &&
!belowDustLimit && (
<WarningMessage
message={localeString(
'views.Receive.warningLndHub'
)}
/>
)}
{!!lnurl && (
<SuccessMessage
message={
!!lnurl &&
`${localeString(
'views.Receive.successCreate'
)} ${localeString(
'views.Receive.andSentTo'
)} ${lnurl.domain}`
}
/>
)}
</>
)}
{creatingInvoice && (
<View style={{ marginTop: 40 }}>
<LoadingIndicator />
</View>
)}
{haveInvoice && account !== 'default' && (
<WarningMessage
message={`${localeString(
'general.externalAccount'
)}: ${account}`}
/>
)}
{haveInvoice &&
lspIsActive &&
satAmount === '0' &&
(selectedIndex === 0 ||
selectedIndex === 1) && (
<View
style={{
backgroundColor:
themeColor('secondary'),
borderRadius: 10,
top: 10,
margin: 10,
padding: 15,
borderWidth: 0.5
}}
>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor('text'),
fontSize: 15
}}
>
{localeString(
'views.Receive.lspZeroAmt'
)}
</Text>
</View>
)}
{haveInvoice &&
zeroConfFee &&
zeroConfFee > 0 &&
(selectedIndex == 0 ||
selectedIndex == 1) && (
<TouchableOpacity
onPress={() =>
navigation.navigate(
new BigNumber(
zeroConfFee
).gt(1000)
? 'LspExplanationFees'
: 'LspExplanationRouting'
)
}
>
<View
style={{
backgroundColor:
themeColor('secondary'),
borderRadius: 10,
top: 10,
margin: 10,
padding: 15,
borderWidth: 0.5
}}
>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'text'
),
marginBottom: 5
}}
>
{localeString(
new BigNumber(
zeroConfFee
).gt(1000)
? selectedIndex ===
0
? 'views.Receive.lspExplainerUnified'
: 'views.Receive.lspExplainer'
: selectedIndex ===
0
? 'views.Receive.lspExplainerRoutingUnified'
: 'views.Receive.lspExplainerRouting'
)}
</Text>
<Amount
sats={zeroConfFee}
fixedUnits="sats"
/>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'secondaryText'
),
fontSize: 15,
top: 5,
textAlign: 'right'
}}
>
{localeString(
'general.tapToLearnMore'
)}
</Text>
</View>
</TouchableOpacity>
)}
{haveInvoice &&
zeroConfFee === 0 &&
(selectedIndex == 0 ||
selectedIndex == 1) && (
<TouchableOpacity
onPress={() =>
navigation.navigate(
'LspExplanationWrappedInvoices'
)
}
>
<View
style={{
backgroundColor:
themeColor('secondary'),
borderRadius: 10,
top: 10,
margin: 10,
padding: 15,
borderWidth: 0.5
}}
>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'text'
),
marginBottom: 5
}}
>
{localeString(
'views.Receive.lspExplainerZeroFeeWrapper'
)}
</Text>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'secondaryText'
),
fontSize: 15,
top: 5,
textAlign: 'right'
}}
>
{localeString(
'general.tapToLearnMore'
)}
</Text>
</View>
</TouchableOpacity>
)}
{haveInvoice && !creatingInvoiceError && (
<View>
{selectedIndex == 0 &&
!belowDustLimit &&
haveUnifiedInvoice &&
!creatingInvoice && (
<CollapsedQR
value={unifiedInvoice || ''}
iconOnly={true}
expanded
textBottom
truncateLongValue
logo={
themeColor(
'invertQrIcons'
)
? ZIconWhite
: ZIcon
}
nfcSupported={nfcSupported}
satAmount={satAmount}
displayAmount
/>
)}
{selectedIndex == 1 &&
!belowDustLimit &&
haveUnifiedInvoice &&
!creatingInvoice && (
<CollapsedQR
value={lnInvoice || ''}
copyValue={
lnInvoiceCopyValue
}
iconOnly={true}
expanded
textBottom
truncateLongValue
logo={
themeColor(
'invertQrIcons'
)
? LightningIconWhite
: LightningIcon
}
nfcSupported={nfcSupported}
satAmount={satAmount}
displayAmount
/>
)}
{selectedIndex == 2 &&
!belowDustLimit &&
btcAddress &&
!creatingInvoice && (
<CollapsedQR
value={btcAddress}
copyValue={
btcAddressCopyValue
}
iconOnly={true}
expanded
textBottom
truncateLongValue
logo={
themeColor(
'invertQrIcons'
)
? OnChainIconWhite
: OnChainIcon
}
nfcSupported={nfcSupported}
satAmount={
satAmount === '0'
? undefined
: satAmount
}
displayAmount
/>
)}
{selectedIndex == 3 &&
!lightningAddressLoading &&
!lightningAddress && (
<View
style={{
marginTop: 20,
marginBottom: 20
}}
>
<Button
title={localeString(
'views.Receive.createLightningAddress'
)}
onPress={() =>
navigation.navigate(
'LightningAddress'
)
}
/>
</View>
)}
{selectedIndex == 3 &&
!lightningAddressLoading &&
lightningAddress &&
!creatingInvoice && (
<CollapsedQR
value={`lightning:${lightningAddress}`}
copyValue={lightningAddress}
iconOnly={true}
expanded
textBottom
hideText
labelBottom={
lightningAddress
}
logo={
themeColor(
'invertQrIcons'
)
? ZPayIconWhite
: ZPayIcon
}
nfcSupported={nfcSupported}
/>
)}
{selectedIndex == 3 &&
lightningAddressLoading && (
<View style={{ margin: 40 }}>
<LoadingIndicator />
</View>
)}
{(selectedIndex === 0 ||
selectedIndex === 1) &&
(belowDustLimit ||
!haveUnifiedInvoice) &&
!creatingInvoice && (
<CollapsedQR
value={lnInvoice || ''}
copyValue={
lnInvoiceCopyValue
}
iconOnly={true}
expanded
textBottom
truncateLongValue
nfcSupported={nfcSupported}
satAmount={satAmount}
displayAmount
/>
)}
{!creatingInvoice &&
(showButtonGroup ||
showNfcButton) && (
<>
<View
style={{
alignItems:
'center',
marginTop: 30
}}
>
<TouchableOpacity
activeOpacity={0.5}
onPress={() =>
this.setState({
showAdvanced:
!this
.state
.showAdvanced
})
}
style={{
height: 40,
paddingHorizontal: 12,
borderRadius: 12,
justifyContent:
'center'
}}
>
<View
style={{
flexDirection:
'row',
alignItems:
'center',
justifyContent:
'center'
}}
>
<Text
style={{
color: themeColor(
'secondaryText'
),
fontFamily:
'PPNeueMontreal-Book',
fontSize: 16
}}
>
{localeString(
'general.advanced'
)}
</Text>
{showAdvanced ? (
<CaretDown
fill={themeColor(
'secondaryText'
)}
width="20"
height="20"
style={{
marginLeft: 8
}}
/>
) : (
<CaretRight
fill={themeColor(
'secondaryText'
)}
width="20"
height="20"
style={{
marginLeft: 8
}}
/>
)}
</View>
</TouchableOpacity>
</View>
{showAdvanced &&
showNfcButton &&
nfcSupported && (
<View
style={[
styles.button
]}
>
<Button
title={
posStatus ===
'active'
? localeString(
'general.payNfc'
)
: localeString(
'general.receiveNfc'
)
}
icon={
<NfcIcon
stroke={themeColor(
'highlight'
)}
width={
25
}
height={
25
}
style={{
marginRight: 10
}}
/>
}
onPress={() =>
this.scanNfc()
}
secondary
/>
</View>
)}
</>
)}
</View>
)}
{!haveInvoice &&
!creatingInvoice &&
route.params?.selectedIndex !== 2 && (
<>
{BackendUtils.supportsFlowLSP() &&
!flowLspNotConfigured && (
<View
style={{
flexDirection:
'row',
marginTop: 10,
marginBottom: 15
}}
>
<View
style={{
flex: 1,
justifyContent:
'center'
}}
>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
infoModalText={[
localeString(
'views.Receive.lspSwitchExplainer1'
),
localeString(
'views.Receive.lspSwitchExplainer2'
)
]}
infoModalAdditionalButtons={[
{
title: localeString(
'general.learnMore'
),
callback:
() =>
navigation.navigate(
'LspExplanationOverview'
)
}
]}
>
{localeString(
'views.Settings.LSP.enableLSP'
)}
</Text>
</View>
<View
style={{
alignSelf:
'center',
marginLeft: 5
}}
>
<Switch
value={
enableLSP
}
onValueChange={async () => {
this.setState(
{
enableLSP:
!enableLSP,
lspIsActive:
!enableLSP &&
BackendUtils.supportsFlowLSP() &&
!flowLspNotConfigured,
// lock toggle if LSP enabled
durationSettingsToggle:
durationSettingsToggle &&
enableLSP ===
true
}
);
await updateSettings(
{
enableLSP:
!enableLSP
}
);
}}
/>
</View>
</View>
)}
{(!enableLSP ||
flowLspNotConfigured) && (
<>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
>
{localeString(
'views.Receive.memo'
)}
</Text>
<TextInput
placeholder={localeString(
'views.Receive.memoPlaceholder'
)}
value={memo}
onChangeText={(
text: string
) => {
this.setState({
memo: text
});
clearUnified();
}}
/>
</>
)}
<AmountInput
amount={value}
title={`${localeString(
'views.Receive.amount'
)} ${
lnurl &&
lnurl.minWithdrawable !==
lnurl.maxWithdrawable
? ` (${Math.ceil(
lnurl.minWithdrawable /
1000
)} - ${Math.floor(
lnurl.maxWithdrawable /
1000
)})`
: ''
}`}
locked={
lnurl &&
lnurl.minWithdrawable ===
lnurl.maxWithdrawable
? true
: false
}
onAmountChange={(
amount: string,
satAmount: string | number
) => {
let needInbound = false;
if (
lspIsActive &&
satAmount != '0' &&
new BigNumber(
satAmount
).gt(
this.props
.ChannelsStore
.totalInbound
)
) {
needInbound = true;
}
this.setState({
value: amount,
satAmount,
needInbound
});
}}
/>
{needInbound && (
<TouchableOpacity
onPress={() =>
navigation.navigate(
'LspExplanationFees'
)
}
>
<View
style={{
backgroundColor:
themeColor(
'secondary'
),
borderRadius: 10,
borderColor:
themeColor(
'highlight'
),
padding: 15,
borderWidth: 0.5,
top: 5,
marginBottom: 20
}}
>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'text'
),
fontSize: 15
}}
>
{this.props
.ChannelsStore
.channels
.length === 0
? localeString(
'views.Wallet.KeypadPane.lspExplainerFirstChannel'
)
: localeString(
'views.Wallet.KeypadPane.lspExplainer'
)}
</Text>
<Text
style={{
fontFamily:
'PPNeueMontreal-Medium',
color: themeColor(
'secondaryText'
),
fontSize: 15,
top: 5,
textAlign:
'right'
}}
>
{localeString(
'general.tapToLearnMore'
)}
</Text>
</View>
</TouchableOpacity>
)}
{BackendUtils.supportsSettingInvoiceExpiration() && (
<>
<TouchableOpacity
onPress={() => {
if (lspIsActive)
return;
this.setState({
durationSettingsToggle:
!durationSettingsToggle
});
}}
>
<View
style={{
marginBottom: 10
}}
>
<Row
justify="space-between"
style={{
alignItems:
'center'
}}
>
<View
style={{
flex: 1
}}
>
<Row
justify="space-between"
style={{
alignItems:
'center'
}}
>
<View
style={{
flex: 1
}}
>
<KeyValue
keyValue={localeString(
'views.Receive.expiration'
)}
/>
</View>
{!durationSettingsToggle && (
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
),
textAlign:
'center',
fontSize: 14
}}
>
{this.getFormattedDuration()}
</Text>
)}
</Row>
</View>
{lspIsActive ? (
<LockIcon
fill={themeColor(
'secondaryText'
)}
width="20"
height="20"
/>
) : durationSettingsToggle ? (
<CaretDown
fill={themeColor(
'text'
)}
width="20"
height="20"
/>
) : (
<CaretRight
fill={themeColor(
'text'
)}
width="20"
height="20"
/>
)}
</Row>
</View>
</TouchableOpacity>
{durationSettingsToggle && (
<>
<Row
style={{
width: '100%'
}}
>
<TextInput
keyboardType="numeric"
value={
expiry
}
style={{
width: '58%'
}}
onChangeText={(
text: string
) => {
const digits =
text.replace(
/[^0-9]/g,
''
);
const expirySeconds =
expirySecondsFromInput(
digits,
timePeriod as TimePeriod
);
this.setState(
{
expiry: digits,
expirySeconds,
expirationIndex:
expirationIndexFromSeconds(
expirySeconds
)
}
);
}}
/>
<Spacer
width={4}
/>
<View
style={{
flex: 1
}}
>
<DropdownSetting
selectedValue={
timePeriod
}
values={
TIME_PERIOD_KEYS
}
onValueChange={async (
value: string
) => {
const expirySeconds =
expirySecondsFromInput(
expiry,
value as TimePeriod
);
this.setState(
{
timePeriod:
value,
expirySeconds,
expirationIndex:
expirationIndexFromSeconds(
expirySeconds
)
}
);
}}
/>
</View>
</Row>
<ButtonGroup
onPress={
this
.updateExpirationIndex
}
selectedIndex={
expirationIndex
}
buttons={
expirationButtons
}
selectedButtonStyle={{
backgroundColor:
themeColor(
'highlight'
),
borderRadius: 12
}}
containerStyle={{
backgroundColor:
themeColor(
'secondary'
),
borderRadius: 12,
borderWidth: 0,
height: 30,
marginBottom:
Platform.OS ===
'ios'
? 16
: 0
}}
innerBorderStyle={{
color: themeColor(
'secondary'
)
}}
/>
</>
)}
</>
)}
{showAdvancedSettingsToggle && (
<TouchableOpacity
onPress={() => {
this.setState({
advancedSettingsToggle:
!advancedSettingsToggle
});
}}
>
<View
style={{
marginBottom: 10
}}
>
<Row justify="space-between">
<View
style={{
flex: 1
}}
>
<KeyValue
keyValue={localeString(
'general.advancedSettings'
)}
/>
</View>
{advancedSettingsToggle ? (
<CaretDown
fill={themeColor(
'text'
)}
width="20"
height="20"
/>
) : (
<CaretRight
fill={themeColor(
'text'
)}
width="20"
height="20"
/>
)}
</Row>
</View>
</TouchableOpacity>
)}
{advancedSettingsToggle && (
<>
{BackendUtils.supportsCustomPreimages() &&
showCustomPreimageField && (
<>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
>
{localeString(
'views.Receive.customPreimage'
)}
</Text>
<TextInput
value={
customPreimage
}
onChangeText={(
text: string
) =>
this.setState(
{
customPreimage:
text
}
)
}
/>
</>
)}
{BackendUtils.isLNDBased() &&
!lspIsActive && (
<View
style={{
flexDirection:
'row',
marginBottom:
!routeHints
? 0
: Platform.OS ===
'ios'
? 10
: 6
}}
>
<View
style={{
flex: 1,
justifyContent:
'center'
}}
>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
infoModalText={[
localeString(
'views.Receive.routeHintSwitchExplainer1'
),
localeString(
'views.Receive.routeHintSwitchExplainer2'
),
localeString(
'views.Receive.routeHintSwitchExplainer3'
)
]}
>
{localeString(
'views.Receive.routeHints'
)}
</Text>
</View>
<View
style={{
alignSelf:
'center',
marginLeft: 5
}}
>
<Switch
value={
routeHints
}
onValueChange={() =>
this.setState(
{
routeHints:
!routeHints
}
)
}
disabled={
blindedPaths
}
/>
</View>
</View>
)}
{BackendUtils.isLNDBased() &&
!lspIsActive &&
routeHints && (
<Row>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
>
{localeString(
'general.mode'
)}
</Text>
<ButtonGroup
onPress={
setRouteHintMode
}
selectedIndex={
routeHintMode
}
buttons={
routeHintModeButtons
}
selectedButtonStyle={{
backgroundColor:
themeColor(
'highlight'
),
borderRadius: 12
}}
containerStyle={{
backgroundColor:
themeColor(
'secondary'
),
borderRadius: 12,
borderWidth: 0,
height: 30,
flex: 1
}}
innerBorderStyle={{
color: themeColor(
'secondary'
)
}}
/>
</Row>
)}
{BackendUtils.isLNDBased() &&
routeHints && (
<HopPicker
ref={(ref) => {
this.hopPickerRef =
ref;
}}
onValueChange={(
channels
) => {
this.setState(
{
selectedRouteHintChannels:
channels
}
);
}}
onCancel={() => {
if (
!selectedRouteHintChannels?.length
) {
setRouteHintMode(
RouteHintMode.Automatic
);
}
}}
title={localeString(
'views.Receive.customRouteHints'
)}
ChannelsStore={
this.props
.ChannelsStore
}
UnitsStore={
UnitsStore
}
containerStyle={{
display:
routeHintMode ===
RouteHintMode.Automatic
? 'none'
: 'flex'
}}
clearOnTap={
false
}
selectionMode={
'multiple'
}
selectedChannels={
selectedRouteHintChannels
}
/>
)}
{BackendUtils.supportsAMP() &&
!lspIsActive && (
<View
style={{
flexDirection:
'row',
marginTop: 20
}}
>
<View
style={{
flex: 1,
justifyContent:
'center'
}}
>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
infoModalText={[
localeString(
'views.Receive.ampSwitchExplainer1'
),
localeString(
'views.Receive.ampSwitchExplainer2'
)
]}
infoModalLink="https://docs.lightning.engineering/lightning-network-tools/lnd/amp"
>
{localeString(
'views.Receive.ampInvoice'
)}
</Text>
</View>
<View
style={{
alignSelf:
'center',
marginLeft: 5
}}
>
<Switch
value={
ampInvoice
}
onValueChange={() =>
this.setState(
{
ampInvoice:
!ampInvoice
}
)
}
disabled={
blindedPaths
}
/>
</View>
</View>
)}
{BackendUtils.supportsBolt11BlindedRoutes() &&
!lspIsActive && (
<View
style={{
flexDirection:
'row',
marginTop: 20
}}
>
<View
style={{
flex: 1,
justifyContent:
'center'
}}
>
<Text
style={{
...styles.secondaryText,
color: themeColor(
'secondaryText'
)
}}
infoModalText={[
localeString(
'views.Receive.blindedPathsExplainer1'
),
localeString(
'views.Receive.blindedPathsExplainer2'
)
]}
infoModalLink="https://lightningprivacy.com/en/blinded-trampoline"
>
{localeString(
'views.Receive.blindedPaths'
)}
</Text>
</View>
<View
style={{
alignSelf:
'center',
marginLeft: 5
}}
>
<Switch
value={
blindedPaths
}
onValueChange={() =>
this.setState(
{
blindedPaths:
!blindedPaths,
ampInvoice:
false,
routeHints:
false
}
)
}
/>
</View>
</View>
)}
</>
)}
<View style={styles.button}>
<Button
title={
localeString(
'views.Receive.createInvoice'
) +
(lnurl
? ` ${localeString(
'views.Receive.andSubmitTo'
)} ${
lnurl.domain
}`
: '')
}
disabled={
(lspIsActive &&
(!satAmount ||
satAmount ===
'0')) ||
!expiry
}
onPress={() => {
// Clear the old error messages (if present)
LSPStore.reset(true);
// If clearButton was used in on-chain tab
// and a new invoice below dust limit is created
// reset selectedIndex to 1 (lightning)
if (
selectedIndex ===
2 &&
belowDustLimit
) {
this.setState({
selectedIndex: 1
});
}
createUnifiedInvoice({
memo: lspIsActive
? ''
: receiverName
? `${receiverName}: ${memo}`
: memo,
value:
satAmount.toString() ||
'0',
expirySeconds:
lspIsActive
? LOCKED_EXPIRY_SECONDS
: expirySeconds ||
'3600',
lnurl,
ampInvoice:
lspIsActive
? false
: ampInvoice ||
false,
blindedPaths:
lspIsActive
? false
: blindedPaths ||
false,
routeHints,
routeHintChannels:
routeHintMode ===
RouteHintMode.Custom
? selectedRouteHintChannels
: undefined,
addressType:
BackendUtils.supportsAddressTypeSelection()
? addressType
: undefined,
customPreimage:
BackendUtils.supportsCustomPreimages() &&
showCustomPreimageField
? customPreimage
: undefined,
noLsp: !lspIsActive,
skipOnchain
}).then(
({
rHash,
onChainAddress
}: {
rHash: string;
onChainAddress?: string;
}) => {
this.subscribeInvoice(
rHash,
onChainAddress
);
}
);
}}
/>
</View>
</>
)}
</View>
</ScrollView>
)}
</View>
{showAdvanced && showButtonGroup && (
<View style={{ bottom: 0 }}>
{!belowDustLimit &&
!lnOnly &&
!onChainOnly &&
!watchedInvoicePaid && (
<ButtonGroup
onPress={this.updateIndex}
selectedIndex={selectedIndex}
buttons={buttons}
selectedButtonStyle={{
backgroundColor:
themeColor('highlight'),
borderRadius: 12
}}
containerStyle={{
backgroundColor:
themeColor('secondary'),
borderRadius: 12,
borderWidth: 0,
height: 80
}}
innerBorderStyle={{
color: themeColor('secondary')
}}
/>
)}
</View>
)}
<ModalBox
style={{
backgroundColor: themeColor('modalBackground'),
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
height: modalHeight,
paddingLeft: 24,
paddingRight: 24
}}
swipeToClose={true}
backButtonClose={true}
position="bottom"
ref={this.modalBoxRef}
>
<ScrollView>
<Text
style={{
color: themeColor('text'),
fontSize: 24,
fontWeight: 'bold',
paddingTop: 24,
paddingBottom: 24
}}
>
{localeString('views.Receive.addressType')}
</Text>
{_map(ADDRESS_TYPES, (d, index) => (
<TouchableOpacity
key={index}
onPress={() => {
// If same address type is selected, close modal
if (d.value === addressType) {
this.modalBoxRef.current?.close();
return;
}
InvoicesStore.clearAddress();
this.setState({ addressType: d.value });
// Only auto-generate if we're in on-chain mode
if (selectedIndex === 2) {
this.autoGenerateOnChainAddress(
account,
d.value
);
}
this.modalBoxRef.current?.close();
}}
style={{
backgroundColor: themeColor('secondary'),
borderColor:
d.value === addressType
? themeColor('highlight')
: themeColor('secondaryText'),
borderRadius: 4,
borderWidth:
d.value === addressType ? 2 : 1,
padding: 16,
marginBottom: 24
}}
>
<Text
style={{
color: themeColor('text'),
fontSize: 16,
fontWeight: 'bold',
marginBottom: 4
}}
>
{d.key}
</Text>
<Text
style={{
color: themeColor('text'),
fontSize: 16,
fontWeight: 'normal'
}}
>
{d.description}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</ModalBox>
</Screen>
);
}
}
const styles = StyleSheet.create({
content: {
paddingLeft: 20,
paddingRight: 20
},
button: {
paddingTop: 25,
paddingBottom: 15
},
text: {
fontFamily: 'PPNeueMontreal-Book'
},
secondaryText: {
fontFamily: 'PPNeueMontreal-Book'
}
});