Merge branch 'master' into mononaut/core-gettxsforblock

This commit is contained in:
softsimon 2024-06-21 08:03:34 +09:00 committed by GitHub
commit e95e64a443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 698 additions and 253 deletions

View File

@ -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

View File

@ -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": {

View File

@ -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",

View File

@ -31,10 +31,7 @@ export interface AccelerationHistory {
feeDelta: number,
blockHash: string,
blockHeight: number,
pools: {
pool_unique_id: number,
username: string,
}[],
pools: number[];
};
class AccelerationApi {

View File

@ -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));

View File

@ -76,7 +76,7 @@ class FreeCurrencyApi implements ConversionFeed {
}
public async $fetchConversionRates(date: string): Promise<ConversionRates> {
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']);

View File

@ -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;
}
}

View File

@ -5,7 +5,7 @@ import config from '../config';
import logger from '../logger';
import * as https from 'https';
export async function query(path): Promise<object | undefined> {
export async function query(path, throwOnFail: boolean = false): Promise<object | undefined> {
type axiosOptions = {
headers: {
'User-Agent': string
@ -21,6 +21,7 @@ export async function query(path): Promise<object | undefined> {
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<object | undefined> {
}
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<object | undefined> {
}
logger.err(`Could not connect to ${path}. All ${config.MEMPOOL.EXTERNAL_MAX_RETRY} attempts failed`);
if (throwOnFail && lastError) {
throw lastError;
}
return undefined;
}

View File

@ -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

View File

@ -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`);

View File

@ -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"
}

View File

@ -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",

View File

@ -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([

View File

@ -4,7 +4,7 @@
<a [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">
<span
*ngIf="label"
class="badge badge-pill badge-warning"
class="badge badge-pill badge-warning {{ class }}"
>{{ label }}</span>
</a>
</div>
@ -15,6 +15,6 @@
<ng-template #default>
<span
*ngIf="label"
class="badge badge-pill badge-warning"
class="badge badge-pill badge-warning {{ class }}"
>{{ label }}</span>
</ng-template>

View File

@ -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);
}
}

View File

@ -3,7 +3,13 @@
<h1 i18n="shared.address">Address</h1>
<div class="tx-link">
<app-truncate [text]="addressString" [lastChars]="8" [link]="['/address/' | relativeUrl, addressString]">
<app-clipboard [text]="addressString"></app-clipboard>
<app-clipboard [text]="addressString" [size]="isMobile ? 'large' : 'normal'"></app-clipboard>
<span style="position: relative; cursor: pointer" (mouseover)="showQR = true" (mouseout)="showQR = false" (pointerdown)="showQR = true">
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true" [style.font-size]="isMobile ? '18px' : '12px'"></fa-icon>
<div class="qr-wrapper" [hidden]="!showQR">
<app-qrcode [size]="200" [data]="addressString"></app-qrcode>
</div>
</span>
</app-truncate>
</div>
</div>
@ -14,40 +20,47 @@
<div class="box">
<div class="row">
<div class="col-md">
<table class="table table-borderless table-striped address-table">
<tbody>
<tr *ngIf="addressInfo && addressInfo.unconfidential">
<td i18n="address.unconfidential">Unconfidential</td>
<td>
<app-truncate [text]="addressInfo.unconfidential" [lastChars]="8" [link]="['/address/' | relativeUrl, addressInfo.unconfidential]">
<app-clipboard [text]="addressInfo.unconfidential"></app-clipboard>
</app-truncate>
</td>
</tr>
<ng-template [ngIf]="!address.electrum">
<tr>
<td i18n="address.total-received">Total received</td>
<td *ngIf="address.chain_stats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="received" [noFiat]="true"></app-amount></td>
</tr>
<tr>
<td i18n="address.total-sent">Total sent</td>
<td *ngIf="address.chain_stats.spent_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="sent" [noFiat]="true"></app-amount></td>
</tr>
</ng-template>
<tr>
<td i18n="address.balance">Balance</td>
<td *ngIf="address.chain_stats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="received - sent" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="received - sent"></app-fiat></span></td>
</tr>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col-md qrcode-col">
<div class="qr-wrapper">
<app-qrcode [data]="address.address"></app-qrcode>
@if (isMobile) {
<div class="col-sm">
<table class="table table-borderless table-striped address-table">
<tbody>
<ng-container *ngTemplateOutlet="balanceRow"></ng-container>
<ng-container *ngTemplateOutlet="pendingBalanceRow"></ng-container>
<ng-container *ngTemplateOutlet="utxoRow"></ng-container>
<ng-container *ngTemplateOutlet="pendingUtxoRow"></ng-container>
@if (network === 'liquid' || network === 'liquidtestnet') {
<ng-container *ngTemplateOutlet="liquidRow"></ng-container>
} @else {
<ng-container *ngTemplateOutlet="volumeRow"></ng-container>
}
<ng-container *ngTemplateOutlet="typeRow"></ng-container>
</tbody>
</table>
</div>
</div>
} @else {
<div class="col-sm">
<table class="table table-borderless table-striped table-fixed address-table">
<tbody>
<ng-container *ngTemplateOutlet="balanceRow"></ng-container>
<ng-container *ngTemplateOutlet="utxoRow"></ng-container>
@if (network === 'liquid' || network === 'liquidtestnet') {
<ng-container *ngTemplateOutlet="liquidRow"></ng-container>
} @else {
<ng-container *ngTemplateOutlet="volumeRow"></ng-container>
}
</tbody>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped table-fixed address-table">
<tbody>
<ng-container *ngTemplateOutlet="pendingBalanceRow"></ng-container>
<ng-container *ngTemplateOutlet="pendingUtxoRow"></ng-container>
<ng-container *ngTemplateOutlet="typeRow"></ng-container>
</tbody>
</table>
</div>
}
</div>
</div>
@ -76,8 +89,8 @@
<div class="title-tx">
<h2 class="text-left">
<ng-template [ngIf]="!transactions?.length">&nbsp;</ng-template>
<ng-template i18n="X of X Address Transaction" [ngIf]="transactions?.length === 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction</ng-template>
<ng-template i18n="X of X Address Transactions (Plural)" [ngIf]="transactions?.length > 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions</ng-template>
<ng-template i18n="X of X Address Transaction" [ngIf]="transactions?.length === 1">{{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transaction</ng-template>
<ng-template i18n="X of X Address Transactions (Plural)" [ngIf]="transactions?.length > 1">{{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transactions</ng-template>
</h2>
</div>
@ -182,3 +195,57 @@
<span class="skeleton-loader"></span>
</div>
</ng-template>
<ng-template #balanceRow>
<tr>
<td i18n="address.balance">Balance</td>
<td *ngIf="chainStats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="chainStats.balance" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="chainStats.balance"></app-fiat></span></td>
</tr>
</ng-template>
<ng-template #pendingBalanceRow>
<tr>
<td i18n="address.unconfirmed-balance" class="font-italic">unconfirmed balance</td>
<td *ngIf="mempoolStats.funded_txo_sum !== undefined; else confidentialTd" class="font-italic"><app-amount [satoshis]="mempoolStats.balance" [noFiat]="true" [addPlus]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolStats.balance"></app-fiat></span></td>
</tr>
</ng-template>
<ng-template #utxoRow>
<tr>
<td i18n="address.utxos" i18n-ngbTooltip="unspent-transaction-outputs" ngbTooltip="unspent transaction outputs">UTXOs</td>
<td>{{ chainStats.utxos }}</td>
</tr>
</ng-template>
<ng-template #pendingUtxoRow>
<tr>
<td i18n="address.unconfirmed-utxos" class="font-italic">unconfirmed UTXOs</td>
<td class="font-italic">{{ mempoolStats.utxos > 0 ? '+' : ''}}{{ mempoolStats.utxos }}</td>
</tr>
</ng-template>
<ng-template #volumeRow>
<tr>
<td i18n="address.volume">Volume</td>
<td *ngIf="chainStats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="chainStats.volume + mempoolStats.volume"></app-amount></td>
</tr>
</ng-template>
<ng-template #typeRow>
<tr>
<td i18n="address.type">Type</td>
<td class="wrap-cell"><app-address-type [address]="addressTypeInfo"></app-address-type><app-address-labels [channel]="exampleChannel" [address]="addressTypeInfo" class="ml-1"></app-address-labels></td>
</tr>
</ng-template>
<ng-template #liquidRow>
<tr *ngIf="addressInfo && addressInfo.unconfidential">
<td i18n="address.unconfidential">Unconfidential</td>
<td>
<app-truncate [text]="addressInfo.unconfidential" [lastChars]="8" [textAlign]="isMobile ? 'end' : 'start'" [link]="['/address/' | relativeUrl, addressInfo.unconfidential]">
<app-clipboard [text]="addressInfo.unconfidential"></app-clipboard>
</app-truncate>
</td>
</tr>
</ng-template>

View File

@ -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;
}
}

View File

@ -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<number>;
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();

View File

@ -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;
}
}

View File

@ -1,7 +1,7 @@
<ng-template [ngIf]="button" [ngIfElse]="btnLink">
<button #btn [attr.data-clipboard-text]="text" [class]="class" type="button" [disabled]="text === ''">
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;top: -2px;left: 1px;">
<app-svg-images name="clippy" [width]="size === 'small' ? '10' : '13'" viewBox="0 0 1000 1000"></app-svg-images>
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
</span>
</button>
</ng-template>
@ -9,7 +9,7 @@
<ng-template #btnLink>
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0 {{ leftPadding ? 'padding' : '' }}" [attr.data-clipboard-text]="text">
<app-svg-images name="clippy" [width]="size === 'small' ? '10' : '13'" viewBox="0 0 1000 1000"></app-svg-images>
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
</button>
</span>
</ng-template>

View File

@ -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() { }

View File

@ -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 {

View File

@ -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;
}

View File

@ -75,7 +75,7 @@
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
<div>
<app-address-labels [vin]="vin" [channel]="tx._channels && tx._channels.inputs[vindex] ? tx._channels.inputs[vindex] : null"></app-address-labels>
<app-address-labels [vin]="vin" [channel]="tx._channels && tx._channels.inputs[vindex] ? tx._channels.inputs[vindex] : null"></app-address-labels>
</div>
</ng-template>
</ng-container>

View File

@ -9126,11 +9126,7 @@ export const restApiDocsData = [
"blockHash": "00000000000000000000482f0746d62141694b9210a813b97eb8445780a32003",
"blockHeight": 829559,
"bidBoost": 6102,
"pools": [
{
"pool_unique_id": 111
}
]
"pools": [111]
}
]`,
},

View File

@ -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<string, ScriptInfo>; // 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'] };
}
}
}

View File

@ -0,0 +1,29 @@
@switch (address.type || null) {
@case ('fee') {
<span i18n="address.fee">fee</span>
}
@case ('empty') {
<span i18n="address.empty">empty</span>
}
@case ('v0_p2wpkh') {
<span>P2WPKH</span>
}
@case ('v0_p2wsh') {
<span>P2WSH</span>
}
@case ('v1_p2tr') {
<span>P2TR</span>
}
@case ('provably_unspendable') {
<span i18n="address.provably-unspendable">provably unspendable</span>
}
@case ('multisig') {
<span i18n="address.bare-multisig">bare multisig</span>
}
@case (null) {
<span>unknown</span>
}
@default {
<span>{{ address.type.toUpperCase() }}</span>
}
}

View File

@ -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;
}

View File

@ -2,7 +2,7 @@
text-overflow: unset;
display: flex;
flex-direction: row;
align-items: baseline;
align-items: start;
position: relative;
.truncate-link {

View File

@ -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)

View File

@ -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;
}

View File

@ -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,