Decode inscription / rune data client-side
This commit is contained in:
		
							parent
							
								
									4143a5f593
								
							
						
					
					
						commit
						8b6db768cd
					
				| @ -6,6 +6,7 @@ import { ZONE_SERVICE } from './injection-tokens'; | ||||
| import { AppRoutingModule } from './app-routing.module'; | ||||
| import { AppComponent } from './components/app/app.component'; | ||||
| import { ElectrsApiService } from './services/electrs-api.service'; | ||||
| import { OrdApiService } from './services/ord-api.service'; | ||||
| import { StateService } from './services/state.service'; | ||||
| import { CacheService } from './services/cache.service'; | ||||
| import { PriceService } from './services/price.service'; | ||||
| @ -32,6 +33,7 @@ import { DatePipe } from '@angular/common'; | ||||
| 
 | ||||
| const providers = [ | ||||
|   ElectrsApiService, | ||||
|   OrdApiService, | ||||
|   StateService, | ||||
|   CacheService, | ||||
|   PriceService, | ||||
|  | ||||
							
								
								
									
										75
									
								
								frontend/src/app/components/ord-data/ord-data.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								frontend/src/app/components/ord-data/ord-data.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| @if (error) { | ||||
|   <div> | ||||
|     <i>Error fetching data (code {{ error.status }})</i> | ||||
|   </div> | ||||
| } @else { | ||||
|   @if (minted) { | ||||
|     <ng-container i18n="ord.mint-n-runes"> | ||||
|       <span>Mint</span> | ||||
|       <span class="amount"> {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} </span> | ||||
|       <ng-container *ngTemplateOutlet="runeName; context: { $implicit: runestone.mint.unwrap().toString() }"></ng-container> | ||||
|     </ng-container> | ||||
|   } | ||||
|   @if (totalSupply > -1) { | ||||
|     @if (premined > 0) { | ||||
|       <ng-container i18n="ord.premine-n-runes"> | ||||
|         <span>Premine</span> | ||||
|         <span class="amount"> {{ premined >= 100000 ? (premined | amountShortener:undefined:undefined:true) : premined }} </span> | ||||
|         {{ etchedSymbol }} | ||||
|         <span class="name">{{ etchedName }}</span> | ||||
|         <span> ({{ premined / totalSupply * 100 | amountShortener:0}}% of total supply)</span> | ||||
|       </ng-container> | ||||
|       } @else { | ||||
|       <ng-container i18n="ord.etch-rune"> | ||||
|         <span>Etching of</span> | ||||
|         {{ etchedSymbol }} | ||||
|         <span class="name">{{ etchedName }}</span> | ||||
|       </ng-container> | ||||
|     } | ||||
|   } | ||||
|   @if (transferredRunes?.length && type === 'vout') { | ||||
|     <div *ngFor="let rune of transferredRunes"> | ||||
|       <ng-container i18n="ord.transfer-rune"> | ||||
|         <span>Transfer</span> | ||||
|         <ng-container *ngTemplateOutlet="runeName; context: { $implicit: rune.key }"></ng-container> | ||||
|       </ng-container> | ||||
|     </div> | ||||
|   } | ||||
| 
 | ||||
|   <!-- @if (runestone && !runestone?.etching && !runestone?.mint && !transferredRunes?.length && type === 'vout') { | ||||
|     <div> | ||||
|       <i>No content in this runestone</i> | ||||
|     </div> | ||||
|   } --> | ||||
| 
 | ||||
|   @if (inscriptions?.length && type === 'vin') { | ||||
|     <div *ngFor="let contentType of inscriptionsData | keyvalue"> | ||||
|       <div> | ||||
|         <span class="badge badge-ord mr-1">{{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }}</span> | ||||
|         <span class="badge badge-ord" *ngIf="contentType.value.totalSize > 0">{{ contentType.value.totalSize | bytes:2:'B':undefined:true }}</span> | ||||
|         <a *ngIf="contentType.value.delegate" [routerLink]="['/tx' | relativeUrl, contentType.value.delegate]"> | ||||
|           <span i18n="ord.source-inscription">Source inscription</span> | ||||
|         </a> | ||||
|       </div> | ||||
|       <pre *ngIf="contentType.value.json" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.json | json }}</pre> | ||||
|       <pre *ngIf="contentType.value.text" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.text }}</pre> | ||||
|       </div> | ||||
|   } | ||||
|    | ||||
|   @if (!runestone && type === 'vout') { | ||||
|     <div class="skeleton-loader" style="width: 50%;"></div> | ||||
|   } | ||||
| 
 | ||||
|   @if (!inscriptions?.length && type === 'vin') { | ||||
|     <div> | ||||
|       <i>Error decoding inscription data</i> | ||||
|     </div> | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| <ng-template #runeName let-id> | ||||
|   {{ runeInfo[id]?.etching.symbol.isSome() ? runeInfo[id]?.etching.symbol.unwrap() : '' }} | ||||
|   <a [routerLink]="id !== '1:0' ? ['/tx' | relativeUrl, runeInfo[id]?.txid] : null" [class.rune-link]="id !== '1:0'" [class.disabled]="id === '1:0'"> | ||||
|     <span class="name">{{ runeInfo[id]?.name }}</span> | ||||
|   </a> | ||||
| </ng-template> | ||||
							
								
								
									
										35
									
								
								frontend/src/app/components/ord-data/ord-data.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								frontend/src/app/components/ord-data/ord-data.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| .amount { | ||||
| 	font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| a.rune-link { | ||||
| 	color: inherit; | ||||
| 	&:hover { | ||||
| 		text-decoration: underline; | ||||
| 		text-decoration-color: var(--transparent-fg);	 | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| a.disabled { | ||||
| 	text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| .name { | ||||
| 	color: var(--transparent-fg); | ||||
| 	font-weight: 700; | ||||
| } | ||||
| 
 | ||||
| .badge-ord { | ||||
| 	background-color: var(--grey); | ||||
| 	position: relative; | ||||
| 	top: -2px; | ||||
| 	font-size: 81%; | ||||
| 	&.primary { | ||||
| 		background-color: var(--primary); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| pre { | ||||
| 	margin-top: 5px; | ||||
| 	max-height: 150px; | ||||
| } | ||||
							
								
								
									
										140
									
								
								frontend/src/app/components/ord-data/ord-data.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								frontend/src/app/components/ord-data/ord-data.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,140 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; | ||||
| import { Runestone } from '../../shared/ord/rune/runestone'; | ||||
| import { Etching } from '../../shared/ord/rune/etching'; | ||||
| import { u128, u32, u8 } from '../../shared/ord/rune/integer'; | ||||
| import { HttpErrorResponse } from '@angular/common/http'; | ||||
| import { SpacedRune } from '../../shared/ord/rune/spacedrune'; | ||||
| 
 | ||||
| export interface Inscription { | ||||
|   body?: Uint8Array; | ||||
|   body_length?: number; | ||||
|   content_type?: Uint8Array; | ||||
|   content_type_str?: string; | ||||
|   delegate_txid?: string; | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-ord-data', | ||||
|   templateUrl: './ord-data.component.html', | ||||
|   styleUrls: ['./ord-data.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush | ||||
| }) | ||||
| export class OrdDataComponent implements OnChanges { | ||||
|   @Input() inscriptions: Inscription[]; | ||||
|   @Input() runestone: Runestone; | ||||
|   @Input() runeInfo: { [id: string]: { etching: Etching; txid: string; name?: string; } }; | ||||
|   @Input() error: HttpErrorResponse; | ||||
|   @Input() type: 'vin' | 'vout'; | ||||
| 
 | ||||
|   // Inscriptions
 | ||||
|   inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } }; | ||||
|   // Rune mints
 | ||||
|   minted: number; | ||||
|   // Rune etching
 | ||||
|   premined: number = -1; | ||||
|   totalSupply: number = -1; | ||||
|   etchedName: string; | ||||
|   etchedSymbol: string; | ||||
|   // Rune transfers
 | ||||
|   transferredRunes: { key: string; etching: Etching; txid: string; name?: string; }[] = []; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnChanges(changes: SimpleChanges): void { | ||||
|     if (changes.runestone && this.runestone) { | ||||
| 
 | ||||
|       Object.keys(this.runeInfo).forEach((key) => { | ||||
|         const rune = this.runeInfo[key].etching.rune.isSome() ? this.runeInfo[key].etching.rune.unwrap() : null; | ||||
|         const spacers = this.runeInfo[key].etching.spacers.isSome() ? this.runeInfo[key].etching.spacers.unwrap() : u32(0); | ||||
|         if (rune) { | ||||
|           this.runeInfo[key].name = new SpacedRune(rune, Number(spacers)).toString(); | ||||
|         } | ||||
|         this.transferredRunes.push({ key, ...this.runeInfo[key] }); | ||||
|       }); | ||||
| 
 | ||||
| 
 | ||||
|       if (this.runestone.mint.isSome() && this.runeInfo[this.runestone.mint.unwrap().toString()]) { | ||||
|         const mint = this.runestone.mint.unwrap().toString(); | ||||
|         this.transferredRunes = this.transferredRunes.filter(rune => rune.key !== mint); | ||||
|         const terms = this.runeInfo[mint].etching.terms.isSome() ? this.runeInfo[mint].etching.terms.unwrap() : null; | ||||
|         let amount: u128; | ||||
|         if (terms) { | ||||
|           amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); | ||||
|         } | ||||
|         const divisibility = this.runeInfo[mint].etching.divisibility.isSome() ? this.runeInfo[mint].etching.divisibility.unwrap() : u8(0); | ||||
|         if (amount) { | ||||
|           this.minted = this.getAmount(amount, divisibility); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (this.runestone.etching.isSome()) { | ||||
|         const etching = this.runestone.etching.unwrap(); | ||||
|         const rune = etching.rune.isSome() ? etching.rune.unwrap() : null; | ||||
|         const spacers = etching.spacers.isSome() ? etching.spacers.unwrap() : u32(0); | ||||
|         if (rune) { | ||||
|           this.etchedName = new SpacedRune(rune, Number(spacers)).toString(); | ||||
|         } | ||||
|         this.etchedSymbol = etching.symbol.isSome() ? etching.symbol.unwrap() : ''; | ||||
| 
 | ||||
|         const divisibility = etching.divisibility.isSome() ? etching.divisibility.unwrap() : u8(0); | ||||
|         const premine = etching.premine.isSome() ? etching.premine.unwrap() : u128(0); | ||||
|         if (premine) { | ||||
|           this.premined = this.getAmount(premine, divisibility); | ||||
|         } else { | ||||
|           this.premined = 0; | ||||
|         } | ||||
|         const terms = etching.terms.isSome() ? etching.terms.unwrap() : null; | ||||
|         let amount: u128; | ||||
|         if (terms) { | ||||
|           amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); | ||||
|           if (amount) { | ||||
|             const cap = terms.cap.isSome() ? terms.cap.unwrap() : u128(0); | ||||
|             this.totalSupply = this.premined + this.getAmount(amount, divisibility) * Number(cap); | ||||
|           } | ||||
|         } else { | ||||
|           this.totalSupply = this.premined; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (changes.inscriptions && this.inscriptions) { | ||||
| 
 | ||||
|       if (this.inscriptions?.length) { | ||||
|         this.inscriptionsData = {}; | ||||
|         this.inscriptions.forEach((inscription) => { | ||||
|           // General: count, total size, delegate
 | ||||
|           const key = inscription.content_type_str || 'undefined'; | ||||
|           if (!this.inscriptionsData[key]) { | ||||
|             this.inscriptionsData[key] = { count: 0, totalSize: 0 }; | ||||
|           } | ||||
|           this.inscriptionsData[key].count++; | ||||
|           this.inscriptionsData[key].totalSize += inscription.body_length; | ||||
|           if (inscription.delegate_txid && !this.inscriptionsData[key].delegate) { | ||||
|             this.inscriptionsData[key].delegate = inscription.delegate_txid; | ||||
|           } | ||||
| 
 | ||||
|           // Text / JSON data
 | ||||
|           if ((key.includes('text') || key.includes('json')) && inscription.body?.length && !this.inscriptionsData[key].text && !this.inscriptionsData[key].json) { | ||||
|             const decoder = new TextDecoder('utf-8'); | ||||
|             const text = decoder.decode(inscription.body); | ||||
|             try { | ||||
|               this.inscriptionsData[key].json = JSON.parse(text); | ||||
|               if (this.inscriptionsData[key].json['p']) { | ||||
|                 this.inscriptionsData[key].tag = this.inscriptionsData[key].json['p'].toUpperCase(); | ||||
|               } | ||||
|             } catch (e) { | ||||
|               this.inscriptionsData[key].text = text; | ||||
|             } | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getAmount(amount: u128 | bigint, divisibility: u8): number { | ||||
|     const divisor = BigInt(10) ** BigInt(divisibility); | ||||
|     const result = amount / divisor; | ||||
| 
 | ||||
|     return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER; | ||||
|   } | ||||
| } | ||||
| @ -81,7 +81,8 @@ | ||||
|                     </ng-container> | ||||
|                   </div> | ||||
|                 </td> | ||||
|                 <td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000}"> | ||||
|                 <td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000 || vin.isInscription}"> | ||||
|                   <button *ngIf="vin.isInscription" (click)="toggleOrdData(tx.txid, 'vin', vindex)" type="button" class="btn btn-sm badge badge-ord primary" style="margin-right: 10px;">Inscription</button> | ||||
|                   <ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput"> | ||||
|                     <div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound"> | ||||
|                       <ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container> | ||||
| @ -96,6 +97,15 @@ | ||||
|                   </ng-template> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr *ngIf="showOrdData[tx.txid + '-vin-' + vindex]?.show" [ngClass]="{ | ||||
|                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, | ||||
|                 'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address)) | ||||
|               }"> | ||||
|                 <td></td> | ||||
|                 <td colspan="2"> | ||||
|                   <app-ord-data [inscriptions]="showOrdData[tx.txid + '-vin-' + vindex]['inscriptions']" [type]="'vin'" [error]="showOrdData[tx.txid + '-vin-' + vindex]['error']"></app-ord-data> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr *ngIf="(showDetails$ | async) === true"> | ||||
|                 <td colspan="3" class="details-container" > | ||||
|                   <table class="table table-striped table-fixed table-borderless details-table mb-3"> | ||||
| @ -236,7 +246,12 @@ | ||||
|                     </ng-template> | ||||
|                     <ng-template #defaultscriptpubkey_type> | ||||
|                       <ng-template [ngIf]="vout.scriptpubkey_type === 'op_return'" [ngIfElse]="otherPubkeyType"> | ||||
|                         OP_RETURN <a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a> | ||||
|                         OP_RETURN  | ||||
|                         @if (vout.isRunestone) { | ||||
|                           <button (click)="toggleOrdData(tx.txid, 'vout', vindex)" type="button" class="btn btn-sm badge badge-ord">Runestone</button> | ||||
|                         } @else { | ||||
|                           <a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a> | ||||
|                         } | ||||
|                       </ng-template> | ||||
|                       <ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template> | ||||
|                     </ng-template> | ||||
| @ -276,6 +291,15 @@ | ||||
|                   </ng-template> | ||||
|                 </td> | ||||
|               </tr> | ||||
| 
 | ||||
|               <tr *ngIf="showOrdData[tx.txid + '-vout-' + vindex]?.show" [ngClass]="{ | ||||
|                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, | ||||
|                 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address)) | ||||
|               }"> | ||||
|                 <td colspan="3"> | ||||
|                     <app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'" [error]="showOrdData[tx.txid + '-vout-' + vindex]['error']"></app-ord-data> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr *ngIf="(showDetails$ | async) === true"> | ||||
|                 <td colspan="3" class=" details-container" > | ||||
|                   <table class="table table-striped table-borderless details-table mb-3"> | ||||
|  | ||||
| @ -175,4 +175,15 @@ h2 { | ||||
| 	.witness-item { | ||||
| 		overflow: hidden;	 | ||||
| 	} | ||||
| } | ||||
| } | ||||
| 
 | ||||
| .badge-ord { | ||||
| 	background-color: var(--grey); | ||||
| 	position: relative; | ||||
| 	top: -2px; | ||||
| 	font-size: 81%; | ||||
| 	border: 0; | ||||
| 	&.primary { | ||||
| 		background-color: var(--primary); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -11,6 +11,10 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { PriceService } from '../../services/price.service'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { OrdApiService } from '../../services/ord-api.service'; | ||||
| import { Inscription } from '../ord-data/ord-data.component'; | ||||
| import { Runestone } from '../../shared/ord/rune/runestone'; | ||||
| import { Etching } from '../../shared/ord/rune/etching'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-transactions-list', | ||||
| @ -50,12 +54,14 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|   outputRowLimit: number = 12; | ||||
|   showFullScript: { [vinIndex: number]: boolean } = {}; | ||||
|   showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {}; | ||||
|   showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {}; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
|     private cacheService: CacheService, | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|     private apiService: ApiService, | ||||
|     private ordApiService: OrdApiService, | ||||
|     private assetsService: AssetsService, | ||||
|     private ref: ChangeDetectorRef, | ||||
|     private priceService: PriceService, | ||||
| @ -239,6 +245,24 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|             tap((price) => tx['price'] = price), | ||||
|           ).subscribe(); | ||||
|         } | ||||
| 
 | ||||
|         // Check for ord data fingerprints in inputs and outputs
 | ||||
|         if (this.stateService.network !== 'liquid' && this.stateService.network !== 'liquidtestnet') { | ||||
|           for (let i = 0; i < tx.vin.length; i++) { | ||||
|             if (tx.vin[i].prevout?.scriptpubkey_type === 'v1_p2tr' && tx.vin[i].witness?.length) { | ||||
|               const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50'); | ||||
|               if (tx.vin[i].witness.length > (hasAnnex ? 2 : 1) && tx.vin[i].witness[tx.vin[i].witness.length - (hasAnnex ? 3 : 2)].includes('0063036f7264')) { | ||||
|                 tx.vin[i].isInscription = true; | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|           for (let i = 0; i < tx.vout.length; i++) { | ||||
|             if (tx.vout[i]?.scriptpubkey?.startsWith('6a5d')) { | ||||
|               tx.vout[i].isRunestone = true; | ||||
|               break; | ||||
|             } | ||||
|           }   | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       if (this.blockTime && this.transactions?.length && this.currency) { | ||||
| @ -372,6 +396,40 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|     this.showFullWitness[vinIndex][witnessIndex] = !this.showFullWitness[vinIndex][witnessIndex]; | ||||
|   } | ||||
| 
 | ||||
|   toggleOrdData(txid: string, type: 'vin' | 'vout', index: number) { | ||||
|     const tx = this.transactions.find((tx) => tx.txid === txid); | ||||
|     if (!tx) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const key = tx.txid + '-' + type + '-' + index; | ||||
|     this.showOrdData[key] = this.showOrdData[key] || { show: false }; | ||||
| 
 | ||||
|     if (type === 'vin') { | ||||
| 
 | ||||
|       if (!this.showOrdData[key].inscriptions) { | ||||
|         const hasAnnex = tx.vin[index].witness?.[tx.vin[index].witness.length - 1].startsWith('50'); | ||||
|         this.showOrdData[key].inscriptions = this.ordApiService.decodeInscriptions(tx.vin[index].witness[tx.vin[index].witness.length - (hasAnnex ? 3 : 2)]); | ||||
|       } | ||||
|       this.showOrdData[key].show = !this.showOrdData[key].show; | ||||
| 
 | ||||
|     } else if (type === 'vout') { | ||||
| 
 | ||||
|       if (!this.showOrdData[key].runestone) { | ||||
|         this.ordApiService.decodeRunestone$(tx).pipe( | ||||
|           tap((runestone) => { | ||||
|             if (runestone) { | ||||
|               Object.assign(this.showOrdData[key], runestone); | ||||
|               this.ref.markForCheck(); | ||||
|             } | ||||
|           }), | ||||
|         ).subscribe(); | ||||
|       } | ||||
|       this.showOrdData[key].show = !this.showOrdData[key].show; | ||||
| 
 | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.outspendsSubscription.unsubscribe(); | ||||
|     this.currencyChangeSubscription?.unsubscribe(); | ||||
|  | ||||
| @ -74,6 +74,8 @@ export interface Vin { | ||||
|   issuance?: Issuance; | ||||
|   // Custom
 | ||||
|   lazy?: boolean; | ||||
|   // Ord
 | ||||
|   isInscription?: boolean; | ||||
| } | ||||
| 
 | ||||
| interface Issuance { | ||||
| @ -98,6 +100,8 @@ export interface Vout { | ||||
|   valuecommitment?: number; | ||||
|   asset?: string; | ||||
|   pegout?: Pegout; | ||||
|   // Ord
 | ||||
|   isRunestone?: boolean; | ||||
| } | ||||
| 
 | ||||
| interface Pegout { | ||||
|  | ||||
| @ -107,6 +107,10 @@ export class ElectrsApiService { | ||||
|     return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'}); | ||||
|   } | ||||
| 
 | ||||
|   getBlockTxId$(hash: string, index: number): Observable<string> { | ||||
|     return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txid/' + index, { responseType: 'text' }); | ||||
|   } | ||||
| 
 | ||||
|   getAddress$(address: string): Observable<Address> { | ||||
|     return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); | ||||
|   } | ||||
|  | ||||
							
								
								
									
										114
									
								
								frontend/src/app/services/ord-api.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								frontend/src/app/services/ord-api.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,114 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; | ||||
| import { Inscription } from '../components/ord-data/ord-data.component'; | ||||
| import { Transaction } from '../interfaces/electrs.interface'; | ||||
| import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils'; | ||||
| import { Runestone } from '../shared/ord/rune/runestone'; | ||||
| import { Etching } from '../shared/ord/rune/etching'; | ||||
| import { ElectrsApiService } from './electrs-api.service'; | ||||
| import { UNCOMMON_GOODS } from '../shared/ord/rune/runestone'; | ||||
| 
 | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class OrdApiService { | ||||
| 
 | ||||
|   constructor( | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|   ) { } | ||||
| 
 | ||||
|   decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> { | ||||
|     const runestoneTx = { vout: tx.vout.map(vout => ({ scriptpubkey: vout.scriptpubkey })) }; | ||||
|     const decipher = Runestone.decipher(runestoneTx); | ||||
| 
 | ||||
|     // For now, ignore cenotaphs
 | ||||
|     let message = decipher.isSome() ? decipher.unwrap() : null; | ||||
|     if (message?.type === 'cenotaph') { | ||||
|       return of({ runestone: null, runeInfo: {} }); | ||||
|     } | ||||
| 
 | ||||
|     const runestone = message as Runestone; | ||||
|     const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {}; | ||||
|     const runesToFetch: Set<string> = new Set(); | ||||
| 
 | ||||
|     if (runestone) { | ||||
|       if (runestone.mint.isSome()) { | ||||
|         const mint = runestone.mint.unwrap().toString(); | ||||
| 
 | ||||
|         if (mint === '1:0') { | ||||
|           runeInfo[mint] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' }; | ||||
|         } else { | ||||
|           runesToFetch.add(mint); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (runestone.edicts.length) { | ||||
|         runestone.edicts.forEach(edict => { | ||||
|           runesToFetch.add(edict.id.toString()); | ||||
|         }); | ||||
|       } | ||||
| 
 | ||||
|       if (runesToFetch.size) { | ||||
|         const runeEtchingObservables = Array.from(runesToFetch).map(runeId => { | ||||
|           return this.getEtchingFromRuneId$(runeId).pipe( | ||||
|             tap(etching => { | ||||
|               if (etching) { | ||||
|                 runeInfo[runeId] = etching; | ||||
|               } | ||||
|             }) | ||||
|           ); | ||||
|         }); | ||||
| 
 | ||||
|         return forkJoin(runeEtchingObservables).pipe( | ||||
|           map(() => { | ||||
|             return { runestone: runestone, runeInfo }; | ||||
|           }) | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return of({ runestone: runestone, runeInfo }); | ||||
|   } | ||||
| 
 | ||||
|   // Get etching from runeId by looking up the transaction that etched the rune
 | ||||
|   getEtchingFromRuneId$(runeId: string): Observable<{ etching: Etching; txid: string; }> { | ||||
|     const [blockNumber, txIndex] = runeId.split(':'); | ||||
| 
 | ||||
|     return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockNumber)).pipe( | ||||
|       switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))), | ||||
|       switchMap(txId => this.electrsApiService.getTransaction$(txId)), | ||||
|       switchMap(tx => { | ||||
|         const decipheredMessage = Runestone.decipher(tx); | ||||
|         if (decipheredMessage.isSome()) { | ||||
|           const message = decipheredMessage.unwrap(); | ||||
|           if (message?.type === 'runestone' && message.etching.isSome()) { | ||||
|             return of({ etching: message.etching.unwrap(), txid: tx.txid }); | ||||
|           } | ||||
|         } | ||||
|         return of(null); | ||||
|       }), | ||||
|       catchError(() => of(null)) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   decodeInscriptions(witness: string): Inscription[] | null { | ||||
| 
 | ||||
|     const inscriptions: Inscription[] = []; | ||||
|     const raw = hexToBytes(witness); | ||||
|     let startPosition = 0; | ||||
| 
 | ||||
|     while (true) { | ||||
|       const pointer = getNextInscriptionMark(raw, startPosition); | ||||
|       if (pointer === -1) break; | ||||
| 
 | ||||
|       const inscription = extractInscriptionData(raw, pointer); | ||||
|       if (inscription) { | ||||
|         inscriptions.push(inscription); | ||||
|       } | ||||
| 
 | ||||
|       startPosition = pointer; | ||||
|     } | ||||
| 
 | ||||
|     return inscriptions; | ||||
|   } | ||||
| } | ||||
| @ -102,6 +102,7 @@ import { AccelerationsListComponent } from '../components/acceleration/accelerat | ||||
| import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; | ||||
| import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component'; | ||||
| import { AccelerationSparklesComponent } from '../components/acceleration/sparkles/acceleration-sparkles.component'; | ||||
| import { OrdDataComponent } from '../components/ord-data/ord-data.component'; | ||||
| 
 | ||||
| import { BlockViewComponent } from '../components/block-view/block-view.component'; | ||||
| import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; | ||||
| @ -229,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     AccelerationStatsComponent, | ||||
|     PendingStatsComponent, | ||||
|     AccelerationSparklesComponent, | ||||
|     OrdDataComponent, | ||||
|     HttpErrorComponent, | ||||
|     TwitterWidgetComponent, | ||||
|     FaucetComponent, | ||||
| @ -361,6 +363,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     AccelerationStatsComponent, | ||||
|     PendingStatsComponent, | ||||
|     AccelerationSparklesComponent, | ||||
|     OrdDataComponent, | ||||
|     HttpErrorComponent, | ||||
|     TwitterWidgetComponent, | ||||
|     TwitterLogin, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user