diff --git a/App.tsx b/App.tsx index f262a1363..bb8f567e8 100644 --- a/App.tsx +++ b/App.tsx @@ -78,6 +78,7 @@ import InvoicesSettings from './views/Settings/InvoicesSettings'; import LSP from './views/Settings/LSP'; import ChannelsSettings from './views/Settings/ChannelsSettings'; import SetWalletPicture from './views/Settings/SetWalletPicture'; +import ChoosePaymentMethod from './views/ChoosePaymentMethod'; // Lightning address import LightningAddress from './views/Settings/LightningAddress'; @@ -245,7 +246,7 @@ export default class App extends React.PureComponent { ); } }} - // @ts-ignore:next-line] + // @ts-ignore:next-line theme={{ dark: true, colors: { @@ -316,6 +317,12 @@ export default class App extends React.PureComponent { name="Accounts" // @ts-ignore:next-line component={Accounts} /> + this.fetchLnInvoice()} + onPress={() => (disabled ? null : this.fetchLnInvoice())} activeOpacity={1} > {children} diff --git a/components/LayerBalances/OnchainSwipeableRow.tsx b/components/LayerBalances/OnchainSwipeableRow.tsx index 22ab3f70e..6fb504d3f 100644 --- a/components/LayerBalances/OnchainSwipeableRow.tsx +++ b/components/LayerBalances/OnchainSwipeableRow.tsx @@ -27,6 +27,7 @@ interface OnchainSwipeableRowProps { account?: string; hidden?: boolean; children?: React.ReactNode; + disabled?: boolean; } export default class OnchainSwipeableRow extends Component< @@ -170,11 +171,11 @@ export default class OnchainSwipeableRow extends Component< }; render() { - const { children, value, locked, hidden } = this.props; + const { children, value, locked, hidden, disabled } = this.props; if (locked && value) { return ( this.sendToAddress()} + onPress={() => (disabled ? null : this.sendToAddress())} activeOpacity={1} style={{ width: '100%' }} > diff --git a/components/LayerBalances/PaymentMethodList.tsx b/components/LayerBalances/PaymentMethodList.tsx new file mode 100644 index 000000000..d1ed1bde5 --- /dev/null +++ b/components/LayerBalances/PaymentMethodList.tsx @@ -0,0 +1,263 @@ +import React, { Component } from 'react'; +import { FlatList, StyleSheet, Text, View, I18nManager } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import { RectButton } from 'react-native-gesture-handler'; +import { StackNavigationProp } from '@react-navigation/stack'; + +import { Spacer } from '../layout/Spacer'; +import OnchainSwipeableRow from './OnchainSwipeableRow'; +import LightningSwipeableRow from './LightningSwipeableRow'; + +import BackendUtils from '../../utils/BackendUtils'; +import { localeString } from '../../utils/LocaleUtils'; +import { themeColor } from '../../utils/ThemeUtils'; + +import OnChainSvg from '../../assets/images/SVG/DynamicSVG/OnChainSvg'; +import LightningSvg from '../../assets/images/SVG/DynamicSVG/LightningSvg'; + +interface PaymentMethodListProps { + navigation: StackNavigationProp; + value?: string; + amount?: string; + lightning?: string; + offer?: string; +} + +// To toggle LTR/RTL change to `true` +I18nManager.allowRTL(false); + +type DataRow = { + layer: string; + subtitle?: string; + disabled?: boolean; +}; + +const Row = ({ item }: { item: DataRow }) => { + return ( + + + + {item.layer === 'On-chain' ? ( + + ) : item.layer === 'Lightning' || item.layer === 'Offer' ? ( + + ) : ( + + )} + + + + {item.layer === 'Lightning' + ? localeString('general.lightning') + : item.layer === 'Offer' + ? localeString('views.Settings.Bolt12Offer') + : item.layer === 'On-chain' + ? localeString('general.onchain') + : item.layer} + + {item.subtitle && ( + + {item.subtitle} + + )} + + + + + ); +}; + +const SwipeableRow = ({ + item, + navigation, + value, + amount, + lightning, + offer +}: { + item: DataRow; + index: number; + navigation: StackNavigationProp; + value?: string; + amount?: string; + lightning?: string; + offer?: string; +}) => { + if (item.layer === 'Lightning') { + return ( + + + + ); + } + + if (item.layer === 'Offer') { + return ( + + + + ); + } + + if (item.layer === 'On-chain') { + return ( + + + + ); + } +}; + +export default class PaymentMethodList extends Component< + PaymentMethodListProps, + {} +> { + render() { + const { navigation, value, amount, lightning, offer } = this.props; + + let DATA: DataRow[] = []; + + if (lightning) { + DATA.push({ + layer: 'Lightning', + subtitle: `${lightning?.slice(0, 12)}...${lightning?.slice( + -12 + )}` + }); + } + + if (offer) { + DATA.push({ + layer: 'Offer', + subtitle: `${offer?.slice(0, 12)}...${offer?.slice(-12)}`, + disabled: !BackendUtils.supportsOffers() + }); + } + + // Only show on-chain balance for non-Lnbank accounts + if (BackendUtils.supportsOnchainReceiving()) { + DATA.push({ + layer: 'On-chain', + subtitle: value + ? `${value.slice(0, 12)}...${value.slice(-12)}` + : undefined, + disabled: !BackendUtils.supportsOnchainSends() + }); + } + + return ( + + ( + + )} + renderItem={({ item, index }) => ( + // @ts-ignore:next-line + + )} + keyExtractor={(_item, index) => `message ${index}`} + style={{ marginTop: 20 }} + refreshing={false} + /> + + ); + } +} + +const styles = StyleSheet.create({ + rectButton: { + flex: 1, + height: 80, + paddingVertical: 10, + paddingLeft: 6, + paddingRight: 20, + justifyContent: 'space-between', + alignItems: 'center', + flexDirection: 'row', + marginLeft: 15, + marginRight: 15, + borderRadius: 50 + }, + moreButton: { + height: 40, + paddingVertical: 10, + paddingHorizontal: 20, + justifyContent: 'space-between', + alignItems: 'center', + flexDirection: 'row', + marginLeft: 15, + marginRight: 15, + borderRadius: 15 + }, + left: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start' + }, + separator: { + backgroundColor: 'transparent', + height: 20 + }, + layerText: { + backgroundColor: 'transparent', + fontSize: 15, + fontFamily: 'PPNeueMontreal-Medium' + }, + eyeIcon: { alignSelf: 'center', margin: 15, marginLeft: 25 } +}); diff --git a/components/LayerBalances/index.tsx b/components/LayerBalances/index.tsx index 70526a51f..446cf2cf2 100644 --- a/components/LayerBalances/index.tsx +++ b/components/LayerBalances/index.tsx @@ -170,7 +170,6 @@ const SwipeableRow = ({ item: DataRow; index: number; navigation: StackNavigationProp; - // selectMode: boolean; // not used for no value?: string; amount?: string; lightning?: string; diff --git a/utils/AddressUtils.test.ts b/utils/AddressUtils.test.ts index a5a767bed..d7c706d11 100644 --- a/utils/AddressUtils.test.ts +++ b/utils/AddressUtils.test.ts @@ -9,70 +9,66 @@ import AddressUtils from './AddressUtils'; import { walletrpc } from '../proto/lightning'; describe('AddressUtils', () => { - describe('isValidBitcoinAddress', () => { - it('validates Bitcoin Addresses properly', () => { - expect(AddressUtils.isValidBitcoinAddress('a', false)).toBeFalsy(); + describe('isValidBIP21Uri', () => { + it('validates all BIP-21 URI variations', () => { expect( - AddressUtils.isValidBitcoinAddress( - 'bcrt1qqgdrlt97x4847rf85utak8gre5q7k83uwh3ajj', - true + AddressUtils.isValidBIP21Uri( + 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU?amount=0.00170003' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - '1AY6gTALH7bGrbN73qqTRnkW271JvBJc9o', - false + AddressUtils.isValidBIP21Uri( + 'BITCOIN:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu?amount=0.00170003' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - '1AevDm7EU7TQn5q4QizTrbkZfEg5xpLM7s', - false + AddressUtils.isValidBIP21Uri( + 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - '3BZj8Xf72guNcHCvaTCyhJqzyKhNJZuSUK', - false + AddressUtils.isValidBIP21Uri( + 'BITCOIN:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - '3BMEXKKbiLv8a4v6Q482EfQAtecQUDAE6w', - false + AddressUtils.isValidBIP21Uri( + 'bitcoin:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu', - false + AddressUtils.isValidBIP21Uri( + 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU?amount=0.00170003' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - 'bc1q073ezlgdrqj8ug8gpmlnh0qa7ztlx65cm62sck', - false + AddressUtils.isValidBIP21Uri( + 'bitcoin:?lno=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' ) - ).toBeTruthy(); + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - 'bc1q073ezlgdrqj8ug8gpmlnh0qa7ztlx65cm62sck-', - false + AddressUtils.isValidBIP21Uri( + 'bitcoin:?lno=lno1qsgqmqvgm96frzdg8m0gc6nzeqffvzsqzrxqy32afmr3jn9ggkwg3egfwch2hy0l6jut6vfd8vpsc3h89l6u3dm4q2d6nuamav3w27xvdmv3lpgklhg7l5teypqz9l53hj7zvuaenh34xqsz2sa967yzqkylfu9xtcd5ymcmfp32h083e805y7jfd236w9afhavqqvl8uyma7x77yun4ehe9pnhu2gekjguexmxpqjcr2j822xr7q34p078gzslf9wpwz5y57alxu99s0z2ql0kfqvwhzycqq45ehh58xnfpuek80hw6spvwrvttjrrq9pphh0dpydh06qqspp5uq4gpyt6n9mwexde44qv7lstzzq60nr40ff38u27un6y53aypmx0p4qruk2tf9mjwqlhxak4znvna5y' ) - ).toBeFalsy(); - + ).toEqual(true); expect( - AddressUtils.isValidBitcoinAddress( - 'lndhub://db48dd0a-a298-405f-b70e-a62d8df5ae45:85f8a5a56c547fd561f6d8902b0ca9d66fc0152a@https://mybtcpayserver.com/plugins/lnbank/api/lndhub', - false + AddressUtils.isValidBIP21Uri( + 'BITCOIN:?LNO=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' ) - ).toBeFalsy(); + ).toEqual(true); + expect( + AddressUtils.isValidBIP21Uri( + 'shitcoin:?LNO=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' + ) + ).toEqual(false); }); + }); - it('processes all Bech32 send address variations', () => { + describe('processBIP21Uri', () => { + it('processes all BIP-21 URI variations', () => { // with fee expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU?amount=0.00170003' ) ).toEqual({ @@ -80,60 +76,45 @@ describe('AddressUtils', () => { amount: '170003' // amount in sats }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu?amount=0.00170003' ) ).toEqual({ value: 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu', amount: '170003' // amount in sats }); - expect( - AddressUtils.processSendAddress( - 'bitcoin:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU?amount=0.00170003' - ) - ).toEqual({ - value: 'BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU', - amount: '170003' // amount in sats - }); - expect( - AddressUtils.processSendAddress( - 'bitcoin:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu?amount=0.00170003' - ) - ).toEqual({ - value: 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu', - amount: '170003' // amount in sats - }); + // without fee expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU' ) ).toEqual({ value: 'BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' ) ).toEqual({ value: 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU' ) ).toEqual({ value: 'BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' ) ).toEqual({ value: 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:BC1Q7065EZYHCD3QTQLCVWCMP9T2WEAXC4SGUUVLWU?amount=0.00170003' ) ).toEqual({ @@ -143,7 +124,7 @@ describe('AddressUtils', () => { // bolt12 offers expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:?lno=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' ) ).toEqual({ @@ -151,7 +132,7 @@ describe('AddressUtils', () => { offer: 'lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:?lno=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' ) ).toEqual({ @@ -159,7 +140,7 @@ describe('AddressUtils', () => { offer: 'lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'BITCOIN:?LNO=lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' ) ).toEqual({ @@ -167,6 +148,67 @@ describe('AddressUtils', () => { offer: 'lno1pgqpvggr3l9u9ppv79mzn7g9v98cf8zw900skucuz53zr5vvjss454zrnyes' }); }); + }); + + describe('isValidBitcoinAddress', () => { + it('validates Bitcoin Addresses properly', () => { + expect(AddressUtils.isValidBitcoinAddress('a', false)).toBeFalsy(); + expect( + AddressUtils.isValidBitcoinAddress( + 'bcrt1qqgdrlt97x4847rf85utak8gre5q7k83uwh3ajj', + true + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + '1AY6gTALH7bGrbN73qqTRnkW271JvBJc9o', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + '1AevDm7EU7TQn5q4QizTrbkZfEg5xpLM7s', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + '3BZj8Xf72guNcHCvaTCyhJqzyKhNJZuSUK', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + '3BMEXKKbiLv8a4v6Q482EfQAtecQUDAE6w', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + 'bc1q7065ezyhcd3qtqlcvwcmp9t2weaxc4sguuvlwu', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + 'bc1q073ezlgdrqj8ug8gpmlnh0qa7ztlx65cm62sck', + false + ) + ).toBeTruthy(); + expect( + AddressUtils.isValidBitcoinAddress( + 'bc1q073ezlgdrqj8ug8gpmlnh0qa7ztlx65cm62sck-', + false + ) + ).toBeFalsy(); + + expect( + AddressUtils.isValidBitcoinAddress( + 'lndhub://db48dd0a-a298-405f-b70e-a62d8df5ae45:85f8a5a56c547fd561f6d8902b0ca9d66fc0152a@https://mybtcpayserver.com/plugins/lnbank/api/lndhub', + false + ) + ).toBeFalsy(); + }); it('validates Bech32m - P2TR properly', () => { expect( @@ -283,10 +325,10 @@ describe('AddressUtils', () => { ).toBeFalsy(); }); - describe('processSendAddress', () => { + describe('processBIP21Uri', () => { it('process address inputed and scanned from the Send view', () => { expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:34K6tvoWM7k2ujeXVuimv29WyAsqzhWofb?amount=0.00170003' ) ).toEqual({ @@ -295,7 +337,7 @@ describe('AddressUtils', () => { }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:34K6tvoWM7k2ujeXVuimv29WyAsqzhWofb?label=test&amount=0.00170003' ) ).toEqual({ @@ -304,7 +346,7 @@ describe('AddressUtils', () => { }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:34K6tvoWM7k2ujeXVuimv29WyAsqzhWofb?amount=0.00170003&label=testw&randomparams=rm2' ) ).toEqual({ @@ -313,7 +355,7 @@ describe('AddressUtils', () => { }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:34K6tvoWM7k2ujeXVuimv29WyAsqzhWofb' ) ).toEqual({ @@ -322,7 +364,7 @@ describe('AddressUtils', () => { }); expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:34K6tvoWM7k2ujeXVuimv29WyAsqzhWofd?label=BitMEX%20Deposit%20-%20randomUser' ) ).toEqual({ @@ -333,7 +375,7 @@ describe('AddressUtils', () => { it('processes BIP21 invoices', () => { expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6' ) ).toEqual({ @@ -346,7 +388,7 @@ describe('AddressUtils', () => { it('processes BIP21 invoices - with hanging values after lightning param', () => { expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6&test=haha' ) ).toEqual({ @@ -359,7 +401,7 @@ describe('AddressUtils', () => { it('processes BIP21 invoices - with lightning param in all caps', () => { expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&LIGHTNING=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6&test=haha' ) ).toEqual({ @@ -372,7 +414,7 @@ describe('AddressUtils', () => { it('processes BIP21 invoices - with amount param in all caps', () => { expect( - AddressUtils.processSendAddress( + AddressUtils.processBIP21Uri( 'bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?AMOUNT=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&LIGHTNING=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6&test=haha' ) ).toEqual({ diff --git a/utils/AddressUtils.ts b/utils/AddressUtils.ts index 21a5effcc..bb1460cf1 100644 --- a/utils/AddressUtils.ts +++ b/utils/AddressUtils.ts @@ -22,6 +22,10 @@ const lnInvoice = /^(lnbc|lntb|lntbs|lnbcrt|LNBC|LNTB|LNTBS|LNBCRT)([0-9]{1,}[a-zA-Z0-9]+){1}$/; const lnPubKey = /^[a-f0-9]{66}$/; +/* BIP-21 */ +const bip21Uri = + /^(bitcoin|BITCOIN):([13a-zA-Z0-9]{25,42})?(\?((amount|AMOUNT)=([0-9]+(\.[0-9]+)?)|(label|LABEL|message|MESSAGE|lightning|LIGHTNING|lno|LNO)=([^&]*))((&((amount|AMOUNT)=([0-9]+(\.[0-9]+)?)|(label|LABEL|message|MESSAGE|lightning|LIGHTNING|lno|LNO)=([^&]*)))*))?/; + /* BOLT 12 */ const lnOffer = /^(lno|LNO)([0-9]{1,}[a-zA-Z0-9]+){1}$/; @@ -103,7 +107,7 @@ const bitcoinQrParser = (input: string, prefix: string) => { }; class AddressUtils { - processSendAddress = (input: string) => { + processBIP21Uri = (input: string) => { let value, amount, lightning, offer; // handle addresses prefixed with 'bitcoin:' and @@ -182,6 +186,8 @@ class AddressUtils { return btcNonBech.test(input) || btcBech.test(input); }; + isValidBIP21Uri = (input: string) => bip21Uri.test(input); + isValidLightningPaymentRequest = (input: string) => lnInvoice.test(input); isValidLightningOffer = (input: string) => lnOffer.test(input); diff --git a/utils/handleAnything.test.ts b/utils/handleAnything.test.ts index 756ffe995..848d2f362 100644 --- a/utils/handleAnything.test.ts +++ b/utils/handleAnything.test.ts @@ -1,5 +1,5 @@ jest.mock('./AddressUtils', () => ({ - processSendAddress: () => mockProcessedSendAddress, + processBIP21Uri: () => mockProcessedSendAddress, isValidBitcoinAddress: () => mockIsValidBitcoinAddress, isValidLightningPubKey: () => mockIsValidLightningPubKey, isValidLightningPaymentRequest: () => mockIsValidLightningPaymentRequest @@ -139,9 +139,13 @@ describe('handleAnything', () => { const result = await handleAnything(data); expect(result).toEqual([ - 'LnurlPay', + 'ChoosePaymentMethod', { - lnurlParams: mockGetLnurlParams + amount: undefined, + lightning: + 'LNURL1DP68GURN8GHJ7ARN9EJX2UN8D9NKJTNRDAKJ7SJ5GVH42J2VFE24YNP0WPSHJTMF9ATKWD622CE953JGWV6XXUMRDPXNVCJ8X4G9GF2CHDF', + offer: undefined, + value: 'BC1QUXCS7V556UTNUKU93HSZ7LHHFFLWN9NF2UTQ6N' } ]); }); @@ -167,20 +171,19 @@ describe('handleAnything', () => { it('should return PaymentRequest screen and call getPayReq if not from clipboard', async () => { const data = 'bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6'; - mockProcessedSendAddress = { - value: 'BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U', - lightning: - 'LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6' - }; - mockIsValidBitcoinAddress = true; - mockSupportsOnchainSends = false; const result = await handleAnything(data); - expect(stores.invoicesStore.getPayReq).toHaveBeenCalledWith( - 'LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6' - ); - expect(result).toEqual(['PaymentRequest', {}]); + expect(result).toEqual([ + 'ChoosePaymentMethod', + { + amount: undefined, + lightning: + 'LNURL1DP68GURN8GHJ7ARN9EJX2UN8D9NKJTNRDAKJ7SJ5GVH42J2VFE24YNP0WPSHJTMF9ATKWD622CE953JGWV6XXUMRDPXNVCJ8X4G9GF2CHDF', + offer: undefined, + value: 'BC1QUXCS7V556UTNUKU93HSZ7LHHFFLWN9NF2UTQ6N' + } + ]); }); it('should return true if from clipboard', async () => { diff --git a/utils/handleAnything.ts b/utils/handleAnything.ts index ab8b8fdf5..df83fd989 100644 --- a/utils/handleAnything.ts +++ b/utils/handleAnything.ts @@ -97,9 +97,12 @@ const handleAnything = async ( ): Promise => { const { nodeInfo } = nodeInfoStore; const { isTestNet, isRegTest, isSigNet } = nodeInfo; - const { value, amount, lightning }: any = - AddressUtils.processSendAddress(data); + const { value, amount, lightning, offer }: any = + AddressUtils.processBIP21Uri(data); const hasAt: boolean = value.includes('@'); + const hasMultiple: boolean = + (value && lightning) || (value && offer) || (lightning && offer); + let lnurl; // if the value is from clipboard and looks like a url we don't want to decode it if (!isClipboardValue || !data.match(/^https?:\/\//i)) { @@ -108,7 +111,29 @@ const handleAnything = async ( } catch (e) {} } - if ( + if (!hasAt && hasMultiple) { + if (isClipboardValue) return true; + return [ + 'ChoosePaymentMethod', + { + value, + amount, + lightning, + offer + } + ]; + } else if (offer && BackendUtils.supportsOffers()) { + if (isClipboardValue) return true; + return [ + 'Send', + { + destination: offer, + bolt12: offer, + transactionType: 'BOLT 12', + isValid: true + } + ]; + } else if ( !hasAt && AddressUtils.isValidBitcoinAddress(value, isTestNet || isRegTest) && lightning @@ -341,66 +366,71 @@ const handleAnything = async ( if (isClipboardValue) return true; // try BOLT 12 address first, if supported - if (BackendUtils.supportsOffers()) { - const [localPart, domain] = value.split('@'); - const dnsUrl = 'https://cloudflare-dns.com/dns-query'; + const [localPart, domain] = value.split('@'); + const dnsUrl = 'https://cloudflare-dns.com/dns-query'; - const name = `${localPart}.user._bitcoin-payment.${domain}`; - const url = `${dnsUrl}?name=${name}&type=TXT`; - let bolt12: string; - try { - const res = await fetch(url, { - headers: { - accept: 'application/dns-json' - } - }); - const json = await res.json(); - if (!json.Answer && !json.Answer[0]) throw 'Bad'; - bolt12 = json.Answer[0].data; - bolt12 = bolt12.replace(/("|\\)/g, ''); - bolt12 = bolt12.replace(/bitcoin:b12=/, ''); + const name = `${localPart}.user._bitcoin-payment.${domain}`; + let url = `${dnsUrl}?name=${name}&type=TXT`; + let bolt12: string; + try { + const res = await fetch(url, { + headers: { + accept: 'application/dns-json' + } + }); + const json = await res.json(); + if (!json.Answer && !json.Answer[0]) throw 'Bad'; + bolt12 = json.Answer[0].data; + bolt12 = bolt12.replace(/("|\\)/g, ''); + bolt12 = bolt12.replace(/bitcoin:b12=/, ''); - const { value, amount, lightning, offer }: any = - AddressUtils.processSendAddress(bolt12); + const { value, amount, lightning, offer }: any = + AddressUtils.processBIP21Uri(bolt12); - if (value) { - return [ - 'Accounts', - { - value, - amount, - lightning, - offer, - locked: true - } - ]; - } + const hasMultiple: boolean = + (value && lightning) || + (value && offer) || + (lightning && offer); + if (hasMultiple) { + return [ + 'ChoosePaymentMethod', + { + value, + amount, + lightning, + offer, + locked: true + } + ]; + } + + if (offer && BackendUtils.supportsOffers()) { return [ 'Send', { - destination: value || offer, + destination: offer, bolt12, transactionType: 'BOLT 12', isValid: true } ]; - } catch (e: any) {} - } + } + } catch (e: any) {} - const [username, domain] = value.split('@'); - let url; - if (domain.includes('.onion')) { - url = `http://${domain}/.well-known/lnurlp/${username.toLowerCase()}`; + // try BOLT 11 address + const [username, bolt11Domain] = value.split('@'); + if (bolt11Domain.includes('.onion')) { + url = `http://${bolt11Domain}/.well-known/lnurlp/${username.toLowerCase()}`; } else { - url = `https://${domain}/.well-known/lnurlp/${username.toLowerCase()}`; + url = `https://${bolt11Domain}/.well-known/lnurlp/${username.toLowerCase()}`; } const error = localeString( 'utils.handleAnything.lightningAddressError' ); // handle Tor LN addresses - if (settingsStore.enableTor && domain.includes('.onion')) { + if (settingsStore.enableTor && bolt11Domain.includes('.onion')) { await doTorRequest(url, RequestMethod.GET) .then((response: any) => { if (!response.callback) { diff --git a/views/ChoosePaymentMethod.tsx b/views/ChoosePaymentMethod.tsx new file mode 100644 index 000000000..078ea2107 --- /dev/null +++ b/views/ChoosePaymentMethod.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { Route } from '@react-navigation/native'; +import { StackNavigationProp } from '@react-navigation/stack'; + +import Button from '../components/Button'; +import Header from '../components/Header'; +import PaymentMethodList from '../components/LayerBalances/PaymentMethodList'; +import Screen from '../components/Screen'; + +import { localeString } from '../utils/LocaleUtils'; +import { themeColor } from '../utils/ThemeUtils'; + +interface ChoosePaymentMethodProps { + navigation: StackNavigationProp; + route: Route< + 'ChoosePaymentMethod', + { + value: string; + amount: string; + lightning: string; + offer: string; + } + >; +} + +interface ChoosePaymentMethodState { + value: string; + amount: string; + lightning: string; + offer: string; +} + +export default class ChoosePaymentMethod extends React.Component< + ChoosePaymentMethodProps, + ChoosePaymentMethodState +> { + state = { + value: '', + amount: '', + lightning: '', + offer: '' + }; + + componentDidMount() { + const { route } = this.props; + const { value, amount, lightning, offer } = route.params ?? {}; + + if (value) { + this.setState({ value }); + } + + if (amount) { + this.setState({ amount }); + } + + if (lightning) { + this.setState({ lightning }); + } + + if (offer) { + this.setState({ offer }); + } + } + + render() { + const { navigation } = this.props; + const { value, amount, lightning, offer } = this.state; + + return ( + +
+ + {!!value && !!lightning && ( +