diff --git a/__tests__/scanner.ts b/__tests__/scanner.ts index 736894168..e8c7417c4 100644 --- a/__tests__/scanner.ts +++ b/__tests__/scanner.ts @@ -1,87 +1,82 @@ -import { findlnurl } from '../src/utils/lnurl'; -import { TBitcoinUrl, decodeQRData } from '../src/utils/scanner'; +import { findLnUrl } from '../src/utils/lnurl'; +import { parseUri } from '../src/utils/scanner/scanner'; +import { TBitcoinData } from '../src/utils/scanner/types'; describe('QR codes', () => { it('decodes a bitcoin URI with params', async () => { - const res = await decodeQRData( + const res = await parseUri( 'bitcoin:1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ?amount=0.0005&label=Nakamoto&message=Donation%20for%20project%20xyz', ); if (res.isErr()) { throw res.error; } - const qrData = res.value[0] as TBitcoinUrl; + const qrData = res.value[0] as TBitcoinData; expect(qrData.network).toEqual('bitcoin'); - expect(qrData.qrDataType).toEqual('bitcoinAddress'); - expect(qrData.sats).toEqual(50000); + expect(qrData.type).toEqual('onchain'); + expect(qrData.amount).toEqual(50000); expect(qrData.message).toEqual('Donation for project xyz'); }); it('decodes a bitcoin legacy address URI', async () => { - const res = await decodeQRData( - 'bitcoin:1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ', - ); + const res = await parseUri('bitcoin:1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ'); if (res.isErr()) { throw res.error; } - const qrData = res.value[0] as TBitcoinUrl; + const qrData = res.value[0] as TBitcoinData; expect(qrData.network).toEqual('bitcoin'); - expect(qrData.qrDataType).toEqual('bitcoinAddress'); + expect(qrData.type).toEqual('onchain'); }); it('decodes a bitcoin wrapped segwit address URI', async () => { - const res = await decodeQRData( - 'bitcoin:3DrziWGfPSYWZpmGxL4WytNeXA2mwzEwWJ', - ); + const res = await parseUri('bitcoin:3DrziWGfPSYWZpmGxL4WytNeXA2mwzEwWJ'); if (res.isErr()) { throw res.error; } - const qrData = res.value[0] as TBitcoinUrl; + const qrData = res.value[0] as TBitcoinData; expect(qrData.network).toEqual('bitcoin'); - expect(qrData.qrDataType).toEqual('bitcoinAddress'); + expect(qrData.type).toEqual('onchain'); }); it('decodes a bitcoin native segwit address URI', async () => { - const res = await decodeQRData( + const res = await parseUri( 'bitcoin:bc1qkk0vs43wzsundw37f8xslw69eddwfe24w9pyrg', ); if (res.isErr()) { throw res.error; } - const qrData = res.value[0] as TBitcoinUrl; + const qrData = res.value[0] as TBitcoinData; expect(qrData.network).toEqual('bitcoin'); - expect(qrData.qrDataType).toEqual('bitcoinAddress'); + expect(qrData.type).toEqual('onchain'); }); it('decodes a plain bitcoin native segwit address', async () => { - const res = await decodeQRData( - 'bc1qkk0vs43wzsundw37f8xslw69eddwfe24w9pyrg', - ); + const res = await parseUri('bc1qkk0vs43wzsundw37f8xslw69eddwfe24w9pyrg'); if (res.isErr()) { throw res.error; } - const qrData = res.value[0] as TBitcoinUrl; + const qrData = res.value[0] as TBitcoinData; expect(qrData.network).toEqual('bitcoin'); - expect(qrData.qrDataType).toEqual('bitcoinAddress'); + expect(qrData.type).toEqual('onchain'); }); it('finds lnurl', async () => { const base = 'lnurl1dp68gurn8ghj7mrww3uxymm59e3xjemnw4hzu7re0ghkcmn4wfkz7urp0ylh2um9wf5kg0fhxycnv9g9w58'; - expect(findlnurl(base)).toEqual(base); - expect(findlnurl(base.toUpperCase())).toEqual(base); - expect(findlnurl('https://site.com/?lightning=' + base)).toEqual(base); + expect(findLnUrl(base)).toEqual(base); + expect(findLnUrl(base.toUpperCase())).toEqual(base); + expect(findLnUrl('https://site.com/?lightning=' + base)).toEqual(base); expect( - findlnurl('https://site.com/?lightning=' + base.toUpperCase()), + findLnUrl('https://site.com/?lightning=' + base.toUpperCase()), ).toEqual(base); - expect(findlnurl('https://site.com/?nada=nada&lightning=' + base)).toEqual( + expect(findLnUrl('https://site.com/?nada=nada&lightning=' + base)).toEqual( base, ); expect( - findlnurl('https://site.com/?nada=nada&lightning=' + base.toUpperCase()), + findLnUrl('https://site.com/?nada=nada&lightning=' + base.toUpperCase()), ).toEqual(base); - expect(findlnurl('bs')).toEqual(null); - expect(findlnurl('https://site.com')).toEqual(null); - expect(findlnurl('https://site.com/?bs=' + base)).toEqual(null); - expect(findlnurl('bitcoin:site.com/?lightning=' + base)).toEqual(base); + expect(findLnUrl('bs')).toEqual(null); + expect(findLnUrl('https://site.com')).toEqual(null); + expect(findLnUrl('https://site.com/?bs=' + base)).toEqual(null); + expect(findLnUrl('bitcoin:site.com/?lightning=' + base)).toEqual(base); }); }); diff --git a/e2e/helpers.js b/e2e/helpers.js index 6ea74c666..444767cf7 100644 --- a/e2e/helpers.js +++ b/e2e/helpers.js @@ -47,22 +47,9 @@ export const sleep = (ms) => { }); }; -export const isVisible = async (id) => { - try { - await expect(element(by.id(id))).toBeVisible(); - return true; - } catch (e) { - return false; - } -}; - export const isButtonEnabled = async (element) => { - try { - await expect(element).tap(); - return true; - } catch (e) { - return false; - } + const attributes = await element.getAttributes(); + return attributes.label !== 'disabled'; }; export async function waitForElementAttribute( @@ -143,6 +130,25 @@ export const launchAndWait = async () => { } }; +export const receiveOnchainFunds = async (rpc, amount = '0.001') => { + await element(by.id('Receive')).tap(); + // Wait for animation + await sleep(1000); + // Get address from QR code + let { label: wAddress } = await element(by.id('QRCode')).getAttributes(); + wAddress = wAddress.replace('bitcoin:', ''); + + // Send and mine + await rpc.sendToAddress(wAddress, amount); + await rpc.generateToAddress(1, await rpc.getNewAddress()); + + await waitFor(element(by.id('NewTxPrompt'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('NewTxPrompt')).swipe('down'); + await sleep(1000); +}; + export const waitForPeerConnection = async (lnd, nodeId, maxRetries = 20) => { let retries = 0; diff --git a/e2e/onchain.e2e.js b/e2e/onchain.e2e.js index 645c64036..1056048c7 100644 --- a/e2e/onchain.e2e.js +++ b/e2e/onchain.e2e.js @@ -99,7 +99,7 @@ d('Onchain', () => { await element(by.id('AddressContinue')).tap(); // Amount / NumberPad - await element(by.id('SendNumberPadMax')).tap(); + await element(by.id('AvailableAmount')).tap(); // cat't use .multitap here, doesn't work properly // maybe some race condition in beignet library ? await element( @@ -112,7 +112,7 @@ d('Onchain', () => { by.id('NRemove').withAncestor(by.id('SendAmountNumberPad')), ).tap(); await expect(element(by.text('199 999'))).toBeVisible(); - await element(by.id('SendNumberPadMax')).tap(); + await element(by.id('AvailableAmount')).tap(); await element(by.id('ContinueAmount')).tap(); // Review & Send diff --git a/e2e/send.e2e.js b/e2e/send.e2e.js new file mode 100644 index 000000000..e1ed2fda4 --- /dev/null +++ b/e2e/send.e2e.js @@ -0,0 +1,399 @@ +import createLndRpc from '@radar/lnrpc'; +import BitcoinJsonRpc from 'bitcoin-json-rpc'; +import { device } from 'detox'; +import jestExpect from 'expect'; +import { encode } from 'bip21'; + +import initWaitForElectrumToSync from '../__tests__/utils/wait-for-electrum'; +import { + bitcoinURL, + lndConfig, + checkComplete, + completeOnboarding, + electrumHost, + electrumPort, + launchAndWait, + markComplete, + sleep, + receiveOnchainFunds, + waitForActiveChannel, + waitForPeerConnection, + isButtonEnabled, +} from './helpers'; + +d = checkComplete(['send-1', 'send-2']) ? describe.skip : describe; + +const enterAddress = async (address) => { + await element(by.id('Send')).tap(); + await element(by.id('RecipientManual')).tap(); + await element(by.id('RecipientInput')).replaceText(address); + await element(by.id('RecipientInput')).tapReturnKey(); + // wait for keyboard to hide + await sleep(1000); + await element(by.id('AddressContinue')).tap(); +}; + +d('Send', () => { + let waitForElectrum; + const rpc = new BitcoinJsonRpc(bitcoinURL); + + beforeAll(async () => { + let balance = await rpc.getBalance(); + const address = await rpc.getNewAddress(); + + while (balance < 10) { + await rpc.generateToAddress(10, address); + balance = await rpc.getBalance(); + } + + waitForElectrum = await initWaitForElectrumToSync( + { host: electrumHost, port: electrumPort }, + bitcoinURL, + ); + }); + + beforeEach(async () => { + await device.launchApp({ delete: true }); + await completeOnboarding(); + await launchAndWait(); + await waitForElectrum(); + }); + + afterAll(() => { + waitForElectrum?.close(); + }); + + it('Validates payment data in the manual input', async () => { + if (checkComplete('send-1')) { + return; + } + + const button = element(by.id('AddressContinue')); + await element(by.id('Send')).tap(); + await element(by.id('RecipientManual')).tap(); + + // check validation for empty address + let buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // check validation for invalid data + await element(by.id('RecipientInput')).replaceText('test123'); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // check validation for invalid address (network mismatch) + const mainnetAddress = 'bc1qnc8at2e2navahnz7lvtl39r4dnfzxv3cc9e7ax'; + await element(by.id('RecipientInput')).replaceText(mainnetAddress); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // check validation for address when balance is 0 + const address = await rpc.getNewAddress(); + await element(by.id('RecipientInput')).replaceText(address); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // check validation for expired invoice + const invoice = + 'lnbcrt1pn3zpqpdqqnp4qfh2x8nyvvzq4kf8j9wcaau2chr580l93pnyrh5027l8f7qtm48h6pp5lmwkulnpze4ek4zqwfepguahcr2ma3vfhwa6uepxfd378xlldprssp5wnq34d553g50suuvfy387csx5hx6mdv8zezem6f4tky7rhezycas9qyysgqcqpcxqrrssrzjqtr7pzpunxgwjddwdqucegdphm6776xcarz60gw9gxva0rhal5ntmapyqqqqqqqqpqqqqqlgqqqqqqgq2ql9zpeakxvff9cz5rd6ssc3cngl256u8htm860qv3r28vqkwy9xe3wp0l9ms3zcqvys95yf3r34ytmegz6zynuthh5s0kh7cueunm3mspg3uwpt'; + await element(by.id('RecipientInput')).replaceText(invoice); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // TODO: check validation for lnurl + // const lnurl = + // 'lnurl1dp68gup69uhkcmmrv9kxsmmnwsarxvpsxqcj7mrww4exc0m385ukvv35v43kyetrx5enqcfcx93r2vtzv93kxcnrvdjxxefkvdnxzcmz8yurxcm98qcnqcfkv56kxvfhvgukye3jvvck2de5x43rqwf3xujlyx'; + // await element(by.id('RecipientInput')).replaceText(lnurl); + // await element(by.id('RecipientInput')).tapReturnKey(); + // buttonEnabled = await isButtonEnabled(button); + // jestExpect(buttonEnabled).toBe(true); + + // check validation for slashtag with empty pay config + const slashpay = + 'slash:9n31tfs4ibg9mqdqzhzwwutbm6nr8e4qxkokyam7mh7a78fkmqmo/profile.json?relay=https://dht-relay.synonym.to/staging/web-relay'; + await element(by.id('RecipientInput')).replaceText(slashpay); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // check validation for node connection URI + const nodeUri = + '0399537c06f0d03e9c75f9116a7709ea608a4db78e7bce9fef09e8c3bbbfed12f7@0.0.0.0:9735'; + await element(by.id('RecipientInput')).replaceText(nodeUri); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(false); + + // Receive funds and check validation w/ balance + await element(by.id('SendSheet')).swipe('down'); + await receiveOnchainFunds(rpc); + + await element(by.id('Send')).tap(); + await element(by.id('RecipientManual')).tap(); + + // check validation for address + const address2 = await rpc.getNewAddress(); + await element(by.id('RecipientInput')).replaceText(address2); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(true); + + // check validation for unified invoice when balance is enough + const unified1 = + 'bitcoin:bcrt1q07x3wl76zdxvdsz3qzzkvxrjg3n6t4tz2vnsx8?amount=0.0001'; + await element(by.id('RecipientInput')).replaceText(unified1); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + jestExpect(buttonEnabled).toBe(true); + + // check validation for unified invoice when balance is too low + const unified2 = + 'bitcoin:bcrt1q07x3wl76zdxvdsz3qzzkvxrjg3n6t4tz2vnsx8?amount=0.002'; + await element(by.id('RecipientInput')).replaceText(unified2); + await element(by.id('RecipientInput')).tapReturnKey(); + buttonEnabled = await isButtonEnabled(button); + // jestExpect(buttonEnabled).toBe(false); + jestExpect(buttonEnabled).toBe(true); + + markComplete('send-1'); + }); + + it('Can receive funds and send to different invoices', async () => { + // Test plan: + // Prepare + // - receive onchain funds + // - open channel to LND node + // - receive lightning funds + + // Send + // - send to onchain address + // - send to lightning invoice + // - send to unified invoice + + if (checkComplete('send-2')) { + return; + } + + await receiveOnchainFunds(rpc); + + // send funds to LND node and open a channel + const lnd = await createLndRpc(lndConfig); + const { address: lndAddress } = await lnd.newAddress(); + await rpc.sendToAddress(lndAddress, '1'); + await rpc.generateToAddress(1, await rpc.getNewAddress()); + await waitForElectrum(); + const { identityPubkey: lndNodeID } = await lnd.getInfo(); + + // get LDK Node id + await element(by.id('Settings')).tap(); + await element(by.id('AdvancedSettings')).tap(); + // wait for LDK to start + await sleep(5000); + await element(by.id('LightningNodeInfo')).tap(); + await waitFor(element(by.id('LDKNodeID'))) + .toBeVisible() + .withTimeout(60000); + let { label: ldkNodeId } = await element( + by.id('LDKNodeID'), + ).getAttributes(); + await element(by.id('NavigationBack')).atIndex(0).tap(); + + // connect to LND + await element(by.id('Channels')).tap(); + await element(by.id('NavigationAction')).tap(); + await element(by.id('FundCustom')).tap(); + await element(by.id('FundManual')).tap(); + await element(by.id('NodeIdInput')).replaceText(lndNodeID); + await element(by.id('HostInput')).replaceText('0.0.0.0'); + await element(by.id('PortInput')).replaceText('9735'); + await element(by.id('PortInput')).tapReturnKey(); + await element(by.id('ExternalContinue')).tap(); + await element(by.id('NavigationClose')).tap(); + + // wait for peer to be connected + await waitForPeerConnection(lnd, ldkNodeId); + + // open a channel + await lnd.openChannelSync({ + nodePubkeyString: ldkNodeId, + localFundingAmount: '100000', + private: true, + }); + await rpc.generateToAddress(6, await rpc.getNewAddress()); + await waitForElectrum(); + + // wait for channel to be active + await waitForActiveChannel(lnd, ldkNodeId); + + // check channel status + await element(by.id('Settings')).tap(); + await element(by.id('AdvancedSettings')).atIndex(0).tap(); + await element(by.id('Channels')).tap(); + await element(by.id('Channel')).atIndex(0).tap(); + await expect( + element(by.id('MoneyText').withAncestor(by.id('TotalSize'))), + ).toHaveText('100 000'); + await element(by.id('ChannelScrollView')).scrollTo('bottom', NaN, 0.1); + await expect(element(by.id('IsUsableYes'))).toBeVisible(); + await element(by.id('NavigationClose')).atIndex(0).tap(); + await sleep(500); + + // receive lightning funds + await element(by.id('Receive')).tap(); + let { label: invoice1 } = await element(by.id('QRCode')).getAttributes(); + invoice1 = invoice1.replaceAll(/bitcoin.*=/gi, '').toLowerCase(); + await lnd.sendPaymentSync({ paymentRequest: invoice1, amt: 50000 }); + await waitFor(element(by.id('NewTxPrompt'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('NewTxPrompt')).swipe('down'); + + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('150 000') + .withTimeout(10000); + + // send to onchain address + const { address: onchainAddress } = await lnd.newAddress(); + await enterAddress(onchainAddress); + await expect(element(by.id('AssetButton-savings'))).toBeVisible(); + await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap(); + await element( + by.id('N0').withAncestor(by.id('SendAmountNumberPad')), + ).multiTap(4); + await element(by.id('ContinueAmount')).tap(); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('139 502') + .withTimeout(10000); + + // send to lightning invoice + const { paymentRequest: lnInvoice1 } = await lnd.addInvoice(); + await enterAddress(lnInvoice1); + await expect(element(by.id('AssetButton-spending'))).toBeVisible(); + await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap(); + await element( + by.id('N0').withAncestor(by.id('SendAmountNumberPad')), + ).multiTap(4); + await element(by.id('ContinueAmount')).tap(); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('129 502') + .withTimeout(10000); + + // send to unified invoice w/ amount + const { paymentRequest: lnInvoice2 } = await lnd.addInvoice({ + value: 10000, + }); + const unified1 = encode(onchainAddress, { + lightning: lnInvoice2, + amount: 10000, + }); + + await enterAddress(unified1); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('119 502') + .withTimeout(10000); + + // send to unified invoice w/ expired invoice + const unified2 = + 'bitcoin:bcrt1qaytrqsrgg75rtxrtr7ur6k75la8p3v95mey48z?lightning=LNBCRT1PN33T20DQQNP4QTNTQ4D2DHDYQ420HAUQF5TS7X32TNW9WGYEPQZQ6R9G69QPHW4RXPP5QU7UYXJYJA9PJV7H6JPEYEFFNZ98N686JDEAAK8AUD5AGC5X70HQSP54V5LEFATCQDEU8TLKAF6MDK3ZLU6MWUA52J4JEMD5XA85KGKMTTQ9QYYSGQCQPCXQRRSSRZJQWU6G4HMGH26EXXQYPQD8XHVWLARA66PL53V7S9CV2EE808UGDRN4APYQQQQQQQGRCQQQQLGQQQQQQGQ2QX7F74RT5SQE0KEYCU47LYMSVY2LM4QA4KLR65PPSY55M0H4VR8AN7WVM9EFVSPYJ5R8EFGVXTGVATAGFTC372VRJ3HEPSEELFZ7FQFCQ9XDU9X'; + + await enterAddress(unified2); + await expect(element(by.id('AssetButton-savings'))).toBeVisible(); + await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap(); + await element( + by.id('N0').withAncestor(by.id('SendAmountNumberPad')), + ).multiTap(4); + await element(by.id('ContinueAmount')).tap(); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('109 170') + .withTimeout(10000); + + // send to unified invoice w/o amount (lightning) + const { paymentRequest: lnInvoice3 } = await lnd.addInvoice(); + const unified3 = encode(onchainAddress, { lightning: lnInvoice3 }); + + await enterAddress(unified3); + // max amount (lightning) + await expect(element(by.text('28 900'))).toBeVisible(); + await element(by.id('AssetButton-switch')).tap(); + // max amount (onchain) + await expect(element(by.text('78 838'))).toBeVisible(); + await element(by.id('AssetButton-switch')).tap(); + await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap(); + await element( + by.id('N0').withAncestor(by.id('SendAmountNumberPad')), + ).multiTap(4); + await element(by.id('ContinueAmount')).tap(); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('99 170') + .withTimeout(10000); + + // send to unified invoice w/o amount (switch to onchain) + const { paymentRequest: lnInvoice4 } = await lnd.addInvoice(); + const unified4 = encode(onchainAddress, { lightning: lnInvoice4 }); + + await enterAddress(unified4); + // max amount (lightning) + await expect(element(by.text('18 900'))).toBeVisible(); + await element(by.id('AssetButton-switch')).tap(); + // max amount (onchain) + await expect(element(by.text('78 838'))).toBeVisible(); + await element(by.id('N1').withAncestor(by.id('SendAmountNumberPad'))).tap(); + await element( + by.id('N0').withAncestor(by.id('SendAmountNumberPad')), + ).multiTap(4); + await element(by.id('ContinueAmount')).tap(); + await element(by.id('GRAB')).swipe('right', 'slow', 0.95, 0.5, 0.5); // Swipe to confirm + await waitFor(element(by.id('SendSuccess'))) + .toBeVisible() + .withTimeout(10000); + await element(by.id('Close')).tap(); + await waitFor( + element(by.id('MoneyText').withAncestor(by.id('TotalBalance'))), + ) + .toHaveText('88 838') + .withTimeout(10000); + + markComplete('send-2'); + }); +}); diff --git a/src/components/buttons/Button.tsx b/src/components/buttons/Button.tsx index 458916d10..720c66b54 100644 --- a/src/components/buttons/Button.tsx +++ b/src/components/buttons/Button.tsx @@ -100,6 +100,7 @@ const Button = ({ return ( [buttonStyle, pressed && buttonPressedStyle]} + accessibilityLabel={disabled ? 'disabled' : 'enabled'} disabled={loading || disabled} {...props}> {({ pressed }) => { diff --git a/src/navigation/bottom-sheet/SendNavigation.tsx b/src/navigation/bottom-sheet/SendNavigation.tsx index d1bd88265..842ccf60f 100644 --- a/src/navigation/bottom-sheet/SendNavigation.tsx +++ b/src/navigation/bottom-sheet/SendNavigation.tsx @@ -26,7 +26,6 @@ import CoinSelection from '../../screens/Wallets/Send/CoinSelection'; import LNURLAmount from '../../screens/Wallets/LNURLPay/Amount'; import LNURLConfirm from '../../screens/Wallets/LNURLPay/Confirm'; import { NavigationContainer } from '../../styles/components'; -import { TProcessedData } from '../../utils/scanner'; import { useSnapPoints } from '../../hooks/bottomSheet'; import { viewControllerSelector } from '../../store/reselect/ui'; import { @@ -52,7 +51,7 @@ export type SendStackParamList = { Recipient: undefined; Contacts: undefined; Address: undefined; - Scanner: { onScan: (data: TProcessedData) => void } | undefined; + Scanner: undefined; Amount: undefined; CoinSelection: undefined; FeeRate: undefined; @@ -134,6 +133,7 @@ const SendNavigation = (): ReactElement => { { // Deep linking if the app is already open const onReceiveURL = ({ url }: { url: string }): void => { rootNavigation.navigate('Wallet'); - processInputData({ data: url }); + processUri({ uri: url }); return listener(url); }; @@ -135,7 +135,7 @@ const RootNavigator = (): ReactElement => { // Deep linking if the app wasn't previously open const initialUrl = await Linking.getInitialURL(); if (initialUrl) { - processInputData({ data: initialUrl }); + processUri({ uri: initialUrl }); return; } @@ -147,7 +147,7 @@ const RootNavigator = (): ReactElement => { const clipboardData = await Clipboard.getString(); await resetSendTransaction(); rootNavigation.navigate('Wallet'); - await processInputData({ data: clipboardData, showErrors: false }); + await processUri({ uri: clipboardData, showErrors: false }); }; const onAuthSuccess = useCallback((): void => { diff --git a/src/screens/Contacts/Contact.tsx b/src/screens/Contacts/Contact.tsx index 24d047ba0..12e0151c3 100644 --- a/src/screens/Contacts/Contact.tsx +++ b/src/screens/Contacts/Contact.tsx @@ -19,7 +19,7 @@ import NavigationHeader from '../../components/NavigationHeader'; import SafeAreaInset from '../../components/SafeAreaInset'; import ProfileCard from '../../components/ProfileCard'; import ProfileLinks from '../../components/ProfileLinks'; -import { processInputData } from '../../utils/scanner'; +import { processUri } from '../../utils/scanner/scanner'; import { useProfile } from '../../hooks/slashtags'; import { useBalance } from '../../hooks/wallet'; import { truncate } from '../../utils/helpers'; @@ -29,10 +29,7 @@ import Dialog from '../../components/Dialog'; import Tooltip from '../../components/Tooltip'; import IconButton from '../../components/buttons/IconButton'; import { deleteContact } from '../../store/slices/slashtags'; -import { - selectedNetworkSelector, - selectedWalletSelector, -} from '../../store/reselect/wallet'; +import { selectedNetworkSelector } from '../../store/reselect/wallet'; import { contactsSelector } from '../../store/reselect/slashtags'; const Contact = ({ @@ -47,7 +44,6 @@ const Contact = ({ const [loading, setLoading] = useState(false); const dispatch = useAppDispatch(); - const selectedWallet = useAppSelector(selectedWalletSelector); const selectedNetwork = useAppSelector(selectedNetworkSelector); const contacts = useAppSelector(contactsSelector); @@ -80,11 +76,10 @@ const Contact = ({ const handleSend = async (): Promise => { setLoading(true); - const res = await processInputData({ - data: url, + const res = await processUri({ + uri: url, source: 'send', selectedNetwork, - selectedWallet, }); setLoading(false); if (res.isOk()) { diff --git a/src/screens/Scanner/MainScanner.tsx b/src/screens/Scanner/MainScanner.tsx index 41d5cbd52..c33e71b61 100644 --- a/src/screens/Scanner/MainScanner.tsx +++ b/src/screens/Scanner/MainScanner.tsx @@ -1,9 +1,8 @@ import React, { memo, ReactElement } from 'react'; import { StyleSheet } from 'react-native'; -import { useAppSelector } from '../../hooks/redux'; import { useTranslation } from 'react-i18next'; -import { processInputData } from '../../utils/scanner'; +import { processUri } from '../../utils/scanner/scanner'; import SafeAreaInset from '../../components/SafeAreaInset'; import NavigationHeader from '../../components/NavigationHeader'; import { showToast } from '../../utils/notifications'; @@ -11,10 +10,6 @@ import ScannerComponent from './ScannerComponent'; import type { RootStackScreenProps } from '../../navigation/types'; import DetectSwipe from '../../components/DetectSwipe'; import { resetSendTransaction } from '../../store/actions/wallet'; -import { - selectedNetworkSelector, - selectedWalletSelector, -} from '../../store/reselect/wallet'; const ScannerScreen = ({ navigation, @@ -22,15 +17,13 @@ const ScannerScreen = ({ }: RootStackScreenProps<'Scanner'>): ReactElement => { const { t } = useTranslation('other'); const onScan = route.params?.onScan; - const selectedNetwork = useAppSelector(selectedNetworkSelector); - const selectedWallet = useAppSelector(selectedWalletSelector); const onSwipeRight = (): void => { navigation.navigate('Wallet'); }; - const onRead = (data: string): void => { - if (!data) { + const onRead = (uri: string): void => { + if (!uri) { showToast({ type: 'warning', title: t('qr_error_header'), @@ -42,17 +35,12 @@ const ScannerScreen = ({ navigation.pop(); if (onScan) { - onScan(data); + onScan(uri); return; } resetSendTransaction().then(() => { - processInputData({ - data, - source: 'mainScanner', - selectedNetwork, - selectedWallet, - }).then(); + processUri({ uri, source: 'mainScanner' }).then(); }); }; diff --git a/src/screens/Wallets/AssetButton.tsx b/src/screens/Wallets/AssetButton.tsx new file mode 100644 index 000000000..def86a856 --- /dev/null +++ b/src/screens/Wallets/AssetButton.tsx @@ -0,0 +1,87 @@ +import React, { memo, ReactElement } from 'react'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { TouchableHighlight } from '../../styles/components'; +import { Caption13Up } from '../../styles/text'; +import { SwitchIcon } from '../../styles/icons'; +import { useAppDispatch, useAppSelector } from '../../hooks/redux'; +import useColors from '../../hooks/colors'; +import { updateUi } from '../../store/slices/ui'; + +const AssetButton = ({ + style, + savings, + spending, +}: { + style?: StyleProp; + savings?: boolean; + spending?: boolean; +}): ReactElement => { + const { t } = useTranslation('wallet'); + const colors = useColors(); + const dispatch = useAppDispatch(); + const method = useAppSelector((state) => state.ui.paymentMethod); + + const usesLightning = method === 'lightning'; + + const canSwitch = savings && spending; + const text = usesLightning ? t('spending.title') : t('savings.title'); + const testId = usesLightning ? 'spending' : 'savings'; + const borderColor = usesLightning ? colors.purple : colors.brand; + const color = usesLightning ? 'purple' : 'brand'; + + const staticStyle = { + borderWidth: 1, + borderColor, + height: 28, + }; + + const onSwitch = (): void => { + const paymentMethod = usesLightning ? 'onchain' : 'lightning'; + dispatch(updateUi({ paymentMethod })); + }; + + if (!canSwitch) { + return ( + + {text} + + ); + } + + return ( + + <> + + {text} + + + ); +}; + +const styles = StyleSheet.create({ + root: { + paddingVertical: 5, + paddingHorizontal: 8, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + }, + icon: { + marginRight: 11, + }, +}); + +export default memo(AssetButton); diff --git a/src/screens/Wallets/LNURLPay/Confirm.tsx b/src/screens/Wallets/LNURLPay/Confirm.tsx index fc5d3cbc2..d3ecd32af 100644 --- a/src/screens/Wallets/LNURLPay/Confirm.tsx +++ b/src/screens/Wallets/LNURLPay/Confirm.tsx @@ -94,9 +94,7 @@ const LNURLConfirm = ({ return; } - const decodeInvoiceResponse = await decodeLightningInvoice({ - paymentRequest: invoice.value, - }); + const decodeInvoiceResponse = await decodeLightningInvoice(invoice.value); if (decodeInvoiceResponse.isErr()) { setIsLoading(false); onError(decodeInvoiceResponse.error.message); diff --git a/src/screens/Wallets/Receive/ReceiveQR.tsx b/src/screens/Wallets/Receive/ReceiveQR.tsx index be6755b4a..f276737e4 100644 --- a/src/screens/Wallets/Receive/ReceiveQR.tsx +++ b/src/screens/Wallets/Receive/ReceiveQR.tsx @@ -458,7 +458,7 @@ const ReceiveQR = ({ {(!jitInvoice || !enableInstant) && ( - + {t('receive_bitcoin_invoice')} @@ -471,7 +471,11 @@ const ReceiveQR = ({ /> - {ellipsis(receiveAddress, 25)} + + {ellipsis(receiveAddress, 25)} + {showTooltip.onchain && ( ): ReactElement => { const colors = useColors(); const { t } = useTranslation('wallet'); const { keyboardShown } = useKeyboard(); const [textFieldValue, setTextFieldValue] = useState(''); - const [isValid, setIsValid] = useState({}); - const selectedWallet = useAppSelector(selectedWalletSelector); - const selectedNetwork = useAppSelector(selectedNetworkSelector); + const [isValid, setIsValid] = useState({}); const onChangeText = async (text: string): Promise => { const diff = Math.abs(text.length - textFieldValue.length); @@ -36,10 +33,11 @@ const Address = ({}: SendScreenProps<'Address'>): ReactElement => { setTextFieldValue(text); - const result = await validateInputData({ - data: text, + const result = await processUri({ + uri: text, source: 'send', showErrors: hasPasted, + validateOnly: true, }); setIsValid((s) => ({ ...s, [text]: !result.isErr() })); @@ -47,13 +45,7 @@ const Address = ({}: SendScreenProps<'Address'>): ReactElement => { const onContinue = async (): Promise => { await Keyboard.dismiss(); - - await processInputData({ - data: textFieldValue, - source: 'send', - selectedNetwork, - selectedWallet, - }); + await processUri({ uri: textFieldValue, source: 'send' }); }; return ( diff --git a/src/screens/Wallets/Send/Amount.tsx b/src/screens/Wallets/Send/Amount.tsx index 3b0a4bad3..aafbdb87b 100644 --- a/src/screens/Wallets/Send/Amount.tsx +++ b/src/screens/Wallets/Send/Amount.tsx @@ -6,11 +6,10 @@ import React, { useState, useEffect, } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, View, TouchableOpacity } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useFocusEffect, useRoute } from '@react-navigation/native'; -import { TouchableHighlight } from '../../../styles/components'; import { Caption13Up } from '../../../styles/text'; import { IColors } from '../../../styles/colors'; import GradientView from '../../../components/GradientView'; @@ -21,6 +20,7 @@ import ContactImage from '../../../components/ContactImage'; import NumberPadTextField from '../../../components/NumberPadTextField'; import SendNumberPad from './SendNumberPad'; import Button from '../../../components/buttons/Button'; +import AssetButton from '../AssetButton'; import UnitButton from '../UnitButton'; import { getTransactionOutputValue, @@ -73,35 +73,29 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { const utxos = useAppSelector(utxosSelector); const { onchainBalance } = useBalance(); + const method = useAppSelector((state) => state.ui.paymentMethod); + const usesLightning = method === 'lightning'; + const outputAmount = useMemo(() => { - return getTransactionOutputValue({ - outputs: transaction.outputs, - }); + const amount = getTransactionOutputValue({ outputs: transaction.outputs }); + return amount; }, [transaction.outputs]); const availableAmount = useMemo(() => { - const maxAmountResponse = getMaxSendAmount({ - selectedWallet, - selectedNetwork, - }); + const maxAmountResponse = getMaxSendAmount({ method }); if (maxAmountResponse.isOk()) { return maxAmountResponse.value.amount; } return 0; - // recalculate max when utxos or fee change + // recalculate max when utxos, fee or payment method change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - transaction.outputs, - transaction.satsPerByte, - selectedWallet, - selectedNetwork, - ]); + }, [transaction.outputs, transaction.satsPerByte, method]); useFocusEffect( useCallback(() => { // This is triggered when the user removes all inputs from the coin selection screen. if ( - !transaction.lightningInvoice && + !usesLightning && onchainBalance > TRANSACTION_DEFAULTS.dustLimit && (availableAmount === 0 || !transaction.inputs.length) ) { @@ -119,9 +113,8 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { }, [ availableAmount, onchainBalance, + usesLightning, transaction.inputs.length, - transaction.lightningInvoice, - // transaction.outputs, transaction.satsPerByte, denomination, unit, @@ -166,18 +159,26 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { }; const onMaxAmount = useCallback((): void => { - if (!transaction.lightningInvoice) { + if (!usesLightning) { const result = getNumberPadText(availableAmount, denomination, unit); setText(result); + } else { + showToast({ + type: 'warning', + title: t('send_max_spending.title'), + description: t('send_max_spending.description'), + }); } + sendMax({ selectedWallet, selectedNetwork }); }, [ availableAmount, selectedNetwork, selectedWallet, - transaction.lightningInvoice, + usesLightning, denomination, unit, + t, ]); const onError = (): void => { @@ -201,11 +202,11 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { return; } - // If auto coin-select is disabled and there is no lightning invoice. - if (!coinSelectAuto && !transaction.lightningInvoice) { + // If coin selection is enabled and the user wants to pay onchain. + if (!coinSelectAuto && !usesLightning) { navigation.navigate('CoinSelection'); } else { - if (!transaction.lightningInvoice) { + if (!usesLightning) { const feeSetupRes = setupFeeForOnChainTransaction(); if (feeSetupRes.isErr()) { showToast({ @@ -224,6 +225,7 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { selectedNetwork, coinSelectAuto, transaction, + usesLightning, navigation, t, ]); @@ -234,7 +236,7 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { } // onchain tx - if (!transaction.lightningInvoice) { + if (!usesLightning) { // amount is below dust limit if (amount <= TRANSACTION_DEFAULTS.dustLimit) { return false; @@ -247,9 +249,10 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { } return true; - }, [amount, transaction.lightningInvoice, availableAmount]); + }, [amount, usesLightning, availableAmount]); const canGoBack = navigation.getState().routes[0]?.key !== route.key; + const hasOutput = !!transaction.outputs[0]?.address; return ( @@ -271,31 +274,25 @@ const Amount = ({ navigation }: SendScreenProps<'Amount'>): ReactElement => { - + - {t( - transaction.lightningInvoice - ? 'send_availabe_spending' - : 'send_availabe_savings', - )} + {t('send_available')} - + - - {t('send_max')} - + savings={hasOutput} + spending={!!transaction.lightningInvoice} + /> diff --git a/src/screens/Wallets/Send/Contacts.tsx b/src/screens/Wallets/Send/Contacts.tsx index f41e9b8af..8f11e6174 100644 --- a/src/screens/Wallets/Send/Contacts.tsx +++ b/src/screens/Wallets/Send/Contacts.tsx @@ -6,14 +6,11 @@ import NavigationHeader from '../../../components/NavigationHeader'; import GradientView from '../../../components/GradientView'; import ContactsList from '../../../components/ContactsList'; import { useAppSelector } from '../../../hooks/redux'; -import { processInputData } from '../../../utils/scanner'; +import { processUri } from '../../../utils/scanner/scanner'; import { showToast } from '../../../utils/notifications'; import type { SendScreenProps } from '../../../navigation/types'; import type { IContactRecord } from '../../../store/types/slashtags'; -import { - selectedNetworkSelector, - selectedWalletSelector, -} from '../../../store/reselect/wallet'; +import { selectedNetworkSelector } from '../../../store/reselect/wallet'; import { resetSendTransaction, setupOnChainTransaction, @@ -24,7 +21,6 @@ const Contacts = ({ }: SendScreenProps<'Contacts'>): ReactElement => { const { t } = useTranslation('slashtags'); const [loading, setLoading] = useState(false); - const selectedWallet = useAppSelector(selectedWalletSelector); const selectedNetwork = useAppSelector(selectedNetworkSelector); const handlePress = async (contact: IContactRecord): Promise => { @@ -32,11 +28,10 @@ const Contacts = ({ await resetSendTransaction(); await setupOnChainTransaction({}); setLoading(true); - const res = await processInputData({ - data: contact.url, + const res = await processUri({ + uri: contact.url, source: 'send', selectedNetwork, - selectedWallet, }); setLoading(false); if (res.isOk()) { diff --git a/src/screens/Wallets/Send/Error.tsx b/src/screens/Wallets/Send/Error.tsx index 773944ae7..a92e4c4b1 100644 --- a/src/screens/Wallets/Send/Error.tsx +++ b/src/screens/Wallets/Send/Error.tsx @@ -1,5 +1,5 @@ -import React, { memo, ReactElement, useState } from 'react'; -import { StyleSheet, View, ActivityIndicator, Image } from 'react-native'; +import React, { memo, ReactElement } from 'react'; +import { StyleSheet, View, Image } from 'react-native'; import { useTranslation } from 'react-i18next'; import { BodyM } from '../../../styles/text'; @@ -8,19 +8,18 @@ import SafeAreaInset from '../../../components/SafeAreaInset'; import GradientView from '../../../components/GradientView'; import Button from '../../../components/buttons/Button'; import { useAppDispatch, useAppSelector } from '../../../hooks/redux'; -import { processInputData } from '../../../utils/scanner'; +import { processUri } from '../../../utils/scanner/scanner'; import { showToast } from '../../../utils/notifications'; import type { SendScreenProps } from '../../../navigation/types'; import { resetSendTransaction, setupOnChainTransaction, } from '../../../store/actions/wallet'; -import { closeSheet } from '../../../store/slices/ui'; -import { - selectedNetworkSelector, - selectedWalletSelector, - transactionSelector, -} from '../../../store/reselect/wallet'; +import { closeSheet, updateUi } from '../../../store/slices/ui'; +import { transactionSelector } from '../../../store/reselect/wallet'; + +const imageCross = require('../../../assets/illustrations/cross.png'); +const imageExclamation = require('../../../assets/illustrations/exclamation-mark.png'); const Error = ({ navigation, @@ -29,29 +28,20 @@ const Error = ({ const { t } = useTranslation('wallet'); let { errorMessage } = route.params; const dispatch = useAppDispatch(); - const selectedWallet = useAppSelector(selectedWalletSelector); - const selectedNetwork = useAppSelector(selectedNetworkSelector); const transaction = useAppSelector(transactionSelector); - const [loading, setLoading] = useState(false); + const { lightningInvoice, slashTagsUrl } = transaction; - const isSlashpay = transaction.lightningInvoice && transaction.slashTagsUrl; + const isSlashpayLightning = !!slashTagsUrl && !!lightningInvoice; let navTitle = t('send_error_tx_failed'); - let imageSrc = require('../../../assets/illustrations/cross.png'); - let retryText: string | ReactElement = t('try_again'); + let imageSrc = imageCross; + let retryText = t('try_again'); - if (transaction.lightningInvoice && transaction.slashTagsUrl) { - imageSrc = require('../../../assets/illustrations/exclamation-mark.png'); + if (isSlashpayLightning) { + imageSrc = imageExclamation; navTitle = t('send_instant_failed'); errorMessage = t('send_error_slash_ln'); - retryText = loading ? ( - <> - - {t('send_regular')} - - ) : ( - t('send_regular') - ); + retryText = t('send_regular'); } const handleClose = (): void => { @@ -59,28 +49,20 @@ const Error = ({ }; const handleRetry = async (): Promise => { - if (transaction.lightningInvoice && transaction.slashTagsUrl) { - setLoading(true); - await resetSendTransaction(); - await setupOnChainTransaction({}); - const res = await processInputData({ - data: transaction.slashTagsUrl, + if (isSlashpayLightning) { + dispatch(updateUi({ paymentMethod: 'onchain' })); + const res = await processUri({ + uri: slashTagsUrl, source: 'send', - selectedNetwork, - selectedWallet, - skip: ['lightningPaymentRequest'], + skipLightning: true, }); - setLoading(false); - if (res.isOk()) { - navigation.navigate('Amount'); - return; + if (res.isErr()) { + showToast({ + type: 'warning', + title: t('other:contact_pay_error'), + description: t('other:try_again'), + }); } - showToast({ - type: 'warning', - title: t('other:contact_pay_error'), - description: t('other:try_again'), - }); - return; } @@ -100,14 +82,13 @@ const Error = ({ return ( - {errorMessage} - {isSlashpay && ( + {isSlashpayLightning && (