From bed2c190fe82795d53e50290c6d614bb06d3c504 Mon Sep 17 00:00:00 2001 From: Louis Singer <41042567+louisinger@users.noreply.github.com> Date: Fri, 7 Apr 2023 19:46:16 +0200 Subject: [PATCH] Improve Restoration and Update process (#460) * add custom restoring loader in onboarding/end-of-flow page * add spinner in onboarding/end-of-flow * assetRegistry port * fix onboarding mnemonic input trim issue * faster StorageContext cache * add Presenter * present update * let marina-logo click triggers a store update * Presenter fix: do not update utxos set onNewTransaction event * sortAssets in Presenter * fix tx flow computing & uncomfirmed event * always add unblinded input * use prevoutInputIndex to fetch blinding data while computing txflow * add "WalletAssets" member in PresentationCache --- src/application/account.ts | 7 +- src/application/blinder.ts | 14 +- src/application/presenter.ts | 313 ++++++++++++++++ src/application/unblinder.ts | 32 +- src/application/updater.ts | 336 ++++++++++++++++++ src/application/utils.ts | 41 --- src/background/background-script.ts | 40 ++- src/background/subscriber.ts | 26 +- src/background/taxi-updater.ts | 9 +- src/background/updater.ts | 167 --------- src/domain/chainsource.ts | 2 +- src/domain/presenter.ts | 25 ++ src/domain/repository.ts | 14 +- src/domain/transaction.ts | 48 +-- src/extension/components/balance.tsx | 9 +- src/extension/components/button-list.tsx | 47 ++- .../components/button-transaction.tsx | 24 +- src/extension/components/mermaid-loader.tsx | 2 +- src/extension/components/shell-popup.tsx | 42 ++- src/extension/components/spinner.tsx | 26 ++ src/extension/context/storage-context.tsx | 206 ++--------- .../onboarding/end-of-flow/index.tsx | 128 ++++--- src/extension/popups/spend.tsx | 6 +- src/extension/utility/sort.ts | 31 -- src/extension/wallet/home/index.tsx | 82 +++-- .../wallet/receive/receive-select-asset.tsx | 12 +- src/extension/wallet/send/address-amount.tsx | 6 +- src/extension/wallet/send/choose-fee.tsx | 4 +- .../wallet/send/send-select-asset.tsx | 15 +- src/extension/wallet/transactions/index.tsx | 4 +- .../storage/asset-repository.ts | 19 +- .../storage/blockheaders-repository.ts | 40 ++- .../storage/wallet-repository.ts | 41 ++- src/port/asset-registry.ts | 77 ++++ src/port/electrum-chain-source.ts | 9 +- test/application.spec.ts | 19 +- 36 files changed, 1304 insertions(+), 619 deletions(-) create mode 100644 src/application/presenter.ts create mode 100644 src/application/updater.ts delete mode 100644 src/background/updater.ts create mode 100644 src/domain/presenter.ts create mode 100644 src/extension/components/spinner.tsx delete mode 100644 src/extension/utility/sort.ts create mode 100644 src/port/asset-registry.ts diff --git a/src/application/account.ts b/src/application/account.ts index a798e72a..8f93ce87 100644 --- a/src/application/account.ts +++ b/src/application/account.ts @@ -115,11 +115,11 @@ export class AccountFactory { // Account is a readonly way to interact with the account data (transactions, utxos, scripts, etc.) export class Account { - private network: networks.Network; private node: BIP32Interface; private blindingKeyNode: Slip77Interface; private walletRepository: WalletRepository; private _cacheAccountType: AccountType | undefined; + readonly network: networks.Network; readonly name: string; static BASE_DERIVATION_PATH = "m/84'/1776'/0'"; @@ -241,6 +241,7 @@ export class Account { gapLimit = GAP_LIMIT, start?: { internal: number; external: number } ): Promise<{ + txIDsFromChain: string[]; next: { internal: number; external: number }; }> { const type = await this.getAccountType(); @@ -272,7 +273,6 @@ export class Account { const scripts = scriptsWithDetails.map(([script]) => h2b(script)); const histories = await chainSource.fetchHistories(scripts); - for (const [index, history] of histories.entries()) { tempRestoredScripts[scriptsWithDetails[index][0]] = scriptsWithDetails[index][1]; if (history.length > 0) { @@ -313,6 +313,7 @@ export class Account { ]); return { + txIDsFromChain: Array.from(historyTxsId), next: { internal: indexes.internal, external: indexes.external, @@ -428,7 +429,7 @@ export class Account { return results; } - private async getNextIndexes(): Promise<{ internal: number; external: number }> { + async getNextIndexes(): Promise<{ internal: number; external: number }> { if (!this.walletRepository || !this.name) return { internal: 0, external: 0 }; const { [this.name]: accountDetails } = await this.walletRepository.getAccountDetails( this.name diff --git a/src/application/blinder.ts b/src/application/blinder.ts index 0d6b7b01..e9e4a149 100644 --- a/src/application/blinder.ts +++ b/src/application/blinder.ts @@ -14,12 +14,14 @@ export class BlinderService { async blindPset(pset: Pset): Promise { const ownedInputs: OwnedInput[] = []; - for (const [inputIndex, input] of pset.inputs.entries()) { - const unblindOutput = await this.walletRepository.getOutputBlindingData( - Buffer.from(input.previousTxid).reverse().toString('hex'), - input.previousTxIndex - ); - + const inputsBlindingData = await this.walletRepository.getOutputBlindingData( + ...pset.inputs.map(({ previousTxIndex, previousTxid }) => ({ + txID: Buffer.from(previousTxid).reverse().toString('hex'), + vout: previousTxIndex, + })) + ); + for (const inputIndex of pset.inputs.keys()) { + const unblindOutput = inputsBlindingData.at(inputIndex); if (!unblindOutput || !unblindOutput.blindingData) continue; ownedInputs.push({ asset: AssetHash.fromHex(unblindOutput.blindingData.asset).bytesWithoutPrefix, diff --git a/src/application/presenter.ts b/src/application/presenter.ts new file mode 100644 index 00000000..2ba56d6f --- /dev/null +++ b/src/application/presenter.ts @@ -0,0 +1,313 @@ +import type { Asset, NetworkString } from 'marina-provider'; +import type { LoadingValue, PresentationCache, Presenter } from '../domain/presenter'; +import type { + AppRepository, + AssetRepository, + BlockheadersRepository, + WalletRepository, +} from '../domain/repository'; +import type { TxDetails } from '../domain/transaction'; +import { computeBalances, computeTxDetailsExtended } from '../domain/transaction'; +import type { BlockHeader } from '../domain/chainsource'; +import { MainAccount, MainAccountLegacy, MainAccountTest } from './account'; + +function createLoadingValue(value: T): LoadingValue { + return { + value, + loading: false, + }; +} + +const setValue = (value: T): LoadingValue => ({ + loading: false, + value, +}); + +const setLoading = (loadingValue: LoadingValue): LoadingValue => ({ + ...loadingValue, + loading: true, +}); + +export class PresenterImpl implements Presenter { + private state: PresentationCache = { + network: 'liquid', + authenticated: createLoadingValue(false), + balances: createLoadingValue({}), + utxos: createLoadingValue([]), + assetsDetails: createLoadingValue>({}), + transactions: createLoadingValue([]), + blockHeaders: createLoadingValue>({}), + walletAssets: createLoadingValue(new Set()), + }; + private closeFunction: (() => void) | null = null; + + constructor( + private appRepository: AppRepository, + private walletRepository: WalletRepository, + private assetsRepository: AssetRepository, + private blockHeadersRepository: BlockheadersRepository + ) {} + + stop() { + if (this.closeFunction) { + this.closeFunction(); + this.closeFunction = null; + } + } + + async present(emits: (cache: PresentationCache) => void) { + this.state = { + ...this.state, + authenticated: setLoading(this.state.authenticated), + balances: setLoading(this.state.balances), + utxos: setLoading(this.state.utxos), + assetsDetails: setLoading(this.state.assetsDetails), + transactions: setLoading(this.state.transactions), + walletAssets: setLoading(this.state.walletAssets), + blockHeaders: setLoading(this.state.blockHeaders), + }; + emits(this.state); + + this.state = await this.updateNetwork(); + this.state = await this.updateAuthenticated(); + emits(this.state); + this.state = await this.updateUtxos(); + this.state = await this.updateBalances(); + emits(this.state); + this.state = await this.updateAssets(); + emits(this.state); + this.state = await this.updateTransactions(); + emits(this.state); + this.state = await this.updateBlockHeaders(); + emits(this.state); + + const closeFns: (() => void)[] = []; + + closeFns.push( + this.blockHeadersRepository.onNewBlockHeader((network, blockHeader) => { + if (network !== this.state.network) return Promise.resolve(); + this.state = { + ...this.state, + blockHeaders: setValue({ + ...this.state.blockHeaders.value, + [blockHeader.height]: blockHeader, + }), + }; + emits(this.state); + return Promise.resolve(); + }) + ); + + closeFns.push( + this.appRepository.onNetworkChanged(async () => { + this.state = await this.updateNetwork(); + this.state = { + ...this.state, + balances: setLoading(this.state.balances), + utxos: setLoading(this.state.utxos), + assetsDetails: setLoading(this.state.assetsDetails), + blockHeaders: setLoading(this.state.blockHeaders), + transactions: setLoading(this.state.transactions), + walletAssets: setLoading(this.state.walletAssets), + }; + emits(this.state); + + this.state = await this.updateUtxos(); + this.state = await this.updateBalances(); + emits(this.state); + this.state = await this.updateAssets(); + emits(this.state); + this.state = await this.updateTransactions(); + emits(this.state); + this.state = await this.updateBlockHeaders(); + emits(this.state); + }) + ); + + closeFns.push( + this.appRepository.onIsAuthenticatedChanged(async (authenticated) => { + this.state = { + ...this.state, + authenticated: setValue(authenticated), + }; + emits(this.state); + return Promise.resolve(); + }) + ); + + closeFns.push( + this.walletRepository.onNewTransaction(async (_, details: TxDetails) => { + if (!this.state.authenticated.value) return; + const scripts = await this.walletRepository.getAccountScripts( + this.state.network, + MainAccountLegacy, + this.state.network === 'liquid' ? MainAccount : MainAccountTest + ); + const extendedTxDetails = await computeTxDetailsExtended( + this.appRepository, + this.walletRepository, + scripts + )(details); + this.state = { + ...this.state, + transactions: setValue( + [extendedTxDetails, ...this.state.transactions.value].sort(sortTxDetails) + ), + walletAssets: setValue( + new Set([...this.state.walletAssets.value, ...Object.keys(extendedTxDetails.txFlow)]) + ), + }; + }) + ); + + closeFns.push( + this.assetsRepository.onNewAsset((asset) => { + if (!this.state.authenticated.value) return Promise.resolve(); + this.state = { + ...this.state, + assetsDetails: setValue({ ...this.state.assetsDetails.value, [asset.assetHash]: asset }), + }; + emits(this.state); + return Promise.resolve(); + }) + ); + + closeFns.push( + ...['liquid', 'testnet', 'regtest'] + .map((network) => [ + this.walletRepository.onNewUtxo(network as NetworkString)( + async ({ txID, vout, blindingData }) => { + if (!this.state.authenticated.value) return; + if (network !== this.state.network) return; + this.state = { + ...this.state, + utxos: setValue([...this.state.utxos.value, { txID, vout, blindingData }]), + }; + this.state = await this.updateBalances(); + emits(this.state); + } + ), + this.walletRepository.onDeleteUtxo(network as NetworkString)(async ({ txID, vout }) => { + if (!this.state.authenticated.value) return; + if (network !== this.state.network) return; + this.state = { + ...this.state, + utxos: setValue( + this.state.utxos.value.filter((utxo) => utxo.txID !== txID || utxo.vout !== vout) + ), + }; + this.state = await this.updateBalances(); + emits(this.state); + }), + ]) + .flat() + ); + + closeFns.push( + this.walletRepository.onUnblindingEvent(async ({ txID, vout, blindingData }) => { + if (!this.state.authenticated.value) return; + if (this.state.utxos.value.find((utxo) => utxo.txID === txID && utxo.vout === vout)) { + this.state = { + ...this.state, + utxos: setValue( + this.state.utxos.value.map((utxo) => + utxo.txID === txID && utxo.vout === vout ? { txID, vout, blindingData } : utxo + ) + ), + }; + emits(this.state); + this.state = await this.updateBalances(); + emits(this.state); + } + this.state = await this.updateTransactions(); + emits(this.state); + }) + ); + + this.closeFunction = () => { + closeFns.forEach((fn) => fn()); + }; + } + + private async updateNetwork(): Promise { + const network = await this.appRepository.getNetwork(); + if (!network) return this.state; + return { + ...this.state, + network, + }; + } + + private async updateAuthenticated(): Promise { + const { isAuthenticated } = await this.appRepository.getStatus(); + return { + ...this.state, + authenticated: setValue(isAuthenticated), + }; + } + + private async updateUtxos(): Promise { + const utxos = await this.walletRepository.getUtxos(this.state.network); + return { + ...this.state, + utxos: setValue(utxos), + }; + } + + private async updateBalances(): Promise { + return Promise.resolve({ + ...this.state, + balances: setValue(computeBalances(this.state.utxos.value)), + }); + } + + private async updateAssets(): Promise { + const assets = await this.assetsRepository.getAllAssets(this.state.network); + return { + ...this.state, + assetsDetails: setValue(Object.fromEntries(assets.map((asset) => [asset.assetHash, asset]))), + }; + } + + private async updateTransactions(): Promise { + const transactions = await this.walletRepository.getTransactions(this.state.network); + const details = await this.walletRepository.getTxDetails(...transactions); + const scripts = await this.walletRepository.getAccountScripts( + this.state.network, + MainAccountLegacy, + this.state.network === 'liquid' ? MainAccount : MainAccountTest + ); + const extendedTxDetails = await Promise.all( + Object.values(details).map( + computeTxDetailsExtended(this.appRepository, this.walletRepository, scripts) + ) + ); + const assetsInTransactions = extendedTxDetails.reduce( + (acc, tx) => [...acc, ...Object.keys(tx.txFlow)], + [] as string[] + ); + return { + ...this.state, + transactions: setValue(extendedTxDetails.sort(sortTxDetails)), + walletAssets: setValue(new Set(assetsInTransactions)), + }; + } + + private async updateBlockHeaders(): Promise { + const blockHeaders = await this.blockHeadersRepository.getAllBlockHeaders(this.state.network); + + return { + ...this.state, + blockHeaders: setValue(blockHeaders), + }; + } +} + +// sort function for txDetails, use the height member to sort +// put unconfirmed txs first and then sort by height (desc) +function sortTxDetails(a: TxDetails, b: TxDetails): number { + if (a.height === b.height) return 0; + if (!a.height || a.height === -1) return -1; + if (!b.height || b.height === -1) return 1; + return b.height - a.height; +} diff --git a/src/application/unblinder.ts b/src/application/unblinder.ts index 7a449b9c..c8273ba3 100644 --- a/src/application/unblinder.ts +++ b/src/application/unblinder.ts @@ -2,16 +2,17 @@ import * as ecc from 'tiny-secp256k1'; import { AssetHash, confidential } from 'liquidjs-lib'; import type { ZKPInterface } from 'liquidjs-lib/src/confidential'; import { confidentialValueToSatoshi } from 'liquidjs-lib/src/confidential'; -import type { Output } from 'liquidjs-lib/src/transaction'; +import type { Output, Transaction } from 'liquidjs-lib/src/transaction'; import { SLIP77Factory } from 'slip77'; import type { AppRepository, AssetRepository, WalletRepository } from '../domain/repository'; import type { UnblindingData } from '../domain/transaction'; -import { assetIsUnknown, fetchAssetDetails } from './utils'; +import { DefaultAssetRegistry } from '../port/asset-registry'; const slip77 = SLIP77Factory(ecc); export interface Unblinder { unblind(...outputs: Output[]): Promise<(UnblindingData | Error)[]>; + unblindTxs(...txs: Transaction[]): Promise<[{ txID: string; vout: number }, UnblindingData][]>; } export class WalletRepositoryUnblinder implements Unblinder { @@ -70,6 +71,7 @@ export class WalletRepositoryUnblinder implements Unblinder { } const network = (await this.appRepository.getNetwork()) ?? 'liquid'; + const assetRegistry = new DefaultAssetRegistry(network); const successfullyUnblinded = unblindingResults.filter( (r): r is UnblindingData => !(r instanceof Error) @@ -77,13 +79,35 @@ export class WalletRepositoryUnblinder implements Unblinder { const assetSet = new Set(successfullyUnblinded.map((u) => u.asset)); for (const asset of assetSet) { const assetDetails = await this.assetRepository.getAsset(asset); - if (assetDetails && !assetIsUnknown(assetDetails)) continue; - const assetFromExplorer = await fetchAssetDetails(network, asset); + if (assetDetails && assetDetails.ticker !== assetDetails.assetHash.substring(0, 4)) continue; + const assetFromExplorer = await assetRegistry.getAsset(asset); await this.assetRepository.addAsset(asset, assetFromExplorer); } return unblindingResults; } + + async unblindTxs( + ...txs: Transaction[] + ): Promise<[{ txID: string; vout: number }, UnblindingData][]> { + const unblindedOutpoints: Array<[{ txID: string; vout: number }, UnblindingData]> = []; + + for (const tx of txs) { + const unblindedResults = await this.unblind(...tx.outs); + const txID = tx.getId(); + for (const [vout, unblinded] of unblindedResults.entries()) { + if (unblinded instanceof Error) { + if (unblinded.message === 'secp256k1_rangeproof_rewind') continue; + if (unblinded.message === 'Empty script: fee output') continue; + console.error('Error while unblinding', unblinded); + continue; + } + unblindedOutpoints.push([{ txID, vout }, unblinded]); + } + } + + return unblindedOutpoints; + } } const emptyNonce: Buffer = Buffer.from('0x00', 'hex'); diff --git a/src/application/updater.ts b/src/application/updater.ts new file mode 100644 index 00000000..98b26c7c --- /dev/null +++ b/src/application/updater.ts @@ -0,0 +1,336 @@ +import type { TxOutput } from 'liquidjs-lib'; +import { Transaction } from 'liquidjs-lib'; +import Browser from 'webextension-polyfill'; +import type { Unblinder } from './unblinder'; +import { WalletRepositoryUnblinder } from './unblinder'; +import type { TxDetails, UnblindingData } from '../domain/transaction'; +import type { + WalletRepository, + AppRepository, + AssetRepository, + BlockheadersRepository, + Outpoint, +} from '../domain/repository'; +import { TxIDsKey } from '../infrastructure/storage/wallet-repository'; +import type { ZKPInterface } from 'liquidjs-lib/src/confidential'; +import type { NetworkString } from 'marina-provider'; +import { AppStorageKeys } from '../infrastructure/storage/app-repository'; +import type { ChainSource } from '../domain/chainsource'; +import { DefaultAssetRegistry } from '../port/asset-registry'; +import { AccountFactory } from './account'; + +/** + * Updater is a class that listens to the chrome storage changes and triggers the right actions + * to update the wallet state. + * Each time a new script or transaction is added to the storage, tries to update and unblind utxos set. + */ +export class UpdaterService { + private processingCount = 0; + private unblinder: Unblinder; + private listener: + | ((changes: Record) => Promise) + | undefined; + + constructor( + private walletRepository: WalletRepository, + private appRepository: AppRepository, + private blockHeadersRepository: BlockheadersRepository, + private assetRepository: AssetRepository, + zkpLib: ZKPInterface + ) { + this.unblinder = new WalletRepositoryUnblinder( + walletRepository, + appRepository, + assetRepository, + zkpLib + ); + } + + // set up the onChanged chrome storage listener + async start() { + if (this.listener) await this.stop(); + this.listener = this.onChangesListener(); + Browser.storage.onChanged.addListener(this.listener); + } + + // remove the onChanged chrome storage listener + async stop() { + await this.waitForProcessing(); + if (this.listener) { + Browser.storage.onChanged.removeListener(this.listener); + this.listener = undefined; + } + } + + async checkAndFixMissingTransactionsData(network: NetworkString) { + this.processingCount += 1; + try { + await this.updateLastestHistory(network); + const txs = await this.walletRepository.getTransactions(network); + if (txs.length === 0) return; + const txsDetailsRecord = await this.walletRepository.getTxDetails(...txs); + const chainSource = await this.appRepository.getChainSource(network); + if (!chainSource) throw new Error('Chain source not found for network ' + network); + const newDetails = await this.fixMissingHex(chainSource, txsDetailsRecord); + + const txsDetails = [...Object.values(txsDetailsRecord), ...newDetails]; + await Promise.all([ + this.fixMissingBlockHeaders(network, chainSource, txsDetails).finally(async () => { + await chainSource.close(); + }), + this.fixMissingUnblindingData(txsDetails), + ]); + await this.fixMissingAssets(network); + } finally { + this.processingCount -= 1; + } + } + + private async updateLastestHistory(network: NetworkString) { + const LAST_ADDRESSES_COUNT = 30; + const chainSource = await this.appRepository.getChainSource(network); + if (!chainSource) throw new Error('Chain source not found for network ' + network); + const accountFactory = await AccountFactory.create(this.walletRepository); + const accounts = await accountFactory.makeAll(network); + // get the last max 30 addresses for all accounts + const scripts = []; + + for (const account of accounts) { + const nextIndexes = await account.getNextIndexes(); + const scriptsAccounts = await this.walletRepository.getAccountScripts(network, account.name); + const lastScripts = Object.entries(scriptsAccounts) + .filter(([_, { derivationPath }]) => { + if (!derivationPath) return false; + const splittedPath = derivationPath.split('/'); + const index = splittedPath.pop(); + const isChange = splittedPath.pop(); + + if (!index) return false; + return ( + parseInt(index) >= + (isChange ? nextIndexes.internal : nextIndexes.external) - LAST_ADDRESSES_COUNT + ); + }) + .map(([script]) => Buffer.from(script, 'hex')); + + scripts.push(...lastScripts); + } + + const histories = await chainSource.fetchHistories(scripts); + await Promise.all([ + this.walletRepository.addTransactions( + network, + ...histories.flat().map(({ tx_hash }) => tx_hash) + ), + this.walletRepository.updateTxDetails( + Object.fromEntries(histories.flat().map(({ tx_hash, height }) => [tx_hash, { height }])) + ), + ]); + } + + private async fixMissingBlockHeaders( + network: NetworkString, + chainSource: ChainSource, + txsDetails: TxDetails[] + ) { + const heightSet = new Set(); + for (const txDetails of txsDetails) { + if (txDetails.height && txDetails.height >= 0) { + if ( + (await this.blockHeadersRepository.getBlockHeader(network, txDetails.height)) === + undefined + ) { + heightSet.add(txDetails.height); + } + } + } + + const blockHeaders = await chainSource.fetchBlockHeaders(Array.from(heightSet)); + await this.blockHeadersRepository.setBlockHeaders(network, ...blockHeaders); + } + + private async fixMissingHex( + chainSource: ChainSource, + txsDetails: Record + ): Promise { + const missingHexes = Object.entries(txsDetails).filter( + ([, txDetails]) => txDetails.hex === undefined + ); + const txIDs = missingHexes.map(([txid]) => txid); + const transactions = await chainSource.fetchTransactions(txIDs); + await this.walletRepository.updateTxDetails( + transactions.reduce((acc, tx) => { + acc[tx.txID] = { hex: tx.hex }; + return acc; + }, {} as Record) + ); + return transactions.map((v, index) => ({ ...v, ...missingHexes[index][1] })); + } + + private async fixMissingAssets(network: NetworkString) { + const assets = await this.assetRepository.getAllAssets(network); + const assetsToFetch = assets.filter((asset) => !asset || asset.name === 'Unknown'); + const registry = new DefaultAssetRegistry(network); + for (const asset of assetsToFetch) { + const assetInfo = await registry.getAsset(asset.assetHash); + if (assetInfo) { + await this.assetRepository.addAsset(asset.assetHash, assetInfo); + } + } + } + + private async fixMissingUnblindingData(txDetails: TxDetails[]) { + const txs = txDetails + .filter(({ hex }) => hex !== undefined) + .map(({ hex }) => Transaction.fromHex(hex!)); + const outpoints = txs.reduce<(TxOutput & Outpoint)[]>((acc, tx) => { + for (const [vout, output] of tx.outs.entries()) { + if (output.script && output.script) { + acc.push({ + txID: tx.getId(), + vout, + ...output, + }); + } + } + return acc; + }, []); + + const unblindOutputsInRepo = await this.walletRepository.getOutputBlindingData(...outpoints); + const toUnblind = unblindOutputsInRepo.filter(({ blindingData }) => blindingData === undefined); + + const outputsToUnblind = toUnblind.map( + ({ txID, vout }) => + outpoints.find(({ txID: txID2, vout: vout2 }) => txID === txID2 && vout === vout2)! + ); + const unblindedResults = await this.unblinder.unblind(...outputsToUnblind); + + const updateArray: [Outpoint, UnblindingData][] = []; + for (const [i, unblinded] of unblindedResults.entries()) { + const { txID, vout } = toUnblind[i]; + if (unblinded instanceof Error) { + if (unblinded.message === 'secp256k1_rangeproof_rewind') continue; + if (unblinded.message === 'Empty script: fee output') continue; + console.error('Error while unblinding', unblinded); + continue; + } + updateArray.push([{ txID, vout }, unblinded]); + } + + const assetsInArray = updateArray.map(([, { asset }]) => asset); + const toFetchAssets = []; + for (const asset of assetsInArray) { + const fromRepo = await this.assetRepository.getAsset(asset); + if (!fromRepo || fromRepo.ticker === fromRepo.assetHash.substring(0, 4)) { + toFetchAssets.push(asset); + } + } + + try { + if (toFetchAssets.length > 0) { + const network = await this.appRepository.getNetwork(); + if (network) { + const registry = new DefaultAssetRegistry(network); + const assets = await Promise.all(toFetchAssets.map((a) => registry.getAsset(a))); + await Promise.allSettled( + assets.map((asset) => this.assetRepository.addAsset(asset.assetHash, asset)) + ); + } + } + } catch (e) { + console.error('Error while fetching assets', e); + } + + await this.walletRepository.updateOutpointBlindingData(updateArray); + } + + // onChangesListener iterates over the storage changes to trigger the right actions + private onChangesListener() { + return async (changes: Record) => { + try { + this.processingCount += 1; + for (const key in changes) { + try { + if (key === AppStorageKeys.NETWORK) { + const newNetwork = changes[key].newValue as NetworkString | undefined; + if (!newNetwork) continue; + await this.checkAndFixMissingTransactionsData(newNetwork); + } else if (TxIDsKey.is(key)) { + // for each new txID, fetch the tx hex + const [network] = TxIDsKey.decode(key); + const newTxIDs = changes[key].newValue as string[] | undefined; + if (!newTxIDs) continue; // it means we just deleted the key + try { + await this.appRepository.updaterLoader.increment(); + const oldTxIDs = changes[key].oldValue ? (changes[key].oldValue as string[]) : []; + // for all new txs, we need to fetch the tx hex + const oldTxIDsSet = new Set(oldTxIDs); + const txIDsToFetch = newTxIDs.filter( + (txID) => isValidTxID(txID) && !oldTxIDsSet.has(txID) + ); + const chainSource = await this.appRepository.getChainSource(network); + if (!chainSource) { + console.error('Chain source not found', network); + continue; + } + const transactions = await chainSource.fetchTransactions(txIDsToFetch); + + // try to unblind the transaction outputs + const unblindedOutpoints = await this.unblinder.unblindTxs( + ...transactions.map(({ hex }) => Transaction.fromHex(hex)) + ); + + await Promise.all([ + this.walletRepository.updateOutpointBlindingData(unblindedOutpoints), + this.walletRepository.updateTxDetails( + Object.fromEntries(transactions.map((tx, i) => [txIDsToFetch[i], tx])) + ), + ]); + + // let's update the block headers + const txDetails = await this.walletRepository.getTxDetails(...txIDsToFetch); + const blockHeights = new Set(); + for (const { height } of Object.values(txDetails)) { + if (height) blockHeights.add(height); + } + + const blockHeaders = await chainSource.fetchBlockHeaders(Array.from(blockHeights)); + + await this.blockHeadersRepository.setBlockHeaders(network, ...blockHeaders); + await chainSource.close(); + } finally { + await this.appRepository.updaterLoader.decrement(); + } + } + } catch (e) { + console.error('Updater silent error: '); + console.error(e); + continue; + } + } + } finally { + this.processingCount -= 1; + } + }; + } + + // isProcessing returns true if the updater is processing a change + isProcessing() { + return this.processingCount > 0; + } + + waitForProcessing() { + return new Promise((resolve) => { + const interval = setInterval(() => { + if (!this.isProcessing()) { + clearInterval(interval); + resolve(); + } + }, 1000); + }); + } +} + +function isValidTxID(txid: string) { + return /^[0-9a-f]{64}$/i.test(txid); +} diff --git a/src/application/utils.ts b/src/application/utils.ts index 3cfc2e32..f669d881 100644 --- a/src/application/utils.ts +++ b/src/application/utils.ts @@ -1,44 +1,3 @@ -import type { Asset, NetworkString } from 'marina-provider'; -import { BlockstreamExplorerURLs, BlockstreamTestnetExplorerURLs } from '../domain/explorer'; - export function h2b(hex: string): Buffer { return Buffer.from(hex, 'hex'); } - -function getAssetEndpoint(network: NetworkString) { - switch (network) { - case 'liquid': - return BlockstreamExplorerURLs.webExplorerURL + '/api/asset'; - case 'testnet': - return BlockstreamTestnetExplorerURLs.webExplorerURL + '/api/asset'; - case 'regtest': - return 'http://localhost:3001/asset'; - default: - throw new Error('Invalid network'); - } -} - -export function assetIsUnknown(asset: Asset): boolean { - return asset.name === 'Unknown'; -} - -export async function fetchAssetDetails(network: NetworkString, assetHash: string): Promise { - try { - const response = await fetch(`${getAssetEndpoint(network)}/${assetHash}`); - const { name, ticker, precision } = await response.json(); - return { - name: name ?? 'Unknown', - ticker: ticker ?? assetHash.substring(0, 4), - precision: precision ?? 8, - assetHash, - }; - } catch (e) { - console.debug(e); - return { - name: 'Unknown', - ticker: assetHash.substring(0, 4), - precision: 8, - assetHash, - }; - } -} diff --git a/src/background/background-script.ts b/src/background/background-script.ts index 40b4746b..708a5fe1 100644 --- a/src/background/background-script.ts +++ b/src/background/background-script.ts @@ -16,11 +16,15 @@ import { AssetStorageAPI } from '../infrastructure/storage/asset-repository'; import { TaxiStorageAPI } from '../infrastructure/storage/taxi-repository'; import { WalletStorageAPI } from '../infrastructure/storage/wallet-repository'; import { TaxiUpdater } from './taxi-updater'; -import { UpdaterService } from './updater'; +import { UpdaterService } from '../application/updater'; import { tabIsOpen } from './utils'; import { AccountFactory } from '../application/account'; import { extractErrorMessage } from '../extension/utility/error'; import { getBackgroundPortImplementation } from '../port/background-port'; +import { BlockHeadersAPI } from '../infrastructure/storage/blockheaders-repository'; +import type { ChainSource } from '../domain/chainsource'; +import { WalletRepositoryUnblinder } from '../application/unblinder'; +import { Transaction } from 'liquidjs-lib'; // manifest v2 needs BrowserAction, v3 needs action const action = Browser.browserAction ?? Browser.action; @@ -40,9 +44,20 @@ const walletRepository = new WalletStorageAPI(); const appRepository = new AppStorageAPI(); const assetRepository = new AssetStorageAPI(walletRepository); const taxiRepository = new TaxiStorageAPI(assetRepository, appRepository); +const blockHeadersRepository = new BlockHeadersAPI(); -const updaterService = new UpdaterService(walletRepository, appRepository, assetRepository, zkpLib); -const subscriberService = new SubscriberService(walletRepository, appRepository); +const updaterService = new UpdaterService( + walletRepository, + appRepository, + blockHeadersRepository, + assetRepository, + zkpLib +); +const subscriberService = new SubscriberService( + walletRepository, + appRepository, + blockHeadersRepository +); const taxiService = new TaxiUpdater(taxiRepository, appRepository, assetRepository); let started = false; @@ -70,16 +85,31 @@ async function startBackgroundServices() { } async function restoreTask(restoreMessage: RestoreMessage): Promise { + let chainSource: ChainSource | null = null; try { await appRepository.restorerLoader.increment(); const factory = await AccountFactory.create(walletRepository); const network = await appRepository.getNetwork(); if (!network) throw new Error('no network selected'); const account = await factory.make(restoreMessage.data.network, restoreMessage.data.accountID); - const chainSource = await appRepository.getChainSource(); + chainSource = await appRepository.getChainSource(); if (!chainSource) throw new Error('no chain source selected'); - await account.sync(chainSource, restoreMessage.data.gapLimit); + const { txIDsFromChain } = await account.sync(chainSource, restoreMessage.data.gapLimit); + const transactions = await chainSource.fetchTransactions(txIDsFromChain); + const unblinder = new WalletRepositoryUnblinder( + walletRepository, + appRepository, + assetRepository, + zkpLib + ); + const unblindingResult = await unblinder.unblindTxs( + ...transactions.map(({ hex }) => Transaction.fromHex(hex)) + ); + await walletRepository.updateOutpointBlindingData(unblindingResult); } finally { + if (chainSource) { + await chainSource.close(); + } await appRepository.restorerLoader.decrement(); } } diff --git a/src/background/subscriber.ts b/src/background/subscriber.ts index 5e6b504c..04d4cb1f 100644 --- a/src/background/subscriber.ts +++ b/src/background/subscriber.ts @@ -1,5 +1,5 @@ import type { NetworkString } from 'marina-provider'; -import type { WalletRepository, AppRepository } from '../domain/repository'; +import type { WalletRepository, AppRepository, BlockheadersRepository } from '../domain/repository'; import type { ChainSource } from '../domain/chainsource'; const ChainSourceError = (network: string) => @@ -11,7 +11,11 @@ export class SubscriberService { private subscribedScripts = new Set(); private network: NetworkString | null = null; - constructor(private walletRepository: WalletRepository, private appRepository: AppRepository) {} + constructor( + private walletRepository: WalletRepository, + private appRepository: AppRepository, + private blockHeadersRepository: BlockheadersRepository + ) {} async start() { const network = await this.appRepository.getNetwork(); @@ -27,8 +31,12 @@ export class SubscriberService { await this.initSubscribtions(); this.appRepository.onNetworkChanged(async (network: NetworkString) => { - await this.unsubscribe(); - await this.chainSource?.close(); + try { + await this.unsubscribe(); + await this.chainSource?.close(); + } catch (e) { + console.error('error while unsubscribing', e); + } this.network = network; this.chainSource = await this.appRepository.getChainSource(network); if (!this.chainSource) throw ChainSourceError(network); @@ -84,6 +92,16 @@ export class SubscriberService { Object.fromEntries(history[0].map(({ tx_hash, height }) => [tx_hash, { height }])) ), ]); + + const heights = Array.from(new Set(history[0].map(({ height }) => height))); + const blockHeaders = await this.chainSource?.fetchBlockHeaders(heights); + if (!blockHeaders) return; + if (blockHeaders.length > 0) { + await this.blockHeadersRepository.setBlockHeaders( + this.network as NetworkString, + ...blockHeaders + ); + } } ); } diff --git a/src/background/taxi-updater.ts b/src/background/taxi-updater.ts index 08aecc46..2d95c999 100644 --- a/src/background/taxi-updater.ts +++ b/src/background/taxi-updater.ts @@ -1,6 +1,6 @@ import Browser from 'webextension-polyfill'; -import { assetIsUnknown, fetchAssetDetails } from '../application/utils'; import type { AppRepository, AssetRepository, TaxiRepository } from '../domain/repository'; +import { DefaultAssetRegistry } from '../port/asset-registry'; // set up a Browser.alarms in order to fetch the taxi assets every minute export class TaxiUpdater { @@ -44,10 +44,12 @@ export class TaxiUpdater { const assets = await fetchAssetsFromTaxi(taxiURL); await this.taxiRepository.setTaxiAssets(network, assets); + const assetRegistry = new DefaultAssetRegistry(network); + for (const asset of assets) { const assetDetails = await this.assetRepository.getAsset(asset); - if (assetDetails && !assetIsUnknown(assetDetails)) continue; - const newAssetDetails = await fetchAssetDetails(network, asset); + if (assetDetails && assetDetails.ticker !== assetDetails.assetHash.substring(0, 4)) continue; + const newAssetDetails = await assetRegistry.getAsset(asset); await this.assetRepository.addAsset(asset, newAssetDetails); } } @@ -61,6 +63,7 @@ interface TaxiAssetDetails { async function fetchAssetsFromTaxi(taxiUrl: string): Promise { const response = await fetch(`${taxiUrl}/assets`); + if (!response.ok) throw new Error('Taxi /assets error' + response.status.toString()); const data = await response.json(); return (data.assets ?? []).map((asset: TaxiAssetDetails) => asset.assetHash); } diff --git a/src/background/updater.ts b/src/background/updater.ts deleted file mode 100644 index 8859b3a5..00000000 --- a/src/background/updater.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Transaction } from 'liquidjs-lib'; -import Browser from 'webextension-polyfill'; -import type { Unblinder } from '../application/unblinder'; -import { WalletRepositoryUnblinder } from '../application/unblinder'; -import type { TxDetails, UnblindingData } from '../domain/transaction'; -import type { WalletRepository, AppRepository, AssetRepository } from '../domain/repository'; -import { TxIDsKey, TxDetailsKey } from '../infrastructure/storage/wallet-repository'; -import type { ZKPInterface } from 'liquidjs-lib/src/confidential'; - -/** - * Updater is a class that listens to the chrome storage changes and triggers the right actions - * to update the wallet state. - * Each time a new script or transaction is added to the storage, tries to update and unblind utxos set. - */ -export class UpdaterService { - private processingCount = 0; - private unblinder: Unblinder; - private listener: - | ((changes: Record) => Promise) - | undefined; - - constructor( - private walletRepository: WalletRepository, - private appRepository: AppRepository, - assetRepository: AssetRepository, - zkpLib: ZKPInterface - ) { - this.unblinder = new WalletRepositoryUnblinder( - walletRepository, - appRepository, - assetRepository, - zkpLib - ); - } - - // set up the onChanged chrome storage listener - async start() { - if (this.listener) await this.stop(); - this.listener = this.onChangesListener(); - Browser.storage.onChanged.addListener(this.listener); - } - - // remove the onChanged chrome storage listener - async stop() { - await this.waitForProcessing(); - if (this.listener) { - Browser.storage.onChanged.removeListener(this.listener); - this.listener = undefined; - } - } - - // onChangesListener iterates over the storage changes to trigger the right actions - private onChangesListener() { - return async (changes: Record) => { - try { - this.processingCount += 1; - for (const key in changes) { - try { - if (TxIDsKey.is(key)) { - // for each new txID, fetch the tx hex - const [network] = TxIDsKey.decode(key); - const newTxIDs = changes[key].newValue as string[] | undefined; - if (!newTxIDs) continue; // it means we just deleted the key - try { - await this.appRepository.updaterLoader.increment(); - const oldTxIDs = changes[key].oldValue ? (changes[key].oldValue as string[]) : []; - // for all new txs, we need to fetch the tx hex - const oldTxIDsSet = new Set(oldTxIDs); - const txIDsToFetch = newTxIDs.filter( - (txID) => isValidTxID(txID) && !oldTxIDsSet.has(txID) - ); - const chainSource = await this.appRepository.getChainSource(network); - if (!chainSource) { - console.error('Chain source not found', network); - continue; - } - const transactions = await chainSource.fetchTransactions(txIDsToFetch); - await chainSource.close(); - - // try to unblind the outputs - const unblindedOutpoints: Array<[{ txID: string; vout: number }, UnblindingData]> = - []; - for (const { txID, hex } of transactions) { - const unblindedResults = await this.unblinder.unblind( - ...Transaction.fromHex(hex).outs - ); - for (const [vout, unblinded] of unblindedResults.entries()) { - if (unblinded instanceof Error) { - if (unblinded.message === 'secp256k1_rangeproof_rewind') continue; - if (unblinded.message === 'Empty script: fee output') continue; - console.error('Error while unblinding', unblinded); - continue; - } - unblindedOutpoints.push([{ txID, vout }, unblinded]); - } - } - - await Promise.all([ - this.walletRepository.updateOutpointBlindingData(unblindedOutpoints), - this.walletRepository.updateTxDetails( - Object.fromEntries(transactions.map((tx, i) => [txIDsToFetch[i], tx])) - ), - ]); - } finally { - await this.appRepository.updaterLoader.decrement(); - } - } else if (TxDetailsKey.is(key) && changes[key].newValue?.hex) { - // for all txs hex change in the store, we'll try unblind the outputs - if (changes[key].oldValue && changes[key].oldValue.hex) continue; - const [txID] = TxDetailsKey.decode(key); - const newTxDetails = changes[key].newValue as TxDetails | undefined; - if (!newTxDetails || !newTxDetails.hex) continue; // it means we just deleted the key - try { - await this.appRepository.updaterLoader.increment(); - const tx = Transaction.fromHex(newTxDetails.hex); - const updateArray: Array<[{ txID: string; vout: number }, UnblindingData]> = []; - for (const [vout, output] of tx.outs.entries()) { - const { blindingData } = await this.walletRepository.getOutputBlindingData( - txID, - vout - ); - if (blindingData) continue; // already unblinded - const unblindResults = await this.unblinder.unblind(output); - if (unblindResults[0] instanceof Error) { - if (unblindResults[0].message === 'secp256k1_rangeproof_rewind') continue; - if (unblindResults[0].message === 'Empty script: fee output') continue; - console.error('Error while unblinding', unblindResults[0]); - continue; - } - updateArray.push([{ txID, vout }, unblindResults[0]]); - } - await this.walletRepository.updateOutpointBlindingData(updateArray); - } finally { - await this.appRepository.updaterLoader.decrement(); - } - } - } catch (e) { - console.error('Updater silent error: ', e); - continue; - } - } - } finally { - this.processingCount -= 1; - } - }; - } - - // isProcessing returns true if the updater is processing a change - isProcessing() { - return this.processingCount > 0; - } - - waitForProcessing() { - return new Promise((resolve) => { - const interval = setInterval(() => { - if (!this.isProcessing()) { - clearInterval(interval); - resolve(); - } - }, 1000); - }); - } -} - -function isValidTxID(txid: string) { - return /^[0-9a-f]{64}$/i.test(txid); -} diff --git a/src/domain/chainsource.ts b/src/domain/chainsource.ts index dd9b2065..4207de6c 100644 --- a/src/domain/chainsource.ts +++ b/src/domain/chainsource.ts @@ -19,7 +19,7 @@ export interface ChainSource { unsubscribeScriptStatus(script: Buffer): Promise; fetchHistories(scripts: Buffer[]): Promise; fetchTransactions(txids: string[]): Promise<{ txID: string; hex: string }[]>; - fetchBlockHeader(height: number): Promise; + fetchBlockHeaders(heights: number[]): Promise; estimateFees(targetNumberBlocks: number): Promise; broadcastTransaction(hex: string): Promise; getRelayFee(): Promise; diff --git a/src/domain/presenter.ts b/src/domain/presenter.ts new file mode 100644 index 00000000..5003a11c --- /dev/null +++ b/src/domain/presenter.ts @@ -0,0 +1,25 @@ +import type { NetworkString, Asset } from 'marina-provider'; +import type { TxDetailsExtended, UnblindedOutput } from './transaction'; +import type { BlockHeader } from './chainsource'; + +export interface LoadingValue { + value: T; + loading: boolean; +} + +export interface PresentationCache { + network: NetworkString; + authenticated: LoadingValue; + balances: LoadingValue>; + utxos: LoadingValue; + assetsDetails: LoadingValue>; + transactions: LoadingValue; + blockHeaders: LoadingValue>; + walletAssets: LoadingValue>; +} +// present computes the frontend data from repositories +// it emits PresentationCache to the frontend +export interface Presenter { + present(onNewCache: (cache: PresentationCache) => void): Promise; // returns a function to stop the presenter + stop(): void; +} diff --git a/src/domain/repository.ts b/src/domain/repository.ts index cd3bce89..747b61ed 100644 --- a/src/domain/repository.ts +++ b/src/domain/repository.ts @@ -101,7 +101,7 @@ export interface WalletRepository { ): Promise; unlockUtxos(): Promise; lockOutpoints(outpoints: Outpoint[]): Promise; - getOutputBlindingData(txID: string, vout: number): Promise; + getOutputBlindingData(...outpoints: Outpoint[]): Promise; getWitnessUtxo(txID: string, vout: number): Promise; getScriptDetails(...scripts: string[]): Promise>; getTxDetails(...txIDs: string[]): Promise>; @@ -132,6 +132,7 @@ export interface WalletRepository { onNewUtxo: (network: NetworkString) => EventEmitter<[utxo: UnblindedOutput]>; onDeleteUtxo: (network: NetworkString) => EventEmitter<[utxo: UnblindedOutput]>; onNewScript: EventEmitter<[script: string, details: ScriptDetails]>; + onUnblindingEvent: EventEmitter<[data: UnblindedOutput]>; } // asset registry is a local cache of remote elements-registry @@ -208,7 +209,9 @@ export interface SendFlowRepository { // this repository aims to cache the block headers export interface BlockheadersRepository { getBlockHeader(network: NetworkString, height: number): Promise; - setBlockHeader(network: NetworkString, blockHeader: BlockHeader): Promise; + setBlockHeaders(network: NetworkString, ...blockHeaders: BlockHeader[]): Promise; + getAllBlockHeaders(network: NetworkString): Promise>; + onNewBlockHeader: EventEmitter<[network: NetworkString, blockHeader: BlockHeader]>; } export async function init(appRepository: AppRepository, sendFlowRepository: SendFlowRepository) { @@ -268,5 +271,10 @@ export async function initWalletRepository( accountNetworks: ['liquid', 'regtest', 'testnet'], nextKeyIndexes: initialNextKeyIndexes, }); - return { masterBlindingKey, defaultMainAccountXPub, defaultLegacyMainAccountXPub }; + return { + masterBlindingKey, + defaultMainAccountXPub, + defaultLegacyMainAccountXPub, + defaultMainAccountXPubTestnet, + }; } diff --git a/src/domain/transaction.ts b/src/domain/transaction.ts index 85caf8e7..8a7086d1 100644 --- a/src/domain/transaction.ts +++ b/src/domain/transaction.ts @@ -1,6 +1,6 @@ import { AssetHash, ElementsValue, networks, script, Transaction } from 'liquidjs-lib'; -import type { BlockHeader } from './chainsource'; -import type { AppRepository, BlockheadersRepository, WalletRepository } from './repository'; +import type { AppRepository, WalletRepository } from './repository'; +import type { ScriptDetails } from 'marina-provider'; export type UnblindingData = { value: number; @@ -32,7 +32,6 @@ export interface TxDetailsExtended extends TxDetails { txID: string; txFlow: TxFlow; feeAmount: number; - blockHeader?: BlockHeader; } export interface UnblindedOutput { @@ -70,10 +69,10 @@ export async function makeURLwithBlinders( const txID = transaction.getId(); const blinders: string[] = []; - for (let i = 0; i < transaction.outs.length; i++) { - const output = transaction.outs[i]; + for (let vout = 0; vout < transaction.outs.length; vout++) { + const output = transaction.outs[vout]; if (output.script.length === 0) continue; - const data = await walletRepository.getOutputBlindingData(txID, i); + const [data] = await walletRepository.getOutputBlindingData({ txID, vout }); if (!data || !data.blindingData) continue; blinders.push( @@ -103,10 +102,17 @@ export async function lockTransactionInputs( export function computeTxDetailsExtended( appRepository: AppRepository, walletRepository: WalletRepository, - blockHeadersRepository: BlockheadersRepository + scriptsState: Record ) { return async (details: TxDetails): Promise => { - if (!details.hex) throw new Error('tx hex not found'); + if (!details.hex) { + return { + ...details, + txID: '', + txFlow: {}, + feeAmount: 0, + }; + } const transaction = Transaction.fromHex(details.hex); const txID = transaction.getId(); @@ -122,9 +128,10 @@ export function computeTxDetailsExtended( feeAmount = elementsValue.number; continue; } + if (!scriptsState[output.script.toString('hex')]) continue; if (elementsValue.isConfidential) { - const data = await walletRepository.getOutputBlindingData(txID, outIndex); + const [data] = await walletRepository.getOutputBlindingData({ txID, vout: outIndex }); if (!data || !data.blindingData) continue; txFlow[data.blindingData.asset] = (txFlow[data.blindingData.asset] || 0) + data.blindingData.value; @@ -140,12 +147,18 @@ export function computeTxDetailsExtended( for (let inIndex = 0; inIndex < transaction.ins.length; inIndex++) { const input = transaction.ins[inIndex]; const inputTxID = Buffer.from(input.hash).reverse().toString('hex'); - const output = await walletRepository.getWitnessUtxo(inputTxID, input.index); + const inputPrevoutIndex = input.index; + + const output = await walletRepository.getWitnessUtxo(inputTxID, inputPrevoutIndex); if (!output) continue; + if (!scriptsState[output.script.toString('hex')]) continue; const elementsValue = ElementsValue.fromBytes(output.value); if (elementsValue.isConfidential) { - const data = await walletRepository.getOutputBlindingData(inputTxID, input.index); + const [data] = await walletRepository.getOutputBlindingData({ + txID: inputTxID, + vout: inputPrevoutIndex, + }); if (!data || !data.blindingData) continue; txFlow[data.blindingData.asset] = (txFlow[data.blindingData.asset] || 0) - data.blindingData.value; @@ -155,20 +168,8 @@ export function computeTxDetailsExtended( const asset = AssetHash.fromBytes(output.asset).hex; txFlow[asset] = (txFlow[asset] || 0) - elementsValue.number; } - - if (details.height === undefined || details.height === -1) - return { ...details, txID, txFlow, feeAmount }; const network = await appRepository.getNetwork(); if (!network) throw new Error('network not found'); - let blockHeader = await blockHeadersRepository.getBlockHeader(network, details.height); - - if (!blockHeader) { - const chainSource = await appRepository.getChainSource(network); - if (!chainSource) return { ...details, txID, txFlow, feeAmount }; - blockHeader = await chainSource.fetchBlockHeader(details.height); - if (blockHeader) await blockHeadersRepository.setBlockHeader(network, blockHeader); - await chainSource.close(); - } // if the flow for L-BTC is -feeAmount, remove it if (txFlow[networks[network].assetHash] + feeAmount === 0) { @@ -185,7 +186,6 @@ export function computeTxDetailsExtended( txID, txFlow, feeAmount, - blockHeader, }; }; } diff --git a/src/extension/components/balance.tsx b/src/extension/components/balance.tsx index 8c05e277..d296fe86 100644 --- a/src/extension/components/balance.tsx +++ b/src/extension/components/balance.tsx @@ -10,6 +10,7 @@ interface Props { assetHash: string; bigBalanceText?: boolean; className?: string; + loading?: boolean; } const Balance: React.FC = ({ @@ -18,6 +19,7 @@ const Balance: React.FC = ({ assetBalance, assetTicker, assetHash, + loading, }) => { const { appRepository } = useStorageContext(); @@ -39,11 +41,12 @@ const Balance: React.FC = ({

- {assetBalance} {assetTicker} + {loading ? 'Loading...' : `${assetBalance} ${assetTicker}`}

diff --git a/src/extension/components/button-list.tsx b/src/extension/components/button-list.tsx index a31c4432..15d1415e 100644 --- a/src/extension/components/button-list.tsx +++ b/src/extension/components/button-list.tsx @@ -1,29 +1,48 @@ import React from 'react'; +import { Spinner } from './spinner'; interface Props { children?: React.ReactElement | React.ReactElement[]; title?: string; titleColor?: string; emptyText: string; + loading?: boolean; + loadingText?: string; } -const ButtonList: React.FC = ({ children, title, titleColor, emptyText }: Props) => { +const ButtonList: React.FC = ({ + children, + title, + titleColor, + emptyText, + loading, + loadingText, +}: Props) => { return (
- {title && ( -

- {title} -

- )} -
-
- {React.Children.count(children) === 0 ? ( - {emptyText} - ) : ( - children - )} + {loading ? ( +
+

{loadingText || 'Loading'}

+
-
+ ) : ( + <> + {title && ( +

+ {title} +

+ )} +
+
+ {React.Children.count(children) === 0 ? ( + {emptyText} + ) : ( + children + )} +
+
+ + )}
); }; diff --git a/src/extension/components/button-transaction.tsx b/src/extension/components/button-transaction.tsx index 478dc8ba..06b8fd91 100644 --- a/src/extension/components/button-transaction.tsx +++ b/src/extension/components/button-transaction.tsx @@ -25,7 +25,7 @@ interface Props { } const ButtonTransaction: React.FC = ({ txDetails, assetSelected }) => { - const { walletRepository, appRepository } = useStorageContext(); + const { walletRepository, appRepository, cache } = useStorageContext(); const [modalOpen, setModalOpen] = useState(false); const handleClick = () => { @@ -54,9 +54,13 @@ const ButtonTransaction: React.FC = ({ txDetails, assetSelected }) => { >
- {txDetails.blockHeader ? ( + {txDetails.height && + txDetails.height >= 0 && + cache?.blockHeaders.value[txDetails.height] ? ( - {moment(txDetails.blockHeader.timestamp * 1000).format('DD MMM YYYY')} + {moment(cache.blockHeaders.value[txDetails.height].timestamp * 1000).format( + 'DD MMM YYYY' + )} ) : ( @@ -87,11 +91,15 @@ const ButtonTransaction: React.FC = ({ txDetails, assetSelected }) => { className="w-8 h-8 mt-0.5 block mx-auto mb-2" />

{txTypeFromTransfer(transferAmount())}

- {txDetails.blockHeader && ( -

- {moment(txDetails.blockHeader.timestamp * 1000).format('DD MMMM YYYY HH:mm')} -

- )} + {txDetails.height && + txDetails.height >= 0 && + cache?.blockHeaders.value[txDetails.height] && ( +

+ {moment(cache.blockHeaders.value[txDetails.height].timestamp * 1000).format( + 'DD MMMM YYYY HH:mm' + )} +

+ )}
diff --git a/src/extension/components/mermaid-loader.tsx b/src/extension/components/mermaid-loader.tsx index ddee53ea..90fe0b91 100644 --- a/src/extension/components/mermaid-loader.tsx +++ b/src/extension/components/mermaid-loader.tsx @@ -13,4 +13,4 @@ const MermaidLoader: React.FC = ({ className }) => { return
; }; -export default MermaidLoader; +export default React.memo(MermaidLoader); diff --git a/src/extension/components/shell-popup.tsx b/src/extension/components/shell-popup.tsx index 35c483de..bca3f379 100644 --- a/src/extension/components/shell-popup.tsx +++ b/src/extension/components/shell-popup.tsx @@ -4,6 +4,9 @@ import ModalMenu from './modal-menu'; import { DEFAULT_ROUTE } from '../routes/constants'; import { useStorageContext } from '../context/storage-context'; import { formatNetwork } from '../utility'; +import { UpdaterService } from '../../application/updater'; +import zkp from '@vulpemventures/secp256k1-zkp'; +import classNames from 'classnames'; interface Props { btnDisabled?: boolean; @@ -26,7 +29,14 @@ const ShellPopUp: React.FC = ({ btnDisabled = false, }) => { const history = useHistory(); - const { appRepository, sendFlowRepository, cache } = useStorageContext(); + const { + walletRepository, + assetRepository, + blockHeadersRepository, + appRepository, + sendFlowRepository, + cache, + } = useStorageContext(); const [isRestorerLoading, setIsRestorerLoading] = useState(false); const [isUpdaterLoading, setIsUpdaterLoading] = useState(false); @@ -48,10 +58,30 @@ const ShellPopUp: React.FC = ({ const openMenuModal = () => showMenuModal(true); const closeMenuModal = () => showMenuModal(false); - const goToHome = async () => { + const [updating, setUpdating] = useState(false); + + const goToHomeOrUpdate = async () => { if (history.location.pathname !== DEFAULT_ROUTE) { await sendFlowRepository.reset(); history.push(DEFAULT_ROUTE); + } else { + if (updating) return; + setUpdating(true); + try { + const updater = new UpdaterService( + walletRepository, + appRepository, + blockHeadersRepository, + assetRepository, + await zkp() + ); + if (!cache?.network) throw new Error('Network not found'); + await updater.checkAndFixMissingTransactionsData(cache.network); + } catch (e) { + console.error(e); + } finally { + setUpdating(false); + } } }; const handleBackBtn = () => { @@ -111,8 +141,12 @@ const ShellPopUp: React.FC = ({
- {cache?.network !== 'liquid' && ( diff --git a/src/extension/components/spinner.tsx b/src/extension/components/spinner.tsx new file mode 100644 index 00000000..04233757 --- /dev/null +++ b/src/extension/components/spinner.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export const Spinner: React.FC<{ color?: string }> = React.memo(({ color }) => { + return ( + + + + + ); +}); diff --git a/src/extension/context/storage-context.tsx b/src/extension/context/storage-context.tsx index 341c29f4..534017aa 100644 --- a/src/extension/context/storage-context.tsx +++ b/src/extension/context/storage-context.tsx @@ -1,6 +1,4 @@ -import type { Asset, NetworkString } from 'marina-provider'; import { createContext, useContext, useEffect, useState } from 'react'; -import { assetIsUnknown, fetchAssetDetails } from '../../application/utils'; import type { AppRepository, AssetRepository, @@ -10,8 +8,6 @@ import type { TaxiRepository, WalletRepository, } from '../../domain/repository'; -import type { TxDetails, TxDetailsExtended, UnblindedOutput } from '../../domain/transaction'; -import { computeTxDetailsExtended, computeBalances } from '../../domain/transaction'; import { AppStorageAPI } from '../../infrastructure/storage/app-repository'; import { AssetStorageAPI } from '../../infrastructure/storage/asset-repository'; import { BlockHeadersAPI } from '../../infrastructure/storage/blockheaders-repository'; @@ -19,7 +15,9 @@ import { OnboardingStorageAPI } from '../../infrastructure/storage/onboarding-re import { SendFlowStorageAPI } from '../../infrastructure/storage/send-flow-repository'; import { TaxiStorageAPI } from '../../infrastructure/storage/taxi-repository'; import { WalletStorageAPI } from '../../infrastructure/storage/wallet-repository'; -import { sortAssets } from '../utility/sort'; +import type { PresentationCache } from '../../domain/presenter'; +import { PresenterImpl } from '../../application/presenter'; +import { useToastContext } from './toast-context'; const walletRepository = new WalletStorageAPI(); const appRepository = new AppStorageAPI(); @@ -29,16 +27,6 @@ const onboardingRepository = new OnboardingStorageAPI(); const sendFlowRepository = new SendFlowStorageAPI(); const blockHeadersRepository = new BlockHeadersAPI(); -interface StorageContextCache { - network: NetworkString; - balances: Record; - utxos: UnblindedOutput[]; - assets: Asset[]; - authenticated: boolean; - loading: boolean; - transactions: TxDetailsExtended[]; -} - interface StorageContextProps { walletRepository: WalletRepository; appRepository: AppRepository; @@ -47,7 +35,7 @@ interface StorageContextProps { onboardingRepository: OnboardingRepository; sendFlowRepository: SendFlowRepository; blockHeadersRepository: BlockheadersRepository; - cache?: StorageContextCache; + cache?: PresentationCache; } const StorageContext = createContext({ @@ -60,159 +48,26 @@ const StorageContext = createContext({ blockHeadersRepository, }); -export const StorageProvider = ({ children }: { children: React.ReactNode }) => { - const [loading, setLoading] = useState(true); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [network, setNetwork] = useState('liquid'); - const [balances, setBalances] = useState>({}); - const [utxos, setUtxos] = useState([]); - const [assets, setAssets] = useState([]); - const [sortedAssets, setSortedAssets] = useState([]); - const [transactions, setTransactions] = useState([]); - - const [closeUtxosListeners, setCloseUtxosListenersFunction] = useState<() => void>(); - const [closeTxListener, setCloseTxListener] = useState<() => void>(); - - // reset to initial state while mounting the context or when the network changes - const setInitialState = async () => { - const network = await appRepository.getNetwork(); - if (network) { - setNetwork(network); - // utxos - const fromRepo = await walletRepository.getUtxos(network); - setUtxos(fromRepo); - setBalances(computeBalances(fromRepo)); - setUtxosListeners(network); - - // assets - const assetsFromRepo = await assetRepository.getAllAssets(network); - setAssets(assetsFromRepo); - try { - const assetsWithUnknownDetails = assetsFromRepo.filter(assetIsUnknown); - const newAssetDetails = await Promise.all( - assetsWithUnknownDetails.map(({ assetHash }) => fetchAssetDetails(network, assetHash)) - ); - await Promise.all( - newAssetDetails.map((asset) => assetRepository.addAsset(asset.assetHash, asset)) - ); - setAssets(await assetRepository.getAllAssets(network)); - } catch (e) { - console.warn('failed to update asset details from registry', e); - } - - // transactions - const txIds = await walletRepository.getTransactions(network); - const details = await walletRepository.getTxDetails(...txIds); - const txDetails = Object.values(details).sort(sortTxDetails()); - const txDetailsExtended = await Promise.all( - txDetails.map( - computeTxDetailsExtended(appRepository, walletRepository, blockHeadersRepository) - ) - ); - setTransactions(txDetailsExtended); - setTransactionsListener(network); - // check if we have the hex, if not fetch it - const chainSource = await appRepository.getChainSource(); - if (chainSource) { - const txIDs = Object.entries(details) - .filter(([, details]) => !details.hex) - .map(([txID]) => txID); - const txs = await chainSource.fetchTransactions(txIDs); - await walletRepository.updateTxDetails(Object.fromEntries(txs.map((tx) => [tx.txID, tx]))); - await chainSource.close(); - } - } - }; - - // use the repositories listeners to update the state and the balances while the utxos change - const setUtxosListeners = (network: NetworkString) => { - const closeOnNewUtxoListener = walletRepository.onNewUtxo(network)(async (_) => { - const fromRepo = await walletRepository.getUtxos(network); - setUtxos(fromRepo); - setBalances(computeBalances(fromRepo)); - }); - - const closeOnDeleteUtxoListener = walletRepository.onDeleteUtxo(network)(async (_) => { - const fromRepo = await walletRepository.getUtxos(network); - setUtxos(fromRepo); - setBalances(computeBalances(fromRepo)); - }); - - // set up the close function for the utxos listeners - // we need this cause we reload the listener when the network changes - setCloseUtxosListenersFunction(() => () => { - closeOnNewUtxoListener?.(); - closeOnDeleteUtxoListener?.(); - }); - }; - - const setTransactionsListener = (network: NetworkString) => { - const closeOnNewTransactionListener = walletRepository.onNewTransaction( - async (_, details: TxDetails, net: NetworkString) => { - if (net !== network) return; - const txDetailsExtended = await computeTxDetailsExtended( - appRepository, - walletRepository, - blockHeadersRepository - )(details); - setTransactions((txs) => [...txs, txDetailsExtended].sort(sortTxDetails())); - } - ); - - setCloseTxListener(closeOnNewTransactionListener); - }; - - useEffect(() => { - setSortedAssets(sortAssets(assets)); - }, [assets]); - - useEffect(() => { - if (isAuthenticated) { - setInitialState().catch(console.error); - const closeAssetListener = assetRepository.onNewAsset((asset) => - Promise.resolve(setAssets((assets) => [...assets, asset])) - ); - - const closeNetworkListener = appRepository.onNetworkChanged(async (net) => { - setNetwork(net); - closeUtxosListeners?.(); - closeTxListener?.(); - await setInitialState(); - }); +const presenter = new PresenterImpl( + appRepository, + walletRepository, + assetRepository, + blockHeadersRepository +); - return () => { - // close all while unmounting - closeUtxosListeners?.(); - closeTxListener?.(); - closeNetworkListener(); - closeAssetListener(); - }; - } else { - setBalances({}); - setUtxos([]); - setAssets([]); - setSortedAssets([]); - setNetwork('liquid'); - } - }, [isAuthenticated]); +export const StorageProvider = ({ children }: { children: React.ReactNode }) => { + const [cache, setCache] = useState(); + const { showToast } = useToastContext(); useEffect(() => { - appRepository - .getStatus() - .then((status) => { - if (status) { - setIsAuthenticated(true); - } - setLoading(false); + presenter + .present((newCache) => { + setCache(newCache); }) - .catch(console.error); - - const closeAuthListener = appRepository.onIsAuthenticatedChanged((auth) => { - setIsAuthenticated(auth); - return Promise.resolve(); - }); - - return closeAuthListener; + .catch((e) => { + console.error(e); + showToast('Error while loading cache context'); + }); }, []); return ( @@ -225,15 +80,7 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) => onboardingRepository, sendFlowRepository, blockHeadersRepository, - cache: { - balances, - network, - utxos, - assets: sortedAssets, - authenticated: isAuthenticated, - loading, - transactions, - }, + cache, }} > {children} @@ -241,15 +88,4 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) => ); }; -// sort function for txDetails, use the height member to sort -// put unconfirmed txs first and then sort by height (desc) -function sortTxDetails(): ((a: TxDetails, b: TxDetails) => number) | undefined { - return (a, b) => { - if (a.height === b.height) return 0; - if (!a.height || a.height === -1) return -1; - if (!b.height || b.height === -1) return 1; - return b.height - a.height; - }; -} - export const useStorageContext = () => useContext(StorageContext); diff --git a/src/extension/onboarding/end-of-flow/index.tsx b/src/extension/onboarding/end-of-flow/index.tsx index a2a22365..3b5adbdc 100644 --- a/src/extension/onboarding/end-of-flow/index.tsx +++ b/src/extension/onboarding/end-of-flow/index.tsx @@ -1,14 +1,15 @@ import React, { useEffect, useState } from 'react'; +import zkp from '@vulpemventures/secp256k1-zkp'; import Button from '../../components/button'; import MermaidLoader from '../../components/mermaid-loader'; import Shell from '../../components/shell'; import { extractErrorMessage } from '../../utility/error'; import Browser from 'webextension-polyfill'; import { - Account, AccountFactory, MainAccount, MainAccountLegacy, + MainAccountTest, makeAccountXPub, SLIP13, } from '../../../application/account'; @@ -19,20 +20,29 @@ import { mnemonicToSeed } from 'bip39'; import { initWalletRepository } from '../../../domain/repository'; import type { ChainSource } from '../../../domain/chainsource'; import { useStorageContext } from '../../context/storage-context'; -import { useBackgroundPortContext } from '../../context/background-port-context'; -import { startServicesMessage } from '../../../domain/message'; +import { UpdaterService } from '../../../application/updater'; +import { Spinner } from '../../components/spinner'; const GAP_LIMIT = 30; const EndOfFlowOnboarding: React.FC = () => { - const { appRepository, onboardingRepository, walletRepository } = useStorageContext(); - const { backgroundPort } = useBackgroundPortContext(); + const { + appRepository, + onboardingRepository, + walletRepository, + assetRepository, + blockHeadersRepository, + } = useStorageContext(); const isFromPopup = useSelectIsFromPopupFlow(); const [isLoading, setIsLoading] = useState(true); + const [numberOfTransactionsToRestore, setNumberOfTransactionsToRestore] = useState(0); + const [numberOfRestoredTransactions, setNumberOfRestoredTransactions] = useState(0); const [errorMsg, setErrorMsg] = useState(); const tryToRestoreWallet = async () => { + setNumberOfRestoredTransactions(0); + setNumberOfTransactionsToRestore(0); if (isFromPopup) { // if the user is here to check its mnemonic, we just update the status of the wallet await appRepository.updateStatus({ isMnemonicVerified: true }); @@ -49,40 +59,54 @@ const EndOfFlowOnboarding: React.FC = () => { setIsLoading(true); setErrorMsg(undefined); checkPassword(onboardingPassword); - // start services in background - await backgroundPort.sendMessage(startServicesMessage()); - const { masterBlindingKey, defaultMainAccountXPub, defaultLegacyMainAccountXPub } = - await initWalletRepository(walletRepository, onboardingMnemonic, onboardingPassword); + await initWalletRepository(walletRepository, onboardingMnemonic, onboardingPassword); + await (Browser.browserAction ?? Browser.action).setPopup({ popup: 'popup.html' }); + await appRepository.updateStatus({ isOnboardingCompleted: true }); // restore main accounts on Liquid network (so only MainAccount & MainAccountLegacy) const liquidChainSource = await appRepository.getChainSource('liquid'); if (!liquidChainSource) { throw new Error('Chain source not found for liquid network'); } + const testnetChainSource = await appRepository.getChainSource('testnet'); + if (!testnetChainSource) { + throw new Error('Chain source not found for testnet network'); + } + + const factory = await AccountFactory.create(walletRepository); + // restore liquid & testnet main accounts + const accountsToRestore = await Promise.all([ + factory.make('liquid', MainAccount), + factory.make('liquid', MainAccountLegacy), + factory.make('testnet', MainAccountLegacy), + factory.make('testnet', MainAccountTest), + ]); + + // start an Updater service (fetch & unblind & persist the transactions) + const updaterSvc = new UpdaterService( + walletRepository, + appRepository, + blockHeadersRepository, + assetRepository, + await zkp() + ); + walletRepository.onNewTransaction(() => { + return Promise.resolve(setNumberOfRestoredTransactions((n) => n + 1)); + }); - const accountsToRestore = [ - new Account({ - name: MainAccount, - masterBlindingKey, - masterPublicKey: defaultMainAccountXPub, - walletRepository, - network: 'liquid', - }), - new Account({ - name: MainAccountLegacy, - masterBlindingKey, - masterPublicKey: defaultLegacyMainAccountXPub, - walletRepository, - network: 'liquid', - }), - ]; - - // restore the Main accounts on mainnet only // restore on other networks will be triggered if the user switch to testnet/regtest in settings await Promise.allSettled( accountsToRestore.map((account) => - account.sync(liquidChainSource, GAP_LIMIT, { internal: 0, external: 0 }) + account + .sync( + account.network.name === 'liquid' ? liquidChainSource : testnetChainSource, + GAP_LIMIT, + { internal: 0, external: 0 } + ) + .then(({ txIDsFromChain }) => + setNumberOfTransactionsToRestore((n) => n + txIDsFromChain.length) + ) ) ); @@ -114,21 +138,21 @@ const EndOfFlowOnboarding: React.FC = () => { } // we already opened the Liquid chain source - const chainSourcesTestnetRegtest: Record = { - testnet: undefined, - regtest: undefined, - }; + let chainSourceRegtest: ChainSource | null = null; // restore the accounts const factory = await AccountFactory.create(walletRepository); for (const [network, restorations] of Object.entries(restoration)) { let chainSource = undefined; if (network === 'liquid') chainSource = liquidChainSource; - else { - if (chainSourcesTestnetRegtest[network] === undefined) { - const chainSource = await appRepository.getChainSource(network as NetworkString); - chainSourcesTestnetRegtest[network] = chainSource ?? undefined; + else if (network === 'testnet') chainSource = testnetChainSource; + else if (network === 'regtest') { + if (!chainSourceRegtest) { + chainSourceRegtest = await appRepository.getChainSource('regtest'); + if (!chainSourceRegtest) { + throw new Error('Chain source not found for regtest network'); + } } - chainSource = chainSourcesTestnetRegtest[network]; + chainSource = chainSourceRegtest; } if (!chainSource) throw new Error(`Chain source not found for ${network} network`); @@ -138,16 +162,19 @@ const EndOfFlowOnboarding: React.FC = () => { } } - // close the chain sources - await chainSourcesTestnetRegtest.testnet?.close(); - await chainSourcesTestnetRegtest.regtest?.close(); + // close the chain source if opened + await chainSourceRegtest?.close(); } + await testnetChainSource.close(); + await liquidChainSource.close(); + + // after all task, wait for the updater if processing + await updaterSvc.checkAndFixMissingTransactionsData('liquid'); + await updaterSvc.checkAndFixMissingTransactionsData('testnet'); + await updaterSvc.waitForProcessing(); // set the popup - await (Browser.browserAction ?? Browser.action).setPopup({ popup: 'popup.html' }); - await appRepository.updateStatus({ isOnboardingCompleted: true }); await onboardingRepository.flush(); - await liquidChainSource.close(); } catch (err: unknown) { console.error(err); setErrorMsg(extractErrorMessage(err)); @@ -161,7 +188,20 @@ const EndOfFlowOnboarding: React.FC = () => { }, [isFromPopup]); if (isLoading) { - return ; + return ( +
+ +

We are restoring your wallet. This can take a while, please do not close this window.

+ {numberOfTransactionsToRestore > 0 && ( +
+ +

+ {numberOfRestoredTransactions}/{numberOfTransactionsToRestore} Transactions +

+
+ )} +
+ ); } return ( diff --git a/src/extension/popups/spend.tsx b/src/extension/popups/spend.tsx index ff160a41..2ac4bc01 100644 --- a/src/extension/popups/spend.tsx +++ b/src/extension/popups/spend.tsx @@ -31,13 +31,15 @@ const ConnectSpend: React.FC = () => { const spendParameters = useSelectPopupSpendParameters(); + const getAssetInfo = (asset: string) => cache?.assetsDetails.value[asset]; + const getTicker = (asset: string) => { - const assetInfo = cache?.assets.find((a) => a.assetHash === asset); + const assetInfo = getAssetInfo(asset); return assetInfo ? assetInfo.ticker : asset.slice(0, 4); }; const getPrecision = (asset: string) => { - const assetInfo = cache?.assets.find((a) => a.assetHash === asset); + const assetInfo = getAssetInfo(asset); return assetInfo ? assetInfo.precision : 8; }; diff --git a/src/extension/utility/sort.ts b/src/extension/utility/sort.ts deleted file mode 100644 index cf707f5c..00000000 --- a/src/extension/utility/sort.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Asset } from 'marina-provider'; -import { FEATURED_ASSETS } from '../../domain/constants'; - -/** - * Takes a list of assets, and sort it by the following criteria: - * - first, featured assets order by L-BTC, USDT and LCAD - * - all remaining assets in no particular order - * @param assets list of assets in no particular order - * @returns assets sorted by criteria defined above - */ -export function sortAssets(assets: Asset[]): Asset[] { - let newAsset; - const newAssetTicker = 'Any'; - const featuredAssets: Asset[] = []; - const remainingAssets = []; - for (const asset of assets) { - if (FEATURED_ASSETS.includes(asset.assetHash)) { - featuredAssets.push(asset); - continue; - } - if (asset.ticker === newAssetTicker) { - newAsset = asset; - } else { - remainingAssets.push(asset); - } - } - // join the two sets of assets and add 'Any' at the end of the list if it exists - const sortedAssets = [...featuredAssets, ...remainingAssets]; - if (newAsset) sortedAssets.push(newAsset); - return sortedAssets; -} diff --git a/src/extension/wallet/home/index.tsx b/src/extension/wallet/home/index.tsx index f49affc3..a7a583cb 100644 --- a/src/extension/wallet/home/index.tsx +++ b/src/extension/wallet/home/index.tsx @@ -23,6 +23,30 @@ import { useStorageContext } from '../../context/storage-context'; const Home: React.FC = () => { const history = useHistory(); const { appRepository, sendFlowRepository, cache } = useStorageContext(); + const [sortedAssets, setSortedAssets] = React.useState([]); + + useEffect(() => { + setSortedAssets( + Array.from(cache?.walletAssets.value || []) + .map( + (assetHash) => + cache?.assetsDetails.value[assetHash] || { + name: 'Unknown', + ticker: assetHash.substring(0, 4), + precision: 8, + assetHash, + } + ) + .sort((a, b) => { + if (a.ticker === 'L-BTC') return -Infinity; + const aBalance = cache?.balances.value[a.assetHash]; + const bBalance = cache?.balances.value[b.assetHash]; + if (aBalance && !bBalance) return -1; + if (!aBalance && bBalance) return 1; + return 0; + }) + ); + }, [cache?.walletAssets, cache?.assetsDetails, cache?.balances, cache?.transactions]); const handleAssetBalanceButtonClick = (asset: Asset) => { history.push({ @@ -70,9 +94,10 @@ const Home: React.FC = () => {
{cache?.network && ( {
- - {cache?.assets - .filter( - (asset: Asset) => - cache?.transactions.find((tx) => tx.txFlow[asset.assetHash] !== undefined) !== - undefined - ) - // put the assets with balance defined on top - .sort((a, b) => { - const aBalance = cache?.balances[a.assetHash]; - const bBalance = cache?.balances[b.assetHash]; - if (aBalance && !bBalance) return -1; - if (!aBalance && bBalance) return 1; - return 0; - }) - .map((asset: Asset, index: React.Key) => { - return ( - - ); - })} + { + if (cache?.assetsDetails.loading) return 'Loading assets...'; + if (cache?.balances.loading) return 'Loading balances...'; + if (cache?.transactions.loading) return 'Loading transactions...'; + if (cache?.walletAssets.loading) return 'Loading wallet assets...'; + })()} + loading={ + cache?.transactions.loading || + cache?.assetsDetails.loading || + cache?.balances.loading || + cache?.walletAssets.loading + } + title="Assets" + emptyText="Click receive to deposit asset..." + > + {sortedAssets.map((asset: Asset, index: React.Key) => { + return ( + + ); + })}
diff --git a/src/extension/wallet/receive/receive-select-asset.tsx b/src/extension/wallet/receive/receive-select-asset.tsx index a784c628..fac3ffa3 100644 --- a/src/extension/wallet/receive/receive-select-asset.tsx +++ b/src/extension/wallet/receive/receive-select-asset.tsx @@ -17,7 +17,17 @@ const ReceiveSelectAsset: React.FC = () => { + cache?.assetsDetails.value[assetHash] || { + name: 'Unknown', + ticker: assetHash.substring(0, 4), + precision: 8, + assetHash, + } + ) + )} /> ); }; diff --git a/src/extension/wallet/send/address-amount.tsx b/src/extension/wallet/send/address-amount.tsx index 03504c9e..dfc5188f 100644 --- a/src/extension/wallet/send/address-amount.tsx +++ b/src/extension/wallet/send/address-amount.tsx @@ -42,12 +42,12 @@ const AddressAmountView: React.FC = () => { className="h-popupContent container pb-20 mx-auto text-center bg-bottom bg-no-repeat" currentPage="Send" > - {!isInitializingFormState && sendAsset && cache?.balances[sendAsset.assetHash] && ( + {!isInitializingFormState && sendAsset && cache?.balances.value[sendAsset.assetHash] && ( <> { {cache?.network && ( { const asset = await assetRepository.getAsset(assetHash); setAssetDetails(asset); if (assetHash === networks[network].assetHash) { - if (cache?.balances[recipient.asset] === recipient.value) { + if (cache?.balances.value[recipient.asset] === recipient.value) { const { pset, feeAmount } = await psetBuilder.createSendAllPset( recipient.address, recipient.asset @@ -137,7 +137,7 @@ const ChooseFee: React.FC = () => { > { const history = useHistory(); @@ -14,12 +15,22 @@ const SendSelectAsset: React.FC = () => { history.push(SEND_ADDRESS_AMOUNT_ROUTE); }; + if (cache?.walletAssets.loading || cache?.balances.loading) return ; + return ( + cache?.assetsDetails.value[assetHash] || { + name: 'Unknown', + ticker: assetHash.substring(0, 4), + precision: 8, + assetHash, + } + )} + balances={cache?.balances.value || {}} emptyText="You don't have any assets to send." /> ); diff --git a/src/extension/wallet/transactions/index.tsx b/src/extension/wallet/transactions/index.tsx index b14cb249..0dfed1c0 100644 --- a/src/extension/wallet/transactions/index.tsx +++ b/src/extension/wallet/transactions/index.tsx @@ -72,7 +72,7 @@ const Transactions: React.FC = () => { <> @@ -84,7 +84,7 @@ const Transactions: React.FC = () => {
{asset && ( - {cache?.transactions + {cache?.transactions.value .filter((tx) => tx.txFlow[asset.assetHash] !== undefined) .map((tx, index) => { return ; diff --git a/src/infrastructure/storage/asset-repository.ts b/src/infrastructure/storage/asset-repository.ts index 1a9f1fb4..6b7fa879 100644 --- a/src/infrastructure/storage/asset-repository.ts +++ b/src/infrastructure/storage/asset-repository.ts @@ -1,4 +1,4 @@ -import { networks, Transaction } from 'liquidjs-lib'; +import { networks } from 'liquidjs-lib'; import type { Asset, NetworkString } from 'marina-provider'; import Browser from 'webextension-polyfill'; import type { AssetRepository, WalletRepository } from '../../domain/repository'; @@ -57,19 +57,10 @@ export class AssetStorageAPI implements AssetRepository { async getAllAssets(network: NetworkString): Promise { const assetList: Asset[] = []; - const txIDs = await this.walletRepository.getTransactions(network); - const allTxDetails = await this.walletRepository.getTxDetails(...txIDs); - - for (const [ID, { hex }] of Object.entries(allTxDetails)) { - if (!hex) continue; - for (const [vout] of Transaction.fromHex(hex).outs.entries()) { - const data = await this.walletRepository.getOutputBlindingData(ID, vout); - if (!data || !data.blindingData) continue; - if (assetList.find((a) => a.assetHash === data.blindingData?.asset)) continue; - const asset = await this.getAsset(data.blindingData.asset); - if (asset) { - assetList.push(asset); - } + const allStorage = await Browser.storage.local.get(null); + for (const [key, value] of Object.entries(allStorage)) { + if (AssetKey.is(key) && value) { + assetList.push(value); } } diff --git a/src/infrastructure/storage/blockheaders-repository.ts b/src/infrastructure/storage/blockheaders-repository.ts index e19278e2..49f2741d 100644 --- a/src/infrastructure/storage/blockheaders-repository.ts +++ b/src/infrastructure/storage/blockheaders-repository.ts @@ -9,14 +9,48 @@ const BlockHeaderKey = new DynamicStorageKey<[network: NetworkString, height: nu ); export class BlockHeadersAPI implements BlockheadersRepository { + async getAllBlockHeaders(network: NetworkString): Promise> { + const all = await Browser.storage.local.get(null); + const blockHeadersKey = Object.keys(all).filter( + (key) => BlockHeaderKey.is(key) && BlockHeaderKey.decode(key)[0] === network + ); + return blockHeadersKey.reduce((acc, key) => { + const [, height] = BlockHeaderKey.decode(key); + acc[height] = all[key]; + return acc; + }, {} as Record); + } + + onNewBlockHeader(callback: (network: NetworkString, blockHeader: BlockHeader) => Promise) { + const listener = async ( + changes: Record, + areaName: string + ) => { + if (areaName !== 'local') return; + for (const key of Object.keys(changes)) { + if (BlockHeaderKey.is(key)) { + const [network] = BlockHeaderKey.decode(key); + await callback(network, changes[key].newValue); + } + } + }; + Browser.storage.onChanged.addListener(listener); + return () => Browser.storage.onChanged.removeListener(listener); + } + async getBlockHeader(network: NetworkString, height: number): Promise { const key = BlockHeaderKey.make(network, height); const { [key]: blockHeader } = await Browser.storage.local.get(key); return blockHeader === null ? undefined : blockHeader; } - async setBlockHeader(network: NetworkString, blockHeader: BlockHeader): Promise { - const key = BlockHeaderKey.make(network, blockHeader.height); - await Browser.storage.local.set({ [key]: blockHeader }); + setBlockHeaders(network: NetworkString, ...blockHeaders: BlockHeader[]): Promise { + return Browser.storage.local.set( + blockHeaders.reduce((acc, blockHeader) => { + const key = BlockHeaderKey.make(network, blockHeader.height); + acc[key] = blockHeader; + return acc; + }, {} as Record) + ); } } diff --git a/src/infrastructure/storage/wallet-repository.ts b/src/infrastructure/storage/wallet-repository.ts index 23801416..1d3cac05 100644 --- a/src/infrastructure/storage/wallet-repository.ts +++ b/src/infrastructure/storage/wallet-repository.ts @@ -165,10 +165,14 @@ export class WalletStorageAPI implements WalletRepository { ); } - async getOutputBlindingData(txID: string, vout: number): Promise { - const keys = [OutpointBlindingDataKey.make(txID, vout)]; - const { [keys[0]]: blindingData } = await Browser.storage.local.get(keys); - return { txID, vout, blindingData: (blindingData as UnblindingData) ?? undefined }; + async getOutputBlindingData(...outpoints: Outpoint[]): Promise { + const keys = outpoints.map((o) => OutpointBlindingDataKey.make(o.txID, o.vout)); + const values = await Browser.storage.local.get(keys); + return outpoints.map(({ txID, vout }) => { + const key = OutpointBlindingDataKey.make(txID, vout); + const blindingData = values[key] as UnblindingData | undefined; + return { txID, vout, blindingData }; + }); } private async getUtxosFromTransactions( @@ -203,9 +207,7 @@ export class WalletStorageAPI implements WalletRepository { return { txID: txid, vout: Number(vout) }; }); - const utxos = await Promise.all( - utxosOutpoints.map((outpoint) => this.getOutputBlindingData(outpoint.txID, outpoint.vout)) - ); + const utxos = await this.getOutputBlindingData(...utxosOutpoints); return utxos; } @@ -451,6 +453,31 @@ export class WalletStorageAPI implements WalletRepository { return () => Browser.storage.onChanged.removeListener(listener); } + onUnblindingEvent(callback: (event: UnblindedOutput) => Promise) { + const listener = async ( + changes: Record, + areaName: string + ) => { + if (areaName !== 'local') return; + const unblindingKeys = Object.entries(changes) + .filter( + ([key, changes]) => + OutpointBlindingDataKey.is(key) && + changes.newValue !== undefined && + changes.oldValue === undefined + ) + .map(([key]) => key); + + for (const unblindingKey of unblindingKeys) { + const [txID, vout] = OutpointBlindingDataKey.decode(unblindingKey); + const data = changes[unblindingKey].newValue as UnblindingData; + await callback({ txID, vout, ...data }); + } + }; + Browser.storage.onChanged.addListener(listener); + return () => Browser.storage.onChanged.removeListener(listener); + } + async getAccountScripts( network: NetworkString, ...names: string[] diff --git a/src/port/asset-registry.ts b/src/port/asset-registry.ts new file mode 100644 index 00000000..1a84d594 --- /dev/null +++ b/src/port/asset-registry.ts @@ -0,0 +1,77 @@ +import type { NetworkString, Asset } from 'marina-provider'; +import { BlockstreamExplorerURLs, BlockstreamTestnetExplorerURLs } from '../domain/explorer'; + +function getDefaultAssetEndpoint(network: NetworkString) { + switch (network) { + case 'liquid': + return BlockstreamExplorerURLs.webExplorerURL + '/api/asset'; + case 'testnet': + return BlockstreamTestnetExplorerURLs.webExplorerURL + '/api/asset'; + case 'regtest': + return 'http://localhost:3001/asset'; + default: + throw new Error('Invalid network'); + } +} + +export interface AssetRegistry { + getAsset(assetId: string): Promise; +} + +export class DefaultAssetRegistry implements AssetRegistry { + static NOT_FOUND_ERROR_LOCKTIME = 60 * 1000 * 60; // 1 hour + private assetsLocker: Map = new Map(); + private endpoint: string; + + constructor(network: NetworkString) { + this.endpoint = getDefaultAssetEndpoint(network); + } + + private isLocked(assetHash: string): boolean { + const lock = this.assetsLocker.get(assetHash); + return !!(lock && lock > Date.now()); + } + + private async fetchAssetDetails(assetHash: string): Promise { + const response = await fetch(`${this.endpoint}/${assetHash}`); + + if (!response.ok) { + // if 404, set a lock on that asset for 1 hour + if (response.status === 404) { + this.assetsLocker.set( + assetHash, + Date.now() + DefaultAssetRegistry.NOT_FOUND_ERROR_LOCKTIME + ); + } + return { + name: 'Unknown', + ticker: assetHash.substring(0, 4), + precision: 8, + assetHash, + }; + } + + const { name, ticker, precision } = await response.json(); + return { + name: name ?? 'Unknown', + ticker: ticker ?? assetHash.substring(0, 4), + precision: precision ?? 8, + assetHash, + }; + } + + getAsset(assetHash: string): Promise { + try { + if (this.isLocked(assetHash)) throw new Error('Asset locked'); // fallback to catch block + this.assetsLocker.delete(assetHash); + return this.fetchAssetDetails(assetHash); + } catch (e) { + return Promise.resolve({ + name: 'Unknown', + ticker: assetHash.substring(0, 4), + precision: 8, + assetHash, + }); + } + } +} diff --git a/src/port/electrum-chain-source.ts b/src/port/electrum-chain-source.ts index d8efc0bd..6c6d7fe6 100644 --- a/src/port/electrum-chain-source.ts +++ b/src/port/electrum-chain-source.ts @@ -49,9 +49,12 @@ export class WsElectrumChainSource implements ChainSource { return responses; } - async fetchBlockHeader(height: number): Promise { - const hex = await this.ws.request(GetBlockHeader, height); - return deserializeBlockHeader(hex); + async fetchBlockHeaders(heights: number[]): Promise { + const responses = await this.ws.batchRequest( + ...heights.map((h) => ({ method: GetBlockHeader, params: [h] })) + ); + + return responses.map(deserializeBlockHeader); } async estimateFees(targetNumberBlocks: number): Promise { diff --git a/test/application.spec.ts b/test/application.spec.ts index 6b2b914c..07041868 100644 --- a/test/application.spec.ts +++ b/test/application.spec.ts @@ -14,7 +14,7 @@ import { } from '../src/application/account'; import { BlinderService } from '../src/application/blinder'; import { SignerService } from '../src/application/signer'; -import { UpdaterService } from '../src/background/updater'; +import { UpdaterService } from '../src/application/updater'; import { SubscriberService } from '../src/background/subscriber'; import { BlockstreamExplorerURLs, @@ -24,6 +24,7 @@ import { import { AppStorageAPI } from '../src/infrastructure/storage/app-repository'; import { AssetStorageAPI } from '../src/infrastructure/storage/asset-repository'; import { WalletStorageAPI } from '../src/infrastructure/storage/wallet-repository'; +import { BlockHeadersAPI } from '../src/infrastructure/storage/blockheaders-repository'; import { PsetBuilder } from '../src/domain/pset'; import { faucet, sleep } from './_regtest'; import captchaArtifact from './fixtures/customscript/transfer_with_captcha.ionio.json'; @@ -47,6 +48,7 @@ const appRepository = new AppStorageAPI(); const walletRepository = new WalletStorageAPI(); const assetRepository = new AssetStorageAPI(walletRepository); const taxiRepository = new TaxiStorageAPI(assetRepository, appRepository); +const blockHeadersRepository = new BlockHeadersAPI(); const psetBuilder = new PsetBuilder(walletRepository, appRepository, taxiRepository); let factory: AccountFactory; @@ -155,6 +157,7 @@ describe('Application Layer', () => { const updater = new UpdaterService( walletRepository, appRepository, + blockHeadersRepository, assetRepository, zkpLib ); @@ -241,8 +244,18 @@ describe('Application Layer', () => { beforeAll(async () => { const zkpLib = await require('@vulpemventures/secp256k1-zkp')(); - const updater = new UpdaterService(walletRepository, appRepository, assetRepository, zkpLib); - const subscriber = new SubscriberService(walletRepository, appRepository); + const updater = new UpdaterService( + walletRepository, + appRepository, + blockHeadersRepository, + assetRepository, + zkpLib + ); + const subscriber = new SubscriberService( + walletRepository, + appRepository, + blockHeadersRepository + ); const seed = await mnemonicToSeed(mnemonic); await updater.start(); await subscriber.start();