From 265a362ea226864731923d4e24d508622b98f3cd Mon Sep 17 00:00:00 2001 From: Janaka-Steph Date: Wed, 20 Dec 2023 15:53:07 +0100 Subject: [PATCH] Add Lighting Network support via Boltz submarine swaps (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Networks bottom sheet modal * Add screens and logic * rm comments + small fixes * Review fixes * Fix popup test * reverse submarine swap wip * Blind pset wip * wip * refactor into BoltzService class * refactor into BoltzService class * refactor into BoltzService class * refactor into BoltzService class * refactor * fix receiving address * fix boltz liquid api endpoint * Fix getInvoiceExpireDate() There's a bug on boltz mainnet api, where invoices DON'T include field `timeExpireDate`, resulting in invoices from mainnet to be considered expired. https://github.com/BoltzExchange/boltz-backend/issues/437 This changes try to find out the correct expiration date cycling through different strategies and returning by default `Date.now() + 3,600,000` * fix bug on claim tx signing: wrong preimage * add working test * fix bug: using wrong brodcast tx function * add finalize * removes don't needed finalizer * new boltz testnet api endpoint * Removes DEFAULT_LIGHTNING_LIMITS Always query boltz api for swap limits Show spinner while querying boltz api for limits * throw error if invoice has no timestamp * removes nsequence and block height timeout * fix variable name * removes timeoutBlockHeight from makeClaimTransaction params: not needed anymore * fix error message * fix bug with claim transaction * fix success message after paying lightning invoice * update yarn.lock * update caniuse-lite * always show lbtc, fusd and usdt on list of assets * fix playwright test * change success message * removes modal unlock from receiving with LN * remove space before bang * fix alignment of SEND ALL button * fix bug where closing popup when entering lightning invoice and re-opening it would send user to liquid send * bug fix: on send don't open network modal for new_asset * calculate fee for claim tx * fix error when extracting axios error message * fix test error * warn user if he's spending too much with ln invoice * fix console error when history pushing for the same pathname * Several bug fixes and improvements: - validate all values in satoshis - show boltz fees right to invoice value - warning of low funds doesn't disable proceed button * show swap fees on new line * change 'Value' to 'Invoice value' * change invoice value unit from L-BTC to BTC * changes multiple of relay fee from 2 to 1.1 --------- Co-authored-by: João Bordalo --- package.json | 1 + playwright-tests/popup.spec.ts | 3 +- public/assets/images/networks/lightning.svg | 9 + public/assets/images/networks/liquid.svg | 19 + src/application/presenter.ts | 6 +- src/domain/chainsource.ts | 6 + src/domain/constants.ts | 39 +- src/domain/pset.ts | 2 +- src/domain/repository.ts | 2 + .../components/address-amount-form.tsx | 2 +- .../components/asset-list-screen.tsx | 31 +- .../components/modal-select-network.tsx | 46 ++ src/extension/routes/constants.ts | 7 + src/extension/routes/index.tsx | 7 + src/extension/utility/error.ts | 4 +- src/extension/wallet/home/index.tsx | 16 +- .../wallet/receive/lightning-enter-amount.tsx | 247 +++++++++ .../wallet/receive/lightning-show-invoice.tsx | 56 ++ .../wallet/receive/receive-select-asset.tsx | 15 +- src/extension/wallet/send/end-of-flow.tsx | 1 - .../wallet/send/lightning-enter-invoice.tsx | 199 +++++++ src/extension/wallet/send/payment-success.tsx | 3 +- .../wallet/send/send-select-asset.tsx | 8 +- src/extension/wallet/transactions/index.tsx | 37 +- .../storage/asset-repository.ts | 20 +- .../storage/send-flow-repository.ts | 8 +- src/pkg/boltz.ts | 521 ++++++++++++++++++ src/port/electrum-chain-source.ts | 38 +- test/boltz.spec.ts | 71 +++ yarn.lock | 89 ++- 30 files changed, 1467 insertions(+), 46 deletions(-) create mode 100644 public/assets/images/networks/lightning.svg create mode 100644 public/assets/images/networks/liquid.svg create mode 100644 src/extension/components/modal-select-network.tsx create mode 100644 src/extension/wallet/receive/lightning-enter-amount.tsx create mode 100644 src/extension/wallet/receive/lightning-show-invoice.tsx create mode 100644 src/extension/wallet/send/lightning-enter-invoice.tsx create mode 100644 src/pkg/boltz.ts create mode 100644 test/boltz.spec.ts diff --git a/package.json b/package.json index 53453301..b1d4de3f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "bip32": "^3.0.1", "bip39": "^3.0.4", "bitcoinjs-message": "^2.2.0", + "bolt11": "^1.4.1", "buffer": "^6.0.3", "coinselect": "^3.1.13", "crypto-browserify": "^3.12.0", diff --git a/playwright-tests/popup.spec.ts b/playwright-tests/popup.spec.ts index 6c86454e..b8897f61 100644 --- a/playwright-tests/popup.spec.ts +++ b/playwright-tests/popup.spec.ts @@ -64,6 +64,7 @@ pwTest( await page.waitForSelector('text=1.00 000 000 L-BTC'); await page.getByRole('button', { name: 'Send' }).click(); // go to send await page.getByText('Liquid Bitcoin').click(); // select L-BTC + await page.getByText('Liquid Network').click(); await page .getByPlaceholder('el1...') .fill( @@ -78,7 +79,7 @@ pwTest( await page.waitForSelector('text=Unlock'); await page.getByPlaceholder('Password').fill(PASSWORD); await page.getByRole('button', { name: 'Unlock' }).click(); - await page.waitForSelector('text=Payment successful !'); + await page.waitForSelector('text=Payment successful!'); await page.waitForTimeout(2000); await page.getByAltText('marina logo').click(); // go to home page diff --git a/public/assets/images/networks/lightning.svg b/public/assets/images/networks/lightning.svg new file mode 100644 index 00000000..f2ca692e --- /dev/null +++ b/public/assets/images/networks/lightning.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/assets/images/networks/liquid.svg b/public/assets/images/networks/liquid.svg new file mode 100644 index 00000000..8fe2b7fd --- /dev/null +++ b/public/assets/images/networks/liquid.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/application/presenter.ts b/src/application/presenter.ts index 894ee8dc..656645b6 100644 --- a/src/application/presenter.ts +++ b/src/application/presenter.ts @@ -10,6 +10,7 @@ import type { TxDetails } from '../domain/transaction'; import { computeBalances, computeTxDetailsExtended } from '../domain/transaction'; import type { BlockHeader } from '../domain/chainsource'; import { MainAccount, MainAccountLegacy, MainAccountTest } from './account'; +import { alwaysPresentAssets } from '../domain/constants'; function createLoadingValue(value: T): LoadingValue { return { @@ -295,10 +296,13 @@ export class PresenterImpl implements Presenter { (acc, tx) => [...acc, ...Object.keys(tx.txFlow)], [] as string[] ); + return { ...this.state, transactions: setValue(extendedTxDetails.sort(sortTxDetails)), - walletAssets: setValue(new Set(assetsInTransactions)), + walletAssets: setValue( + new Set([...alwaysPresentAssets[this.state.network], ...assetsInTransactions]) + ), }; } diff --git a/src/domain/chainsource.ts b/src/domain/chainsource.ts index 4207de6c..118701e0 100644 --- a/src/domain/chainsource.ts +++ b/src/domain/chainsource.ts @@ -1,3 +1,5 @@ +import type { Utxo } from 'marina-provider'; + export type TransactionHistory = Array<{ tx_hash: string; height: number; @@ -11,6 +13,8 @@ export interface BlockHeader { height: number; } +export type Unspent = Omit; + export interface ChainSource { subscribeScriptStatus( script: Buffer, @@ -24,4 +28,6 @@ export interface ChainSource { broadcastTransaction(hex: string): Promise; getRelayFee(): Promise; close(): Promise; + waitForAddressReceivesTx(addr: string): Promise; + listUnspents(addr: string): Promise; } diff --git a/src/domain/constants.ts b/src/domain/constants.ts index 53b7e17e..101a8a74 100644 --- a/src/domain/constants.ts +++ b/src/domain/constants.ts @@ -7,39 +7,54 @@ export const SOMETHING_WENT_WRONG_ERROR = 'Oops, something went wrong...'; const getLocalImagePath = (asset: string) => `/assets/images/liquid-assets/${asset}`; // featured assets -const featuredAssets = { +export const featuredAssets = { lbtc: { - mainnet: networks.liquid.assetHash, + liquid: networks.liquid.assetHash, testnet: networks.testnet.assetHash, regtest: networks.regtest.assetHash, }, lcad: { - mainnet: '0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a', + liquid: '0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a', testnet: 'ac3e0ff248c5051ffd61e00155b7122e5ebc04fd397a0ecbdd4f4e4a56232926', + regtest: '', }, usdt: { - mainnet: 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2', + liquid: 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2', testnet: 'f3d1ec678811398cd2ae277cbe3849c6f6dbd72c74bc542f7c4b11ff0e820958', + regtest: '', }, fusd: { - mainnet: '0dea022a8a25abb128b42b0f8e98532bc8bd74f8a77dc81251afcc13168acef7', + liquid: '0dea022a8a25abb128b42b0f8e98532bc8bd74f8a77dc81251afcc13168acef7', testnet: '0d86b2f6a8c3b02a8c7c8836b83a081e68b7e2b4bcdfc58981fc5486f59f7518', + regtest: '', }, }; +export const alwaysPresentAssets = { + liquid: [ + '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d', // lbtc + '0dea022a8a25abb128b42b0f8e98532bc8bd74f8a77dc81251afcc13168acef7', // fusd + 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2', // usdt + ], + regtest: [], + testnet: [ + '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', // lbtc + '0d86b2f6a8c3b02a8c7c8836b83a081e68b7e2b4bcdfc58981fc5486f59f7518', // fusd + 'f3d1ec678811398cd2ae277cbe3849c6f6dbd72c74bc542f7c4b11ff0e820958', // usdt + ], +}; + // featured assets map: from an asset hash, get local image path const featuredAssetsMap = new Map(); -featuredAssetsMap.set(featuredAssets.lbtc.mainnet, getLocalImagePath('lbtc.png')); +featuredAssetsMap.set(featuredAssets.lbtc.liquid, getLocalImagePath('lbtc.png')); featuredAssetsMap.set(featuredAssets.lbtc.testnet, getLocalImagePath('lbtc.png')); featuredAssetsMap.set(featuredAssets.lbtc.regtest, getLocalImagePath('lbtc.png')); -featuredAssetsMap.set(featuredAssets.usdt.mainnet, getLocalImagePath('usdt.png')); +featuredAssetsMap.set(featuredAssets.usdt.liquid, getLocalImagePath('usdt.png')); featuredAssetsMap.set(featuredAssets.usdt.testnet, getLocalImagePath('usdt.png')); -featuredAssetsMap.set(featuredAssets.lcad.mainnet, getLocalImagePath('lcad.png')); +featuredAssetsMap.set(featuredAssets.lcad.liquid, getLocalImagePath('lcad.png')); featuredAssetsMap.set(featuredAssets.lcad.testnet, getLocalImagePath('lcad.png')); featuredAssetsMap.set(featuredAssets.fusd.testnet, getLocalImagePath('fusd.png')); -featuredAssetsMap.set(featuredAssets.fusd.mainnet, getLocalImagePath('fusd.png')); - -export const FEATURED_ASSETS = Array.from(featuredAssetsMap.keys()); +featuredAssetsMap.set(featuredAssets.fusd.liquid, getLocalImagePath('fusd.png')); // given an asset hash, return url for image path from mempool const getRemoteImagePath = (hash: string) => `https://liquid.network/api/v1/asset/${hash}/icon`; @@ -52,4 +67,6 @@ export function getAssetImagePath(assetHash: string): string { return getRemoteImagePath(assetHash); } +export const UNKNOWN_ASSET_HASH = 'new_asset'; + export const defaultPrecision = 8; diff --git a/src/domain/pset.ts b/src/domain/pset.ts index 961cc784..3cfaf2b0 100644 --- a/src/domain/pset.ts +++ b/src/domain/pset.ts @@ -236,7 +236,7 @@ export class PsetBuilder { if (!chainSource) throw new Error('chain source not set'); // we add 100% to the min relay fee in order to be sure that the transaction will be accepted by the network // some inputs and outputs may be added later to pay the fees - const relayFee = (await chainSource.getRelayFee()) * 2; + const relayFee = (await chainSource.getRelayFee()) * 1.1; await chainSource.close(); const sats1000Bytes = relayFee * 10 ** 8; const estimatedSize = estimateVirtualSize(updater.pset, true); diff --git a/src/domain/repository.ts b/src/domain/repository.ts index 70efca9d..5961d472 100644 --- a/src/domain/repository.ts +++ b/src/domain/repository.ts @@ -193,6 +193,7 @@ export enum SendFlowStep { AssetSelected, AddressAmountFormDone, FeeFormDone, + Lightning, } // this repository is used to cache data during the UI send flow @@ -206,6 +207,7 @@ export interface SendFlowRepository { setUnsignedPset(pset: string): Promise; getUnsignedPset(): Promise; getStep(): Promise; + setLightning(bool: boolean): Promise; } // this repository aims to cache the block headers diff --git a/src/extension/components/address-amount-form.tsx b/src/extension/components/address-amount-form.tsx index 983ec75b..e56a6942 100644 --- a/src/extension/components/address-amount-form.tsx +++ b/src/extension/components/address-amount-form.tsx @@ -107,7 +107,7 @@ const BaseForm = (props: FormProps & FormikProps) => {
+ setShowBottomSheet(false)} + onLightning={() => onClick(selectedAsset, true)} + onLiquid={() => onClick(selectedAsset, false)} + > ); }; diff --git a/src/extension/components/modal-select-network.tsx b/src/extension/components/modal-select-network.tsx new file mode 100644 index 00000000..e575d826 --- /dev/null +++ b/src/extension/components/modal-select-network.tsx @@ -0,0 +1,46 @@ +import React, { useRef } from 'react'; +import useOnClickOutside from '../hooks/use-onclick-outside'; + +interface Props { + isOpen: boolean; + onClose: () => any; + onLightning: any; + onLiquid: any; +} + +const ModalSelectNetwork: React.FC = ({ isOpen, onClose, onLightning, onLiquid }) => { + const ref = useRef(null); + useOnClickOutside(ref, onClose); + + if (!isOpen) return <>; + + return ( +
+
+
+

Select network

+
+
+ liquid network logo +

Liquid Network

+
+
+ lightning network logo +

Lightning Network

+
+
+
+
+
+ ); +}; + +export default ModalSelectNetwork; diff --git a/src/extension/routes/constants.ts b/src/extension/routes/constants.ts index ff10ae2b..8537d538 100644 --- a/src/extension/routes/constants.ts +++ b/src/extension/routes/constants.ts @@ -36,6 +36,10 @@ const SEND_END_OF_FLOW_ROUTE = '/send/end-of-flow'; const SEND_PAYMENT_SUCCESS_ROUTE = '/send/payment-success'; const SEND_PAYMENT_ERROR_ROUTE = '/send/payment-error'; +// Lightning Receive +const LIGHTNING_ENTER_AMOUNT_ROUTE = '/lightning/invoice-amount'; +const LIGHTNING_ENTER_INVOICE_ROUTE = '/lightning/invoice'; + // Settings const SETTINGS_MENU_SECURITY_ROUTE = '/settings/security'; const SETTINGS_SHOW_MNEMONIC_ROUTE = '/settings/security/show-mnemonic'; @@ -82,6 +86,9 @@ export { SEND_END_OF_FLOW_ROUTE, SEND_PAYMENT_SUCCESS_ROUTE, SEND_PAYMENT_ERROR_ROUTE, + // Lightning Receive + LIGHTNING_ENTER_AMOUNT_ROUTE, + LIGHTNING_ENTER_INVOICE_ROUTE, // Settings SETTINGS_MENU_SECURITY_ROUTE, SETTINGS_MENU_SETTINGS_ROUTE, diff --git a/src/extension/routes/index.tsx b/src/extension/routes/index.tsx index 73b47c6e..b3897c05 100644 --- a/src/extension/routes/index.tsx +++ b/src/extension/routes/index.tsx @@ -40,6 +40,8 @@ import { CONNECT_CREATE_ACCOUNT, SETTINGS_EXPLORER_CUSTOM_ROUTE, SETTINGS_ACCOUNTS_RESTORE_IONIO_ROUTE, + LIGHTNING_ENTER_AMOUNT_ROUTE, + LIGHTNING_ENTER_INVOICE_ROUTE, } from './constants'; // Connect @@ -58,6 +60,7 @@ import BackUpUnlock from '../onboarding/backup-unlock'; // Wallet import LogIn from '../wallet/log-in'; import PaymentError from '../wallet/send/payment-error'; +import LightningAmount from '../wallet/receive/lightning-enter-amount'; // Settings import SettingsMenuSecurity from '../settings/menu-security'; import SettingsMenuSettings from '../settings/menu-settings'; @@ -86,6 +89,7 @@ import SettingsNetworksView from '../settings/networks'; import SettingsDeepRestorer from '../settings/deep-restorer'; import SettingsAccounts from '../settings/accounts'; import SettingsAccountsRestoreIonio from '../settings/accounts-restore-ionio'; +import LightningInvoice from '../wallet/send/lightning-enter-invoice'; const Routes: React.FC = () => { return ( @@ -112,6 +116,9 @@ const Routes: React.FC = () => { + {/*Lightning*/} + + {/*Settings*/} diff --git a/src/extension/utility/error.ts b/src/extension/utility/error.ts index 0b7a7e7c..723938f5 100644 --- a/src/extension/utility/error.ts +++ b/src/extension/utility/error.ts @@ -17,8 +17,8 @@ export function extractErrorMessage( // since AxiosError is an instance of Error, this should come first if (axios.isAxiosError(error)) { - if (error.response) return error.response.data; - if (error.request) return error.request.data; + if (error.response) return error.response.data.error; + if (error.request) return error.request.data.error; } // this should be last diff --git a/src/extension/wallet/home/index.tsx b/src/extension/wallet/home/index.tsx index a076f120..e928b840 100644 --- a/src/extension/wallet/home/index.tsx +++ b/src/extension/wallet/home/index.tsx @@ -8,6 +8,7 @@ import { SEND_CONFIRMATION_ROUTE, TRANSACTIONS_ROUTE, LOGIN_ROUTE, + LIGHTNING_ENTER_INVOICE_ROUTE, } from '../../routes/constants'; import Balance from '../../components/balance'; import ButtonAsset from '../../components/button-asset'; @@ -63,22 +64,29 @@ const Home: React.FC = () => { useEffect(() => { (async () => { + const safeHistoryPush = (pathname: string) => { + if (history.location.pathname !== pathname) history.push(pathname); + }; + const { isAuthenticated } = await appRepository.getStatus(); if (!isAuthenticated) { - history.push(LOGIN_ROUTE); + safeHistoryPush(LOGIN_ROUTE); return; } const step = await sendFlowRepository.getStep(); switch (step) { case SendFlowStep.AssetSelected: - history.push(SEND_ADDRESS_AMOUNT_ROUTE); + safeHistoryPush(SEND_ADDRESS_AMOUNT_ROUTE); break; case SendFlowStep.AddressAmountFormDone: - history.push(SEND_CHOOSE_FEE_ROUTE); + safeHistoryPush(SEND_CHOOSE_FEE_ROUTE); break; case SendFlowStep.FeeFormDone: - history.push(SEND_CONFIRMATION_ROUTE); + safeHistoryPush(SEND_CONFIRMATION_ROUTE); + break; + case SendFlowStep.Lightning: + safeHistoryPush(LIGHTNING_ENTER_INVOICE_ROUTE); break; } })().catch(console.error); diff --git a/src/extension/wallet/receive/lightning-enter-amount.tsx b/src/extension/wallet/receive/lightning-enter-amount.tsx new file mode 100644 index 00000000..77a774f3 --- /dev/null +++ b/src/extension/wallet/receive/lightning-enter-amount.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useHistory } from 'react-router'; +import { address, networks } from 'liquidjs-lib'; +import ShellPopUp from '../../components/shell-popup'; +import cx from 'classnames'; +import Button from '../../components/button'; +import LightningShowInvoice from './lightning-show-invoice'; +import { SEND_PAYMENT_SUCCESS_ROUTE } from '../../routes/constants'; +import { fromSatoshi, toSatoshi } from '../../utility'; +import { useStorageContext } from '../../context/storage-context'; +import * as ecc from 'tiny-secp256k1'; +import { toBlindingData } from 'liquidjs-lib/src/psbt'; +import { randomBytes } from 'crypto'; +import ECPairFactory from 'ecpair'; +import { Boltz, boltzUrl } from '../../../pkg/boltz'; +import zkp from '@vulpemventures/secp256k1-zkp'; +import { AccountFactory, MainAccount, MainAccountTest } from '../../../application/account'; +import { toOutputScript } from 'liquidjs-lib/src/address'; +import { Spinner } from '../../components/spinner'; + +const zkpLib = await zkp(); + +const LightningAmount: React.FC = () => { + const history = useHistory(); + const { appRepository, walletRepository, cache } = useStorageContext(); + const [errors, setErrors] = useState({ amount: '', submit: '' }); + const [invoice, setInvoice] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [limits, setLimits] = useState({ minimal: 0, maximal: 0 }); + const [lookingForPayment, setIsLookingForPayment] = useState(false); + const [touched, setTouched] = useState(false); + + const swapValue = useRef(''); + const network = cache?.network ?? 'liquid'; + const boltz = new Boltz(boltzUrl[network], networks[network].assetHash, zkpLib); + + useEffect(() => { + // get maximal and minimal amount for pair + const fetchData = async () => { + const pair = await boltz.getBoltzPair('L-BTC/BTC'); + if (pair?.limits) { + setLimits({ + maximal: fromSatoshi(pair.limits.maximal), + minimal: fromSatoshi(pair.limits.minimal), + }); + } + }; + fetchData().catch(console.error); + }, []); + + const handleBackBtn = () => history.goBack(); + + const handleChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + if (!limits.minimal) return; + + setTouched(true); + + const value = event.target.value; + + if (!value) { + setTouched(false); + setErrors({ amount: '', submit: '' }); + swapValue.current = ''; + return; + } + + if (Number.isNaN(value)) { + setErrors({ amount: 'Not valid number', submit: '' }); + swapValue.current = ''; + return; + } + + if (Number(value) <= 0) { + setErrors({ amount: 'Number must be positive', submit: '' }); + swapValue.current = ''; + return; + } + + if (Number(value) < limits.minimal) { + setErrors({ amount: `Number must be equal or higher then ${limits.minimal}`, submit: '' }); + swapValue.current = ''; + return; + } + + if (Number(value) > limits.maximal) { + setErrors({ amount: `Number must be equal or lower then ${limits.maximal}`, submit: '' }); + swapValue.current = ''; + return; + } + + setErrors({ amount: '', submit: '' }); + swapValue.current = value; + }; + + const handleUnlock = async () => { + // disable Generate button + setIsSubmitting(true); + + try { + // we will create an ephemeral key pair: + // - it will generate a public key to be used with the Boltz swap + // - later we will sign the claim transaction with the private key + const claimPrivateKey = randomBytes(32); + const claimKeyPair = ECPairFactory(ecc).fromPrivateKey(claimPrivateKey); + const claimPublicKey = claimKeyPair.publicKey; + + // create reverse submarine swap + const { redeemScript, lockupAddress, invoice, preimage, blindingPrivateKey } = + await boltz.createReverseSubmarineSwap( + claimPublicKey, + network, + toSatoshi(Number(swapValue.current)) + ); + + // all good, update states + setInvoice(invoice); + setIsLookingForPayment(true); + + // prepare timeout handler + const invoiceExpireDate = Number(boltz.getInvoiceExpireDate(invoice)); + + const invoiceExpirationTimeout = setTimeout(() => { + setErrors({ submit: 'Invoice has expired', amount: '' }); + setIsSubmitting(false); + setIsLookingForPayment(false); + return; + }, invoiceExpireDate - Date.now()); + + const chainSource = await appRepository.getChainSource(network); + if (!chainSource) throw new Error('chain source is not set up, cannot broadcast'); + // wait for tx to be available (mempool or confirmed) + await chainSource.waitForAddressReceivesTx(lockupAddress); + + // fetch utxos for address + const [utxo] = await chainSource.listUnspents(lockupAddress); + const { asset, assetBlindingFactor, value, valueBlindingFactor } = await toBlindingData( + Buffer.from(blindingPrivateKey, 'hex'), + utxo.witnessUtxo + ); + utxo['blindingData'] = { + asset: asset.reverse().toString('hex'), + assetBlindingFactor: assetBlindingFactor.toString('hex'), + value: parseInt(value, 10), + valueBlindingFactor: valueBlindingFactor.toString('hex'), + }; + + // Claim transaction + if ( + utxo.witnessUtxo?.script.toString('hex') === + address.toOutputScript(lockupAddress).toString('hex') + ) { + clearTimeout(invoiceExpirationTimeout); + + // Receiving address + const accountFactory = await AccountFactory.create(walletRepository); + const accountName = network === 'liquid' ? MainAccount : MainAccountTest; + const mainAccount = await accountFactory.make(network, accountName); + const addr = await mainAccount.getNextAddress(false); + const blindingPublicKey = address.fromConfidential(addr.confidentialAddress).blindingKey; + const destinationScript = toOutputScript(addr.confidentialAddress).toString('hex'); + + const claimTransaction = boltz.makeClaimTransaction({ + utxo, + claimKeyPair, + preimage, + redeemScript: Buffer.from(redeemScript, 'hex'), + destinationScript: Buffer.from(destinationScript, 'hex'), + blindingPublicKey, + }); + + await chainSource.broadcastTransaction(claimTransaction.toHex()); + + history.push({ + pathname: SEND_PAYMENT_SUCCESS_ROUTE, + state: { txhex: claimTransaction.toHex(), text: 'Payment received!' }, + }); + } + } catch (err: any) { + setErrors({ submit: err.message, amount: '' }); + setIsSubmitting(false); + setIsLookingForPayment(false); + } + }; + + return ( + + {!limits.minimal ? ( + + ) : invoice ? ( + + ) : ( +
+
+
+ +
+

+ {errors.submit && errors.submit} + {errors.amount && touched && errors.amount} +

+
+ +
+
+
+ )} +
+ ); +}; + +export default LightningAmount; diff --git a/src/extension/wallet/receive/lightning-show-invoice.tsx b/src/extension/wallet/receive/lightning-show-invoice.tsx new file mode 100644 index 00000000..8e91d9ea --- /dev/null +++ b/src/extension/wallet/receive/lightning-show-invoice.tsx @@ -0,0 +1,56 @@ +import QRCode from 'qrcode.react'; +import { useState } from 'react'; +import Button from '../../components/button'; +import { formatAddress } from '../../utility'; + +interface LightningShowInvoiceViewProps { + errors: any; + invoice: string; +} + +const LightningShowInvoice = ({ errors, invoice }: LightningShowInvoiceViewProps) => { + const [buttonText, setButtonText] = useState('Copy'); + const [isInvoiceExpanded, setisInvoiceExpanded] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(invoice).then( + () => setButtonText('Copied'), + (err) => console.error('Could not copy text: ', err) + ); + }; + + const AuxiliarButton = ({ children }: { children: React.ReactNode }) => ( + + ); + + return ( +
+

⏳ Waiting for payment...

+ {isInvoiceExpanded ? ( + <> +

{invoice}

+ Show QR Code + + ) : ( + <> + +

{formatAddress(invoice)}

+ Expand + + )} + {errors.submit && ( +

{errors.submit}

+ )} + +
+ ); +}; + +export default LightningShowInvoice; diff --git a/src/extension/wallet/receive/receive-select-asset.tsx b/src/extension/wallet/receive/receive-select-asset.tsx index fac3ffa3..c6ee6e0f 100644 --- a/src/extension/wallet/receive/receive-select-asset.tsx +++ b/src/extension/wallet/receive/receive-select-asset.tsx @@ -1,22 +1,26 @@ import React from 'react'; import { useHistory } from 'react-router'; -import { RECEIVE_ADDRESS_ROUTE } from '../../routes/constants'; +import { LIGHTNING_ENTER_AMOUNT_ROUTE, RECEIVE_ADDRESS_ROUTE } from '../../routes/constants'; import AssetListScreen from '../../components/asset-list-screen'; import type { Asset } from 'marina-provider'; import { useStorageContext } from '../../context/storage-context'; +import { UNKNOWN_ASSET_HASH } from '../../../domain/constants'; const ReceiveSelectAsset: React.FC = () => { const { cache } = useStorageContext(); const history = useHistory(); - const handleSend = (asset: string) => { - return Promise.resolve(history.push(`${RECEIVE_ADDRESS_ROUTE}/${asset}`)); + const handleReceive = async (asset: string, isSubmarineSwap: boolean) => { + const route = isSubmarineSwap + ? LIGHTNING_ENTER_AMOUNT_ROUTE + : `${RECEIVE_ADDRESS_ROUTE}/${asset}`; + return Promise.resolve(history.push(route)); }; return ( @@ -28,6 +32,7 @@ const ReceiveSelectAsset: React.FC = () => { } ) )} + network={cache?.network || 'liquid'} /> ); }; @@ -36,7 +41,7 @@ const UnknowAsset: Asset = { ticker: 'Any', name: 'New asset', precision: 8, - assetHash: 'new_asset', + assetHash: UNKNOWN_ASSET_HASH, }; export default ReceiveSelectAsset; diff --git a/src/extension/wallet/send/end-of-flow.tsx b/src/extension/wallet/send/end-of-flow.tsx index 008c5f16..e4527f12 100644 --- a/src/extension/wallet/send/end-of-flow.tsx +++ b/src/extension/wallet/send/end-of-flow.tsx @@ -27,7 +27,6 @@ const SendEndOfFlow: React.FC = () => { const handleUnlock = async function (password: string) { let extractedTx = undefined; - console.log('signed pset'); try { const unsignedPset = await sendFlowRepository.getUnsignedPset(); if (!unsignedPset) throw new Error('unsigned pset not found'); diff --git a/src/extension/wallet/send/lightning-enter-invoice.tsx b/src/extension/wallet/send/lightning-enter-invoice.tsx new file mode 100644 index 00000000..7e963426 --- /dev/null +++ b/src/extension/wallet/send/lightning-enter-invoice.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import { useHistory } from 'react-router'; +import ShellPopUp from '../../components/shell-popup'; +import cx from 'classnames'; +import Button from '../../components/button'; +import { SEND_CHOOSE_FEE_ROUTE } from '../../routes/constants'; +import { networks } from 'liquidjs-lib'; +import { fromSatoshi, toSatoshi } from '../../utility'; +import { AccountFactory, MainAccount, MainAccountTest } from '../../../application/account'; +import { useStorageContext } from '../../context/storage-context'; +import { BIP32Factory } from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import type { BoltzPair } from '../../../pkg/boltz'; +import { Boltz, boltzUrl } from '../../../pkg/boltz'; +import zkp from '@vulpemventures/secp256k1-zkp'; +import { Spinner } from '../../components/spinner'; + +const zkpLib = await zkp(); + +const LightningInvoice: React.FC = () => { + const history = useHistory(); + const { cache, sendFlowRepository, walletRepository } = useStorageContext(); + const [swapFees, setSwapFees] = useState(0); + const [error, setError] = useState(''); + const [invoice, setInvoice] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [pair, setPair] = useState(); + const [touched, setTouched] = useState(false); + const [value, setValue] = useState(0); + const [warning, setWarning] = useState(''); + + const network = cache?.network ?? 'liquid'; + const boltz = new Boltz(boltzUrl[network], networks[network].assetHash, zkpLib); + + void sendFlowRepository.setLightning(true); + + // get maximal and minimal amount for pair + useEffect(() => { + const fetchData = async () => { + const _pair = await boltz.getBoltzPair('L-BTC/BTC'); + if (_pair) setPair(_pair); + }; + fetchData().catch(console.error); + }, []); + + const handleBackBtn = () => history.goBack(); + + const handleChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + setError(''); + setWarning(''); + setTouched(true); + + if (!pair) return; + + const invoice = event.target.value; + + if (!invoice) { + setTouched(false); + setValue(0); + return; + } + + const lbtcBalance = cache?.balances.value[networks[network].assetHash] ?? 0; + + try { + // get value from invoice + const value = boltz.getInvoiceValue(invoice); + const valueInSats = toSatoshi(value); + const fees = boltz.calcBoltzFees(pair, valueInSats); + + setSwapFees(fees); + setValue(value); + + const { minimal, maximal } = pair.limits; + + // validate value + if (Number.isNaN(value)) return setError('Invalid value'); + if (valueInSats <= 0) return setError('Value must be positive'); + if (valueInSats < minimal) return setError(`Value must be higher or equal then ${minimal}`); + if (valueInSats > maximal) return setError(`Value must be lower or equal then ${maximal}`); + if (valueInSats + fees > lbtcBalance) return setError('Insufficient funds to pay swap fee'); + if (valueInSats > lbtcBalance) return setError('Insufficient funds'); + + if (valueInSats + fees + 300 > lbtcBalance) + setWarning('You may not have enough funds to pay swap'); + + setInvoice(invoice); + } catch (_) { + setError('Invalid invoice'); + } + }; + + const handleProceed = async () => { + setIsSubmitting(true); + + // get account + const accountFactory = await AccountFactory.create(walletRepository); + const accountName = network === 'liquid' ? MainAccount : MainAccountTest; + const mainAccount = await accountFactory.make(network, accountName); + + // get refund pub key and change address + const refundAddress = await mainAccount.getNextAddress(false); + const accountDetails = Object.values(await walletRepository.getAccountDetails(accountName))[0]; + const refundPublicKey = BIP32Factory(ecc) + .fromBase58(accountDetails.masterXPub) + .derivePath(refundAddress.derivationPath?.replace('m/', '') ?? '') + .publicKey.toString('hex'); + + try { + // create submarine swap + const { address, expectedAmount } = await boltz.createSubmarineSwap( + invoice, + network, + refundPublicKey + ); + + // push to store payment to be made + await sendFlowRepository.setReceiverAddressAmount(address, expectedAmount); + + // go to choose fee route + history.push(SEND_CHOOSE_FEE_ROUTE); + } catch (err: any) { + setError(err.message); + setIsSubmitting(false); + return; + } + }; + + const isButtonDisabled = () => Boolean(!invoice || error || isSubmitting); + + return ( + + {!pair?.limits.minimal ? ( + + ) : ( +
+
+
+ +
+ {value > 0 && touched && ( +

Invoice value {value} BTC

+ )} + {swapFees > 0 && touched && ( +

+ Swap fee {fromSatoshi(swapFees)} L-BTC +

+ )} + {error && touched && ( +

{error}

+ )} + {warning && touched && ( +

{warning}

+ )} +
+ +
+
+
+ )} +
+ ); +}; + +export default LightningInvoice; diff --git a/src/extension/wallet/send/payment-success.tsx b/src/extension/wallet/send/payment-success.tsx index fc2bbdfd..b32df3f1 100644 --- a/src/extension/wallet/send/payment-success.tsx +++ b/src/extension/wallet/send/payment-success.tsx @@ -10,6 +10,7 @@ import { makeURLwithBlinders } from '../../../domain/transaction'; import { useStorageContext } from '../../context/storage-context'; interface LocationState { + text?: string; txhex: string; } @@ -40,7 +41,7 @@ const PaymentSuccessView: React.FC = () => { currentPage="Success" hasBackBtn={false} > -

Payment successful !

+

{state.text ?? 'Payment successful!'}