diff --git a/BlueApp.js b/BlueApp.js
index f3c50a461..c465f4d4f 100644
--- a/BlueApp.js
+++ b/BlueApp.js
@@ -1,6 +1,3 @@
-/**
- * @exports {AppStorage}
- */
import { AppStorage } from './class';
import DeviceQuickActions from './class/quick-actions';
import Biometric from './class/biometrics';
@@ -10,8 +7,7 @@ const prompt = require('./blue_modules/prompt');
const EV = require('./blue_modules/events');
const currency = require('./blue_modules/currency');
const BlueElectrum = require('./blue_modules/BlueElectrum'); // eslint-disable-line no-unused-vars
-/** @type {AppStorage} */
-const BlueApp = new AppStorage();
+const BlueApp: AppStorage = new AppStorage();
// If attempt reaches 10, a wipe keychain option will be provided to the user.
let unlockAttempt = 0;
diff --git a/BlueComponents.js b/BlueComponents.js
index 43eaccb4b..238aef5e0 100644
--- a/BlueComponents.js
+++ b/BlueComponents.js
@@ -39,6 +39,8 @@ import QRCode from 'react-native-qrcode-svg';
import { useTheme } from '@react-navigation/native';
import { BlueCurrentTheme } from './components/themes';
import loc, { formatBalance, formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros, transactionTimeToReadable } from './loc';
+import AsyncStorage from '@react-native-community/async-storage';
+import Lnurl from './class/lnurl';
/** @type {AppStorage} */
const BlueApp = require('./BlueApp');
const { height, width } = Dimensions.get('window');
@@ -1698,7 +1700,7 @@ export const BlueTransactionListItem = React.memo(({ item, itemPriceUnit = Bitco
return (item.confirmations < 7 ? loc.transactions.list_conf + ': ' + item.confirmations + ' ' : '') + txMemo() + (item.memo || '');
};
- const onPress = () => {
+ const onPress = async () => {
if (item.hash) {
NavigationService.navigate('TransactionStatus', { hash: item.hash });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
@@ -1710,6 +1712,25 @@ export const BlueTransactionListItem = React.memo(({ item, itemPriceUnit = Bitco
}
});
if (lightningWallet.length === 1) {
+ // is it a successful lnurl-pay?
+ const LN = new Lnurl(false, AsyncStorage);
+ let paymentHash = item.payment_hash;
+ if (typeof paymentHash === 'object') {
+ paymentHash = Buffer.from(paymentHash.data).toString('hex');
+ }
+ const loaded = await LN.loadSuccessfulPayment(paymentHash);
+ if (loaded) {
+ NavigationService.navigate('ScanLndInvoiceRoot', {
+ screen: 'LnurlPaySuccess',
+ params: {
+ paymentHash: paymentHash,
+ justPaid: false,
+ fromWalletID: lightningWallet[0].getID(),
+ },
+ });
+ return;
+ }
+
NavigationService.navigate('LNDViewInvoice', {
invoice: item,
fromWallet: lightningWallet[0],
diff --git a/Navigation.js b/Navigation.js
index a1601f735..ff412b305 100644
--- a/Navigation.js
+++ b/Navigation.js
@@ -60,6 +60,8 @@ import LappBrowser from './screen/lnd/browser';
import LNDCreateInvoice from './screen/lnd/lndCreateInvoice';
import LNDViewInvoice from './screen/lnd/lndViewInvoice';
import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation';
+import LnurlPay from './screen/lnd/lnurlPay';
+import LnurlPaySuccess from './screen/lnd/lnurlPaySuccess';
import LoadingScreen from './LoadingScreen';
import UnlockWith from './UnlockWith';
import { BlueNavigationStyle } from './BlueComponents';
@@ -135,6 +137,8 @@ const WalletsRoot = () => (
/>
+
+
);
@@ -203,6 +207,8 @@ const ScanLndInvoiceRoot = () => (
+
+
);
diff --git a/class/deeplink-schema-match.js b/class/deeplink-schema-match.js
index cf8c8e909..778532a71 100644
--- a/class/deeplink-schema-match.js
+++ b/class/deeplink-schema-match.js
@@ -96,6 +96,9 @@ class DeeplinkSchemaMatch {
},
]);
} else if (DeeplinkSchemaMatch.isLnUrl(event.url)) {
+ // at this point we can not tell if it is lnurl-pay or lnurl-withdraw since it needs additional async call
+ // to the server, which is undesirable here, so LNDCreateInvoice screen will handle it for us and will
+ // redirect user to LnurlPay screen if necessary
completionHandler([
'LNDCreateInvoiceRoot',
{
diff --git a/class/lnurl.js b/class/lnurl.js
new file mode 100644
index 000000000..3b8d76003
--- /dev/null
+++ b/class/lnurl.js
@@ -0,0 +1,243 @@
+import bech32 from 'bech32';
+import bolt11 from 'bolt11';
+const CryptoJS = require('crypto-js');
+
+const createHash = require('create-hash');
+
+/**
+ * @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md
+ */
+export default class Lnurl {
+ static TAG_PAY_REQUEST = 'payRequest'; // type of LNURL
+ static TAG_WITHDRAW_REQUEST = "withdrawRequest"; // type of LNURL
+
+ constructor(url, AsyncStorage) {
+ this._lnurl = url;
+ this._lnurlPayServiceBolt11Payload = false;
+ this._lnurlPayServicePayload = false;
+ this._AsyncStorage = AsyncStorage;
+ this._preimage = false;
+ }
+
+ static findlnurl(bodyOfText) {
+ var res = /,*?((lnurl)([0-9]{1,}[a-z0-9]+){1})/.exec(bodyOfText.toLowerCase());
+ if (res) {
+ return res[1];
+ }
+ return null;
+ }
+
+ static getUrlFromLnurl(lnurlExample) {
+ const found = Lnurl.findlnurl(lnurlExample);
+ if (!found) return false;
+
+ const decoded = bech32.decode(lnurlExample, 10000);
+ return Buffer.from(bech32.fromWords(decoded.words)).toString();
+ }
+
+ static isLnurl(url) {
+ return url.toLowerCase().startsWith('lnurl1');
+ }
+
+ async fetchGet(url) {
+ const resp = await fetch(url, { method: 'GET' });
+ if (resp.status >= 300) {
+ throw new Error('Bad response from server');
+ }
+ const reply = await resp.json();
+ if (reply.status === 'ERROR') {
+ throw new Error('Reply from server: ' + reply.reason);
+ }
+ return reply;
+ }
+
+ decodeInvoice(invoice) {
+ const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice);
+
+ const decoded = {
+ destination: payeeNodeKey,
+ num_satoshis: satoshis ? satoshis.toString() : '0',
+ num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0',
+ timestamp: timestamp.toString(),
+ fallback_addr: '',
+ route_hints: [],
+ };
+
+ for (let i = 0; i < tags.length; i++) {
+ const { tagName, data } = tags[i];
+ switch (tagName) {
+ case 'payment_hash':
+ decoded.payment_hash = data;
+ break;
+ case 'purpose_commit_hash':
+ decoded.description_hash = data;
+ break;
+ case 'min_final_cltv_expiry':
+ decoded.cltv_expiry = data.toString();
+ break;
+ case 'expire_time':
+ decoded.expiry = data.toString();
+ break;
+ case 'description':
+ decoded.description = data;
+ break;
+ }
+ }
+
+ if (!decoded.expiry) decoded.expiry = '3600'; // default
+
+ if (parseInt(decoded.num_satoshis) === 0 && decoded.num_millisatoshis > 0) {
+ decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString();
+ }
+
+ return decoded;
+ }
+
+ async requestBolt11FromLnurlPayService(amountSat) {
+ if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set');
+ if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set');
+ if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max)
+ throw new Error('amount is not right, ' + amountSat + ' should be between ' + this._lnurlPayServicePayload.min + ' and ' + this._lnurlPayServicePayload.max);
+ const nonce = Math.floor(Math.random() * 2e16).toString(16);
+ const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&';
+ const urlToFetch = this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce;
+ this._lnurlPayServiceBolt11Payload = await this.fetchGet(urlToFetch);
+ if (this._lnurlPayServiceBolt11Payload.status === 'ERROR')
+ throw new Error(this._lnurlPayServiceBolt11Payload.reason || 'requestBolt11FromLnurlPayService() error');
+
+ // check pr description_hash, amount etc:
+ const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr);
+ const metadataHash = createHash('sha256').update(this._lnurlPayServicePayload.metadata).digest('hex');
+ if (metadataHash !== decoded.description_hash) {
+ throw new Error(`Invoice description_hash doesn't match metadata.`);
+ }
+ if (parseInt(decoded.num_satoshis) !== Math.round(amountSat)) {
+ throw new Error(`Invoice doesn't match specified amount, got ${decoded.num_satoshis}, expected ${Math.round(amountSat)}`);
+ }
+
+ return this._lnurlPayServiceBolt11Payload;
+ }
+
+ async callLnurlPayService() {
+ if (!this._lnurl) throw new Error('this._lnurl is not set');
+ const url = Lnurl.getUrlFromLnurl(this._lnurl);
+ // calling the url
+ const reply = await this.fetchGet(url);
+
+ if (reply.tag !== Lnurl.TAG_PAY_REQUEST) {
+ throw new Error('lnurl-pay expected, found tag ' + reply.tag);
+ }
+
+ const data = reply;
+
+ // parse metadata and extract things from it
+ var image;
+ var description;
+ const kvs = JSON.parse(data.metadata);
+ for (let i = 0; i < kvs.length; i++) {
+ const [k, v] = kvs[i];
+ switch (k) {
+ case 'text/plain':
+ description = v;
+ break;
+ case 'image/png;base64':
+ case 'image/jpeg;base64':
+ image = 'data:' + k + ',' + v;
+ break;
+ }
+ }
+
+ // setting the payment screen with the parameters
+ const min = Math.ceil((data.minSendable || 0) / 1000);
+ const max = Math.floor(data.maxSendable / 1000);
+
+ this._lnurlPayServicePayload = {
+ callback: data.callback,
+ fixed: min === max,
+ min,
+ max,
+ domain: data.callback.match(new RegExp('https://([^/]+)/'))[1],
+ metadata: data.metadata,
+ description,
+ image,
+ amount: min,
+ // lnurl: uri,
+ };
+ return this._lnurlPayServicePayload;
+ }
+
+ async loadSuccessfulPayment(paymentHash) {
+ if (!paymentHash) throw new Error('No paymentHash provided');
+ let data;
+ try {
+ data = await this._AsyncStorage.getItem('lnurlpay_success_data_' + paymentHash);
+ data = JSON.parse(data);
+ } catch (_) {
+ return false;
+ }
+
+ if (!data) return false;
+
+ this._lnurlPayServicePayload = data.lnurlPayServicePayload;
+ this._lnurlPayServiceBolt11Payload = data.lnurlPayServiceBolt11Payload;
+ this._lnurl = data.lnurl;
+ this._preimage = data.preimage;
+
+ return true;
+ }
+
+ async storeSuccess(paymentHash, preimage) {
+ if (typeof preimage === 'object') {
+ preimage = Buffer.from(preimage.data).toString('hex');
+ }
+ this._preimage = preimage;
+
+ await this._AsyncStorage.setItem(
+ 'lnurlpay_success_data_' + paymentHash,
+ JSON.stringify({
+ lnurlPayServicePayload: this._lnurlPayServicePayload,
+ lnurlPayServiceBolt11Payload: this._lnurlPayServiceBolt11Payload,
+ lnurl: this._lnurl,
+ preimage,
+ }),
+ );
+ }
+
+ getSuccessAction() {
+ return this._lnurlPayServiceBolt11Payload.successAction;
+ }
+
+ getDomain() {
+ return this._lnurlPayServicePayload.domain;
+ }
+
+ getDescription() {
+ return this._lnurlPayServicePayload.description;
+ }
+
+ getImage() {
+ return this._lnurlPayServicePayload.image;
+ }
+
+ getLnurl() {
+ return this._lnurl;
+ }
+
+ getDisposable() {
+ return this._lnurlPayServiceBolt11Payload.disposable;
+ }
+
+ getPreimage() {
+ return this._preimage;
+ }
+
+ static decipherAES(ciphertextBase64, preimageHex, ivBase64) {
+ const iv = CryptoJS.enc.Base64.parse(ivBase64);
+ const key = CryptoJS.enc.Hex.parse(preimageHex);
+ return CryptoJS.AES.decrypt(Buffer.from(ciphertextBase64, 'base64').toString('hex'), key, {
+ iv,
+ mode: CryptoJS.mode.CBC,
+ format: CryptoJS.format.Hex,
+ }).toString(CryptoJS.enc.Utf8);
+ }
+}
diff --git a/screen/lnd/lndCreateInvoice.js b/screen/lnd/lndCreateInvoice.js
index bae5087b7..51e51a22e 100644
--- a/screen/lnd/lndCreateInvoice.js
+++ b/screen/lnd/lndCreateInvoice.js
@@ -29,6 +29,7 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import { Icon } from 'react-native-elements';
import loc, { formatBalanceWithoutSuffix, formatBalancePlain } from '../../loc';
import { BlueCurrentTheme } from '../../components/themes';
+import Lnurl from '../../class/lnurl';
const currency = require('../../blue_modules/currency');
const BlueApp = require('../../BlueApp');
const EV = require('../../blue_modules/events');
@@ -290,7 +291,20 @@ export default class LNDCreateInvoice extends Component {
throw new Error('Reply from server: ' + reply.reason);
}
- if (reply.tag !== 'withdrawRequest') {
+ if (reply.tag === Lnurl.TAG_PAY_REQUEST) {
+ // we are here by mistake. user wants to SEND to lnurl-pay, but he is on a screen that creates
+ // invoices (including through lnurl-withdraw)
+ this.props.navigation.navigate('ScanLndInvoiceRoot', {
+ screen: 'LnurlPay',
+ params: {
+ lnurl: data,
+ fromWalletID: this.state.fromWallet.getID(),
+ },
+ });
+ return;
+ }
+
+ if (reply.tag !== Lnurl.TAG_WITHDRAW_REQUEST) {
throw new Error('Unsupported lnurl');
}
diff --git a/screen/lnd/lnurlPay.js b/screen/lnd/lnurlPay.js
new file mode 100644
index 000000000..fdff56eb3
--- /dev/null
+++ b/screen/lnd/lnurlPay.js
@@ -0,0 +1,270 @@
+/* global alert */
+import React, { Component } from 'react';
+import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
+import AsyncStorage from '@react-native-community/async-storage';
+import {
+ BlueBitcoinAmount,
+ BlueButton,
+ BlueCard,
+ BlueDismissKeyboardInputAccessory,
+ BlueLoading,
+ BlueNavigationStyle,
+ BlueSpacing20,
+ BlueText,
+ SafeBlueArea,
+} from '../../BlueComponents';
+import { BlueCurrentTheme } from '../../components/themes';
+import Lnurl from '../../class/lnurl';
+import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
+import loc, { formatBalanceWithoutSuffix } from '../../loc';
+import { Icon } from 'react-native-elements';
+import Biometric from '../../class/biometrics';
+import PropTypes from 'prop-types';
+
+const BlueApp = require('../../BlueApp');
+const EV = require('../../blue_modules/events');
+const currency = require('../../blue_modules/currency');
+
+export default class LnurlPay extends Component {
+ constructor(props) {
+ super(props);
+ const fromWalletID = props.route.params.fromWalletID;
+ const lnurl = props.route.params.lnurl;
+
+ const fromWallet = BlueApp.getWallets().find(w => w.getID() === fromWalletID);
+
+ this.state = {
+ isLoading: true,
+ fromWalletID,
+ fromWallet,
+ lnurl,
+ payButtonDisabled: false,
+ };
+ }
+
+ async componentDidMount() {
+ const LN = new Lnurl(this.state.lnurl, AsyncStorage);
+ const payload = await LN.callLnurlPayService();
+
+ this.setState({
+ payload,
+ amount: payload.min,
+ isLoading: false,
+ unit: BitcoinUnit.SATS,
+ LN,
+ });
+ }
+
+ onWalletSelect = wallet => {
+ this.setState({ fromWallet: wallet, fromWalletID: wallet.getID() }, () => {
+ this.props.navigation.pop();
+ });
+ };
+
+ pay = async () => {
+ this.setState({
+ payButtonDisabled: true,
+ });
+ /** @type {Lnurl} */
+ const LN = this.state.LN;
+
+ const isBiometricsEnabled = await Biometric.isBiometricUseCapableAndEnabled();
+ if (isBiometricsEnabled) {
+ if (!(await Biometric.unlockWithBiometrics())) {
+ return;
+ }
+ }
+
+ let amountSats = this.state.amount;
+ switch (this.state.unit) {
+ case BitcoinUnit.SATS:
+ amountSats = parseInt(amountSats); // nop
+ break;
+ case BitcoinUnit.BTC:
+ amountSats = currency.btcToSatoshi(amountSats);
+ break;
+ case BitcoinUnit.LOCAL_CURRENCY:
+ amountSats = currency.btcToSatoshi(currency.fiatToBTC(amountSats));
+ break;
+ }
+
+ /** @type {LightningCustodianWallet} */
+ const fromWallet = this.state.fromWallet;
+
+ let bolt11payload;
+ try {
+ bolt11payload = await LN.requestBolt11FromLnurlPayService(amountSats);
+ await fromWallet.payInvoice(bolt11payload.pr);
+ const decoded = fromWallet.decodeInvoice(bolt11payload.pr);
+ this.setState({ payButtonDisabled: false });
+
+ // success, probably
+ ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false });
+ EV(EV.enum.REMOTE_TRANSACTIONS_COUNT_CHANGED); // someone should fetch txs
+ if (fromWallet.last_paid_invoice_result && fromWallet.last_paid_invoice_result.payment_preimage) {
+ await LN.storeSuccess(decoded.payment_hash, fromWallet.last_paid_invoice_result.payment_preimage);
+ }
+
+ this.props.navigation.navigate('ScanLndInvoiceRoot', {
+ screen: 'LnurlPaySuccess',
+ params: {
+ paymentHash: decoded.payment_hash,
+ justPaid: true,
+ fromWalletID: this.state.fromWalletID,
+ },
+ });
+ } catch (Err) {
+ console.log(Err.message);
+ this.setState({ isLoading: false, payButtonDisabled: false });
+ ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false });
+ return alert(Err.message);
+ }
+ };
+
+ renderWalletSelectionButton = () => {
+ if (this.state.renderWalletSelectionButtonHidden) return;
+ return (
+
+ {!this.state.isLoading && (
+
+ this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.OFFCHAIN })
+ }
+ >
+ {loc.wallets.select_wallet.toLowerCase()}
+
+
+ )}
+
+
+ this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.OFFCHAIN })
+ }
+ >
+ {this.state.fromWallet.getLabel()}
+
+ {formatBalanceWithoutSuffix(this.state.fromWallet.getBalance(), BitcoinUnit.SATS, false)}
+
+ {BitcoinUnit.SATS}
+
+
+
+ );
+ };
+
+ renderGotPayload() {
+ return (
+
+
+
+ {
+ this.setState({ unit });
+ }}
+ onChangeText={text => {
+ this.setState({ amount: text });
+ }}
+ disabled={this.state.payload && this.state.payload.fixed}
+ unit={this.state.unit}
+ inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
+ />
+
+ please pay between {this.state.payload.min} and {this.state.payload.max} sat
+
+
+ {this.state.payload.image && }
+ {this.state.payload.description}
+ {this.state.payload.domain}
+
+
+
+ {this.renderWalletSelectionButton()}
+
+
+
+ );
+ }
+
+ render() {
+ if (this.state.isLoading) {
+ return ;
+ }
+
+ return this.renderGotPayload();
+ }
+}
+
+LnurlPay.propTypes = {
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ fromWalletID: PropTypes.string.isRequired,
+ lnurl: PropTypes.string.isRequired,
+ }),
+ }),
+ navigation: PropTypes.shape({
+ navigate: PropTypes.func,
+ pop: PropTypes.func,
+ dangerouslyGetParent: PropTypes.func,
+ }),
+};
+
+const styles = StyleSheet.create({
+ img: { width: 200, height: 200, alignSelf: 'center' },
+ alignSelfCenter: {
+ alignSelf: 'center',
+ },
+ walletSelectRoot: {
+ marginBottom: 16,
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ },
+ 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: {
+ color: BlueCurrentTheme.colors.buttonAlternativeTextColor,
+ fontSize: 14,
+ },
+ walletWrapBalance: {
+ color: BlueCurrentTheme.colors.buttonAlternativeTextColor,
+ fontSize: 14,
+ fontWeight: '600',
+ marginLeft: 4,
+ marginRight: 4,
+ },
+ walletWrapSats: {
+ color: BlueCurrentTheme.colors.buttonAlternativeTextColor,
+ fontSize: 11,
+ fontWeight: '600',
+ textAlignVertical: 'bottom',
+ marginTop: 2,
+ },
+});
+
+LnurlPay.navigationOptions = ({ navigation, route }) => {
+ return {
+ ...BlueNavigationStyle(navigation, true, () => navigation.dangerouslyGetParent().popToTop()),
+ title: '',
+ headerLeft: null,
+ };
+};
diff --git a/screen/lnd/lnurlPaySuccess.js b/screen/lnd/lnurlPaySuccess.js
new file mode 100644
index 000000000..580daf389
--- /dev/null
+++ b/screen/lnd/lnurlPaySuccess.js
@@ -0,0 +1,210 @@
+import React, { Component } from 'react';
+import LottieView from 'lottie-react-native';
+import { View, Text, Linking, StyleSheet, Image, ScrollView } from 'react-native';
+import AsyncStorage from '@react-native-community/async-storage';
+import { Icon } from 'react-native-elements';
+import {
+ BlueButton,
+ BlueButtonLink,
+ BlueNavigationStyle,
+ SafeBlueArea,
+ BlueCard,
+ BlueLoading,
+ BlueText,
+ BlueSpacing20,
+} from '../../BlueComponents';
+import PropTypes from 'prop-types';
+import Lnurl from '../../class/lnurl';
+import loc from '../../loc';
+
+export default class LnurlPaySuccess extends Component {
+ constructor(props) {
+ super(props);
+
+ const paymentHash = props.route.params.paymentHash;
+ const fromWalletID = props.route.params.fromWalletID;
+ const justPaid = !!props.route.params.justPaid;
+
+ this.state = {
+ paymentHash,
+ isLoading: true,
+ fromWalletID,
+ justPaid,
+ };
+ }
+
+ async componentDidMount() {
+ const LN = new Lnurl(false, AsyncStorage);
+ await LN.loadSuccessfulPayment(this.state.paymentHash);
+
+ const successAction = LN.getSuccessAction();
+ if (!successAction) {
+ this.setState({ isLoading: false, LN });
+ return;
+ }
+
+ const newState = { LN, isLoading: false };
+
+ switch (successAction.tag) {
+ case 'aes': {
+ const preimage = LN.getPreimage();
+ newState.message = Lnurl.decipherAES(successAction.ciphertext, preimage, successAction.iv);
+ newState.preamble = successAction.description;
+ break;
+ }
+ case 'url':
+ newState.url = successAction.url;
+ newState.preamble = successAction.description;
+ break;
+ case 'message':
+ this.setState({ message: successAction.message });
+ newState.message = successAction.message;
+ break;
+ }
+
+ this.setState(newState);
+ }
+
+ render() {
+ if (this.state.isLoading || !this.state.LN) {
+ return ;
+ }
+
+ /** @type {Lnurl} */
+ const LN = this.state.LN;
+ const domain = LN.getDomain();
+ const repeatable = !LN.getDisposable();
+ const lnurl = LN.getLnurl();
+ const description = LN.getDescription();
+ const image = LN.getImage();
+ const { preamble, message, url, justPaid } = this.state;
+
+ return (
+
+
+ {justPaid ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {domain}
+ {description}
+ {image && }
+
+
+ {(preamble || url || message) && (
+
+
+ {preamble}
+ {url ? (
+ {
+ Linking.openURL(url);
+ }}
+ />
+ ) : (
+
+ {message}
+
+ )}
+
+
+ )}
+
+
+ {repeatable ? (
+ {
+ this.props.navigation.navigate('ScanLndInvoiceRoot', {
+ screen: 'LnurlPay',
+ params: {
+ lnurl: lnurl,
+ fromWalletID: this.state.fromWalletID,
+ },
+ });
+ }}
+ title="repeat"
+ icon={{ name: 'refresh', type: 'font-awesome', color: '#9aa0aa' }}
+ />
+ ) : (
+ {
+ this.props.navigation.dangerouslyGetParent().popToTop();
+ }}
+ title={loc.send.success_done}
+ />
+ )}
+
+
+
+ );
+ }
+}
+
+LnurlPaySuccess.propTypes = {
+ navigation: PropTypes.shape({
+ navigate: PropTypes.func,
+ pop: PropTypes.func,
+ dangerouslyGetParent: PropTypes.func,
+ }),
+ route: PropTypes.shape({
+ name: PropTypes.string,
+ params: PropTypes.shape({
+ paymentHash: PropTypes.string.isRequired,
+ fromWalletID: PropTypes.string.isRequired,
+ justPaid: PropTypes.bool.isRequired,
+ }),
+ }),
+};
+
+const styles = StyleSheet.create({
+ img: { width: 200, height: 200, alignSelf: 'center' },
+ alignSelfCenter: {
+ alignSelf: 'center',
+ },
+ root: {
+ flex: 1,
+ paddingTop: 0,
+ },
+ iconContainer: {
+ backgroundColor: '#ccddf9',
+ width: 120,
+ height: 120,
+ maxWidth: 120,
+ maxHeight: 120,
+ padding: 0,
+ borderRadius: 60,
+ alignSelf: 'center',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ icon: {
+ width: 400,
+ height: 400,
+ },
+ successContainer: {
+ marginTop: 10,
+ },
+ successText: {
+ textAlign: 'center',
+ margin: 4,
+ },
+ successValue: {
+ fontWeight: 'bold',
+ },
+});
+
+LnurlPaySuccess.navigationOptions = ({ navigation, route }) => {
+ return {
+ ...BlueNavigationStyle(navigation, true, () => navigation.dangerouslyGetParent().popToTop()),
+ title: '',
+ headerLeft: null,
+ };
+};
diff --git a/screen/lnd/scanLndInvoice.js b/screen/lnd/scanLndInvoice.js
index 6ca53067f..16968cc5d 100644
--- a/screen/lnd/scanLndInvoice.js
+++ b/screen/lnd/scanLndInvoice.js
@@ -23,6 +23,7 @@ import {
BlueLoading,
} from '../../BlueComponents';
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
+import Lnurl from '../../class/lnurl';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
import { Icon } from 'react-native-elements';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
@@ -213,9 +214,20 @@ export default class ScanLndInvoice extends React.Component {
};
processInvoice = data => {
+ if (Lnurl.isLnurl(data)) return this.processLnurlPay(data);
this.props.navigation.setParams({ uri: data });
};
+ processLnurlPay = data => {
+ this.props.navigation.navigate('ScanLndInvoiceRoot', {
+ screen: 'LnurlPay',
+ params: {
+ lnurl: data,
+ fromWalletID: this.state.fromWallet.getID(),
+ },
+ });
+ };
+
async pay() {
if (!('decoded' in this.state)) {
return null;
@@ -286,7 +298,7 @@ export default class ScanLndInvoice extends React.Component {
}
processTextForInvoice = text => {
- if (text.toLowerCase().startsWith('lnb') || text.toLowerCase().startsWith('lightning:lnb')) {
+ if (text.toLowerCase().startsWith('lnb') || text.toLowerCase().startsWith('lightning:lnb') || Lnurl.isLnurl(text)) {
this.processInvoice(text);
} else {
this.setState({ decoded: undefined, expiresIn: undefined, destination: text });
diff --git a/tests/unit/lnurl.test.js b/tests/unit/lnurl.test.js
new file mode 100644
index 000000000..7444c0150
--- /dev/null
+++ b/tests/unit/lnurl.test.js
@@ -0,0 +1,89 @@
+/* global describe, it */
+import Lnurl from '../../class/lnurl';
+const assert = require('assert');
+
+describe('LNURL', function () {
+ it('can findlnurl', () => {
+ const lnurlExample = 'LNURL1DP68GURN8GHJ7MRWW3UXYMM59E3XJEMNW4HZU7RE0GHKCMN4WFKZ7URP0YLH2UM9WF5KG0FHXYCNV9G9W58';
+ const found = Lnurl.findlnurl(lnurlExample);
+ assert.strictEqual(found, 'lnurl1dp68gurn8ghj7mrww3uxymm59e3xjemnw4hzu7re0ghkcmn4wfkz7urp0ylh2um9wf5kg0fhxycnv9g9w58');
+ });
+
+ it('can getUrlFromLnurl()', () => {
+ assert.strictEqual(
+ Lnurl.getUrlFromLnurl('LNURL1DP68GURN8GHJ7MRWW3UXYMM59E3XJEMNW4HZU7RE0GHKCMN4WFKZ7URP0YLH2UM9WF5KG0FHXYCNV9G9W58'),
+ 'https://lntxbot.bigsun.xyz/lnurl/pay?userid=7116',
+ );
+ assert.strictEqual(Lnurl.getUrlFromLnurl('bs'), false);
+ });
+
+ it('can isLnurl()', () => {
+ assert.ok(Lnurl.isLnurl('LNURL1DP68GURN8GHJ7MRWW3UXYMM59E3XJEMNW4HZU7RE0GHKCMN4WFKZ7URP0YLH2UM9WF5KG0FHXYCNV9G9W58'));
+ assert.ok(!Lnurl.isLnurl('bs'));
+ });
+
+ it('can callLnurlPayService() and requestBolt11FromLnurlPayService()', async () => {
+ const LN = new Lnurl('LNURL1DP68GURN8GHJ7MRWW3UXYMM59E3XJEMNW4HZU7RE0GHKCMN4WFKZ7URP0YLH2UM9WF5KG0FHXYCNV9G9W58');
+
+ // poor-man's mock:
+ LN._fetchGet = LN.fetchGet;
+ LN.fetchGet = () => {
+ return {
+ status: 'OK',
+ callback: 'https://lntxbot.bigsun.xyz/lnurl/pay/callback?userid=7116',
+ tag: 'payRequest',
+ maxSendable: 1000000000,
+ minSendable: 1000,
+ metadata: '[["text/plain","Fund @overtorment account on t.me/lntxbot."]]',
+ };
+ };
+ const lnurlpayPayload = await LN.callLnurlPayService();
+ assert.deepStrictEqual(lnurlpayPayload, {
+ amount: 1,
+ callback: 'https://lntxbot.bigsun.xyz/lnurl/pay/callback?userid=7116',
+ description: 'Fund @overtorment account on t.me/lntxbot.',
+ domain: 'lntxbot.bigsun.xyz',
+ fixed: false,
+ image: undefined,
+ max: 1000000,
+ metadata: '[["text/plain","Fund @overtorment account on t.me/lntxbot."]]',
+ min: 1,
+ });
+
+ // mock:
+ LN.fetchGet = () => {
+ return {
+ status: 'OK',
+ successAction: null,
+ routes: [],
+ pr:
+ 'lnbc20n1p03s853pp58v9lrqahj2zyuzsdqqm3wnt2damlnkkuzwm8s7jkmnauhtkq4fjshp5z766racq95ncpk27nksev2ntu8wte77zd46g8uvzlnm5hhwukjrqcqzysxq9p5hsqrzjq29zewx4rezd04lpprpwsz5cesrfz30qtfkjqfw0249a3pn0uv5exzdefqqqxecqqqqqqqlgqqqq03sq9qsp52guktgy9u0xpky06n7slhjcvkassj0xpc3t9wadfsa0sl5x4fz9s9qy9qsqff5ycjg6xh3cc0vf8wxzxdajrdl9pka3nl3v37vcqj0qrdkzhsqxs8atfnxm2xenlkz7fpghlnuypux7hdp63zct3fr9px2e349kyqspu3gswx',
+ disposable: false,
+ };
+ };
+ const rez = await LN.requestBolt11FromLnurlPayService(2);
+ assert.deepStrictEqual(rez, {
+ status: 'OK',
+ successAction: null,
+ routes: [],
+ pr:
+ 'lnbc20n1p03s853pp58v9lrqahj2zyuzsdqqm3wnt2damlnkkuzwm8s7jkmnauhtkq4fjshp5z766racq95ncpk27nksev2ntu8wte77zd46g8uvzlnm5hhwukjrqcqzysxq9p5hsqrzjq29zewx4rezd04lpprpwsz5cesrfz30qtfkjqfw0249a3pn0uv5exzdefqqqxecqqqqqqqlgqqqq03sq9qsp52guktgy9u0xpky06n7slhjcvkassj0xpc3t9wadfsa0sl5x4fz9s9qy9qsqff5ycjg6xh3cc0vf8wxzxdajrdl9pka3nl3v37vcqj0qrdkzhsqxs8atfnxm2xenlkz7fpghlnuypux7hdp63zct3fr9px2e349kyqspu3gswx',
+ disposable: false,
+ });
+
+ assert.strictEqual(LN.getSuccessAction(), null);
+ assert.strictEqual(LN.getDomain(), 'lntxbot.bigsun.xyz');
+ assert.strictEqual(LN.getDescription(), 'Fund @overtorment account on t.me/lntxbot.');
+ assert.strictEqual(LN.getImage(), undefined);
+ assert.strictEqual(LN.getLnurl(), 'LNURL1DP68GURN8GHJ7MRWW3UXYMM59E3XJEMNW4HZU7RE0GHKCMN4WFKZ7URP0YLH2UM9WF5KG0FHXYCNV9G9W58');
+ assert.strictEqual(LN.getDisposable(), false);
+ });
+
+ it('can decipher AES', () => {
+ const ciphertext = 'vCWn4TMhIKubUc5+aBVfvw==';
+ const iv = 'eTGduB45hWTOxHj1dR+LJw==';
+ const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
+
+ assert.strictEqual(Lnurl.decipherAES(ciphertext, preimage, iv), '1234');
+ });
+});