ADD: lnurl-pay support

This commit is contained in:
Overtorment 2020-07-23 19:06:13 +01:00
parent 3a96d1eb71
commit f386bb345b
10 changed files with 872 additions and 8 deletions

View File

@ -1,6 +1,3 @@
/**
* @exports {AppStorage}
*/
import { AppStorage } from './class'; import { AppStorage } from './class';
import DeviceQuickActions from './class/quick-actions'; import DeviceQuickActions from './class/quick-actions';
import Biometric from './class/biometrics'; import Biometric from './class/biometrics';
@ -10,8 +7,7 @@ const prompt = require('./blue_modules/prompt');
const EV = require('./blue_modules/events'); const EV = require('./blue_modules/events');
const currency = require('./blue_modules/currency'); const currency = require('./blue_modules/currency');
const BlueElectrum = require('./blue_modules/BlueElectrum'); // eslint-disable-line no-unused-vars const BlueElectrum = require('./blue_modules/BlueElectrum'); // eslint-disable-line no-unused-vars
/** @type {AppStorage} */ const BlueApp: AppStorage = new AppStorage();
const BlueApp = new AppStorage();
// If attempt reaches 10, a wipe keychain option will be provided to the user. // If attempt reaches 10, a wipe keychain option will be provided to the user.
let unlockAttempt = 0; let unlockAttempt = 0;

View File

@ -39,6 +39,8 @@ import QRCode from 'react-native-qrcode-svg';
import { useTheme } from '@react-navigation/native'; import { useTheme } from '@react-navigation/native';
import { BlueCurrentTheme } from './components/themes'; import { BlueCurrentTheme } from './components/themes';
import loc, { formatBalance, formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros, transactionTimeToReadable } from './loc'; import loc, { formatBalance, formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros, transactionTimeToReadable } from './loc';
import AsyncStorage from '@react-native-community/async-storage';
import Lnurl from './class/lnurl';
/** @type {AppStorage} */ /** @type {AppStorage} */
const BlueApp = require('./BlueApp'); const BlueApp = require('./BlueApp');
const { height, width } = Dimensions.get('window'); 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 || ''); return (item.confirmations < 7 ? loc.transactions.list_conf + ': ' + item.confirmations + ' ' : '') + txMemo() + (item.memo || '');
}; };
const onPress = () => { const onPress = async () => {
if (item.hash) { if (item.hash) {
NavigationService.navigate('TransactionStatus', { hash: item.hash }); NavigationService.navigate('TransactionStatus', { hash: item.hash });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') { } 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) { 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', { NavigationService.navigate('LNDViewInvoice', {
invoice: item, invoice: item,
fromWallet: lightningWallet[0], fromWallet: lightningWallet[0],

View File

@ -60,6 +60,8 @@ import LappBrowser from './screen/lnd/browser';
import LNDCreateInvoice from './screen/lnd/lndCreateInvoice'; import LNDCreateInvoice from './screen/lnd/lndCreateInvoice';
import LNDViewInvoice from './screen/lnd/lndViewInvoice'; import LNDViewInvoice from './screen/lnd/lndViewInvoice';
import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation'; import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation';
import LnurlPay from './screen/lnd/lnurlPay';
import LnurlPaySuccess from './screen/lnd/lnurlPaySuccess';
import LoadingScreen from './LoadingScreen'; import LoadingScreen from './LoadingScreen';
import UnlockWith from './UnlockWith'; import UnlockWith from './UnlockWith';
import { BlueNavigationStyle } from './BlueComponents'; import { BlueNavigationStyle } from './BlueComponents';
@ -135,6 +137,8 @@ const WalletsRoot = () => (
/> />
<WalletsStack.Screen name="HodlHodlViewOffer" component={HodlHodlViewOffer} options={HodlHodlViewOffer.navigationOptions} /> <WalletsStack.Screen name="HodlHodlViewOffer" component={HodlHodlViewOffer} options={HodlHodlViewOffer.navigationOptions} />
<WalletsStack.Screen name="Broadcast" component={Broadcast} options={Broadcast.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> </WalletsStack.Navigator>
); );
@ -203,6 +207,8 @@ const ScanLndInvoiceRoot = () => (
<ScanLndInvoiceStack.Screen name="ScanLndInvoice" component={ScanLndInvoice} options={ScanLndInvoice.navigationOptions} /> <ScanLndInvoiceStack.Screen name="ScanLndInvoice" component={ScanLndInvoice} options={ScanLndInvoice.navigationOptions} />
<ScanLndInvoiceStack.Screen name="SelectWallet" component={SelectWallet} options={SelectWallet.navigationOptions} /> <ScanLndInvoiceStack.Screen name="SelectWallet" component={SelectWallet} options={SelectWallet.navigationOptions} />
<ScanLndInvoiceStack.Screen name="Success" component={Success} options={Success.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> </ScanLndInvoiceStack.Navigator>
); );

View File

@ -96,6 +96,9 @@ class DeeplinkSchemaMatch {
}, },
]); ]);
} else if (DeeplinkSchemaMatch.isLnUrl(event.url)) { } 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([ completionHandler([
'LNDCreateInvoiceRoot', 'LNDCreateInvoiceRoot',
{ {

243
class/lnurl.js Normal file
View 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);
}
}

View File

@ -29,6 +29,7 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import { Icon } from 'react-native-elements'; import { Icon } from 'react-native-elements';
import loc, { formatBalanceWithoutSuffix, formatBalancePlain } from '../../loc'; import loc, { formatBalanceWithoutSuffix, formatBalancePlain } from '../../loc';
import { BlueCurrentTheme } from '../../components/themes'; import { BlueCurrentTheme } from '../../components/themes';
import Lnurl from '../../class/lnurl';
const currency = require('../../blue_modules/currency'); const currency = require('../../blue_modules/currency');
const BlueApp = require('../../BlueApp'); const BlueApp = require('../../BlueApp');
const EV = require('../../blue_modules/events'); const EV = require('../../blue_modules/events');
@ -290,7 +291,20 @@ export default class LNDCreateInvoice extends Component {
throw new Error('Reply from server: ' + reply.reason); 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'); throw new Error('Unsupported lnurl');
} }

270
screen/lnd/lnurlPay.js Normal file
View 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,
};
};

View 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,
};
};

View File

@ -23,6 +23,7 @@ import {
BlueLoading, BlueLoading,
} from '../../BlueComponents'; } from '../../BlueComponents';
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet'; import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
import Lnurl from '../../class/lnurl';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
import { Icon } from 'react-native-elements'; import { Icon } from 'react-native-elements';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
@ -213,9 +214,20 @@ export default class ScanLndInvoice extends React.Component {
}; };
processInvoice = data => { processInvoice = data => {
if (Lnurl.isLnurl(data)) return this.processLnurlPay(data);
this.props.navigation.setParams({ uri: 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() { async pay() {
if (!('decoded' in this.state)) { if (!('decoded' in this.state)) {
return null; return null;
@ -286,7 +298,7 @@ export default class ScanLndInvoice extends React.Component {
} }
processTextForInvoice = text => { 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); this.processInvoice(text);
} else { } else {
this.setState({ decoded: undefined, expiresIn: undefined, destination: text }); this.setState({ decoded: undefined, expiresIn: undefined, destination: text });

89
tests/unit/lnurl.test.js Normal file
View 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');
});
});