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/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
- 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions
+ {{ (transactions?.length | number) || '?' }} of {{ mempoolStats.tx_count + chainStats.tx_count | number }} transaction
+ 1">{{ (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/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/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/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,