diff --git a/backend/README.md b/backend/README.md index cd96a672c..8b186c397 100644 --- a/backend/README.md +++ b/backend/README.md @@ -181,7 +181,7 @@ Create a new wallet, if needed: bitcoin-cli -regtest createwallet test ``` -Load wallet (this command may take a while if you have lot of UTXOs): +Load wallet (this command may take a while if you have a lot of UTXOs): ``` bitcoin-cli -regtest loadwallet test ``` @@ -233,9 +233,9 @@ By default, mining pools will be not automatically updated regularly (`config.ME To manually update your mining pools, you can use the `--update-pools` command line flag when you run the nodejs backend. For example `npm run start --update-pools`. This will trigger the mining pools update and automatically re-index appropriate blocks. -You can enabled the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` to `true` in your `mempool-config.json`. +You can enable the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` to `true` in your `mempool-config.json`. -When a `coinbase tag` or `coinbase address` change is detected, all blocks tagged to the `unknown` mining pools (starting from height 130635) will be deleted from the `blocks` table. Additionaly, all blocks which were tagged to the pool which has been updated will also be deleted from the `blocks` table. Of course, those blocks will be automatically reindexed. +When a `coinbase tag` or `coinbase address` change is detected, all blocks tagged to the `unknown` mining pools (starting from height 130635) will be deleted from the `blocks` table. Additionally, all blocks which were tagged to the pool which has been updated will also be deleted from the `blocks` table. Of course, those blocks will be automatically reindexed. ### Re-index tables diff --git a/backend/package-lock.json b/backend/package-lock.json index a81a848e6..570d825a3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,7 +23,7 @@ "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.17.0" + "ws": "~8.17.1" }, "devDependencies": { "@babel/code-frame": "^7.18.6", @@ -7690,9 +7690,9 @@ } }, "node_modules/ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, @@ -13424,9 +13424,9 @@ } }, "ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, "y18n": { diff --git a/backend/package.json b/backend/package.json index e4e4c9e2f..906b97523 100644 --- a/backend/package.json +++ b/backend/package.json @@ -52,7 +52,7 @@ "redis": "^4.6.6", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.17.0" + "ws": "~8.17.1" }, "devDependencies": { "@babel/code-frame": "^7.18.6", diff --git a/backend/src/api/services/acceleration.ts b/backend/src/api/services/acceleration.ts index eba5ab7e6..7debe7119 100644 --- a/backend/src/api/services/acceleration.ts +++ b/backend/src/api/services/acceleration.ts @@ -31,10 +31,7 @@ export interface AccelerationHistory { feeDelta: number, blockHash: string, blockHeight: number, - pools: { - pool_unique_id: number, - username: string, - }[], + pools: number[]; }; class AccelerationApi { diff --git a/backend/src/repositories/AccelerationRepository.ts b/backend/src/repositories/AccelerationRepository.ts index eea60fcff..0da66228c 100644 --- a/backend/src/repositories/AccelerationRepository.ts +++ b/backend/src/repositories/AccelerationRepository.ts @@ -308,10 +308,10 @@ class AccelerationRepository { } const accelerationSummaries = accelerations.map(acc => ({ ...acc, - pools: acc.pools.map(pool => pool.pool_unique_id), + pools: acc.pools, })) for (const acc of accelerations) { - if (blockTxs[acc.txid] && acc.pools.some(pool => pool.pool_unique_id === block.extras.pool.id)) { + if (blockTxs[acc.txid] && acc.pools.includes(block.extras.pool.id)) { const tx = blockTxs[acc.txid]; const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions); accelerationInfo.cost = Math.max(0, Math.min(acc.feeDelta, accelerationInfo.cost)); diff --git a/backend/src/tasks/price-feeds/free-currency-api.ts b/backend/src/tasks/price-feeds/free-currency-api.ts index 2a21a16d2..48e511aa8 100644 --- a/backend/src/tasks/price-feeds/free-currency-api.ts +++ b/backend/src/tasks/price-feeds/free-currency-api.ts @@ -76,7 +76,7 @@ class FreeCurrencyApi implements ConversionFeed { } public async $fetchConversionRates(date: string): Promise { - const response = await query(`${this.API_URL_PREFIX}historical?date=${date}&apikey=${this.API_KEY}`); + const response = await query(`${this.API_URL_PREFIX}historical?date=${date}&apikey=${this.API_KEY}`, true); if (response && response['data'] && (response['data'][date] || this.PAID)) { if (this.PAID) { response['data'] = this.convertData(response['data']); diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index 16fe713b7..467669a6f 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -59,7 +59,7 @@ class PriceUpdater { private currencyConversionFeed: ConversionFeed | undefined; private newCurrencies: string[] = ['BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR']; private lastTimeConversionsRatesFetched: number = 0; - private latestConversionsRatesFromFeed: ConversionRates = {}; + private latestConversionsRatesFromFeed: ConversionRates = { USD: -1 }; private ratesChangedCallback: ((rates: ApiPrice) => void) | undefined; constructor() { @@ -157,9 +157,9 @@ class PriceUpdater { try { this.latestConversionsRatesFromFeed = await this.currencyConversionFeed.$fetchLatestConversionRates(); this.lastTimeConversionsRatesFetched = Math.round(new Date().getTime() / 1000); - logger.debug(`Fetched currencies conversion rates from external API: ${JSON.stringify(this.latestConversionsRatesFromFeed)}`); + logger.debug(`Fetched currencies conversion rates from conversions API: ${JSON.stringify(this.latestConversionsRatesFromFeed)}`); } catch (e) { - logger.err(`Cannot fetch conversion rates from the API. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Cannot fetch conversion rates from conversions API. Reason: ${(e instanceof Error ? e.message : e)}`); } } @@ -408,17 +408,17 @@ class PriceUpdater { try { const remainingQuota = await this.currencyConversionFeed?.$getQuota(); if (remainingQuota['month']['remaining'] < 500) { // We need some calls left for the daily updates - logger.debug(`Not enough currency API credit to insert missing prices in ${priceTimesToFill.length} rows (${remainingQuota['month']['remaining']} calls left).`, logger.tags.mining); + logger.debug(`Not enough conversions API credit to insert missing prices in ${priceTimesToFill.length} rows (${remainingQuota['month']['remaining']} calls left).`, logger.tags.mining); this.additionalCurrenciesHistoryInserted = true; // Do not try again until next day return; } } catch (e) { - logger.err(`Cannot fetch currency API credit, insertion of missing prices aborted. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Cannot fetch conversions API credit, insertion of missing prices aborted. Reason: ${(e instanceof Error ? e.message : e)}`); return; } this.additionalCurrenciesHistoryRunning = true; - logger.debug(`Fetching missing conversion rates from external API to fill ${priceTimesToFill.length} rows`, logger.tags.mining); + logger.debug(`Inserting missing historical conversion rates using conversions API to fill ${priceTimesToFill.length} rows`, logger.tags.mining); let conversionRates: { [timestamp: number]: ConversionRates } = {}; let totalInserted = 0; @@ -430,10 +430,23 @@ class PriceUpdater { const month = new Date(priceTime.time * 1000).getMonth(); const yearMonthTimestamp = new Date(year, month, 1).getTime() / 1000; if (conversionRates[yearMonthTimestamp] === undefined) { - conversionRates[yearMonthTimestamp] = await this.currencyConversionFeed?.$fetchConversionRates(`${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01`) || { USD: -1 }; - if (conversionRates[yearMonthTimestamp]['USD'] < 0) { - logger.err(`Cannot fetch conversion rates from the API for ${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01. Aborting insertion of missing prices.`, logger.tags.mining); - this.lastFailedHistoricalRun = Math.round(new Date().getTime() / 1000); + try { + if (year === new Date().getFullYear() && month === new Date().getMonth()) { // For rows in the current month, we use the latest conversion rates + conversionRates[yearMonthTimestamp] = this.latestConversionsRatesFromFeed; + } else { + conversionRates[yearMonthTimestamp] = await this.currencyConversionFeed?.$fetchConversionRates(`${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-15`) || { USD: -1 }; + } + + if (conversionRates[yearMonthTimestamp]['USD'] < 0) { + throw new Error('Incorrect USD conversion rate'); + } + } catch (e) { + if ((e instanceof Error ? e.message : '').includes('429')) { // Continue 60 seconds later if and only if error is 429 + this.lastFailedHistoricalRun = Math.round(new Date().getTime() / 1000); + logger.info(`Got a 429 error from conversions API. This is expected to happen a few times during the initial historical price insertion, process will resume in 60 seconds.`, logger.tags.mining); + } else { + logger.err(`Cannot fetch conversion rates from conversions API for ${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01, trying again next day. Error: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining); + } break; } } diff --git a/backend/src/utils/axios-query.ts b/backend/src/utils/axios-query.ts index 0a155fd55..e641d3ce9 100644 --- a/backend/src/utils/axios-query.ts +++ b/backend/src/utils/axios-query.ts @@ -5,7 +5,7 @@ import config from '../config'; import logger from '../logger'; import * as https from 'https'; -export async function query(path): Promise { +export async function query(path, throwOnFail: boolean = false): Promise { type axiosOptions = { headers: { 'User-Agent': string @@ -21,6 +21,7 @@ export async function query(path): Promise { timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000 }; let retry = 0; + let lastError: any = null; while (retry < config.MEMPOOL.EXTERNAL_MAX_RETRY) { try { @@ -50,6 +51,7 @@ export async function query(path): Promise { } return data.data; } catch (e) { + lastError = e; logger.warn(`Could not connect to ${path} (Attempt ${retry + 1}/${config.MEMPOOL.EXTERNAL_MAX_RETRY}). Reason: ` + (e instanceof Error ? e.message : e)); retry++; } @@ -59,5 +61,10 @@ export async function query(path): Promise { } logger.err(`Could not connect to ${path}. All ${config.MEMPOOL.EXTERNAL_MAX_RETRY} attempts failed`); + + if (throwOnFail && lastError) { + throw lastError; + } + return undefined; } diff --git a/contributors/mackalex.txt b/contributors/mackalex.txt new file mode 100644 index 000000000..667e31a22 --- /dev/null +++ b/contributors/mackalex.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of June 18th, 2024. + +Signed: mackalex diff --git a/frontend/cypress/e2e/liquid/liquid.spec.ts b/frontend/cypress/e2e/liquid/liquid.spec.ts index b355af0d2..c7d2a92ee 100644 --- a/frontend/cypress/e2e/liquid/liquid.spec.ts +++ b/frontend/cypress/e2e/liquid/liquid.spec.ts @@ -72,20 +72,6 @@ describe('Liquid', () => { }); }); - it('renders unconfidential addresses correctly on mobile', () => { - cy.viewport('iphone-6'); - cy.visit(`${basePath}/address/ex1qqmmjdwrlg59c8q4l75sj6wedjx57tj5grt8pat`); - cy.waitForSkeletonGone(); - //TODO: Add proper IDs for these selectors - const firstRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(1) > .table > tbody'; - const thirdRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(3)'; - cy.get(firstRowSelector).invoke('css', 'width').then(firstRowWidth => { - cy.get(thirdRowSelector).invoke('css', 'width').then(thirdRowWidth => { - expect(parseInt(firstRowWidth)).to.be.lessThan(parseInt(thirdRowWidth)); - }); - }); - }); - describe('peg in/peg out', () => { it('loads peg in addresses', () => { cy.visit(`${basePath}/tx/fe764f7bedfc2a37b29d9c8aef67d64a57d253a6b11c5a55555cfd5826483a58`); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e790f7ba0..effc6874a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,6 @@ "bootstrap": "~4.6.2", "browserify": "^17.0.0", "clipboard": "^2.0.11", - "cypress": "^13.11.0", "domino": "^2.1.6", "echarts": "~5.5.0", "esbuild": "^0.21.1", @@ -63,7 +62,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.11.0", + "cypress": "^13.12.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", @@ -6106,11 +6105,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -8029,9 +8028,9 @@ "peer": true }, "node_modules/cypress": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.11.0.tgz", - "integrity": "sha512-NXXogbAxVlVje4XHX+Cx5eMFZv4Dho/2rIcdBHg9CNPFUGZdM4cRdgIgM7USmNYsC12XY0bZENEQ+KBk72fl+A==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.12.0.tgz", + "integrity": "sha512-udzS2JilmI9ApO/UuqurEwOvThclin5ntz7K0BtnHBs+tg2Bl9QShLISXpSEMDv/u8b6mqdoAdyKeZiSqKWL8g==", "hasInstallScript": true, "optional": true, "dependencies": { @@ -10152,9 +10151,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -22636,11 +22635,11 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "brorand": { @@ -24112,9 +24111,9 @@ "peer": true }, "cypress": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.11.0.tgz", - "integrity": "sha512-NXXogbAxVlVje4XHX+Cx5eMFZv4Dho/2rIcdBHg9CNPFUGZdM4cRdgIgM7USmNYsC12XY0bZENEQ+KBk72fl+A==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.12.0.tgz", + "integrity": "sha512-udzS2JilmI9ApO/UuqurEwOvThclin5ntz7K0BtnHBs+tg2Bl9QShLISXpSEMDv/u8b6mqdoAdyKeZiSqKWL8g==", "optional": true, "requires": { "@cypress/request": "^3.0.0", @@ -25757,9 +25756,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "requires": { "to-regex-range": "^5.0.1" } diff --git a/frontend/package.json b/frontend/package.json index f94dd8759..1a54c930d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -115,7 +115,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.11.0", + "cypress": "^13.12.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts index 5f9017bbd..c003fe5ca 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts @@ -96,10 +96,16 @@ export class AcceleratorDashboardComponent implements OnInit, OnDestroy { share(), ); - this.minedAccelerations$ = this.accelerations$.pipe( - map(accelerations => { - return accelerations.filter(acc => ['completed_provisional', 'completed'].includes(acc.status)); - }) + this.minedAccelerations$ = this.stateService.chainTip$.pipe( + distinctUntilChanged(), + switchMap(() => { + return this.serviceApiServices.getAccelerationHistory$({ status: 'completed', pageLength: 6 }).pipe( + catchError(() => { + return of([]); + }), + ); + }), + share(), ); this.blocks$ = combineLatest([ diff --git a/frontend/src/app/components/address-labels/address-labels.component.html b/frontend/src/app/components/address-labels/address-labels.component.html index dfc6647f4..b055cf606 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.html +++ b/frontend/src/app/components/address-labels/address-labels.component.html @@ -4,7 +4,7 @@ {{ label }} @@ -15,6 +15,6 @@ {{ label }} \ No newline at end of file diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index 2365c167f..72a58bfca 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -1,7 +1,7 @@ import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; import { Vin, Vout } from '../../interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; -import { parseMultisigScript } from '../../bitcoin.utils'; +import { AddressType, AddressTypeInfo } from '../../shared/address-utils'; @Component({ selector: 'app-address-labels', @@ -12,9 +12,11 @@ import { parseMultisigScript } from '../../bitcoin.utils'; export class AddressLabelsComponent implements OnChanges { network = ''; + @Input() address: AddressTypeInfo; @Input() vin: Vin; @Input() vout: Vout; @Input() channel: any; + @Input() class: string = ''; label?: string; @@ -27,10 +29,10 @@ export class AddressLabelsComponent implements OnChanges { ngOnChanges() { if (this.channel) { this.handleChannel(); + } else if (this.address) { + this.handleAddress(); } else if (this.vin) { this.handleVin(); - } else if (this.vout) { - this.handleVout(); } } @@ -41,74 +43,22 @@ export class AddressLabelsComponent implements OnChanges { this.label = `Channel ${type}: ${leftNodeName} <> ${rightNodeName}`; } + handleAddress() { + if (this.address?.scripts.size) { + const script = this.address?.scripts.values().next().value; + if (script.template?.label) { + this.label = script.template.label; + } + } + } + handleVin() { - if (this.vin.inner_witnessscript_asm) { - if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || this.vin.inner_witnessscript_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) { - if (this.vin.witness.length > 11) { - this.label = 'Liquid Peg Out'; - } else { - this.label = 'Emergency Liquid Peg Out'; - } - return; + const address = new AddressTypeInfo(this.network || 'mainnet', this.vin.prevout?.scriptpubkey_address, this.vin.prevout?.scriptpubkey_type as AddressType, [this.vin]) + if (address?.scripts.size) { + const script = address?.scripts.values().next().value; + if (script.template?.label) { + this.label = script.template.label; } - - const topElement = this.vin.witness[this.vin.witness.length - 2]; - if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(this.vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs - if (topElement === '01') { - // top element is '01' to get in the revocation path - this.label = 'Revoked Lightning Force Close'; - } else { - // top element is '', this is a delayed to_local output - this.label = 'Lightning Force Close'; - } - return; - } else if ( - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm) || - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm) - ) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs - // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs - if (topElement.length === 66) { - // top element is a public key - this.label = 'Revoked Lightning HTLC'; - } else if (topElement) { - // top element is a preimage - this.label = 'Lightning HTLC'; - } else { - // top element is '' to get in the expiry of the script - this.label = 'Expired Lightning HTLC'; - } - return; - } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors - if (topElement) { - // top element is a signature - this.label = 'Lightning Anchor'; - } else { - // top element is '', it has been swept after 16 blocks - this.label = 'Swept Lightning Anchor'; - } - return; - } - - this.detectMultisig(this.vin.inner_witnessscript_asm); } - - this.detectMultisig(this.vin.inner_redeemscript_asm); - - this.detectMultisig(this.vin.prevout.scriptpubkey_asm); - } - - detectMultisig(script: string) { - const ms = parseMultisigScript(script); - - if (ms) { - this.label = $localize`:@@address-label.multisig:Multisig ${ms.m}:multisigM: of ${ms.n}:multisigN:`; - } - } - - handleVout() { - this.detectMultisig(this.vout.scriptpubkey_asm); } } diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index 26e5d8203..f0e9012c2 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -3,7 +3,13 @@

Address

@@ -14,40 +20,47 @@
-
- - - - - - - - - - - - - - - - - - - - - -
Unconfidential - - - -
Total received
Total sent
Balance
-
-
-
-
- + @if (isMobile) { +
+ + + + + + + @if (network === 'liquid' || network === 'liquidtestnet') { + + } @else { + + } + + +
-
+ } @else { +
+ + + + + @if (network === 'liquid' || network === 'liquidtestnet') { + + } @else { + + } + +
+
+
+ + + + + + +
+
+ }
@@ -76,8 +89,8 @@

  - {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction - {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions + {{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transaction + {{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transactions

@@ -182,3 +195,57 @@
+ + + + + Balance + + + + + + + unconfirmed balance + + + + + + + UTXOs + {{ chainStats.utxos }} + + + + + + unconfirmed UTXOs + {{ mempoolStats.utxos > 0 ? '+' : ''}}{{ mempoolStats.utxos }} + + + + + + Volume + + + + + + + Type + + + + + + + Unconfidential + + + + + + + \ No newline at end of file diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index da615376c..8e04ffe8b 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -1,16 +1,14 @@ .qr-wrapper { + position: absolute; + top: 30px; + right: 0px; + border: solid 10px var(--active-bg); + border-radius: 5px; background-color: #fff; padding: 10px; padding-bottom: 5px; - display: inline-block; -} - -.qrcode-col { - margin: 20px auto 10px; - text-align: center; - @media (min-width: 992px){ - margin: 0px auto 0px; - } + display: block; + z-index: 99; } .fiat { @@ -25,10 +23,14 @@ tr td { &:last-child { text-align: right; - @media (min-width: 576px) { + @media (min-width: 768px) { text-align: left; } } + + &.wrap-cell { + white-space: normal; + } } } @@ -78,10 +80,10 @@ h1 { top: 9px; position: relative; @media (min-width: 576px) { + max-width: calc(100% - 180px); top: 11px; } @media (min-width: 768px) { - max-width: calc(100% - 180px); top: 17px; } } @@ -96,17 +98,6 @@ h1 { .liquid-address { .address-table { table-layout: fixed; - - tr td:first-child { - width: 170px; - } - tr td:last-child { - width: 80%; - } - } - - .qrcode-col { - flex-grow: 0.5; } } diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index e79ad45e2..477954805 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; -import { Address, ScriptHash, Transaction } from '../../interfaces/electrs.interface'; +import { Address, ChainStats, Transaction, Vin } from '../../interfaces/electrs.interface'; import { WebsocketService } from '../../services/websocket.service'; import { StateService } from '../../services/state.service'; import { AudioService } from '../../services/audio.service'; @@ -11,6 +11,83 @@ import { of, merge, Subscription, Observable } from 'rxjs'; import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; import { AddressInformation } from '../../interfaces/node-api.interface'; +import { AddressTypeInfo } from '../../shared/address-utils'; + +class AddressStats implements ChainStats { + address: string; + scriptpubkey?: string; + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + + constructor (stats: ChainStats, address: string, scriptpubkey?: string) { + Object.assign(this, stats); + this.address = address; + this.scriptpubkey = scriptpubkey; + } + + public addTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === this.address || (this.scriptpubkey === vin.prevout?.scriptpubkey)) { + this.spendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === this.address || (this.scriptpubkey === vout.scriptpubkey)) { + this.fundTxo(vout.value); + } + } + this.tx_count++; + } + + public removeTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === this.address || (this.scriptpubkey === vin.prevout?.scriptpubkey)) { + this.unspendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === this.address || (this.scriptpubkey === vout.scriptpubkey)) { + this.unfundTxo(vout.value); + } + } + this.tx_count--; + } + + private fundTxo(value: number): void { + this.funded_txo_sum += value; + this.funded_txo_count++; + } + + private unfundTxo(value: number): void { + this.funded_txo_sum -= value; + this.funded_txo_count--; + } + + private spendTxo(value: number): void { + this.spent_txo_sum += value; + this.spent_txo_count++; + } + + private unspendTxo(value: number): void { + this.spent_txo_sum -= value; + this.spent_txo_count--; + } + + get balance(): number { + return this.funded_txo_sum - this.spent_txo_sum; + } + + get volume(): number { + return this.funded_txo_sum + this.spent_txo_sum; + } + + get utxos(): number { + return this.funded_txo_count - this.spent_txo_count; + } +} @Component({ selector: 'app-address', @@ -20,6 +97,9 @@ import { AddressInformation } from '../../interfaces/node-api.interface'; export class AddressComponent implements OnInit, OnDestroy { network = ''; + isMobile: boolean; + showQR: boolean = false; + address: Address; addressString: string; isLoadingAddress = true; @@ -33,11 +113,14 @@ export class AddressComponent implements OnInit, OnDestroy { blockTxSubscription: Subscription; addressLoadingStatus$: Observable; addressInfo: null | AddressInformation = null; + addressTypeInfo: null | AddressTypeInfo; fullyLoaded = false; - txCount = 0; - received = 0; - sent = 0; + chainStats: AddressStats; + mempoolStats: AddressStats; + + exampleChannel?: any; + now = Date.now() / 1000; balancePeriod: 'all' | '1m' = 'all'; @@ -55,10 +138,12 @@ export class AddressComponent implements OnInit, OnDestroy { private seoService: SeoService, ) { } - ngOnInit() { + ngOnInit(): void { this.stateService.networkChanged$.subscribe((network) => this.network = network); this.websocketService.want(['blocks']); + this.onResize(); + this.addressLoadingStatus$ = this.route.paramMap .pipe( switchMap(() => this.stateService.loadingIndicators$), @@ -75,6 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.isLoadingTransactions = true; this.transactions = null; this.addressInfo = null; + this.exampleChannel = null; document.body.scrollTo(0, 0); this.addressString = params.get('id') || ''; if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(this.addressString)) { @@ -83,6 +169,8 @@ export class AddressComponent implements OnInit, OnDestroy { this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); + this.addressTypeInfo = new AddressTypeInfo(this.stateService.network || 'mainnet', this.addressString); + return merge( of(true), this.stateService.connectionState$ @@ -175,11 +263,19 @@ export class AddressComponent implements OnInit, OnDestroy { }); this.transactions = this.tempTransactions; - if (this.transactions.length === this.txCount) { + if (this.transactions.length === (this.mempoolStats.tx_count + this.chainStats.tx_count)) { this.fullyLoaded = true; } this.isLoadingTransactions = false; + let addressVin: Vin[] = []; + for (const tx of this.transactions) { + addressVin = addressVin.concat(tx.vin.filter(v => v.prevout?.scriptpubkey_address === this.address.address)); + } + this.addressTypeInfo.processInputs(addressVin); + // hack to trigger change detection + this.addressTypeInfo = this.addressTypeInfo.clone(); + if (!this.showBalancePeriod()) { this.setBalancePeriod('all'); } else { @@ -196,11 +292,13 @@ export class AddressComponent implements OnInit, OnDestroy { this.mempoolTxSubscription = this.stateService.mempoolTransactions$ .subscribe(tx => { this.addTransaction(tx); + this.mempoolStats.addTx(tx); }); this.mempoolRemovedTxSubscription = this.stateService.mempoolRemovedTransactions$ .subscribe(tx => { this.removeTransaction(tx); + this.mempoolStats.removeTx(tx); }); this.blockTxSubscription = this.stateService.blockTransactions$ @@ -209,12 +307,14 @@ export class AddressComponent implements OnInit, OnDestroy { if (tx) { tx.status = transaction.status; this.transactions = this.transactions.slice(); + this.mempoolStats.removeTx(transaction); this.audioService.playSound('magic'); } else { if (this.addTransaction(transaction, false)) { this.audioService.playSound('magic'); } } + this.chainStats.addTx(transaction); }); } @@ -225,7 +325,6 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions.unshift(transaction); this.transactions = this.transactions.slice(); - this.txCount++; if (playSound) { if (transaction.vout.some((vout) => vout?.scriptpubkey_address === this.address.address)) { @@ -235,17 +334,6 @@ export class AddressComponent implements OnInit, OnDestroy { } } - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === this.address.address) { - this.sent += vin.prevout.value; - } - }); - transaction.vout.forEach((vout) => { - if (vout?.scriptpubkey_address === this.address.address) { - this.received += vout.value; - } - }); - return true; } @@ -257,23 +345,11 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions.splice(index, 1); this.transactions = this.transactions.slice(); - this.txCount--; - - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === this.address.address) { - this.sent -= vin.prevout.value; - } - }); - transaction.vout.forEach((vout) => { - if (vout?.scriptpubkey_address === this.address.address) { - this.received -= vout.value; - } - }); return true; } - loadMore() { + loadMore(): void { if (this.isLoadingTransactions || this.fullyLoaded) { return; } @@ -301,10 +377,9 @@ export class AddressComponent implements OnInit, OnDestroy { }); } - updateChainStats() { - this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum; - this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum; - this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; + updateChainStats(): void { + this.chainStats = new AddressStats(this.address.chain_stats, this.address.address); + this.mempoolStats = new AddressStats(this.address.mempool_stats, this.address.address); } setBalancePeriod(period: 'all' | '1m'): boolean { @@ -319,7 +394,12 @@ export class AddressComponent implements OnInit, OnDestroy { ); } - ngOnDestroy() { + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth < 768; + } + + ngOnDestroy(): void { this.mainSubscription.unsubscribe(); this.mempoolTxSubscription.unsubscribe(); this.mempoolRemovedTxSubscription.unsubscribe(); diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index d762a879e..471c1dfca 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -313,7 +313,7 @@ export class BlockComponent implements OnInit, OnDestroy { const acceleratedInBlock = {}; for (const acc of accelerations) { - if (acc.pools?.some(pool => pool === this.block?.extras?.pool.id || pool?.['pool_unique_id'] === this.block?.extras?.pool.id)) { + if (acc.pools?.some(pool => pool === this.block?.extras?.pool.id)) { acceleratedInBlock[acc.txid] = acc; } } diff --git a/frontend/src/app/components/clipboard/clipboard.component.html b/frontend/src/app/components/clipboard/clipboard.component.html index ec8802634..d23ccdf8c 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.html +++ b/frontend/src/app/components/clipboard/clipboard.component.html @@ -1,7 +1,7 @@ @@ -9,7 +9,7 @@ diff --git a/frontend/src/app/components/clipboard/clipboard.component.ts b/frontend/src/app/components/clipboard/clipboard.component.ts index 7fbffdca3..6e577d8b3 100644 --- a/frontend/src/app/components/clipboard/clipboard.component.ts +++ b/frontend/src/app/components/clipboard/clipboard.component.ts @@ -13,11 +13,17 @@ export class ClipboardComponent implements AfterViewInit { @ViewChild('buttonWrapper') buttonWrapper: ElementRef; @Input() button = false; @Input() class = 'btn btn-secondary ml-1'; - @Input() size: 'small' | 'normal' = 'normal'; + @Input() size: 'small' | 'normal' | 'large' = 'normal'; @Input() text: string; @Input() leftPadding = true; copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`; + widths = { + small: '10', + normal: '13', + large: '18', + }; + clipboard: any; constructor() { } diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 1685ee530..7de851c6e 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -665,7 +665,7 @@ export class TrackerComponent implements OnInit, OnDestroy { } setIsAccelerated(initialState: boolean = false) { - this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id || pool?.['pool_unique_id'] === this.pool.id)))); + this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); } dismissAccelAlert(): void { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 9f7e2aa40..d03a8a9cc 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -726,7 +726,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } setIsAccelerated(initialState: boolean = false) { - this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id || pool?.['pool_unique_id'] === this.pool.id)))); + this.isAcceleration = (this.tx.acceleration || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); if (this.isAcceleration && initialState) { this.showAccelerationSummary = false; } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index ee7ac52f5..88a984942 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -75,7 +75,7 @@ {{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
- +
diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index c337b7520..b799d79a2 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -9126,11 +9126,7 @@ export const restApiDocsData = [ "blockHash": "00000000000000000000482f0746d62141694b9210a813b97eb8445780a32003", "blockHeight": 829559, "bidBoost": 6102, - "pools": [ - { - "pool_unique_id": 111 - } - ] + "pools": [111] } ]`, }, diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts new file mode 100644 index 000000000..c5e1fcf3d --- /dev/null +++ b/frontend/src/app/shared/address-utils.ts @@ -0,0 +1,193 @@ +import '@angular/localize/init'; +import { ScriptInfo } from './script.utils'; +import { Vin } from '../interfaces/electrs.interface'; +import { BECH32_CHARS_LW, BASE58_CHARS, HEX_CHARS } from './regex.utils'; + +export type AddressType = 'fee' + | 'empty' + | 'provably_unspendable' + | 'op_return' + | 'multisig' + | 'p2pk' + | 'p2pkh' + | 'p2sh' + | 'p2sh-p2wpkh' + | 'p2sh-p2wsh' + | 'v0_p2wpkh' + | 'v0_p2wsh' + | 'v1_p2tr' + | 'confidential' + | 'unknown' + +const ADDRESS_PREFIXES = { + mainnet: { + base58: { + pubkey: ['1'], + script: ['3'], + }, + bech32: 'bc1', + }, + testnet: { + base58: { + pubkey: ['m', 'n'], + script: '2', + }, + bech32: 'tb1', + }, + testnet4: { + base58: { + pubkey: ['m', 'n'], + script: '2', + }, + bech32: 'tb1', + }, + signet: { + base58: { + pubkey: ['m', 'n'], + script: '2', + }, + bech32: 'tb1', + }, + liquid: { + base58: { + pubkey: ['P','Q'], + script: ['G','H'], + confidential: ['V'], + }, + bech32: 'ex1', + confidential: 'lq1', + }, + liquidtestnet: { + base58: { + pubkey: ['F'], + script: ['8','9'], + confidential: ['V'], // TODO: check if this is actually correct + }, + bech32: 'tex1', + confidential: 'tlq1', + }, +}; + +// precompiled regexes for common address types (excluding prefixes) +const base58Regex = RegExp('^' + BASE58_CHARS + '{26,34}$'); +const confidentialb58Regex = RegExp('^[TJ]' + BASE58_CHARS + '{78}$'); +const p2wpkhRegex = RegExp('^q' + BECH32_CHARS_LW + '{38}$'); +const p2wshRegex = RegExp('^q' + BECH32_CHARS_LW + '{58}$'); +const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$'); +const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`); + +export function detectAddressType(address: string, network: string): AddressType { + // normal address types + const firstChar = address.substring(0, 1); + if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) { + return 'p2pkh'; + } else if (ADDRESS_PREFIXES[network].base58.script.includes(firstChar) && base58Regex.test(address.slice(1))) { + return 'p2sh'; + } else if (address.startsWith(ADDRESS_PREFIXES[network].bech32)) { + const suffix = address.slice(ADDRESS_PREFIXES[network].bech32.length); + if (p2wpkhRegex.test(suffix)) { + return 'v0_p2wpkh'; + } else if (p2wshRegex.test(suffix)) { + return 'v0_p2wsh'; + } else if (p2trRegex.test(suffix)) { + return 'v1_p2tr'; + } + } + + // p2pk + if (pubkeyRegex.test(address)) { + return 'p2pk'; + } + + // liquid-specific types + if (network.startsWith('liquid')) { + if (ADDRESS_PREFIXES[network].base58.confidential.includes(firstChar) && confidentialb58Regex.test(address.slice(1))) { + return 'confidential'; + } else if (address.startsWith(ADDRESS_PREFIXES[network].confidential)) { + return 'confidential'; + } + } + + return 'unknown'; +} + +/** + * Parses & classifies address types + properties from address strings + * + * can optionally augment this data with examples of spends from the address, + * e.g. to classify revealed scripts for scripthash-type addresses. + */ +export class AddressTypeInfo { + network: string; + address: string; + type: AddressType; + // script data + scripts: Map; // raw script + // flags + isMultisig?: { m: number, n: number }; + tapscript?: boolean; + + constructor (network: string, address: string, type?: AddressType, vin?: Vin[]) { + this.network = network; + this.address = address; + this.scripts = new Map(); + if (type) { + this.type = type; + } else { + this.type = detectAddressType(address, network); + } + this.processInputs(vin); + } + + public clone(): AddressTypeInfo { + const cloned = new AddressTypeInfo(this.network, this.address, this.type); + cloned.scripts = new Map(Array.from(this.scripts, ([key, value]) => [key, value?.clone()])); + cloned.isMultisig = this.isMultisig; + cloned.tapscript = this.tapscript; + return cloned; + } + + public processInputs(vin: Vin[] = []): void { + // taproot can have multiple script paths + if (this.type === 'v1_p2tr') { + for (const v of vin) { + if (v.inner_witnessscript_asm) { + this.tapscript = true; + const controlBlock = v.witness[v.witness.length - 1].startsWith('50') ? v.witness[v.witness.length - 2] : v.witness[v.witness.length - 1]; + this.processScript(new ScriptInfo('inner_witnessscript', undefined, v.inner_witnessscript_asm, v.witness, controlBlock)); + } + } + // for single-script types, if we've seen one input we've seen them all + } else if (['p2sh', 'v0_p2wsh'].includes(this.type)) { + if (!this.scripts.size && vin.length) { + const v = vin[0]; + // wrapped segwit + if (this.type === 'p2sh' && v.witness?.length) { + if (v.scriptsig.startsWith('160014')) { + this.type = 'p2sh-p2wpkh'; + } else if (v.scriptsig.startsWith('220020')) { + this.type = 'p2sh-p2wsh'; + } + } + // real script + if (this.type !== 'p2sh-p2wpkh') { + if (v.inner_witnessscript_asm) { + this.processScript(new ScriptInfo('inner_witnessscript', undefined, v.inner_witnessscript_asm, v.witness)); + } else if (v.inner_redeemscript_asm) { + this.processScript(new ScriptInfo('inner_redeemscript', undefined, v.inner_redeemscript_asm, v.witness)); + } else if (v.scriptsig || v.scriptsig_asm) { + this.processScript(new ScriptInfo('scriptsig', v.scriptsig, v.scriptsig_asm, v.witness)); + } + } + } + } + // and there's nothing more to learn from processing inputs for non-scripthash types + } + + private processScript(script: ScriptInfo): void { + this.scripts.set(script.key, script); + if (script.template?.type === 'multisig') { + this.isMultisig = { m: script.template['m'], n: script.template['n'] }; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-type/address-type.component.html b/frontend/src/app/shared/components/address-type/address-type.component.html new file mode 100644 index 000000000..fe4286689 --- /dev/null +++ b/frontend/src/app/shared/components/address-type/address-type.component.html @@ -0,0 +1,29 @@ +@switch (address.type || null) { + @case ('fee') { + fee + } + @case ('empty') { + empty + } + @case ('v0_p2wpkh') { + P2WPKH + } + @case ('v0_p2wsh') { + P2WSH + } + @case ('v1_p2tr') { + P2TR + } + @case ('provably_unspendable') { + provably unspendable + } + @case ('multisig') { + bare multisig + } + @case (null) { + unknown + } + @default { + {{ address.type.toUpperCase() }} + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/address-type/address-type.component.ts b/frontend/src/app/shared/components/address-type/address-type.component.ts new file mode 100644 index 000000000..1a2456c07 --- /dev/null +++ b/frontend/src/app/shared/components/address-type/address-type.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; +import { AddressTypeInfo } from '../../address-utils'; + +@Component({ + selector: 'app-address-type', + templateUrl: './address-type.component.html', + styleUrls: [] +}) +export class AddressTypeComponent { + @Input() address: AddressTypeInfo; +} diff --git a/frontend/src/app/shared/components/truncate/truncate.component.scss b/frontend/src/app/shared/components/truncate/truncate.component.scss index 57f92f719..8c22dd836 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.scss +++ b/frontend/src/app/shared/components/truncate/truncate.component.scss @@ -2,7 +2,7 @@ text-overflow: unset; display: flex; flex-direction: row; - align-items: baseline; + align-items: start; position: relative; .truncate-link { diff --git a/frontend/src/app/shared/regex.utils.ts b/frontend/src/app/shared/regex.utils.ts index 187111a59..cdc2963e8 100644 --- a/frontend/src/app/shared/regex.utils.ts +++ b/frontend/src/app/shared/regex.utils.ts @@ -1,14 +1,14 @@ import { Env } from '../services/state.service'; // all base58 characters -const BASE58_CHARS = `[a-km-zA-HJ-NP-Z1-9]`; +export const BASE58_CHARS = `[a-km-zA-HJ-NP-Z1-9]`; // all bech32 characters (after the separator) -const BECH32_CHARS_LW = `[ac-hj-np-z02-9]`; +export const BECH32_CHARS_LW = `[ac-hj-np-z02-9]`; const BECH32_CHARS_UP = `[AC-HJ-NP-Z02-9]`; // Hex characters -const HEX_CHARS = `[a-fA-F0-9]`; +export const HEX_CHARS = `[a-fA-F0-9]`; // A regex to say "A single 0 OR any number with no leading zeroes" // Capped at 9 digits so as to not be confused with lightning channel IDs (which are around 17 digits) diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts index 556cd40f2..171112dcc 100644 --- a/frontend/src/app/shared/script.utils.ts +++ b/frontend/src/app/shared/script.utils.ts @@ -145,8 +145,116 @@ for (let i = 187; i <= 255; i++) { export { opcodes }; +export type ScriptType = 'scriptpubkey' + | 'scriptsig' + | 'inner_witnessscript' + | 'inner_redeemscript' + +export interface ScriptTemplate { + type: string; + label: string; +} + +export const ScriptTemplates: { [type: string]: (...args: any) => ScriptTemplate } = { + liquid_peg_out: () => ({ type: 'liquid_peg_out', label: 'Liquid Peg Out' }), + liquid_peg_out_emergency: () => ({ type: 'liquid_peg_out_emergency', label: 'Emergency Liquid Peg Out' }), + ln_force_close: () => ({ type: 'ln_force_close', label: 'Lightning Force Close' }), + ln_force_close_revoked: () => ({ type: 'ln_force_close_revoked', label: 'Revoked Lightning Force Close' }), + ln_htlc: () => ({ type: 'ln_htlc', label: 'Lightning HTLC' }), + ln_htlc_revoked: () => ({ type: 'ln_htlc_revoked', label: 'Revoked Lightning HTLC' }), + ln_htlc_expired: () => ({ type: 'ln_htlc_expired', label: 'Expired Lightning HTLC' }), + ln_anchor: () => ({ type: 'ln_anchor', label: 'Lightning Anchor' }), + ln_anchor_swept: () => ({ type: 'ln_anchor_swept', label: 'Swept Lightning Anchor' }), + multisig: (m: number, n: number) => ({ type: 'multisig', m, n, label: $localize`:@@address-label.multisig:Multisig ${m}:multisigM: of ${n}:multisigN:` }), +}; + +export class ScriptInfo { + type: ScriptType; + scriptPath?: string; + hex?: string; + asm?: string; + template: ScriptTemplate; + + constructor(type: ScriptType, hex?: string, asm?: string, witness?: string[], scriptPath?: string) { + this.type = type; + this.hex = hex; + this.asm = asm; + if (scriptPath) { + this.scriptPath = scriptPath; + } + if (this.asm) { + this.template = detectScriptTemplate(this.type, this.asm, witness); + } + } + + public clone(): ScriptInfo { + return { ...this }; + } + + get key(): string { + return this.type + (this.scriptPath || ''); + } +} + +/** parses an inner_witnessscript + witness stack, and detects named script types */ +export function detectScriptTemplate(type: ScriptType, script_asm: string, witness?: string[]): ScriptTemplate | undefined { + if (type === 'inner_witnessscript' && witness?.length) { + if (script_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || script_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) { + if (witness.length > 11) { + return ScriptTemplates.liquid_peg_out(); + } else { + return ScriptTemplates.liquid_peg_out_emergency(); + } + } + + const topElement = witness[witness.length - 2]; + if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(script_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs + if (topElement === '01') { + // top element is '01' to get in the revocation path + return ScriptTemplates.ln_force_close_revoked(); + } else { + // top element is '', this is a delayed to_local output + return ScriptTemplates.ln_force_close(); + } + } else if ( + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(script_asm) || + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(script_asm) + ) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs + // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs + if (topElement.length === 66) { + // top element is a public key + return ScriptTemplates.ln_htlc_revoked(); + } else if (topElement) { + // top element is a preimage + return ScriptTemplates.ln_htlc(); + } else { + // top element is '' to get in the expiry of the script + return ScriptTemplates.ln_htlc_expired(); + } + } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(script_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors + if (topElement) { + // top element is a signature + return ScriptTemplates.ln_anchor(); + } else { + // top element is '', it has been swept after 16 blocks + return ScriptTemplates.ln_anchor_swept(); + } + } + } + + const multisig = parseMultisigScript(script_asm); + if (multisig) { + return ScriptTemplates.multisig(multisig.m, multisig.n); + } + + return; +} + /** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */ -export function parseMultisigScript(script: string): void | { m: number, n: number } { +export function parseMultisigScript(script: string): undefined | { m: number, n: number } { if (!script) { return; } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 3b56d3510..ead9060ae 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -87,6 +87,7 @@ import { ChangeComponent } from '../components/change/change.component'; import { SatsComponent } from './components/sats/sats.component'; import { BtcComponent } from './components/btc/btc.component'; import { FeeRateComponent } from './components/fee-rate/fee-rate.component'; +import { AddressTypeComponent } from './components/address-type/address-type.component'; import { TruncateComponent } from './components/truncate/truncate.component'; import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; import { TimestampComponent } from './components/timestamp/timestamp.component'; @@ -202,6 +203,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir SatsComponent, BtcComponent, FeeRateComponent, + AddressTypeComponent, TruncateComponent, SearchResultsComponent, TimestampComponent, @@ -343,6 +345,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir SatsComponent, BtcComponent, FeeRateComponent, + AddressTypeComponent, TruncateComponent, SearchResultsComponent, TimestampComponent,