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 - {{ (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/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,