Merge branch 'master' into natsoni/address-history-chart-usd

This commit is contained in:
natsoni
2024-06-22 14:36:40 +09:00
62 changed files with 1228 additions and 493 deletions

View File

@@ -0,0 +1,193 @@
import '@angular/localize/init';
import { ScriptInfo } from './script.utils';
import { Vin } from '../interfaces/electrs.interface';
import { BECH32_CHARS_LW, BASE58_CHARS, HEX_CHARS } from './regex.utils';
export type AddressType = 'fee'
| 'empty'
| 'provably_unspendable'
| 'op_return'
| 'multisig'
| 'p2pk'
| 'p2pkh'
| 'p2sh'
| 'p2sh-p2wpkh'
| 'p2sh-p2wsh'
| 'v0_p2wpkh'
| 'v0_p2wsh'
| 'v1_p2tr'
| 'confidential'
| 'unknown'
const ADDRESS_PREFIXES = {
mainnet: {
base58: {
pubkey: ['1'],
script: ['3'],
},
bech32: 'bc1',
},
testnet: {
base58: {
pubkey: ['m', 'n'],
script: '2',
},
bech32: 'tb1',
},
testnet4: {
base58: {
pubkey: ['m', 'n'],
script: '2',
},
bech32: 'tb1',
},
signet: {
base58: {
pubkey: ['m', 'n'],
script: '2',
},
bech32: 'tb1',
},
liquid: {
base58: {
pubkey: ['P','Q'],
script: ['G','H'],
confidential: ['V'],
},
bech32: 'ex1',
confidential: 'lq1',
},
liquidtestnet: {
base58: {
pubkey: ['F'],
script: ['8','9'],
confidential: ['V'], // TODO: check if this is actually correct
},
bech32: 'tex1',
confidential: 'tlq1',
},
};
// precompiled regexes for common address types (excluding prefixes)
const base58Regex = RegExp('^' + BASE58_CHARS + '{26,34}$');
const confidentialb58Regex = RegExp('^[TJ]' + BASE58_CHARS + '{78}$');
const p2wpkhRegex = RegExp('^q' + BECH32_CHARS_LW + '{38}$');
const p2wshRegex = RegExp('^q' + BECH32_CHARS_LW + '{58}$');
const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$');
const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`);
export function detectAddressType(address: string, network: string): AddressType {
// normal address types
const firstChar = address.substring(0, 1);
if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) {
return 'p2pkh';
} else if (ADDRESS_PREFIXES[network].base58.script.includes(firstChar) && base58Regex.test(address.slice(1))) {
return 'p2sh';
} else if (address.startsWith(ADDRESS_PREFIXES[network].bech32)) {
const suffix = address.slice(ADDRESS_PREFIXES[network].bech32.length);
if (p2wpkhRegex.test(suffix)) {
return 'v0_p2wpkh';
} else if (p2wshRegex.test(suffix)) {
return 'v0_p2wsh';
} else if (p2trRegex.test(suffix)) {
return 'v1_p2tr';
}
}
// p2pk
if (pubkeyRegex.test(address)) {
return 'p2pk';
}
// liquid-specific types
if (network.startsWith('liquid')) {
if (ADDRESS_PREFIXES[network].base58.confidential.includes(firstChar) && confidentialb58Regex.test(address.slice(1))) {
return 'confidential';
} else if (address.startsWith(ADDRESS_PREFIXES[network].confidential)) {
return 'confidential';
}
}
return 'unknown';
}
/**
* Parses & classifies address types + properties from address strings
*
* can optionally augment this data with examples of spends from the address,
* e.g. to classify revealed scripts for scripthash-type addresses.
*/
export class AddressTypeInfo {
network: string;
address: string;
type: AddressType;
// script data
scripts: Map<string, ScriptInfo>; // raw script
// flags
isMultisig?: { m: number, n: number };
tapscript?: boolean;
constructor (network: string, address: string, type?: AddressType, vin?: Vin[]) {
this.network = network;
this.address = address;
this.scripts = new Map();
if (type) {
this.type = type;
} else {
this.type = detectAddressType(address, network);
}
this.processInputs(vin);
}
public clone(): AddressTypeInfo {
const cloned = new AddressTypeInfo(this.network, this.address, this.type);
cloned.scripts = new Map(Array.from(this.scripts, ([key, value]) => [key, value?.clone()]));
cloned.isMultisig = this.isMultisig;
cloned.tapscript = this.tapscript;
return cloned;
}
public processInputs(vin: Vin[] = []): void {
// taproot can have multiple script paths
if (this.type === 'v1_p2tr') {
for (const v of vin) {
if (v.inner_witnessscript_asm) {
this.tapscript = true;
const controlBlock = v.witness[v.witness.length - 1].startsWith('50') ? v.witness[v.witness.length - 2] : v.witness[v.witness.length - 1];
this.processScript(new ScriptInfo('inner_witnessscript', undefined, v.inner_witnessscript_asm, v.witness, controlBlock));
}
}
// for single-script types, if we've seen one input we've seen them all
} else if (['p2sh', 'v0_p2wsh'].includes(this.type)) {
if (!this.scripts.size && vin.length) {
const v = vin[0];
// wrapped segwit
if (this.type === 'p2sh' && v.witness?.length) {
if (v.scriptsig.startsWith('160014')) {
this.type = 'p2sh-p2wpkh';
} else if (v.scriptsig.startsWith('220020')) {
this.type = 'p2sh-p2wsh';
}
}
// real script
if (this.type !== 'p2sh-p2wpkh') {
if (v.inner_witnessscript_asm) {
this.processScript(new ScriptInfo('inner_witnessscript', undefined, v.inner_witnessscript_asm, v.witness));
} else if (v.inner_redeemscript_asm) {
this.processScript(new ScriptInfo('inner_redeemscript', undefined, v.inner_redeemscript_asm, v.witness));
} else if (v.scriptsig || v.scriptsig_asm) {
this.processScript(new ScriptInfo('scriptsig', v.scriptsig, v.scriptsig_asm, v.witness));
}
}
}
}
// and there's nothing more to learn from processing inputs for non-scripthash types
}
private processScript(script: ScriptInfo): void {
this.scripts.set(script.key, script);
if (script.template?.type === 'multisig') {
this.isMultisig = { m: script.template['m'], n: script.template['n'] };
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
import { Component, Input } from '@angular/core';
import { AddressTypeInfo } from '../../address-utils';
@Component({
selector: 'app-address-type',
templateUrl: './address-type.component.html',
styleUrls: []
})
export class AddressTypeComponent {
@Input() address: AddressTypeInfo;
}

View File

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

View File

@@ -12,7 +12,9 @@ export class RelativeUrlPipe implements PipeTransform {
transform(value: string, swapNetwork?: string): string {
let network = swapNetwork || this.stateService.network;
if (network === 'mainnet') network = '';
if (network === 'mainnet' || network === this.stateService.env.ROOT_NETWORK) {
network = '';
}
if (this.stateService.env.BASE_MODULE === 'liquid' && network === 'liquidtestnet') {
network = 'testnet';
} else if (this.stateService.env.BASE_MODULE !== 'mempool') {

View File

@@ -1,14 +1,14 @@
import { Env } from '../services/state.service';
// all base58 characters
const BASE58_CHARS = `[a-km-zA-HJ-NP-Z1-9]`;
export const BASE58_CHARS = `[a-km-zA-HJ-NP-Z1-9]`;
// all bech32 characters (after the separator)
const BECH32_CHARS_LW = `[ac-hj-np-z02-9]`;
export const BECH32_CHARS_LW = `[ac-hj-np-z02-9]`;
const BECH32_CHARS_UP = `[AC-HJ-NP-Z02-9]`;
// Hex characters
const HEX_CHARS = `[a-fA-F0-9]`;
export const HEX_CHARS = `[a-fA-F0-9]`;
// A regex to say "A single 0 OR any number with no leading zeroes"
// Capped at 9 digits so as to not be confused with lightning channel IDs (which are around 17 digits)

View File

@@ -145,8 +145,116 @@ for (let i = 187; i <= 255; i++) {
export { opcodes };
export type ScriptType = 'scriptpubkey'
| 'scriptsig'
| 'inner_witnessscript'
| 'inner_redeemscript'
export interface ScriptTemplate {
type: string;
label: string;
}
export const ScriptTemplates: { [type: string]: (...args: any) => ScriptTemplate } = {
liquid_peg_out: () => ({ type: 'liquid_peg_out', label: 'Liquid Peg Out' }),
liquid_peg_out_emergency: () => ({ type: 'liquid_peg_out_emergency', label: 'Emergency Liquid Peg Out' }),
ln_force_close: () => ({ type: 'ln_force_close', label: 'Lightning Force Close' }),
ln_force_close_revoked: () => ({ type: 'ln_force_close_revoked', label: 'Revoked Lightning Force Close' }),
ln_htlc: () => ({ type: 'ln_htlc', label: 'Lightning HTLC' }),
ln_htlc_revoked: () => ({ type: 'ln_htlc_revoked', label: 'Revoked Lightning HTLC' }),
ln_htlc_expired: () => ({ type: 'ln_htlc_expired', label: 'Expired Lightning HTLC' }),
ln_anchor: () => ({ type: 'ln_anchor', label: 'Lightning Anchor' }),
ln_anchor_swept: () => ({ type: 'ln_anchor_swept', label: 'Swept Lightning Anchor' }),
multisig: (m: number, n: number) => ({ type: 'multisig', m, n, label: $localize`:@@address-label.multisig:Multisig ${m}:multisigM: of ${n}:multisigN:` }),
};
export class ScriptInfo {
type: ScriptType;
scriptPath?: string;
hex?: string;
asm?: string;
template: ScriptTemplate;
constructor(type: ScriptType, hex?: string, asm?: string, witness?: string[], scriptPath?: string) {
this.type = type;
this.hex = hex;
this.asm = asm;
if (scriptPath) {
this.scriptPath = scriptPath;
}
if (this.asm) {
this.template = detectScriptTemplate(this.type, this.asm, witness);
}
}
public clone(): ScriptInfo {
return { ...this };
}
get key(): string {
return this.type + (this.scriptPath || '');
}
}
/** parses an inner_witnessscript + witness stack, and detects named script types */
export function detectScriptTemplate(type: ScriptType, script_asm: string, witness?: string[]): ScriptTemplate | undefined {
if (type === 'inner_witnessscript' && witness?.length) {
if (script_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || script_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) {
if (witness.length > 11) {
return ScriptTemplates.liquid_peg_out();
} else {
return ScriptTemplates.liquid_peg_out_emergency();
}
}
const topElement = witness[witness.length - 2];
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(script_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
if (topElement === '01') {
// top element is '01' to get in the revocation path
return ScriptTemplates.ln_force_close_revoked();
} else {
// top element is '', this is a delayed to_local output
return ScriptTemplates.ln_force_close();
}
} else if (
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(script_asm) ||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(script_asm)
) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
if (topElement.length === 66) {
// top element is a public key
return ScriptTemplates.ln_htlc_revoked();
} else if (topElement) {
// top element is a preimage
return ScriptTemplates.ln_htlc();
} else {
// top element is '' to get in the expiry of the script
return ScriptTemplates.ln_htlc_expired();
}
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(script_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if (topElement) {
// top element is a signature
return ScriptTemplates.ln_anchor();
} else {
// top element is '', it has been swept after 16 blocks
return ScriptTemplates.ln_anchor_swept();
}
}
}
const multisig = parseMultisigScript(script_asm);
if (multisig) {
return ScriptTemplates.multisig(multisig.m, multisig.n);
}
return;
}
/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */
export function parseMultisigScript(script: string): void | { m: number, n: number } {
export function parseMultisigScript(script: string): undefined | { m: number, n: number } {
if (!script) {
return;
}

View File

@@ -87,6 +87,7 @@ import { ChangeComponent } from '../components/change/change.component';
import { SatsComponent } from './components/sats/sats.component';
import { BtcComponent } from './components/btc/btc.component';
import { FeeRateComponent } from './components/fee-rate/fee-rate.component';
import { AddressTypeComponent } from './components/address-type/address-type.component';
import { TruncateComponent } from './components/truncate/truncate.component';
import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component';
import { TimestampComponent } from './components/timestamp/timestamp.component';
@@ -202,6 +203,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
SatsComponent,
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@@ -343,6 +345,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
SatsComponent,
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,

View File

@@ -147,9 +147,15 @@ export function isNonStandard(tx: Transaction): boolean {
let opreturnCount = 0;
for (const vout of tx.vout) {
// scriptpubkey
if (['unknown', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) {
if (['nonstandard', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) {
// (non-standard output type)
return true;
} else if (vout.scriptpubkey_type === 'unknown') {
// undefined segwit version/length combinations are actually standard in outputs
// https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/interpreter.cpp#L1950-L1951
if (vout.scriptpubkey.startsWith('00') || !isWitnessProgram(vout.scriptpubkey)) {
return true;
}
} else if (vout.scriptpubkey_type === 'multisig') {
if (!DEFAULT_PERMIT_BAREMULTISIG) {
// bare-multisig
@@ -197,6 +203,27 @@ export function isNonStandard(tx: Transaction): boolean {
return false;
}
// A witness program is any valid scriptpubkey that consists of a 1-byte push opcode
// followed by a data push between 2 and 40 bytes.
// https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/script.cpp#L224-L240
function isWitnessProgram(scriptpubkey: string): false | { version: number, program: string } {
if (scriptpubkey.length < 8 || scriptpubkey.length > 84) {
return false;
}
const version = parseInt(scriptpubkey.slice(0,2), 16);
if (version !== 0 && version < 0x51 || version > 0x60) {
return false;
}
const push = parseInt(scriptpubkey.slice(2,4), 16);
if (push + 2 === (scriptpubkey.length / 2)) {
return {
version: version ? version - 0x50 : 0,
program: scriptpubkey.slice(4),
};
}
return false;
}
export function getNonWitnessSize(tx: Transaction): number {
let weight = tx.weight;
let hasWitness = false;