Address & script parsing refactor
This commit is contained in:
parent
3b419be341
commit
7dfdb5553e
@ -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,6 +12,7 @@ import { parseMultisigScript } from '../../bitcoin.utils';
|
||||
export class AddressLabelsComponent implements OnChanges {
|
||||
network = '';
|
||||
|
||||
@Input() address: AddressTypeInfo;
|
||||
@Input() vin: Vin;
|
||||
@Input() vout: Vout;
|
||||
@Input() channel: any;
|
||||
@ -28,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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,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);
|
||||
}
|
||||
}
|
||||
|
@ -220,14 +220,14 @@
|
||||
<ng-template #volumeRow>
|
||||
<tr>
|
||||
<td i18n="address.volume">Volume</td>
|
||||
<td><app-amount [satoshis]="chainStats.volume + mempoolStats.volume"></app-amount></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><app-address-type [vout]="exampleVout || exampleVin?.prevout || null"></app-address-type><app-address-labels [channel]="exampleChannel" [vin]="exampleVin" [vout]="exampleVout" class="ml-1"></app-address-labels></td>
|
||||
<td><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>
|
||||
|
||||
|
@ -2,7 +2,7 @@ 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, ChainStats, Transaction, Vin, Vout } 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,7 @@ 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;
|
||||
@ -112,14 +113,13 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
blockTxSubscription: Subscription;
|
||||
addressLoadingStatus$: Observable<number>;
|
||||
addressInfo: null | AddressInformation = null;
|
||||
addressTypeInfo: null | AddressTypeInfo;
|
||||
|
||||
fullyLoaded = false;
|
||||
chainStats: AddressStats;
|
||||
mempoolStats: AddressStats;
|
||||
|
||||
exampleChannel?: any;
|
||||
exampleVin?: Vin;
|
||||
exampleVout?: Vout;
|
||||
|
||||
now = Date.now() / 1000;
|
||||
balancePeriod: 'all' | '1m' = 'all';
|
||||
@ -161,8 +161,6 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.transactions = null;
|
||||
this.addressInfo = null;
|
||||
this.exampleChannel = null;
|
||||
this.exampleVin = null;
|
||||
this.exampleVout = 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)) {
|
||||
@ -171,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$
|
||||
@ -268,17 +268,13 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.isLoadingTransactions = false;
|
||||
|
||||
let addressVin: Vin[] = [];
|
||||
for (const tx of this.transactions) {
|
||||
if (!this.exampleVin) {
|
||||
this.exampleVin = tx.vin.find(v => v.prevout?.scriptpubkey_address === this.address.address);
|
||||
}
|
||||
if (!this.exampleVout) {
|
||||
this.exampleVout = tx.vout.find(v => v.scriptpubkey_address === this.address.address);
|
||||
}
|
||||
if (this.exampleVin && this.exampleVout) {
|
||||
break;
|
||||
}
|
||||
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');
|
||||
|
@ -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>
|
||||
|
193
frontend/src/app/shared/address-utils.ts
Normal file
193
frontend/src/app/shared/address-utils.ts
Normal 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'] };
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
@switch (vout?.scriptpubkey_type || null) {
|
||||
@switch (address.type || null) {
|
||||
@case ('fee') {
|
||||
<span i18n="address.fee">fee</span>
|
||||
}
|
||||
@ -24,6 +24,6 @@
|
||||
<span>unknown</span>
|
||||
}
|
||||
@default {
|
||||
<span>{{ vout.scriptpubkey_type.toUpperCase() }}</span>
|
||||
<span>{{ address.type.toUpperCase() }}</span>
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Vout } from '../../../interfaces/electrs.interface';
|
||||
import { AddressTypeInfo } from '../../address-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-address-type',
|
||||
@ -7,5 +7,5 @@ import { Vout } from '../../../interfaces/electrs.interface';
|
||||
styleUrls: []
|
||||
})
|
||||
export class AddressTypeComponent {
|
||||
@Input() vout: Vout;
|
||||
@Input() address: AddressTypeInfo;
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user