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 { AppRoutingModule } from './app-routing.module'; | ||||||
| import { AppComponent } from './components/app/app.component'; | import { AppComponent } from './components/app/app.component'; | ||||||
| import { ElectrsApiService } from './services/electrs-api.service'; | import { ElectrsApiService } from './services/electrs-api.service'; | ||||||
|  | import { OrdApiService } from './services/ord-api.service'; | ||||||
| import { StateService } from './services/state.service'; | import { StateService } from './services/state.service'; | ||||||
| import { CacheService } from './services/cache.service'; | import { CacheService } from './services/cache.service'; | ||||||
| import { PriceService } from './services/price.service'; | import { PriceService } from './services/price.service'; | ||||||
| @ -32,6 +33,7 @@ import { DatePipe } from '@angular/common'; | |||||||
| 
 | 
 | ||||||
| const providers = [ | const providers = [ | ||||||
|   ElectrsApiService, |   ElectrsApiService, | ||||||
|  |   OrdApiService, | ||||||
|   StateService, |   StateService, | ||||||
|   CacheService, |   CacheService, | ||||||
|   PriceService, |   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> |                     </ng-container> | ||||||
|                   </div> |                   </div> | ||||||
|                 </td> |                 </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"> |                   <ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput"> | ||||||
|                     <div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound"> |                     <div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound"> | ||||||
|                       <ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container> |                       <ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container> | ||||||
| @ -96,6 +97,15 @@ | |||||||
|                   </ng-template> |                   </ng-template> | ||||||
|                 </td> |                 </td> | ||||||
|               </tr> |               </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"> |               <tr *ngIf="(showDetails$ | async) === true"> | ||||||
|                 <td colspan="3" class="details-container" > |                 <td colspan="3" class="details-container" > | ||||||
|                   <table class="table table-striped table-fixed table-borderless details-table mb-3"> |                   <table class="table table-striped table-fixed table-borderless details-table mb-3"> | ||||||
| @ -236,7 +246,12 @@ | |||||||
|                     </ng-template> |                     </ng-template> | ||||||
|                     <ng-template #defaultscriptpubkey_type> |                     <ng-template #defaultscriptpubkey_type> | ||||||
|                       <ng-template [ngIf]="vout.scriptpubkey_type === 'op_return'" [ngIfElse]="otherPubkeyType"> |                       <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> | ||||||
|                       <ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template> |                       <ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template> | ||||||
|                     </ng-template> |                     </ng-template> | ||||||
| @ -276,6 +291,15 @@ | |||||||
|                   </ng-template> |                   </ng-template> | ||||||
|                 </td> |                 </td> | ||||||
|               </tr> |               </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"> |               <tr *ngIf="(showDetails$ | async) === true"> | ||||||
|                 <td colspan="3" class=" details-container" > |                 <td colspan="3" class=" details-container" > | ||||||
|                   <table class="table table-striped table-borderless details-table mb-3"> |                   <table class="table table-striped table-borderless details-table mb-3"> | ||||||
|  | |||||||
| @ -175,4 +175,15 @@ h2 { | |||||||
| 	.witness-item { | 	.witness-item { | ||||||
| 		overflow: hidden;	 | 		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 { ApiService } from '../../services/api.service'; | ||||||
| import { PriceService } from '../../services/price.service'; | import { PriceService } from '../../services/price.service'; | ||||||
| import { StorageService } from '../../services/storage.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({ | @Component({ | ||||||
|   selector: 'app-transactions-list', |   selector: 'app-transactions-list', | ||||||
| @ -50,12 +54,14 @@ export class TransactionsListComponent implements OnInit, OnChanges { | |||||||
|   outputRowLimit: number = 12; |   outputRowLimit: number = 12; | ||||||
|   showFullScript: { [vinIndex: number]: boolean } = {}; |   showFullScript: { [vinIndex: number]: boolean } = {}; | ||||||
|   showFullWitness: { [vinIndex: number]: { [witnessIndex: 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( |   constructor( | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|     private cacheService: CacheService, |     private cacheService: CacheService, | ||||||
|     private electrsApiService: ElectrsApiService, |     private electrsApiService: ElectrsApiService, | ||||||
|     private apiService: ApiService, |     private apiService: ApiService, | ||||||
|  |     private ordApiService: OrdApiService, | ||||||
|     private assetsService: AssetsService, |     private assetsService: AssetsService, | ||||||
|     private ref: ChangeDetectorRef, |     private ref: ChangeDetectorRef, | ||||||
|     private priceService: PriceService, |     private priceService: PriceService, | ||||||
| @ -239,6 +245,24 @@ export class TransactionsListComponent implements OnInit, OnChanges { | |||||||
|             tap((price) => tx['price'] = price), |             tap((price) => tx['price'] = price), | ||||||
|           ).subscribe(); |           ).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) { |       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]; |     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 { |   ngOnDestroy(): void { | ||||||
|     this.outspendsSubscription.unsubscribe(); |     this.outspendsSubscription.unsubscribe(); | ||||||
|     this.currencyChangeSubscription?.unsubscribe(); |     this.currencyChangeSubscription?.unsubscribe(); | ||||||
|  | |||||||
| @ -74,6 +74,8 @@ export interface Vin { | |||||||
|   issuance?: Issuance; |   issuance?: Issuance; | ||||||
|   // Custom
 |   // Custom
 | ||||||
|   lazy?: boolean; |   lazy?: boolean; | ||||||
|  |   // Ord
 | ||||||
|  |   isInscription?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface Issuance { | interface Issuance { | ||||||
| @ -98,6 +100,8 @@ export interface Vout { | |||||||
|   valuecommitment?: number; |   valuecommitment?: number; | ||||||
|   asset?: string; |   asset?: string; | ||||||
|   pegout?: Pegout; |   pegout?: Pegout; | ||||||
|  |   // Ord
 | ||||||
|  |   isRunestone?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface Pegout { | interface Pegout { | ||||||
|  | |||||||
| @ -107,6 +107,10 @@ export class ElectrsApiService { | |||||||
|     return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'}); |     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> { |   getAddress$(address: string): Observable<Address> { | ||||||
|     return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + 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 { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; | ||||||
| import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component'; | import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component'; | ||||||
| import { AccelerationSparklesComponent } from '../components/acceleration/sparkles/acceleration-sparkles.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 { BlockViewComponent } from '../components/block-view/block-view.component'; | ||||||
| import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; | import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; | ||||||
| @ -229,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | |||||||
|     AccelerationStatsComponent, |     AccelerationStatsComponent, | ||||||
|     PendingStatsComponent, |     PendingStatsComponent, | ||||||
|     AccelerationSparklesComponent, |     AccelerationSparklesComponent, | ||||||
|  |     OrdDataComponent, | ||||||
|     HttpErrorComponent, |     HttpErrorComponent, | ||||||
|     TwitterWidgetComponent, |     TwitterWidgetComponent, | ||||||
|     FaucetComponent, |     FaucetComponent, | ||||||
| @ -361,6 +363,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | |||||||
|     AccelerationStatsComponent, |     AccelerationStatsComponent, | ||||||
|     PendingStatsComponent, |     PendingStatsComponent, | ||||||
|     AccelerationSparklesComponent, |     AccelerationSparklesComponent, | ||||||
|  |     OrdDataComponent, | ||||||
|     HttpErrorComponent, |     HttpErrorComponent, | ||||||
|     TwitterWidgetComponent, |     TwitterWidgetComponent, | ||||||
|     TwitterLogin, |     TwitterLogin, | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user