mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2024-11-19 09:50:15 +01:00
ADD: lnurl-pay support
This commit is contained in:
parent
3a96d1eb71
commit
f386bb345b
@ -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;
|
||||
|
||||
|
@ -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],
|
||||
|
@ -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 = () => (
|
||||
/>
|
||||
<WalletsStack.Screen name="HodlHodlViewOffer" component={HodlHodlViewOffer} options={HodlHodlViewOffer.navigationOptions} />
|
||||
<WalletsStack.Screen name="Broadcast" component={Broadcast} options={Broadcast.navigationOptions} />
|
||||
<WalletsStack.Screen name="LnurlPay" component={LnurlPay} options={LnurlPay.navigationOptions} />
|
||||
<WalletsStack.Screen name="LnurlPaySuccess" component={LnurlPaySuccess} options={LnurlPaySuccess.navigationOptions} />
|
||||
</WalletsStack.Navigator>
|
||||
);
|
||||
|
||||
@ -203,6 +207,8 @@ const ScanLndInvoiceRoot = () => (
|
||||
<ScanLndInvoiceStack.Screen name="ScanLndInvoice" component={ScanLndInvoice} options={ScanLndInvoice.navigationOptions} />
|
||||
<ScanLndInvoiceStack.Screen name="SelectWallet" component={SelectWallet} options={SelectWallet.navigationOptions} />
|
||||
<ScanLndInvoiceStack.Screen name="Success" component={Success} options={Success.navigationOptions} />
|
||||
<ScanLndInvoiceStack.Screen name="LnurlPay" component={LnurlPay} options={LnurlPay.navigationOptions} />
|
||||
<ScanLndInvoiceStack.Screen name="LnurlPaySuccess" component={LnurlPaySuccess} options={LnurlPaySuccess.navigationOptions} />
|
||||
</ScanLndInvoiceStack.Navigator>
|
||||
);
|
||||
|
||||
|
@ -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',
|
||||
{
|
||||
|
243
class/lnurl.js
Normal file
243
class/lnurl.js
Normal file
@ -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);
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
|
||||
|
270
screen/lnd/lnurlPay.js
Normal file
270
screen/lnd/lnurlPay.js
Normal file
@ -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 (
|
||||
<View style={styles.walletSelectRoot}>
|
||||
{!this.state.isLoading && (
|
||||
<TouchableOpacity
|
||||
style={styles.walletSelectTouch}
|
||||
onPress={() =>
|
||||
this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.OFFCHAIN })
|
||||
}
|
||||
>
|
||||
<Text style={styles.walletSelectText}>{loc.wallets.select_wallet.toLowerCase()}</Text>
|
||||
<Icon name="angle-right" size={18} type="font-awesome" color="#9aa0aa" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View style={styles.walletWrap}>
|
||||
<TouchableOpacity
|
||||
style={styles.walletWrapTouch}
|
||||
onPress={() =>
|
||||
this.props.navigation.navigate('SelectWallet', { onWalletSelect: this.onWalletSelect, chainType: Chain.OFFCHAIN })
|
||||
}
|
||||
>
|
||||
<Text style={styles.walletWrapLabel}>{this.state.fromWallet.getLabel()}</Text>
|
||||
<Text style={styles.walletWrapBalance}>
|
||||
{formatBalanceWithoutSuffix(this.state.fromWallet.getBalance(), BitcoinUnit.SATS, false)}
|
||||
</Text>
|
||||
<Text style={styles.walletWrapSats}>{BitcoinUnit.SATS}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
renderGotPayload() {
|
||||
return (
|
||||
<SafeBlueArea>
|
||||
<ScrollView>
|
||||
<BlueCard>
|
||||
<BlueBitcoinAmount
|
||||
isLoading={this.state.isLoading}
|
||||
amount={this.state.amount.toString()}
|
||||
onAmountUnitChange={unit => {
|
||||
this.setState({ unit });
|
||||
}}
|
||||
onChangeText={text => {
|
||||
this.setState({ amount: text });
|
||||
}}
|
||||
disabled={this.state.payload && this.state.payload.fixed}
|
||||
unit={this.state.unit}
|
||||
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
|
||||
/>
|
||||
<BlueText style={styles.alignSelfCenter}>
|
||||
please pay between {this.state.payload.min} and {this.state.payload.max} sat
|
||||
</BlueText>
|
||||
<BlueSpacing20 />
|
||||
{this.state.payload.image && <Image style={styles.img} source={{ uri: this.state.payload.image }} />}
|
||||
<BlueText style={styles.alignSelfCenter}>{this.state.payload.description}</BlueText>
|
||||
<BlueText style={styles.alignSelfCenter}>{this.state.payload.domain}</BlueText>
|
||||
<BlueSpacing20 />
|
||||
<BlueButton title={loc.lnd.payButton} onPress={this.pay} disabled={this.state.payButtonDisabled} />
|
||||
<BlueSpacing20 />
|
||||
{this.renderWalletSelectionButton()}
|
||||
</BlueCard>
|
||||
</ScrollView>
|
||||
</SafeBlueArea>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.isLoading) {
|
||||
return <BlueLoading />;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
210
screen/lnd/lnurlPaySuccess.js
Normal file
210
screen/lnd/lnurlPaySuccess.js
Normal file
@ -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 <BlueLoading />;
|
||||
}
|
||||
|
||||
/** @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 (
|
||||
<SafeBlueArea style={styles.root}>
|
||||
<ScrollView>
|
||||
{justPaid ? (
|
||||
<View style={styles.iconContainer}>
|
||||
<LottieView style={styles.icon} source={require('../../img/bluenice.json')} autoPlay loop={false} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name="check" size={50} type="font-awesome" color="#0f5cc0" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<BlueSpacing20 />
|
||||
<BlueText style={styles.alignSelfCenter}>{domain}</BlueText>
|
||||
<BlueText style={styles.alignSelfCenter}>{description}</BlueText>
|
||||
{image && <Image style={styles.img} source={{ uri: image }} />}
|
||||
<BlueSpacing20 />
|
||||
|
||||
{(preamble || url || message) && (
|
||||
<BlueCard>
|
||||
<View style={styles.successContainer}>
|
||||
<Text style={styles.successText}>{preamble}</Text>
|
||||
{url ? (
|
||||
<BlueButtonLink
|
||||
title={url}
|
||||
onPress={() => {
|
||||
Linking.openURL(url);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text selectable style={{ ...styles.successText, ...styles.successValue }}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</BlueCard>
|
||||
)}
|
||||
|
||||
<BlueCard>
|
||||
{repeatable ? (
|
||||
<BlueButton
|
||||
onPress={() => {
|
||||
this.props.navigation.navigate('ScanLndInvoiceRoot', {
|
||||
screen: 'LnurlPay',
|
||||
params: {
|
||||
lnurl: lnurl,
|
||||
fromWalletID: this.state.fromWalletID,
|
||||
},
|
||||
});
|
||||
}}
|
||||
title="repeat"
|
||||
icon={{ name: 'refresh', type: 'font-awesome', color: '#9aa0aa' }}
|
||||
/>
|
||||
) : (
|
||||
<BlueButton
|
||||
onPress={() => {
|
||||
this.props.navigation.dangerouslyGetParent().popToTop();
|
||||
}}
|
||||
title={loc.send.success_done}
|
||||
/>
|
||||
)}
|
||||
</BlueCard>
|
||||
</ScrollView>
|
||||
</SafeBlueArea>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
@ -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 });
|
||||
|
89
tests/unit/lnurl.test.js
Normal file
89
tests/unit/lnurl.test.js
Normal file
@ -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');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user