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 { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; | ||||||
| import { Vin, Vout } from '../../interfaces/electrs.interface'; | import { Vin, Vout } from '../../interfaces/electrs.interface'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| import { parseMultisigScript } from '../../bitcoin.utils'; | import { AddressType, AddressTypeInfo } from '../../shared/address-utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-address-labels', |   selector: 'app-address-labels', | ||||||
| @ -12,6 +12,7 @@ import { parseMultisigScript } from '../../bitcoin.utils'; | |||||||
| export class AddressLabelsComponent implements OnChanges { | export class AddressLabelsComponent implements OnChanges { | ||||||
|   network = ''; |   network = ''; | ||||||
| 
 | 
 | ||||||
|  |   @Input() address: AddressTypeInfo; | ||||||
|   @Input() vin: Vin; |   @Input() vin: Vin; | ||||||
|   @Input() vout: Vout; |   @Input() vout: Vout; | ||||||
|   @Input() channel: any; |   @Input() channel: any; | ||||||
| @ -28,10 +29,10 @@ export class AddressLabelsComponent implements OnChanges { | |||||||
|   ngOnChanges() { |   ngOnChanges() { | ||||||
|     if (this.channel) { |     if (this.channel) { | ||||||
|       this.handleChannel(); |       this.handleChannel(); | ||||||
|  |     } else if (this.address) { | ||||||
|  |       this.handleAddress(); | ||||||
|     } else if (this.vin) { |     } else if (this.vin) { | ||||||
|       this.handleVin(); |       this.handleVin(); | ||||||
|     } else if (this.vout) { |  | ||||||
|       this.handleVout(); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -42,74 +43,22 @@ export class AddressLabelsComponent implements OnChanges { | |||||||
|     this.label = `Channel ${type}: ${leftNodeName} <> ${rightNodeName}`; |     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() { |   handleVin() { | ||||||
|     if (this.vin.inner_witnessscript_asm) { |     const address = new AddressTypeInfo(this.network || 'mainnet', this.vin.prevout?.scriptpubkey_address, this.vin.prevout?.scriptpubkey_type as AddressType, [this.vin]) | ||||||
|       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 (address?.scripts.size) { | ||||||
|         if (this.vin.witness.length > 11) { |       const script = address?.scripts.values().next().value; | ||||||
|           this.label = 'Liquid Peg Out'; |       if (script.template?.label) { | ||||||
|         } else { |         this.label = script.template.label; | ||||||
|           this.label = 'Emergency Liquid Peg Out'; |  | ||||||
|         } |  | ||||||
|         return; |  | ||||||
|       } |       } | ||||||
| 
 |  | ||||||
|       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> | <ng-template #volumeRow> | ||||||
|   <tr> |   <tr> | ||||||
|     <td i18n="address.volume">Volume</td> |     <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> |   </tr> | ||||||
| </ng-template> | </ng-template> | ||||||
| 
 | 
 | ||||||
| <ng-template #typeRow> | <ng-template #typeRow> | ||||||
|   <tr> |   <tr> | ||||||
|     <td i18n="address.type">Type</td> |     <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> |   </tr> | ||||||
| </ng-template> | </ng-template> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; | |||||||
| import { ActivatedRoute, ParamMap } from '@angular/router'; | import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||||
| import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; | 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 { WebsocketService } from '../../services/websocket.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| import { AudioService } from '../../services/audio.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 { SeoService } from '../../services/seo.service'; | ||||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||||
|  | import { AddressTypeInfo } from '../../shared/address-utils'; | ||||||
| 
 | 
 | ||||||
| class AddressStats implements ChainStats { | class AddressStats implements ChainStats { | ||||||
|   address: string; |   address: string; | ||||||
| @ -112,14 +113,13 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|   blockTxSubscription: Subscription; |   blockTxSubscription: Subscription; | ||||||
|   addressLoadingStatus$: Observable<number>; |   addressLoadingStatus$: Observable<number>; | ||||||
|   addressInfo: null | AddressInformation = null; |   addressInfo: null | AddressInformation = null; | ||||||
|  |   addressTypeInfo: null | AddressTypeInfo; | ||||||
| 
 | 
 | ||||||
|   fullyLoaded = false; |   fullyLoaded = false; | ||||||
|   chainStats: AddressStats; |   chainStats: AddressStats; | ||||||
|   mempoolStats: AddressStats; |   mempoolStats: AddressStats; | ||||||
| 
 | 
 | ||||||
|   exampleChannel?: any; |   exampleChannel?: any; | ||||||
|   exampleVin?: Vin; |  | ||||||
|   exampleVout?: Vout; |  | ||||||
| 
 | 
 | ||||||
|   now = Date.now() / 1000; |   now = Date.now() / 1000; | ||||||
|   balancePeriod: 'all' | '1m' = 'all'; |   balancePeriod: 'all' | '1m' = 'all'; | ||||||
| @ -161,8 +161,6 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|           this.transactions = null; |           this.transactions = null; | ||||||
|           this.addressInfo = null; |           this.addressInfo = null; | ||||||
|           this.exampleChannel = null; |           this.exampleChannel = null; | ||||||
|           this.exampleVin = null; |  | ||||||
|           this.exampleVout = null; |  | ||||||
|           document.body.scrollTo(0, 0); |           document.body.scrollTo(0, 0); | ||||||
|           this.addressString = params.get('id') || ''; |           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)) { |           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.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.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( |           return merge( | ||||||
|             of(true), |             of(true), | ||||||
|             this.stateService.connectionState$ |             this.stateService.connectionState$ | ||||||
| @ -268,17 +268,13 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|         } |         } | ||||||
|         this.isLoadingTransactions = false; |         this.isLoadingTransactions = false; | ||||||
| 
 | 
 | ||||||
|  |         let addressVin: Vin[] = []; | ||||||
|         for (const tx of this.transactions) { |         for (const tx of this.transactions) { | ||||||
|           if (!this.exampleVin) { |           addressVin = addressVin.concat(tx.vin.filter(v => v.prevout?.scriptpubkey_address === this.address.address)); | ||||||
|             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; |  | ||||||
|           } |  | ||||||
|         } |         } | ||||||
|  |         this.addressTypeInfo.processInputs(addressVin); | ||||||
|  |         // hack to trigger change detection
 | ||||||
|  |         this.addressTypeInfo = this.addressTypeInfo.clone(); | ||||||
| 
 | 
 | ||||||
|         if (!this.showBalancePeriod()) { |         if (!this.showBalancePeriod()) { | ||||||
|           this.setBalancePeriod('all'); |           this.setBalancePeriod('all'); | ||||||
|  | |||||||
| @ -75,7 +75,7 @@ | |||||||
|                           {{ vin.prevout.scriptpubkey_type?.toUpperCase() }} |                           {{ vin.prevout.scriptpubkey_type?.toUpperCase() }} | ||||||
|                         </ng-template> |                         </ng-template> | ||||||
|                         <div> |                         <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> |                         </div> | ||||||
|                       </ng-template> |                       </ng-template> | ||||||
|                     </ng-container> |                     </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') { |   @case ('fee') { | ||||||
|     <span i18n="address.fee">fee</span> |     <span i18n="address.fee">fee</span> | ||||||
|   } |   } | ||||||
| @ -24,6 +24,6 @@ | |||||||
|     <span>unknown</span> |     <span>unknown</span> | ||||||
|   } |   } | ||||||
|   @default { |   @default { | ||||||
|     <span>{{ vout.scriptpubkey_type.toUpperCase() }}</span> |     <span>{{ address.type.toUpperCase() }}</span> | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import { Component, Input } from '@angular/core'; | import { Component, Input } from '@angular/core'; | ||||||
| import { Vout } from '../../../interfaces/electrs.interface'; | import { AddressTypeInfo } from '../../address-utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-address-type', |   selector: 'app-address-type', | ||||||
| @ -7,5 +7,5 @@ import { Vout } from '../../../interfaces/electrs.interface'; | |||||||
|   styleUrls: [] |   styleUrls: [] | ||||||
| }) | }) | ||||||
| export class AddressTypeComponent { | export class AddressTypeComponent { | ||||||
|   @Input() vout: Vout; |   @Input() address: AddressTypeInfo; | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,14 +1,14 @@ | |||||||
| import { Env } from '../services/state.service'; | import { Env } from '../services/state.service'; | ||||||
| 
 | 
 | ||||||
| // all base58 characters
 | // 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)
 | // 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]`; | const BECH32_CHARS_UP = `[AC-HJ-NP-Z02-9]`; | ||||||
| 
 | 
 | ||||||
| // Hex characters
 | // 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"
 | // 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)
 | // 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 { 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 */ | /** 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) { |   if (!script) { | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user