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'); + }); +});