Merge branch 'master' into nymkappa/faucet-unverified
This commit is contained in:
@@ -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';
|
||||
@@ -21,6 +22,7 @@ import { StorageService } from './services/storage.service';
|
||||
import { HttpCacheInterceptor } from './services/http-cache.interceptor';
|
||||
import { LanguageService } from './services/language.service';
|
||||
import { ThemeService } from './services/theme.service';
|
||||
import { TimeService } from './services/time.service';
|
||||
import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe';
|
||||
import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe';
|
||||
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe';
|
||||
@@ -31,6 +33,7 @@ import { DatePipe } from '@angular/common';
|
||||
|
||||
const providers = [
|
||||
ElectrsApiService,
|
||||
OrdApiService,
|
||||
StateService,
|
||||
CacheService,
|
||||
PriceService,
|
||||
@@ -42,6 +45,7 @@ const providers = [
|
||||
EnterpriseService,
|
||||
LanguageService,
|
||||
ThemeService,
|
||||
TimeService,
|
||||
ShortenStringPipe,
|
||||
FiatShortenerPipe,
|
||||
FiatCurrencyPipe,
|
||||
|
||||
@@ -75,6 +75,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
@Output() changeMode = new EventEmitter<boolean>();
|
||||
|
||||
calculating = true;
|
||||
processing = false;
|
||||
selectedOption: 'wait' | 'accel';
|
||||
cantPayReason = '';
|
||||
quoteError = ''; // error fetching estimate or initial data
|
||||
@@ -196,9 +197,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
if (changes.scrollEvent && this.scrollEvent) {
|
||||
this.scrollToElement('acceleratePreviewAnchor', 'start');
|
||||
}
|
||||
if (changes.accelerating) {
|
||||
if ((this.step === 'processing' || this.step === 'paid') && this.accelerating) {
|
||||
if (changes.accelerating && this.accelerating) {
|
||||
if (this.step === 'processing' || this.step === 'paid') {
|
||||
this.moveToStep('success');
|
||||
} else { // Edge case where the transaction gets accelerated by someone else or on another session
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,9 +381,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
* Account-based acceleration request
|
||||
*/
|
||||
accelerateWithMempoolAccount(): void {
|
||||
if (!this.canPay || this.calculating) {
|
||||
if (!this.canPay || this.calculating || this.processing) {
|
||||
return;
|
||||
}
|
||||
this.processing = true;
|
||||
if (this.accelerationSubscription) {
|
||||
this.accelerationSubscription.unsubscribe();
|
||||
}
|
||||
@@ -390,6 +394,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
this.accelerationUUID
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||
this.audioService.playSound('ascend-chime-cartoon');
|
||||
this.showSuccess = true;
|
||||
@@ -397,6 +402,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
this.moveToStep('paid');
|
||||
},
|
||||
error: (response) => {
|
||||
this.processing = false;
|
||||
this.accelerateError = response.error;
|
||||
}
|
||||
});
|
||||
@@ -466,10 +472,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
* APPLE PAY
|
||||
*/
|
||||
async requestApplePayPayment(): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
@@ -494,6 +504,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
console.error(`Unable to find apple pay button id='apple-pay-button'`);
|
||||
// Try again
|
||||
setTimeout(this.requestApplePayPayment.bind(this), 500);
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
this.loadingApplePay = false;
|
||||
@@ -505,6 +516,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
||||
console.error(`Cannot retreive payment card details`);
|
||||
this.accelerateError = 'apple_pay_no_card_details';
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
||||
@@ -513,9 +525,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
cardTag,
|
||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||
this.accelerationUUID
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||
this.audioService.playSound('ascend-chime-cartoon');
|
||||
if (this.applePay) {
|
||||
@@ -526,6 +540,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}, 1000);
|
||||
},
|
||||
error: (response) => {
|
||||
this.processing = false;
|
||||
this.accelerateError = response.error;
|
||||
if (!(response.status === 403 && response.error === 'not_available')) {
|
||||
setTimeout(() => {
|
||||
@@ -537,6 +552,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.processing = false;
|
||||
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
||||
if (tokenResult.errors) {
|
||||
errorMessage += ` and errors: ${JSON.stringify(
|
||||
@@ -547,6 +563,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.processing = false;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -557,10 +574,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
* GOOGLE PAY
|
||||
*/
|
||||
async requestGooglePayPayment(): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
@@ -595,6 +616,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
||||
console.error(`Cannot retreive payment card details`);
|
||||
this.accelerateError = 'apple_pay_no_card_details';
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
||||
@@ -603,9 +625,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
cardTag,
|
||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||
this.accelerationUUID
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||
this.audioService.playSound('ascend-chime-cartoon');
|
||||
if (this.googlePay) {
|
||||
@@ -616,6 +640,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}, 1000);
|
||||
},
|
||||
error: (response) => {
|
||||
this.processing = false;
|
||||
this.accelerateError = response.error;
|
||||
if (!(response.status === 403 && response.error === 'not_available')) {
|
||||
setTimeout(() => {
|
||||
@@ -627,6 +652,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.processing = false;
|
||||
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
||||
if (tokenResult.errors) {
|
||||
errorMessage += ` and errors: ${JSON.stringify(
|
||||
@@ -644,10 +670,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
* CASHAPP
|
||||
*/
|
||||
async requestCashAppPayment(): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
@@ -678,6 +708,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
this.cashAppPay.addEventListener('ontokenization', event => {
|
||||
const { tokenResult, error } = event.detail;
|
||||
if (error) {
|
||||
this.processing = false;
|
||||
this.accelerateError = error;
|
||||
} else if (tokenResult.status === 'OK') {
|
||||
this.servicesApiService.accelerateWithCashApp$(
|
||||
@@ -685,9 +716,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
tokenResult.details.cashAppPay.cashtag,
|
||||
tokenResult.details.cashAppPay.referenceId,
|
||||
this.accelerationUUID
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||
this.audioService.playSound('ascend-chime-cartoon');
|
||||
if (this.cashAppPay) {
|
||||
@@ -702,6 +735,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
}, 1000);
|
||||
},
|
||||
error: (response) => {
|
||||
this.processing = false;
|
||||
this.accelerateError = response.error;
|
||||
if (!(response.status === 403 && response.error === 'not_available')) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="fee">{{ bar.class === 'tx' ? '' : '+' }}{{ bar.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span>
|
||||
<span class="fee">{{ bar.class === 'tx' ? '' : '+' }}{{ bar.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></span>
|
||||
<div class="spacer"></div>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
</tr>
|
||||
<tr *ngIf="accelerationInfo.fee">
|
||||
<td class="label" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td class="value">{{ accelerationInfo.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
|
||||
<td class="value">{{ accelerationInfo.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
|
||||
</tr>
|
||||
<tr *ngIf="accelerationInfo.bidBoost >= 0 || accelerationInfo.feeDelta">
|
||||
<td class="label" i18n="transaction.out-of-band-fees">Out-of-band fees</td>
|
||||
@if (accelerationInfo.status === 'accelerated') {
|
||||
<td class="value oobFees">{{ accelerationInfo.feeDelta | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
|
||||
<td class="value oobFees">{{ accelerationInfo.feeDelta | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
|
||||
} @else {
|
||||
<td class="value oobFees">{{ accelerationInfo.bidBoost | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
|
||||
<td class="value oobFees">{{ accelerationInfo.bidBoost | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
|
||||
}
|
||||
</tr>
|
||||
<tr *ngIf="accelerationInfo.fee && accelerationInfo.weight">
|
||||
@@ -47,13 +47,14 @@
|
||||
<tr *ngIf="['accelerated', 'mined'].includes(accelerationInfo.status) && hasPoolsData()">
|
||||
<td class="label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td>
|
||||
<td class="value" *ngIf="accelerationInfo.pools">
|
||||
<ng-container *ngFor="let pool of accelerationInfo.pools">
|
||||
<ng-container *ngFor="let pool of accelerationInfo.pools; let i = index;">
|
||||
<img *ngIf="accelerationInfo.poolsData[pool]"
|
||||
class="pool-logo"
|
||||
[style.opacity]="accelerationInfo?.minedByPoolUniqueId && pool !== accelerationInfo?.minedByPoolUniqueId ? '0.3' : '1'"
|
||||
[src]="'/resources/mining-pools/' + accelerationInfo.poolsData[pool].slug + '.svg'"
|
||||
onError="this.src = '/resources/mining-pools/default.svg'"
|
||||
[alt]="'Logo of ' + pool.name + ' mining pool'">
|
||||
<br *ngIf="i % 6 === 5">
|
||||
</ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
.label {
|
||||
padding-right: 30px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
@@ -30,7 +31,8 @@
|
||||
height: 22px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 3px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.oobFees {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="interval">
|
||||
<div class="interval-time">
|
||||
@if (eta) {
|
||||
~<app-time [time]="eta?.wait / 1000"></app-time> <!-- <span *ngIf="accelerateRatio > 1" class="compare"> ({{ accelerateRatio }}x faster)</span> -->
|
||||
~<app-time [time]="eta?.wait / 1000"></app-time>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="node-spacer"></div>
|
||||
<div class="interval">
|
||||
<div class="interval-time">
|
||||
<app-time [time]="acceleratedAt - transactionTime"></app-time>
|
||||
<app-time [time]="firstSeenToAccelerated"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-spacer"></div>
|
||||
@@ -46,10 +46,8 @@
|
||||
<div class="interval-time">
|
||||
@if (tx.status.confirmed) {
|
||||
<div class="interval-time">
|
||||
<app-time [time]="tx.status.block_time - acceleratedAt"></app-time>
|
||||
<app-time [time]="acceleratedToMined"></app-time>
|
||||
</div>
|
||||
} @else if (standardETA && !tx.status.confirmed) {
|
||||
<!-- ~<app-time [time]="standardETA / 1000 - now"></app-time> -->
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,16 @@ import { MiningService } from '../../services/mining.service';
|
||||
})
|
||||
export class AccelerationTimelineComponent implements OnInit, OnChanges {
|
||||
@Input() transactionTime: number;
|
||||
@Input() acceleratedAt: number;
|
||||
@Input() tx: Transaction;
|
||||
@Input() accelerationInfo: Acceleration;
|
||||
@Input() eta: ETA;
|
||||
// A mined transaction has standard ETA and accelerated ETA undefined
|
||||
// A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet)
|
||||
@Input() standardETA: number;
|
||||
@Input() acceleratedETA: number;
|
||||
|
||||
acceleratedAt: number;
|
||||
now: number;
|
||||
accelerateRatio: number;
|
||||
useAbsoluteTime: boolean = false;
|
||||
interval: number;
|
||||
firstSeenToAccelerated: number;
|
||||
acceleratedToMined: number;
|
||||
|
||||
tooltipPosition = null;
|
||||
hoverInfo: any = null;
|
||||
@@ -34,38 +31,24 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000;
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
this.updateTimes();
|
||||
|
||||
this.miningService.getPools().subscribe(pools => {
|
||||
for (const pool of pools) {
|
||||
this.poolsData[pool.unique_id] = pool;
|
||||
}
|
||||
});
|
||||
|
||||
this.interval = window.setInterval(() => {
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
ngOnChanges(changes): void {
|
||||
// Hide standard ETA while we don't have a proper standard ETA calculation, see https://github.com/mempool/mempool/issues/65
|
||||
|
||||
// if (changes?.eta?.currentValue || changes?.standardETA?.currentValue || changes?.acceleratedETA?.currentValue) {
|
||||
// if (changes?.eta?.currentValue) {
|
||||
// if (changes?.acceleratedETA?.currentValue) {
|
||||
// this.accelerateRatio = Math.floor((Math.floor(changes.eta.currentValue.time / 1000) - this.now) / (Math.floor(changes.acceleratedETA.currentValue / 1000) - this.now));
|
||||
// } else if (changes?.standardETA?.currentValue) {
|
||||
// this.accelerateRatio = Math.floor((Math.floor(changes.standardETA.currentValue / 1000) - this.now) / (Math.floor(changes.eta.currentValue.time / 1000) - this.now));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
this.updateTimes();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.interval);
|
||||
updateTimes(): void {
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
this.firstSeenToAccelerated = Math.max(0, this.acceleratedAt - this.transactionTime);
|
||||
this.acceleratedToMined = Math.max(0, this.tx.status.block_time - this.acceleratedAt);
|
||||
}
|
||||
|
||||
onHover(event, status: string): void {
|
||||
|
||||
@@ -264,7 +264,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
||||
type: 'bar',
|
||||
barWidth: '90%',
|
||||
large: true,
|
||||
barMinHeight: 1,
|
||||
barMinHeight: 3,
|
||||
},
|
||||
],
|
||||
dataZoom: (this.widget || data.length === 0 )? undefined : [{
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<app-fee-rate [fee]="acceleration.effectiveFee" [weight]="acceleration.effectiveVsize * 4"></app-fee-rate>
|
||||
</td>
|
||||
<td class="bid text-right">
|
||||
{{ (acceleration.feeDelta) | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>
|
||||
{{ (acceleration.feeDelta) | number }} <span class="symbol" i18n="shared.sats">sats</span>
|
||||
</td>
|
||||
<td class="time text-right">
|
||||
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
|
||||
@@ -41,7 +41,7 @@
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!pending">
|
||||
<td *ngIf="acceleration.boost != null" class="fee text-right">
|
||||
{{ acceleration.boost | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>
|
||||
{{ acceleration.boost | number }} <span class="symbol" i18n="shared.sats">sats</span>
|
||||
</td>
|
||||
<td *ngIf="acceleration.boost == null" class="fee text-right">
|
||||
~
|
||||
@@ -64,7 +64,7 @@
|
||||
<span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span>
|
||||
<span *ngIf="acceleration.status.includes('completed') && acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]" class="badge badge-success"><ng-container i18n="accelerator.completed">Completed</ng-container><span *ngIf="acceleration.status === 'completed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('completed') && (!acceleration.minedByPoolUniqueId || !pools[acceleration.minedByPoolUniqueId])" class="badge badge-success"><ng-container i18n="transaction.rbf.mined">Mined</ng-container><span *ngIf="acceleration.status === 'completed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Failed</ng-container><span *ngIf="acceleration.status === 'failed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Canceled</ng-container><span *ngIf="acceleration.status === 'failed_provisional'"> ⌛</span></span>
|
||||
</td>
|
||||
<td class="date text-right" *ngIf="!this.widget">
|
||||
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subscription, catchError, filter, of, switchMap, tap, throttleTime } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription, catchError, combineLatest, filter, of, switchMap, tap, throttleTime, timer } from 'rxjs';
|
||||
import { Acceleration, BlockExtended, SinglePoolStats } from '../../../interfaces/node-api.interface';
|
||||
import { StateService } from '../../../services/state.service';
|
||||
import { WebsocketService } from '../../../services/websocket.service';
|
||||
@@ -61,8 +61,11 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
|
||||
this.websocketService.want(['blocks']);
|
||||
this.seoService.setTitle($localize`:@@02573b6980a2d611b4361a2595a4447e390058cd:Accelerations`);
|
||||
|
||||
this.paramSubscription = this.route.params.pipe(
|
||||
tap(params => {
|
||||
this.paramSubscription = combineLatest([
|
||||
this.route.params,
|
||||
timer(0),
|
||||
]).pipe(
|
||||
tap(([params]) => {
|
||||
this.page = +params['page'] || 1;
|
||||
this.pageSubject.next(this.page);
|
||||
})
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
</td>
|
||||
<td class="field-value" [class]="chartPositionLeft ? 'chart-left' : ''">
|
||||
<div class="effective-fee-container">
|
||||
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) {
|
||||
@if (accelerationInfo?.acceleratedFeeRate && (!effectiveFeeRate || accelerationInfo.acceleratedFeeRate >= effectiveFeeRate)) {
|
||||
<app-fee-rate class="oobFees" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
|
||||
} @else {
|
||||
<app-fee-rate class="oobFees" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
|
||||
<app-fee-rate class="oobFees" [fee]="effectiveFeeRate"></app-fee-rate>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, OnChanges, SimpleChanges, EventEmitter } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, OnChanges, SimpleChanges, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
||||
import { Transaction } from '../../../interfaces/electrs.interface';
|
||||
import { Acceleration, SinglePoolStats } from '../../../interfaces/node-api.interface';
|
||||
import { EChartsOption, PieSeriesOption } from '../../../graphs/echarts';
|
||||
@@ -23,7 +23,8 @@ function toRGB({r,g,b}): string {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ActiveAccelerationBox implements OnChanges {
|
||||
@Input() tx: Transaction;
|
||||
@Input() acceleratedBy?: number[];
|
||||
@Input() effectiveFeeRate?: number;
|
||||
@Input() accelerationInfo: Acceleration;
|
||||
@Input() miningStats: MiningStats;
|
||||
@Input() pools: number[];
|
||||
@@ -41,10 +42,12 @@ export class ActiveAccelerationBox implements OnChanges {
|
||||
timespan = '';
|
||||
chartInstance: any = undefined;
|
||||
|
||||
constructor() {}
|
||||
constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
const pools = this.pools || this.accelerationInfo?.pools || this.tx.acceleratedBy;
|
||||
const pools = this.pools || this.accelerationInfo?.pools || this.acceleratedBy;
|
||||
if (pools && this.miningStats) {
|
||||
this.prepareChartOptions(pools);
|
||||
}
|
||||
@@ -132,6 +135,7 @@ export class ActiveAccelerationBox implements OnChanges {
|
||||
}
|
||||
]
|
||||
};
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
onChartInit(ec) {
|
||||
|
||||
@@ -94,6 +94,20 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="(stateService.backend$ | async) === 'esplora' && address && utxos && utxos.length > 2">
|
||||
<br>
|
||||
<div class="title-tx">
|
||||
<h2 class="text-left" i18n="address.unspent-outputs">Unspent Outputs</h2>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<app-utxo-graph [utxos]="utxos" left="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<br>
|
||||
<div class="title-tx">
|
||||
<h2 class="text-left">
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { switchMap, filter, catchError, map, tap } from 'rxjs/operators';
|
||||
import { Address, ChainStats, Transaction, Vin } from '../../interfaces/electrs.interface';
|
||||
import { Address, ChainStats, Transaction, Utxo, Vin } from '../../interfaces/electrs.interface';
|
||||
import { WebsocketService } from '../../services/websocket.service';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { AudioService } from '../../services/audio.service';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { of, merge, Subscription, Observable } from 'rxjs';
|
||||
import { of, merge, Subscription, Observable, forkJoin } from 'rxjs';
|
||||
import { SeoService } from '../../services/seo.service';
|
||||
import { seoDescriptionNetwork } from '../../shared/common.utils';
|
||||
import { AddressInformation } from '../../interfaces/node-api.interface';
|
||||
@@ -104,6 +104,7 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
addressString: string;
|
||||
isLoadingAddress = true;
|
||||
transactions: Transaction[];
|
||||
utxos: Utxo[];
|
||||
isLoadingTransactions = true;
|
||||
retryLoadMore = false;
|
||||
error: any;
|
||||
@@ -159,6 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.address = null;
|
||||
this.isLoadingTransactions = true;
|
||||
this.transactions = null;
|
||||
this.utxos = null;
|
||||
this.addressInfo = null;
|
||||
this.exampleChannel = null;
|
||||
document.body.scrollTo(0, 0);
|
||||
@@ -212,11 +214,23 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.updateChainStats();
|
||||
this.isLoadingAddress = false;
|
||||
this.isLoadingTransactions = true;
|
||||
return address.is_pubkey
|
||||
const utxoCount = this.chainStats.utxos + this.mempoolStats.utxos;
|
||||
return forkJoin([
|
||||
address.is_pubkey
|
||||
? this.electrsApiService.getScriptHashTransactions$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
|
||||
: this.electrsApiService.getAddressTransactions$(address.address);
|
||||
: this.electrsApiService.getAddressTransactions$(address.address),
|
||||
(utxoCount > 2 && utxoCount <= 500 ? (address.is_pubkey
|
||||
? this.electrsApiService.getScriptHashUtxos$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
|
||||
: this.electrsApiService.getAddressUtxos$(address.address)) : of(null)).pipe(
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
]);
|
||||
}),
|
||||
switchMap((transactions) => {
|
||||
switchMap(([transactions, utxos]) => {
|
||||
this.utxos = utxos;
|
||||
|
||||
this.tempTransactions = transactions;
|
||||
if (transactions.length) {
|
||||
this.lastTransactionTxId = transactions[transactions.length - 1].txid;
|
||||
@@ -309,6 +323,7 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.transactions = this.transactions.slice();
|
||||
this.mempoolStats.removeTx(transaction);
|
||||
this.audioService.playSound('magic');
|
||||
this.confirmTransaction(tx);
|
||||
} else {
|
||||
if (this.addTransaction(transaction, false)) {
|
||||
this.audioService.playSound('magic');
|
||||
@@ -334,6 +349,31 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: transaction.txid,
|
||||
vout: index,
|
||||
value: vout.value,
|
||||
status: JSON.parse(JSON.stringify(transaction.status)),
|
||||
});
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -346,9 +386,65 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.transactions.splice(index, 1);
|
||||
this.transactions = this.transactions.slice();
|
||||
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
if (vin.prevout?.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: vin.txid,
|
||||
vout: vin.vout,
|
||||
value: vin.prevout.value,
|
||||
status: { confirmed: true }, // Assuming the input was confirmed
|
||||
});
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
confirmTransaction(transaction: Transaction): void {
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
if (vin.prevout?.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.isLoadingTransactions || this.fullyLoaded) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div [formGroup]="amountForm" class="text-small text-center">
|
||||
<select formControlName="mode" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 70px;" (change)="changeMode()">
|
||||
<option value="btc" i18n="shared.btc|BTC">BTC</option>
|
||||
<option value="sats" i18n="shared.sats">sats</option>
|
||||
<option value="fiat" i18n="shared.fiat|Fiat">Fiat</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
|
||||
import { StorageService } from '../../services/storage.service';
|
||||
import { StateService } from '../../services/state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-amount-selector',
|
||||
templateUrl: './amount-selector.component.html',
|
||||
styleUrls: ['./amount-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AmountSelectorComponent implements OnInit {
|
||||
amountForm: UntypedFormGroup;
|
||||
modes = ['btc', 'sats', 'fiat'];
|
||||
|
||||
constructor(
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private stateService: StateService,
|
||||
private storageService: StorageService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.amountForm = this.formBuilder.group({
|
||||
mode: ['btc']
|
||||
});
|
||||
this.stateService.viewAmountMode$.subscribe((mode) => {
|
||||
this.amountForm.get('mode')?.setValue(mode);
|
||||
});
|
||||
}
|
||||
|
||||
changeMode() {
|
||||
const newMode = this.amountForm.get('mode')?.value;
|
||||
this.storageService.setValue('view-amount-mode', newMode);
|
||||
this.stateService.viewAmountMode$.next(newMode);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
@if (digitsInfo === '1.8-8') {
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }}
|
||||
} @else {
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }}
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : (satoshis < 1000 && satoshis > -1000 ? 0 : 1) : undefined : true }}
|
||||
}
|
||||
<span class="symbol">
|
||||
<ng-container *ngTemplateOutlet="prefix"></ng-container>sats
|
||||
|
||||
@@ -11,6 +11,10 @@ export function hexToColor(hex: string): Color {
|
||||
};
|
||||
}
|
||||
|
||||
export function colorToHex(color: Color): string {
|
||||
return [color.r, color.g, color.b].map(c => Math.round(c * 255).toString(16)).join('');
|
||||
}
|
||||
|
||||
export function desaturate(color: Color, amount: number): Color {
|
||||
const gray = (color.r + color.g + color.b) / 6;
|
||||
return {
|
||||
@@ -30,6 +34,15 @@ export function darken(color: Color, amount: number): Color {
|
||||
};
|
||||
}
|
||||
|
||||
export function mix(color1: Color, color2: Color, amount: number): Color {
|
||||
return {
|
||||
r: color1.r * (1 - amount) + color2.r * amount,
|
||||
g: color1.g * (1 - amount) + color2.g * amount,
|
||||
b: color1.b * (1 - amount) + color2.b * amount,
|
||||
a: color1.a * (1 - amount) + color2.a * amount,
|
||||
};
|
||||
}
|
||||
|
||||
export function setOpacity(color: Color, opacity: number): Color {
|
||||
return {
|
||||
...color,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td class="value">{{ fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [blockConversion]="blockConversion" [value]="fee"></app-fiat></span>
|
||||
<td class="value">{{ fee | number }} <span class="symbol" i18n="shared.sats">sats</span> <span class="fiat"><app-fiat [blockConversion]="blockConversion" [value]="fee"></app-fiat></span>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label" i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
<td i18n="block.miner">Miner</td>
|
||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name }}
|
||||
</a>
|
||||
@@ -60,8 +67,15 @@
|
||||
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
||||
<span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge"
|
||||
[class]="!block?.extras.pool.name || block?.extras.pool.slug === 'unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||
{{ block?.extras.pool.name }}
|
||||
</span>
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
{{ block.extras.pool.name }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -137,7 +137,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
),
|
||||
this.stateService.env.ACCELERATOR === true && block.height > 819500
|
||||
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
|
||||
? this.servicesApiService.getAllAccelerationHistory$({ blockHeight: block.height })
|
||||
.pipe(catchError(() => {
|
||||
return of([]);
|
||||
}))
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
[class.badge-success]="blockAudit?.matchRate >= 99"
|
||||
[class.badge-warning]="blockAudit?.matchRate >= 75 && blockAudit?.matchRate < 99"
|
||||
[class.badge-danger]="blockAudit?.matchRate < 75"
|
||||
*ngIf="blockAudit?.matchRate != null; else nullHealth"
|
||||
*ngIf="blockAudit?.matchRate != null && blockAudit?.id === block.id; else nullHealth"
|
||||
>{{ blockAudit?.matchRate }}%</span>
|
||||
<ng-template #nullHealth>
|
||||
<ng-container *ngIf="!isLoadingOverview; else loadingHealth">
|
||||
<ng-container *ngIf="!isLoadingOverview && blockAudit?.id === block.id; else loadingHealth">
|
||||
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
@@ -182,6 +182,13 @@
|
||||
<td i18n="block.miner">Miner</td>
|
||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name }}
|
||||
</a>
|
||||
|
||||
@@ -81,6 +81,19 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
.miner-name {
|
||||
margin-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -319,7 +319,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
this.accelerationsSubscription = this.block$.pipe(
|
||||
switchMap((block) => {
|
||||
return this.stateService.env.ACCELERATOR === true && block.height > 819500
|
||||
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
|
||||
? this.servicesApiService.getAllAccelerationHistory$({ blockHeight: block.height })
|
||||
.pipe(catchError(() => {
|
||||
return of([]);
|
||||
}))
|
||||
@@ -327,7 +327,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
).subscribe((accelerations) => {
|
||||
this.accelerations = accelerations;
|
||||
if (accelerations.length) {
|
||||
if (accelerations.length && this.strippedTransactions) { // Don't call setupBlockAudit if we don't have transactions yet; it will be called later in overviewSubscription
|
||||
this.setupBlockAudit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,9 +60,14 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="animated" *ngIf="block.extras?.pool != undefined && showPools">
|
||||
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name}}
|
||||
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge" [class.miner-name]="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
|
||||
<ng-container *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''; else centralisedPool">
|
||||
<img [ngbTooltip]="block.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
</ng-container>
|
||||
<ng-template #centralisedPool>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'"> {{ block.extras.pool.name }}
|
||||
</ng-template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,38 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.on-pool-name-text {
|
||||
display: inline-block;
|
||||
padding-top: 2px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
.on-pool {
|
||||
align-items: center;
|
||||
background-color: var(--bg);
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: .25em .4em;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.on-pool-container {
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: -8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.on-pool-container.selected {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.pool-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mined-block {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
@@ -155,9 +187,16 @@
|
||||
|
||||
.badge {
|
||||
position: relative;
|
||||
top: 15px;
|
||||
top: 19px;
|
||||
z-index: 101;
|
||||
color: #FFF;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 145px;
|
||||
|
||||
&.miner-name {
|
||||
max-width: 125px;
|
||||
}
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
@@ -168,6 +207,10 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.pool-logo.faded {
|
||||
filter: grayscale(100%) brightness(1.5);
|
||||
}
|
||||
|
||||
.animated {
|
||||
transition: all 0.15s ease-in-out;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
|
||||
|
||||
<div class="container-xl" style="min-height: 335px" [ngClass]="{'widget': widget, 'full-height': !widget, 'legacy': !isMempoolModule}">
|
||||
<h1 *ngIf="!widget" class="float-left" i18n="master-page.blocks">Blocks</h1>
|
||||
<div *ngIf="!widget && isLoading" class="spinner-border ml-3" role="status"></div>
|
||||
<div *ngIf="!widget" class="float-left" style="display: flex; width: 100%; align-items: center;">
|
||||
<h1 i18n="master-page.blocks">Blocks</h1>
|
||||
<app-svg-images name="blocks-2-3" style="width: 275px; max-width: 90%; margin-top: -10px"></app-svg-images>
|
||||
<div *ngIf="!widget && isLoading" class="spinner-border" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.spinner-border {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-top: 13px;
|
||||
margin-top: -10px;
|
||||
margin-left: -13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.container-xl {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">{{ currency$ | async }}</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)">
|
||||
<input type="text" inputmode="numeric" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('fiat').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">BTC</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)">
|
||||
<input type="text" inputmode="numeric" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('bitcoin').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text" i18n="shared.sats">sats</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="satoshis" (input)="transformInput('satoshis')" (click)="selectAll($event)">
|
||||
<input type="text" inputmode="numeric" class="form-control" formControlName="satoshis" (input)="transformInput('satoshis')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('satoshis').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -77,7 +77,7 @@ export class DifficultyMiningComponent implements OnInit {
|
||||
base: `${da.progressPercent.toFixed(2)}%`,
|
||||
change: da.difficultyChange,
|
||||
progress: da.progressPercent,
|
||||
remainingBlocks: da.remainingBlocks - 1,
|
||||
remainingBlocks: da.remainingBlocks,
|
||||
colorAdjustments,
|
||||
colorPreviousAdjustments,
|
||||
newDifficultyHeight: da.nextRetargetHeight,
|
||||
|
||||
@@ -153,8 +153,8 @@ export class DifficultyComponent implements OnInit {
|
||||
base: `${da.progressPercent.toFixed(2)}%`,
|
||||
change: da.difficultyChange,
|
||||
progress: da.progressPercent,
|
||||
minedBlocks: this.currentIndex + 1,
|
||||
remainingBlocks: da.remainingBlocks - 1,
|
||||
minedBlocks: this.currentIndex,
|
||||
remainingBlocks: da.remainingBlocks,
|
||||
expectedBlocks: Math.floor(da.expectedBlocks),
|
||||
colorAdjustments,
|
||||
colorPreviousAdjustments,
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home" *ngIf="network.val === '' && stateService.env.ACCELERATOR">
|
||||
<a class="nav-link" [routerLink]="['/acceleration' | relativeUrl]" (click)="collapse()">
|
||||
<fa-icon [icon]="['fas', 'rocket']" [fixedWidth]="true" i18n-title="master-page.accelerator-dashboard" title="Accelerator Dashboard"></fa-icon>
|
||||
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
|
||||
@@ -12,9 +12,15 @@
|
||||
<span class="badge mr-1 badge-og" *ngIf="user.ogRank">
|
||||
OG #{{ user.ogRank }}
|
||||
</span>
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-' + user.subscription_tag" *ngIf="user.subscription_tag !== 'free'">
|
||||
{{ user.subscription_tag.toUpperCase() }}
|
||||
</span>
|
||||
@if (user.subscription_tag !== 'free') {
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-' + user.subscription_tag">
|
||||
{{ user.subscription_tag.toUpperCase() }}
|
||||
</span>
|
||||
} @else if (user.type === 'mining_pool') {
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-mining-pool'">
|
||||
MINING POOL
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0 menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')">
|
||||
<fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon>
|
||||
|
||||
65
frontend/src/app/components/ord-data/ord-data.component.html
Normal file
65
frontend/src/app/components/ord-data/ord-data.component.html
Normal file
@@ -0,0 +1,65 @@
|
||||
@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.toString() }"></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
@if (runestone?.etching?.supply) {
|
||||
@if (runestone?.etching.premine > 0) {
|
||||
<ng-container i18n="ord.premine-n-runes">
|
||||
<span>Premine</span>
|
||||
<span class="amount"> {{ getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) >= 100000 ? (getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) | amountShortener:undefined:undefined:true) : getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) }} </span>
|
||||
{{ runestone.etching.symbol }}
|
||||
<span class="name">{{ runestone.etching.spacedName }}</span>
|
||||
<span> ({{ toNumber(runestone.etching.premine) / toNumber(runestone.etching.supply) * 100 | amountShortener:0}}% of total supply)</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="ord.etch-rune">
|
||||
<span>Etching of</span>
|
||||
{{ runestone.etching.symbol }}
|
||||
<span class="name">{{ runestone.etching.spacedName }}</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 (inscriptions?.length && type === 'vin') {
|
||||
<div *ngFor="let contentType of inscriptionsData | keyvalue">
|
||||
<div>
|
||||
@if (contentType.key !== 'undefined') {
|
||||
<span class="badge badge-ord mr-1">{{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }}</span>
|
||||
} @else {
|
||||
<span class="badge badge-ord mr-1" i18n="unknown">Unknown</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 ((runestone && !minted && !runestone.etching?.supply && !transferredRunes?.length && type === 'vout') || (!inscriptions?.length && type === 'vin')) {
|
||||
<i i18n="error.decoding-data">Error decoding data</i>
|
||||
}
|
||||
|
||||
<ng-template #runeName let-id>
|
||||
{{ runeInfo[id]?.etching.symbol || '' }}
|
||||
<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]?.etching.spacedName }}</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: 200px;
|
||||
}
|
||||
87
frontend/src/app/components/ord-data/ord-data.component.ts
Normal file
87
frontend/src/app/components/ord-data/ord-data.component.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Runestone, Etching } from '../../shared/ord/rune.utils';
|
||||
import { Inscription } from '../../shared/ord/inscription.utils';
|
||||
|
||||
@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 } };
|
||||
@Input() type: 'vin' | 'vout';
|
||||
|
||||
toNumber = (value: bigint): number => Number(value);
|
||||
|
||||
// Inscriptions
|
||||
inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } };
|
||||
// Rune mints
|
||||
minted: number;
|
||||
// Rune transfers
|
||||
transferredRunes: { key: string; etching: Etching; txid: string }[] = [];
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.runestone && this.runestone) {
|
||||
if (this.runestone.mint && this.runeInfo[this.runestone.mint.toString()]) {
|
||||
const mint = this.runestone.mint.toString();
|
||||
const terms = this.runeInfo[mint].etching.terms;
|
||||
const amount = terms?.amount;
|
||||
const divisibility = this.runeInfo[mint].etching.divisibility;
|
||||
if (amount) {
|
||||
this.minted = this.getAmount(amount, divisibility);
|
||||
}
|
||||
}
|
||||
|
||||
this.runestone.edicts.forEach(edict => {
|
||||
if (this.runeInfo[edict.id.toString()]) {
|
||||
this.transferredRunes.push({ key: edict.id.toString(), ...this.runeInfo[edict.id.toString()] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.is_cropped && !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: bigint, divisibility: number): number {
|
||||
const divisor = BigInt(10) ** BigInt(divisibility);
|
||||
const result = amount / divisor;
|
||||
|
||||
return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td>{{ rbfInfo.tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
|
||||
<td>{{ rbfInfo.tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></td>
|
||||
</tr>
|
||||
<tr *only-vsize>
|
||||
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,8 @@
|
||||
<div class="container-xl">
|
||||
<h1 class="text-left" i18n="shared.test-transactions|Test Transactions">Test Transactions</h1>
|
||||
<div style="display: flex; width: 100%; align-items: center; flex-wrap: wrap;">
|
||||
<h1 class="text-left" i18n="shared.test-transactions|Test Transactions">Test Transactions</h1>
|
||||
<app-svg-images name="blocks-3-2" style="width: 275px; max-width: 90%; margin-top: -9px"></app-svg-images>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="testTxsForm" (submit)="testTxsForm.valid && testTxs()" novalidate>
|
||||
<label for="maxfeerate" i18n="test.tx.raw-hex">Raw hex</label>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnChanges } from '@angular/core';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { dates } from '../../shared/i18n/dates';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { TimeService } from '../../services/time.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-time',
|
||||
@@ -12,19 +11,9 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
interval: number;
|
||||
text: string;
|
||||
tooltip: string;
|
||||
precisionThresholds = {
|
||||
year: 100,
|
||||
month: 18,
|
||||
week: 12,
|
||||
day: 31,
|
||||
hour: 48,
|
||||
minute: 90,
|
||||
second: 90
|
||||
};
|
||||
intervals = {};
|
||||
|
||||
@Input() time: number;
|
||||
@Input() dateString: number;
|
||||
@Input() dateString: string;
|
||||
@Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain';
|
||||
@Input() fastRender = false;
|
||||
@Input() fixedRender = false;
|
||||
@@ -40,37 +29,26 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
constructor(
|
||||
private ref: ChangeDetectorRef,
|
||||
private stateService: StateService,
|
||||
private datePipe: DatePipe,
|
||||
) {
|
||||
this.intervals = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
week: 604800,
|
||||
day: 86400,
|
||||
hour: 3600,
|
||||
minute: 60,
|
||||
second: 1
|
||||
};
|
||||
}
|
||||
private timeService: TimeService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.calculateTime();
|
||||
if(this.fixedRender){
|
||||
this.text = this.calculate();
|
||||
return;
|
||||
}
|
||||
if (!this.stateService.isBrowser) {
|
||||
this.text = this.calculate();
|
||||
this.ref.markForCheck();
|
||||
return;
|
||||
}
|
||||
this.interval = window.setInterval(() => {
|
||||
this.text = this.calculate();
|
||||
this.calculateTime();
|
||||
this.ref.markForCheck();
|
||||
}, 1000 * (this.fastRender ? 1 : 60));
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.text = this.calculate();
|
||||
this.calculateTime();
|
||||
this.ref.markForCheck();
|
||||
}
|
||||
|
||||
@@ -78,224 +56,21 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
calculate() {
|
||||
if (this.time == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let seconds: number;
|
||||
switch (this.kind) {
|
||||
case 'since':
|
||||
seconds = Math.floor((+new Date() - +new Date(this.dateString || this.time * 1000)) / 1000);
|
||||
this.tooltip = this.datePipe.transform(new Date(this.dateString || this.time * 1000), 'yyyy-MM-dd HH:mm');
|
||||
break;
|
||||
case 'until':
|
||||
case 'within':
|
||||
seconds = (+new Date(this.time) - +new Date()) / 1000;
|
||||
this.tooltip = this.datePipe.transform(new Date(this.time), 'yyyy-MM-dd HH:mm');
|
||||
break;
|
||||
default:
|
||||
seconds = Math.floor(this.time);
|
||||
this.tooltip = '';
|
||||
}
|
||||
|
||||
if (!this.showTooltip || this.relative) {
|
||||
this.tooltip = '';
|
||||
}
|
||||
|
||||
if (seconds < 1 && this.kind === 'span') {
|
||||
return $localize`:@@date-base.immediately:Immediately`;
|
||||
} else if (seconds < 60) {
|
||||
if (this.relative || this.kind === 'since') {
|
||||
if (this.lowercaseStart) {
|
||||
return $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1);
|
||||
}
|
||||
return $localize`:@@date-base.just-now:Just now`;
|
||||
} else if (this.kind === 'until' || this.kind === 'within') {
|
||||
seconds = 60;
|
||||
}
|
||||
}
|
||||
|
||||
let counter: number;
|
||||
const result = [];
|
||||
let usedUnits = 0;
|
||||
for (const [index, unit] of this.units.entries()) {
|
||||
let precisionUnit = this.units[Math.min(this.units.length - 1, index + this.precision)];
|
||||
counter = Math.floor(seconds / this.intervals[unit]);
|
||||
const precisionCounter = Math.round(seconds / this.intervals[precisionUnit]);
|
||||
if (precisionCounter > this.precisionThresholds[precisionUnit]) {
|
||||
precisionUnit = unit;
|
||||
}
|
||||
if (this.units.indexOf(precisionUnit) === this.units.indexOf(this.minUnit)) {
|
||||
counter = Math.max(1, counter);
|
||||
}
|
||||
if (counter > 0) {
|
||||
let rounded;
|
||||
const roundFactor = Math.pow(10,this.fractionDigits || 0);
|
||||
if ((this.kind === 'until' || this.kind === 'within') && usedUnits < this.numUnits) {
|
||||
rounded = Math.floor((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
} else {
|
||||
rounded = Math.round((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
}
|
||||
if ((this.kind !== 'until' && this.kind !== 'within')|| this.numUnits === 1) {
|
||||
return this.formatTime(this.kind, precisionUnit, rounded);
|
||||
} else {
|
||||
if (!usedUnits) {
|
||||
result.push(this.formatTime(this.kind, precisionUnit, rounded));
|
||||
} else {
|
||||
result.push(this.formatTime('', precisionUnit, rounded));
|
||||
}
|
||||
seconds -= (rounded * this.intervals[precisionUnit]);
|
||||
usedUnits++;
|
||||
if (usedUnits >= this.numUnits) {
|
||||
return result.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.join(', ');
|
||||
}
|
||||
|
||||
private formatTime(kind, unit, number): string {
|
||||
const dateStrings = dates(number);
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'until':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'within':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'before':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return dateStrings.i18nYear; break;
|
||||
case 'month': return dateStrings.i18nMonth; break;
|
||||
case 'week': return dateStrings.i18nWeek; break;
|
||||
case 'day': return dateStrings.i18nDay; break;
|
||||
case 'hour': return dateStrings.i18nHour; break;
|
||||
case 'minute': return dateStrings.i18nMinute; break;
|
||||
case 'second': return dateStrings.i18nSecond; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return dateStrings.i18nYears; break;
|
||||
case 'month': return dateStrings.i18nMonths; break;
|
||||
case 'week': return dateStrings.i18nWeeks; break;
|
||||
case 'day': return dateStrings.i18nDays; break;
|
||||
case 'hour': return dateStrings.i18nHours; break;
|
||||
case 'minute': return dateStrings.i18nMinutes; break;
|
||||
case 'second': return dateStrings.i18nSeconds; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
calculateTime(): void {
|
||||
const { text, tooltip } = this.timeService.calculate(
|
||||
this.time,
|
||||
this.kind,
|
||||
this.relative,
|
||||
this.precision,
|
||||
this.minUnit,
|
||||
this.showTooltip,
|
||||
this.units,
|
||||
this.dateString,
|
||||
this.lowercaseStart,
|
||||
this.numUnits,
|
||||
this.fractionDigits,
|
||||
);
|
||||
this.text = text;
|
||||
this.tooltip = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="blockchain-wrapper" [style]="{ height: blockchainHeight * 1.16 + 'px' }">
|
||||
<app-clockchain [height]="blockchainHeight" [width]="blockchainWidth" mode="none"></app-clockchain>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel" *ngIf="!error || waitingForTransaction">
|
||||
@if (replaced) {
|
||||
<div class="alert-replaced" role="alert">
|
||||
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
|
||||
@@ -65,23 +65,25 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field narrower">
|
||||
<div class="label" i18n="transaction.eta|Transaction ETA">ETA</div>
|
||||
<div class="value">
|
||||
<ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton">
|
||||
<span class="justify-content-end d-flex align-items-center">
|
||||
@if (eta.blocks >= 7) {
|
||||
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
|
||||
} @else {
|
||||
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||
}
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-template #etaSkeleton>
|
||||
<span class="skeleton-loader" style="max-width: 200px;"></span>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
@if (!replaced) {
|
||||
<div class="field narrower">
|
||||
<div class="label" i18n="transaction.eta|Transaction ETA">ETA</div>
|
||||
<div class="value">
|
||||
<ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton">
|
||||
<span class="justify-content-end d-flex align-items-center">
|
||||
@if (eta.blocks >= 7) {
|
||||
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
|
||||
} @else {
|
||||
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||
}
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-template #etaSkeleton>
|
||||
<span class="skeleton-loader" style="max-width: 200px;"></span>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else if (tx && tx.status?.confirmed) {
|
||||
<div class="field narrower mt-2">
|
||||
<div class="label" i18n="transaction.confirmed-at">Confirmed at</div>
|
||||
@@ -111,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-panel">
|
||||
<div class="bottom-panel" *ngIf="!error || waitingForTransaction">
|
||||
@if (isLoading) {
|
||||
<div class="progress-icon">
|
||||
<div class="spinner-border text-light" style="width: 1em; height: 1em"></div>
|
||||
@@ -184,6 +186,12 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bottom-panel" *ngIf="error && !waitingForTransaction">
|
||||
<app-http-error [error]="error">
|
||||
<span i18n="transaction.error.loading-transaction-data">Error loading transaction data.</span>
|
||||
</app-http-error>
|
||||
</div>
|
||||
|
||||
<div class="footer-link"
|
||||
[routerLink]="['/tx' | relativeUrl, tx?.txid || txId]"
|
||||
|
||||
@@ -286,7 +286,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
|
||||
this.accelerationInfo = null;
|
||||
}),
|
||||
switchMap((blockHash: string) => {
|
||||
return this.servicesApiService.getAccelerationHistory$({ blockHash });
|
||||
return this.servicesApiService.getAllAccelerationHistory$({ blockHash }, null, this.txId);
|
||||
}),
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<p>The mempool Square Logo</p>
|
||||
<br><br>
|
||||
|
||||
<app-svg-images name="accelerator" height="76px"></app-svg-images>
|
||||
<app-svg-images name="accelerator" style="width: 500px; max-width: 80%"></app-svg-images>
|
||||
<br><br>
|
||||
<p>The Mempool Accelerator Logo</p>
|
||||
<br><br>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</ng-template>
|
||||
</span>
|
||||
<span class="field col-sm-4 text-center"><ng-container *ngIf="transactionTime > 0">‎{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }}</ng-container></span>
|
||||
<span class="field col-sm-4 text-right"><span class="label" i18n="transaction.fee|Transaction fee">Fee</span> {{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span>
|
||||
<span class="field col-sm-4 text-right"><span class="label" i18n="transaction.fee|Transaction fee">Fee</span> {{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span></span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -164,12 +164,12 @@
|
||||
<br>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="transactionTime && isAcceleration">
|
||||
<ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && isAcceleration">
|
||||
<div class="title float-left">
|
||||
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)" [standardETA]="(standardETA$ | async)?.time"></app-acceleration-timeline>
|
||||
<app-acceleration-timeline [transactionTime]="transactionTime" [acceleratedAt]="tx.acceleratedAt" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)"></app-acceleration-timeline>
|
||||
<br>
|
||||
</ng-container>
|
||||
|
||||
@@ -554,13 +554,13 @@
|
||||
@if (network === 'liquid' || network === 'liquidtestnet') {
|
||||
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||
} @else {
|
||||
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary) ? 'etaDeepMempool d-flex justify-content-between' : ''">
|
||||
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && notAcceleratedOnLoad) ? 'etaDeepMempool d-flex justify-content-between' : ''">
|
||||
@if (eta.blocks >= 7) {
|
||||
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
|
||||
} @else {
|
||||
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
|
||||
}
|
||||
@if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary) {
|
||||
@if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && notAcceleratedOnLoad) {
|
||||
<div class="d-flex accelerate">
|
||||
<a class="btn btn-sm accelerateDeepMempool btn-small-height" [class.disabled]="!eligibleForAcceleration" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
|
||||
<a *ngIf="!eligibleForAcceleration" href="https://mempool.space/accelerator#why-cant-accelerate" target="_blank" class="info-badges ml-1" i18n-ngbTooltip="Mempool Accelerator™ tooltip" ngbTooltip="This transaction cannot be accelerated">
|
||||
@@ -606,9 +606,9 @@
|
||||
@if (!isLoadingTx) {
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>
|
||||
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span>
|
||||
@if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) {
|
||||
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sat|sat">sat</span>
|
||||
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sats">sats</span>
|
||||
}
|
||||
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0)"></app-fiat></span>
|
||||
</td>
|
||||
@@ -670,7 +670,7 @@
|
||||
<ng-template #acceleratingRow>
|
||||
<tr>
|
||||
<td rowspan="2" colspan="2" style="padding: 0;">
|
||||
<app-active-acceleration-box [tx]="tx" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="showCpfpDetails = !showCpfpDetails" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
|
||||
<app-active-acceleration-box [acceleratedBy]="tx.acceleratedBy" [effectiveFeeRate]="tx.effectiveFeePerVsize" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="showCpfpDetails = !showCpfpDetails" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
@@ -684,8 +684,15 @@
|
||||
@if (pool) {
|
||||
<td class="wrap-cell">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="pool.minerNames?.length > 1 && pool.minerNames[1] != ''">
|
||||
@if (pool.minerNames[1].length > 16) {
|
||||
{{ pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pool.name + ' mining pool'">
|
||||
{{ pool.name }}
|
||||
{{ pool.name }}
|
||||
</a>
|
||||
</td>
|
||||
} @else {
|
||||
|
||||
@@ -60,6 +60,19 @@
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.miner-name {
|
||||
margin-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.badge.badge-accelerated {
|
||||
background-color: var(--tertiary);
|
||||
color: white;
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Pool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
minerNames: string[] | null;
|
||||
}
|
||||
|
||||
export interface TxAuditStatus {
|
||||
@@ -118,7 +119,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
|
||||
isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself
|
||||
ETA$: Observable<ETA | null>;
|
||||
standardETA$: Observable<ETA | null>;
|
||||
isCached: boolean = false;
|
||||
now = Date.now();
|
||||
da$: Observable<DifficultyAdjustment>;
|
||||
@@ -139,6 +139,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
firstLoad = true;
|
||||
waitingForAccelerationInfo: boolean = false;
|
||||
isLoadingFirstSeen = false;
|
||||
notAcceleratedOnLoad: boolean = null;
|
||||
|
||||
featuresEnabled: boolean;
|
||||
segwitEnabled: boolean;
|
||||
@@ -191,7 +192,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.hideAccelerationSummary = this.stateService.isMempoolSpaceBuild ? this.storageService.getValue('hide-accelerator-pref') == 'true' : true;
|
||||
|
||||
if (!this.stateService.isLiquid()) {
|
||||
this.miningService.getMiningStats('1w').subscribe(stats => {
|
||||
this.miningService.getMiningStats('1m').subscribe(stats => {
|
||||
this.miningStats = stats;
|
||||
});
|
||||
}
|
||||
@@ -343,7 +344,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.setIsAccelerated();
|
||||
}),
|
||||
switchMap((blockHeight: number) => {
|
||||
return this.servicesApiService.getAccelerationHistory$({ blockHeight }).pipe(
|
||||
return this.servicesApiService.getAllAccelerationHistory$({ blockHeight }, null, this.txId).pipe(
|
||||
switchMap((accelerationHistory: Acceleration[]) => {
|
||||
if (this.tx.acceleration && !accelerationHistory.length) { // If the just mined transaction was accelerated, but services backend did not return any acceleration data, retry
|
||||
return throwError('retry');
|
||||
@@ -490,7 +491,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.stateService.network === '') {
|
||||
if (!this.mempoolPosition.accelerated) {
|
||||
if (!this.accelerationFlowCompleted && !this.hideAccelerationSummary && !this.showAccelerationSummary) {
|
||||
this.miningService.getMiningStats('1w').subscribe(stats => {
|
||||
this.miningService.getMiningStats('1m').subscribe(stats => {
|
||||
this.miningStats = stats;
|
||||
});
|
||||
}
|
||||
@@ -848,6 +849,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.tx.feeDelta = cpfpInfo.feeDelta;
|
||||
this.setIsAccelerated(firstCpfp);
|
||||
}
|
||||
|
||||
if (this.notAcceleratedOnLoad === null) {
|
||||
this.notAcceleratedOnLoad = !this.isAcceleration;
|
||||
}
|
||||
|
||||
if (!this.isAcceleration && this.fragmentParams.has('accelerate')) {
|
||||
this.forceAccelerationSummary = true;
|
||||
@@ -877,21 +882,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.miningStats = stats;
|
||||
this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable
|
||||
});
|
||||
if (!this.tx.status?.confirmed) {
|
||||
this.standardETA$ = combineLatest([
|
||||
this.stateService.mempoolBlocks$.pipe(startWith(null)),
|
||||
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
|
||||
]).pipe(
|
||||
map(([mempoolBlocks, da]) => {
|
||||
return this.etaService.calculateUnacceleratedETA(
|
||||
this.tx,
|
||||
mempoolBlocks,
|
||||
da,
|
||||
this.cpfpInfo,
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
this.isAccelerated$.next(this.isAcceleration);
|
||||
}
|
||||
@@ -966,6 +956,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.filters = [];
|
||||
this.showCpfpDetails = false;
|
||||
this.showAccelerationDetails = false;
|
||||
this.accelerationFlowCompleted = false;
|
||||
this.accelerationInfo = null;
|
||||
this.cashappEligible = false;
|
||||
this.txInBlockIndex = null;
|
||||
@@ -1083,6 +1074,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
(!this.hideAccelerationSummary && !this.accelerationFlowCompleted)
|
||||
|| this.forceAccelerationSummary
|
||||
)
|
||||
&& this.notAcceleratedOnLoad // avoid briefly showing accelerator checkout on already accelerated txs
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'"></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'"></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">
|
||||
@@ -321,7 +345,7 @@
|
||||
<div class="float-left mt-2-5" *ngIf="!transactionPage && !tx.vin[0].is_coinbase && tx.fee !== -1">
|
||||
<app-fee-rate [fee]="tx.fee" [weight]="tx.weight"></app-fee-rate>
|
||||
<span class="d-none d-sm-inline-block"> – {{ tx.fee | number }} <span class="symbol"
|
||||
i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee"></app-fiat></span></span>
|
||||
i18n="shared.sats">sats</span> <span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee"></app-fiat></span></span>
|
||||
</div>
|
||||
<div class="float-left mt-2-5 grey-info-text" *ngIf="tx.fee === -1" i18n="transactions-list.load-to-reveal-fee-info">Show more inputs to reveal fee data</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.inter
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AssetsService } from '../../services/assets.service';
|
||||
import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/operators';
|
||||
import { filter, map, tap, switchMap, catchError } from 'rxjs/operators';
|
||||
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 '../../shared/ord/inscription.utils';
|
||||
import { Etching, Runestone } from '../../shared/ord/rune.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transactions-list',
|
||||
@@ -50,12 +53,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 +244,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 +395,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();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
|
||||
|
||||
<div [class.full-container]="!widget">
|
||||
<ng-container *ngIf="!error">
|
||||
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, paddingBottom: !widget}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="error">
|
||||
<div class="error-wrapper">
|
||||
<p class="error">{{ error }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
font-size: 18px;
|
||||
@media (min-width: 465px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-title {
|
||||
position: relative;
|
||||
color: var(--fg);
|
||||
opacity: var(--opacity);
|
||||
margin-top: -13px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.full-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0px;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.error-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 15px;
|
||||
color: grey;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.chart-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
374
frontend/src/app/components/utxo-graph/utxo-graph.component.ts
Normal file
374
frontend/src/app/components/utxo-graph/utxo-graph.component.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
|
||||
import { EChartsOption } from '../../graphs/echarts';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Utxo } from '../../interfaces/electrs.interface';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
import { renderSats } from '../../shared/common.utils';
|
||||
import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils';
|
||||
import { TimeService } from '../../services/time.service';
|
||||
|
||||
const newColorHex = '1bd8f4';
|
||||
const oldColorHex = '9339f4';
|
||||
const pendingColorHex = 'eba814';
|
||||
const newColor = hexToColor(newColorHex);
|
||||
const oldColor = hexToColor(oldColorHex);
|
||||
|
||||
interface Circle {
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
i: number,
|
||||
}
|
||||
|
||||
interface UtxoCircle extends Circle {
|
||||
utxo: Utxo;
|
||||
}
|
||||
|
||||
function sortedInsert(positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[], newPosition: { c1: Circle, c2: Circle, d: number, p: number }): void {
|
||||
let left = 0;
|
||||
let right = positions.length;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (positions[mid].p > newPosition.p) {
|
||||
right = mid;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
positions.splice(left, 0, newPosition, {...newPosition, side: true });
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-utxo-graph',
|
||||
templateUrl: './utxo-graph.component.html',
|
||||
styleUrls: ['./utxo-graph.component.scss'],
|
||||
styles: [`
|
||||
.loadingGraphs {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(50% - 15px);
|
||||
z-index: 99;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
@Input() utxos: Utxo[];
|
||||
@Input() height: number = 200;
|
||||
@Input() right: number | string = 10;
|
||||
@Input() left: number | string = 70;
|
||||
@Input() widget: boolean = false;
|
||||
|
||||
subscription: Subscription;
|
||||
lastUpdate: number = 0;
|
||||
updateInterval;
|
||||
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'svg',
|
||||
};
|
||||
|
||||
error: any;
|
||||
isLoading = true;
|
||||
chartInstance: any = undefined;
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private cd: ChangeDetectorRef,
|
||||
private zone: NgZone,
|
||||
private router: Router,
|
||||
private relativeUrlPipe: RelativeUrlPipe,
|
||||
private timeService: TimeService,
|
||||
) {
|
||||
// re-render the chart every 10 seconds, to keep the age colors up to date
|
||||
this.updateInterval = setInterval(() => {
|
||||
if (this.lastUpdate < Date.now() - 10000 && this.utxos) {
|
||||
this.prepareChartOptions(this.utxos);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.isLoading = true;
|
||||
if (!this.utxos) {
|
||||
return;
|
||||
}
|
||||
if (changes.utxos) {
|
||||
this.prepareChartOptions(this.utxos);
|
||||
}
|
||||
}
|
||||
|
||||
prepareChartOptions(utxos: Utxo[]): void {
|
||||
if (!utxos || utxos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
// Helper functions
|
||||
const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
const intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => {
|
||||
const d1 = c1.r + r;
|
||||
const d2 = c2.r + r;
|
||||
const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d);
|
||||
const h = Math.sqrt(d1 * d1 - a * a);
|
||||
const x3 = c1.x + a * (c2.x - c1.x) / d;
|
||||
const y3 = c1.y + a * (c2.y - c1.y) / d;
|
||||
return side
|
||||
? { x: x3 + h * (c2.y - c1.y) / d, y: y3 - h * (c2.x - c1.x) / d }
|
||||
: { x: x3 - h * (c2.y - c1.y) / d, y: y3 + h * (c2.x - c1.x) / d };
|
||||
};
|
||||
|
||||
// ~Linear algorithm to pack circles as tightly as possible without overlaps
|
||||
const placedCircles: UtxoCircle[] = [];
|
||||
const positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[] = [];
|
||||
// Pack in descending order of value, and limit to the top 500 to preserve performance
|
||||
const sortedUtxos = utxos.sort((a, b) => {
|
||||
if (a.value === b.value) {
|
||||
if (a.status.confirmed && !b.status.confirmed) {
|
||||
return -1;
|
||||
} else if (!a.status.confirmed && b.status.confirmed) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.status.block_height - b.status.block_height;
|
||||
}
|
||||
}
|
||||
return b.value - a.value;
|
||||
}).slice(0, 500);
|
||||
const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0));
|
||||
sortedUtxos.forEach((utxo, index) => {
|
||||
// area proportional to value
|
||||
const r = Math.sqrt(utxo.value);
|
||||
|
||||
// special cases for the first two utxos
|
||||
if (index === 0) {
|
||||
placedCircles.push({ x: 0, y: 0, r, utxo, i: index });
|
||||
return;
|
||||
}
|
||||
if (index === 1) {
|
||||
const c = placedCircles[0];
|
||||
placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index });
|
||||
sortedInsert(positions, { c1: c, c2: placedCircles[1], d: c.r + r, p: 0 });
|
||||
return;
|
||||
}
|
||||
if (index === 2) {
|
||||
const c = placedCircles[0];
|
||||
placedCircles.push({ x: -c.r - r, y: 0, r, utxo, i: index });
|
||||
sortedInsert(positions, { c1: c, c2: placedCircles[2], d: c.r + r, p: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// The best position will be touching two other circles
|
||||
// find the closest such position to the center of the graph
|
||||
// where the circle can be placed without overlapping other circles
|
||||
const numCircles = placedCircles.length;
|
||||
let newCircle: UtxoCircle = null;
|
||||
while (positions.length > 0) {
|
||||
const position = positions.shift();
|
||||
// if the circles are too far apart, skip
|
||||
if (position.d > (position.c1.r + position.c2.r + r + r)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = intersection(position.c1, position.c2, position.d, r, position.side);
|
||||
if (isNaN(x) || isNaN(y)) {
|
||||
// should never happen
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the circle would overlap any other circles here
|
||||
let valid = true;
|
||||
const nearbyCircles: { c: UtxoCircle, d: number, s: number }[] = [];
|
||||
for (let k = 0; k < numCircles; k++) {
|
||||
const c = placedCircles[k];
|
||||
if (k === position.c1.i || k === position.c2.i) {
|
||||
nearbyCircles.push({ c, d: c.r + r, s: 0 });
|
||||
continue;
|
||||
}
|
||||
const d = distance(x, y, c.x, c.y);
|
||||
if (d < (r + c.r)) {
|
||||
valid = false;
|
||||
break;
|
||||
} else {
|
||||
nearbyCircles.push({ c, d, s: d - c.r - r });
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
newCircle = { x, y, r, utxo, i: index };
|
||||
// add new positions to the candidate list
|
||||
const nearest = nearbyCircles.sort((a, b) => a.s - b.s).slice(0, 5);
|
||||
for (const n of nearest) {
|
||||
if (n.d < (n.c.r + r + maxR + maxR)) {
|
||||
sortedInsert(positions, { c1: newCircle, c2: n.c, d: n.d, p: distance((n.c.x + x) / 2, (n.c.y + y), 0, 0) });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newCircle) {
|
||||
placedCircles.push(newCircle);
|
||||
} else {
|
||||
// should never happen
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Precompute the bounding box of the graph
|
||||
const minX = Math.min(...placedCircles.map(d => d.x - d.r));
|
||||
const maxX = Math.max(...placedCircles.map(d => d.x + d.r));
|
||||
const minY = Math.min(...placedCircles.map(d => d.y - d.r));
|
||||
const maxY = Math.max(...placedCircles.map(d => d.y + d.r));
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
const data = placedCircles.map((circle) => [
|
||||
circle.utxo.txid + circle.utxo.vout,
|
||||
circle.utxo,
|
||||
circle.x,
|
||||
circle.y,
|
||||
circle.r,
|
||||
]);
|
||||
|
||||
this.chartOptions = {
|
||||
series: [{
|
||||
type: 'custom',
|
||||
coordinateSystem: undefined,
|
||||
data: data,
|
||||
encode: {
|
||||
itemName: 0,
|
||||
x: 2,
|
||||
y: 3,
|
||||
r: 4,
|
||||
},
|
||||
renderItem: (params, api) => {
|
||||
const chartWidth = api.getWidth();
|
||||
const chartHeight = api.getHeight();
|
||||
const scale = Math.min(chartWidth / width, chartHeight / height);
|
||||
const scaledWidth = width * scale;
|
||||
const scaledHeight = height * scale;
|
||||
const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
|
||||
const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale;
|
||||
|
||||
const datum = data[params.dataIndex];
|
||||
const utxo = datum[1] as Utxo;
|
||||
const x = datum[2] as number;
|
||||
const y = datum[3] as number;
|
||||
const r = datum[4] as number;
|
||||
if (r * scale < 2) {
|
||||
// skip items too small to render cleanly
|
||||
return;
|
||||
}
|
||||
|
||||
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||
const elements: any[] = [
|
||||
{
|
||||
type: 'circle',
|
||||
autoBatch: true,
|
||||
shape: {
|
||||
r: (r * scale) - 1,
|
||||
},
|
||||
style: {
|
||||
fill: '#' + this.getColor(utxo),
|
||||
}
|
||||
},
|
||||
];
|
||||
const labelFontSize = Math.min(36, r * scale * 0.3);
|
||||
if (labelFontSize > 8) {
|
||||
elements.push({
|
||||
type: 'text',
|
||||
style: {
|
||||
text: valueStr,
|
||||
fontSize: labelFontSize,
|
||||
fill: '#fff',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: 'group',
|
||||
x: (x * scale) + offsetX,
|
||||
y: (y * scale) + offsetY,
|
||||
children: elements,
|
||||
};
|
||||
},
|
||||
}],
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||
borderRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textStyle: {
|
||||
color: 'var(--tooltip-grey)',
|
||||
align: 'left',
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: (params: any): string => {
|
||||
const utxo = params.data[1] as Utxo;
|
||||
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||
return `
|
||||
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
|
||||
<br>
|
||||
${valueStr}
|
||||
<br>
|
||||
${utxo.status.confirmed ? 'Confirmed ' + this.timeService.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'}
|
||||
`;
|
||||
},
|
||||
}
|
||||
};
|
||||
this.lastUpdate = Date.now();
|
||||
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
getColor(utxo: Utxo): string {
|
||||
if (utxo.status.confirmed) {
|
||||
const age = Date.now() / 1000 - utxo.status.block_time;
|
||||
const oneHour = 60 * 60;
|
||||
const fourYears = 4 * 365 * 24 * 60 * 60;
|
||||
|
||||
if (age < oneHour) {
|
||||
return newColorHex;
|
||||
} else if (age >= fourYears) {
|
||||
return oldColorHex;
|
||||
} else {
|
||||
// Logarithmic scale between 1 hour and 4 years
|
||||
const logAge = Math.log(age / oneHour);
|
||||
const logMax = Math.log(fourYears / oneHour);
|
||||
const t = logAge / logMax;
|
||||
return colorToHex(mix(newColor, oldColor, t));
|
||||
}
|
||||
} else {
|
||||
return pendingColorHex;
|
||||
}
|
||||
}
|
||||
|
||||
onChartClick(e): void {
|
||||
if (e.data?.[1]?.txid) {
|
||||
this.zone.run(() => {
|
||||
const url = this.relativeUrlPipe.transform(`/tx/${e.data[1].txid}`);
|
||||
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
|
||||
window.open(url + '?mode=details#vout=' + e.data[1].vout);
|
||||
} else {
|
||||
this.router.navigate([url], { fragment: `vout=${e.data[1].vout}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onChartInit(ec): void {
|
||||
this.chartInstance = ec;
|
||||
this.chartInstance.on('click', 'series', this.onChartClick.bind(this));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
isMobile(): boolean {
|
||||
return (window.innerWidth <= 767.98);
|
||||
}
|
||||
}
|
||||
@@ -9163,11 +9163,13 @@ export const restApiDocsData = [
|
||||
Filters can be applied:<ul>
|
||||
<li><code>status</code>: <code>all</code>, <code>requested</code>, <code>accelerating</code>, <code>mined</code>, <code>completed</code>, <code>failed</code></li>
|
||||
<li><code>timeframe</code>: <code>24h</code>, <code>3d</code>, <code>1w</code>, <code>1m</code>, <code>3m</code>, <code>6m</code>, <code>1y</code>, <code>2y</code>, <code>3y</code>, <code>4y</code>, <code>all</code></li>
|
||||
<li><code>poolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">https://github.com/mempool/mining-pools/blob/master/pools-v2.json</a>. <i>Note: This will return all acceleration requests accepted by the pool but the the listed transactions may have been mined by another pool.</i>
|
||||
<li><code>minedByPoolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">pools-v2.json</a>
|
||||
<li><code>blockHash</code>: a block hash</a>
|
||||
<li><code>blockHeight</code>: a block height</a>
|
||||
<li><code>page</code>: the requested page number if using pagination <i>(min: 1)</i></a>
|
||||
<li><code>pageLength</code>: the page lenght if using pagination <i>(min: 1, max: 50)</i></a>
|
||||
<li><code>from</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
|
||||
<li><code>to</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
|
||||
</ul></p>`
|
||||
},
|
||||
urlString: "/v1/services/accelerator/accelerations/history",
|
||||
@@ -9187,21 +9189,22 @@ export const restApiDocsData = [
|
||||
headers: '',
|
||||
response: `[
|
||||
{
|
||||
"txid": "d7e1796d8eb4a09d4e6c174e36cfd852f1e6e6c9f7df4496339933cd32cbdd1d",
|
||||
"status": "completed",
|
||||
"added": 1707421053,
|
||||
"lastUpdated": 1719134667,
|
||||
"effectiveFee": 146,
|
||||
"effectiveVsize": 141,
|
||||
"feeDelta": 14000,
|
||||
"blockHash": "00000000000000000000482f0746d62141694b9210a813b97eb8445780a32003",
|
||||
"blockHeight": 829559,
|
||||
"bidBoost": 3239,
|
||||
"boostVersion": "v1",
|
||||
"txid": "f829900985aad885c13fb90555d27514b05a338202c7ef5d694f4813ad474487",
|
||||
"status": "completed_provisional",
|
||||
"added": 1728111527,
|
||||
"lastUpdated": 1728112113,
|
||||
"effectiveFee": 1385,
|
||||
"effectiveVsize": 276,
|
||||
"feeDelta": 3000,
|
||||
"blockHash": "00000000000000000000cde89e34036ece454ca2d07ddd7f71ab46307ca87423",
|
||||
"blockHeight": 864248,
|
||||
"bidBoost": 65,
|
||||
"boostVersion": "v2",
|
||||
"pools": [
|
||||
111
|
||||
111,
|
||||
115,
|
||||
],
|
||||
"minedByPoolUniqueId": 111
|
||||
"minedByPoolUniqueId": 115
|
||||
}
|
||||
]`,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Import tree-shakeable echarts
|
||||
import * as echarts from 'echarts/core';
|
||||
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart } from 'echarts/charts';
|
||||
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts';
|
||||
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components';
|
||||
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
|
||||
// Typescript interfaces
|
||||
@@ -12,6 +12,7 @@ echarts.use([
|
||||
TitleComponent, TooltipComponent, GridComponent,
|
||||
LegendComponent, GeoComponent, DataZoomComponent,
|
||||
VisualMapComponent, MarkLineComponent,
|
||||
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart
|
||||
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart,
|
||||
CustomChart,
|
||||
]);
|
||||
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };
|
||||
@@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools
|
||||
import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component';
|
||||
import { AddressComponent } from '../components/address/address.component';
|
||||
import { AddressGraphComponent } from '../components/address-graph/address-graph.component';
|
||||
import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component';
|
||||
import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@@ -76,6 +77,7 @@ import { CommonModule } from '@angular/common';
|
||||
HashrateChartPoolsComponent,
|
||||
BlockHealthGraphComponent,
|
||||
AddressGraphComponent,
|
||||
UtxoGraphComponent,
|
||||
ActiveAccelerationBox,
|
||||
],
|
||||
imports: [
|
||||
|
||||
@@ -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 {
|
||||
@@ -233,3 +237,10 @@ interface AssetStats {
|
||||
peg_out_amount: number;
|
||||
burn_count: number;
|
||||
}
|
||||
|
||||
export interface Utxo {
|
||||
txid: string;
|
||||
vout: number;
|
||||
value: number;
|
||||
status: Status;
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export interface BlockExtension {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
minerNames: string[] | null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ class GuardService {
|
||||
|
||||
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
||||
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
||||
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98;
|
||||
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
|
||||
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test'].includes(path[1].path));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, catchError, filter, from, of, shareReplay, switchMap, take, tap } from 'rxjs';
|
||||
import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary } from '../interfaces/electrs.interface';
|
||||
import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary, Utxo } from '../interfaces/electrs.interface';
|
||||
import { StateService } from './state.service';
|
||||
import { BlockExtended } from '../interfaces/node-api.interface';
|
||||
import { calcScriptHash$ } from '../bitcoin.utils';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -166,6 +170,16 @@ export class ElectrsApiService {
|
||||
);
|
||||
}
|
||||
|
||||
getAddressUtxos$(address: string): Observable<Utxo[]> {
|
||||
return this.httpClient.get<Utxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/utxo');
|
||||
}
|
||||
|
||||
getScriptHashUtxos$(script: string): Observable<Utxo[]> {
|
||||
return from(calcScriptHash$(script)).pipe(
|
||||
switchMap(scriptHash => this.httpClient.get<Utxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/utxo')),
|
||||
);
|
||||
}
|
||||
|
||||
getAsset$(assetId: string): Observable<Asset> {
|
||||
return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class EtaService {
|
||||
return combineLatest([
|
||||
this.stateService.mempoolTxPosition$.pipe(map(p => p?.position)),
|
||||
this.stateService.difficultyAdjustment$,
|
||||
miningStats ? of(miningStats) : this.miningService.getMiningStats('1w'),
|
||||
miningStats ? of(miningStats) : this.miningService.getMiningStats('1m'),
|
||||
]).pipe(
|
||||
map(([mempoolPosition, da, miningStats]) => {
|
||||
if (!mempoolPosition || !estimate?.pools?.length || !miningStats || !da) {
|
||||
@@ -166,7 +166,7 @@ export class EtaService {
|
||||
pools[pool.poolUniqueId] = pool;
|
||||
}
|
||||
const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks);
|
||||
const totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId].lastEstimatedHashrate), 0);
|
||||
const totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId]?.lastEstimatedHashrate || 0), 0);
|
||||
const shares = [
|
||||
{
|
||||
block: unacceleratedPosition.block,
|
||||
@@ -174,7 +174,7 @@ export class EtaService {
|
||||
},
|
||||
...accelerationPositions.map(pos => ({
|
||||
block: pos.block,
|
||||
hashrateShare: ((pools[pos.poolId].lastEstimatedHashrate) / miningStats.lastEstimatedHashrate)
|
||||
hashrateShare: ((pools[pos.poolId]?.lastEstimatedHashrate || 0) / miningStats.lastEstimatedHashrate)
|
||||
}))
|
||||
];
|
||||
return this.calculateETAFromShares(shares, da);
|
||||
@@ -204,7 +204,7 @@ export class EtaService {
|
||||
|
||||
let tailProb = 0;
|
||||
let Q = 0;
|
||||
for (let i = 0; i < max; i++) {
|
||||
for (let i = 0; i <= max; i++) {
|
||||
// find H_i
|
||||
const H = shares.reduce((total, share) => total + (share.block <= i ? share.hashrateShare : 0), 0);
|
||||
// find S_i
|
||||
@@ -215,7 +215,7 @@ export class EtaService {
|
||||
tailProb += S;
|
||||
}
|
||||
// at max depth, the transaction is guaranteed to be mined in the next block if it hasn't already
|
||||
Q += (1-tailProb);
|
||||
Q += ((max + 1) * (1-tailProb));
|
||||
const eta = da.timeAvg * Q; // T x Q
|
||||
|
||||
return {
|
||||
|
||||
100
frontend/src/app/services/ord-api.service.ts
Normal file
100
frontend/src/app/services/ord-api.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs';
|
||||
import { Inscription } from '../shared/ord/inscription.utils';
|
||||
import { Transaction } from '../interfaces/electrs.interface';
|
||||
import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils';
|
||||
import { decipherRunestone, Runestone, Etching, UNCOMMON_GOODS } from '../shared/ord/rune.utils';
|
||||
import { ElectrsApiService } from './electrs-api.service';
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OrdApiService {
|
||||
|
||||
constructor(
|
||||
private electrsApiService: ElectrsApiService,
|
||||
) { }
|
||||
|
||||
decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> {
|
||||
const runestone = decipherRunestone(tx);
|
||||
const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {};
|
||||
|
||||
if (runestone) {
|
||||
const runesToFetch: Set<string> = new Set();
|
||||
|
||||
if (runestone.mint) {
|
||||
runesToFetch.add(runestone.mint.toString());
|
||||
}
|
||||
|
||||
if (runestone.edicts.length) {
|
||||
runestone.edicts.forEach(edict => {
|
||||
runesToFetch.add(edict.id.toString());
|
||||
});
|
||||
}
|
||||
|
||||
if (runesToFetch.size) {
|
||||
const runeEtchingObservables = Array.from(runesToFetch).map(runeId => this.getEtchingFromRuneId$(runeId));
|
||||
|
||||
return forkJoin(runeEtchingObservables).pipe(
|
||||
map((etchings) => {
|
||||
etchings.forEach((el) => {
|
||||
if (el) {
|
||||
runeInfo[el.runeId] = { etching: el.etching, txid: el.txid };
|
||||
}
|
||||
});
|
||||
return { runestone: runestone, runeInfo };
|
||||
})
|
||||
);
|
||||
}
|
||||
return of({ runestone: runestone, runeInfo });
|
||||
} else {
|
||||
return of({ runestone: null, runeInfo: {} });
|
||||
}
|
||||
}
|
||||
|
||||
// Get etching from runeId by looking up the transaction that etched the rune
|
||||
getEtchingFromRuneId$(runeId: string): Observable<{ runeId: string; etching: Etching; txid: string; }> {
|
||||
if (runeId === '1:0') {
|
||||
return of({ runeId, etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' });
|
||||
} else {
|
||||
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 runestone = decipherRunestone(tx);
|
||||
if (runestone) {
|
||||
const etching = runestone.etching;
|
||||
if (etching) {
|
||||
return of({ runeId, etching, 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;
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,17 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { StateService } from './state.service';
|
||||
import { StorageService } from './storage.service';
|
||||
import { MenuGroup } from '../interfaces/services.interface';
|
||||
import { Observable, of, ReplaySubject, tap, catchError, share, filter, switchMap } from 'rxjs';
|
||||
import { Observable, of, ReplaySubject, tap, catchError, share, filter, switchMap, map } from 'rxjs';
|
||||
import { IBackendInfo } from '../interfaces/websocket.interface';
|
||||
import { Acceleration, AccelerationHistoryParams } from '../interfaces/node-api.interface';
|
||||
import { AccelerationStats } from '../components/acceleration/acceleration-stats/acceleration-stats.component';
|
||||
|
||||
export type ProductType = 'enterprise' | 'community' | 'mining_pool' | 'custom';
|
||||
export interface IUser {
|
||||
username: string;
|
||||
email: string | null;
|
||||
passwordIsSet: boolean;
|
||||
snsId: string;
|
||||
type: ProductType;
|
||||
type: 'enterprise' | 'community' | 'mining_pool';
|
||||
subscription_tag: string;
|
||||
status: 'pending' | 'verified' | 'disabled';
|
||||
features: string | null;
|
||||
@@ -136,16 +135,16 @@ export class ServicesApiServices {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid, accelerationUUID: accelerationUUID });
|
||||
}
|
||||
|
||||
accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
}
|
||||
|
||||
accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
}
|
||||
|
||||
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
}
|
||||
|
||||
getAccelerations$(): Observable<Acceleration[]> {
|
||||
@@ -160,6 +159,29 @@ export class ServicesApiServices {
|
||||
return this.httpClient.get<Acceleration[]>(`${this.stateService.env.SERVICES_API}/accelerator/accelerations/history`, { params: { ...params } });
|
||||
}
|
||||
|
||||
getAllAccelerationHistory$(params: AccelerationHistoryParams, limit?: number, findTxid?: string): Observable<Acceleration[]> {
|
||||
const getPage$ = (page: number, accelerations: Acceleration[] = []): Observable<{ page: number, total: number, accelerations: Acceleration[] }> => {
|
||||
return this.getAccelerationHistoryObserveResponse$({...params, page}).pipe(
|
||||
map((response) => ({
|
||||
page,
|
||||
total: parseInt(response.headers.get('X-Total-Count'), 10) || 0,
|
||||
accelerations: accelerations.concat(response.body || []),
|
||||
})),
|
||||
switchMap(({page, total, accelerations}) => {
|
||||
if (accelerations.length >= Math.min(total, limit ?? Infinity) || (findTxid && accelerations.find((acc) => acc.txid === findTxid))) {
|
||||
return of({ page, total, accelerations });
|
||||
} else {
|
||||
return getPage$(page + 1, accelerations);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return getPage$(1).pipe(
|
||||
map(({ accelerations }) => accelerations),
|
||||
);
|
||||
}
|
||||
|
||||
getAccelerationHistoryObserveResponse$(params: AccelerationHistoryParams): Observable<any> {
|
||||
return this.httpClient.get<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerations/history`, { params: { ...params }, observe: 'response'});
|
||||
}
|
||||
|
||||
266
frontend/src/app/services/time.service.ts
Normal file
266
frontend/src/app/services/time.service.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { dates } from '../shared/i18n/dates';
|
||||
|
||||
const intervals = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
week: 604800,
|
||||
day: 86400,
|
||||
hour: 3600,
|
||||
minute: 60,
|
||||
second: 1
|
||||
};
|
||||
|
||||
const precisionThresholds = {
|
||||
year: 100,
|
||||
month: 18,
|
||||
week: 12,
|
||||
day: 31,
|
||||
hour: 48,
|
||||
minute: 90,
|
||||
second: 90
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TimeService {
|
||||
|
||||
constructor(private datePipe: DatePipe) {}
|
||||
|
||||
calculate(
|
||||
time: number,
|
||||
kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within',
|
||||
relative: boolean = false,
|
||||
precision: number = 0,
|
||||
minUnit: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' = 'second',
|
||||
showTooltip: boolean = false,
|
||||
units: string[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'],
|
||||
dateString?: string,
|
||||
lowercaseStart: boolean = false,
|
||||
numUnits: number = 1,
|
||||
fractionDigits: number = 0,
|
||||
): { text: string, tooltip: string } {
|
||||
if (time == null) {
|
||||
return { text: '', tooltip: '' };
|
||||
}
|
||||
|
||||
let seconds: number;
|
||||
let tooltip: string = '';
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
seconds = Math.floor((+new Date() - +new Date(dateString || time * 1000)) / 1000);
|
||||
tooltip = this.datePipe.transform(new Date(dateString || time * 1000), 'yyyy-MM-dd HH:mm') || '';
|
||||
break;
|
||||
case 'until':
|
||||
case 'within':
|
||||
seconds = (+new Date(time) - +new Date()) / 1000;
|
||||
tooltip = this.datePipe.transform(new Date(time), 'yyyy-MM-dd HH:mm') || '';
|
||||
break;
|
||||
default:
|
||||
seconds = Math.floor(time);
|
||||
tooltip = '';
|
||||
}
|
||||
|
||||
if (!showTooltip || relative) {
|
||||
tooltip = '';
|
||||
}
|
||||
|
||||
if (seconds < 1 && kind === 'span') {
|
||||
return { tooltip, text: $localize`:@@date-base.immediately:Immediately` };
|
||||
} else if (seconds < 60) {
|
||||
if (relative || kind === 'since') {
|
||||
if (lowercaseStart) {
|
||||
return { tooltip, text: $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1) };
|
||||
}
|
||||
return { tooltip, text: $localize`:@@date-base.just-now:Just now` };
|
||||
} else if (kind === 'until' || kind === 'within') {
|
||||
seconds = 60;
|
||||
}
|
||||
}
|
||||
|
||||
let counter: number;
|
||||
const result: string[] = [];
|
||||
let usedUnits = 0;
|
||||
for (const [index, unit] of units.entries()) {
|
||||
let precisionUnit = units[Math.min(units.length - 1, index + precision)];
|
||||
counter = Math.floor(seconds / intervals[unit]);
|
||||
const precisionCounter = Math.round(seconds / intervals[precisionUnit]);
|
||||
if (precisionCounter > precisionThresholds[precisionUnit]) {
|
||||
precisionUnit = unit;
|
||||
}
|
||||
if (units.indexOf(precisionUnit) === units.indexOf(minUnit)) {
|
||||
counter = Math.max(1, counter);
|
||||
}
|
||||
if (counter > 0) {
|
||||
let rounded;
|
||||
const roundFactor = Math.pow(10,fractionDigits || 0);
|
||||
if ((kind === 'until' || kind === 'within') && usedUnits < numUnits) {
|
||||
rounded = Math.floor((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
} else {
|
||||
rounded = Math.round((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
}
|
||||
if ((kind !== 'until' && kind !== 'within')|| numUnits === 1) {
|
||||
return { tooltip, text: this.formatTime(kind, precisionUnit, rounded) };
|
||||
} else {
|
||||
if (!usedUnits) {
|
||||
result.push(this.formatTime(kind, precisionUnit, rounded));
|
||||
} else {
|
||||
result.push(this.formatTime('', precisionUnit, rounded));
|
||||
}
|
||||
seconds -= (rounded * intervals[precisionUnit]);
|
||||
usedUnits++;
|
||||
if (usedUnits >= numUnits) {
|
||||
return { tooltip, text: result.join(', ') };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tooltip, text: result.join(', ') };
|
||||
}
|
||||
|
||||
private formatTime(kind, unit, number): string {
|
||||
const dateStrings = dates(number);
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'until':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'within':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'before':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return dateStrings.i18nYear; break;
|
||||
case 'month': return dateStrings.i18nMonth; break;
|
||||
case 'week': return dateStrings.i18nWeek; break;
|
||||
case 'day': return dateStrings.i18nDay; break;
|
||||
case 'hour': return dateStrings.i18nHour; break;
|
||||
case 'minute': return dateStrings.i18nMinute; break;
|
||||
case 'second': return dateStrings.i18nSecond; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return dateStrings.i18nYears; break;
|
||||
case 'month': return dateStrings.i18nMonths; break;
|
||||
case 'week': return dateStrings.i18nWeeks; break;
|
||||
case 'day': return dateStrings.i18nDays; break;
|
||||
case 'hour': return dateStrings.i18nHours; break;
|
||||
case 'minute': return dateStrings.i18nMinutes; break;
|
||||
case 'second': return dateStrings.i18nSeconds; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MempoolBlockDelta, MempoolBlockDeltaCompressed, MempoolDeltaChange, TransactionCompressed } from "../interfaces/websocket.interface";
|
||||
import { TransactionStripped } from "../interfaces/node-api.interface";
|
||||
import { AmountShortenerPipe } from "./pipes/amount-shortener.pipe";
|
||||
const amountShortenerPipe = new AmountShortenerPipe();
|
||||
|
||||
export function isMobile(): boolean {
|
||||
return (window.innerWidth <= 767.98);
|
||||
@@ -184,6 +186,33 @@ export function uncompressDeltaChange(block: number, delta: MempoolBlockDeltaCom
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSats(value: number, network: string, mode: 'sats' | 'btc' | 'auto' = 'auto'): string {
|
||||
let prefix = '';
|
||||
switch (network) {
|
||||
case 'liquid':
|
||||
prefix = 'L';
|
||||
break;
|
||||
case 'liquidtestnet':
|
||||
prefix = 'tL';
|
||||
break;
|
||||
case 'testnet':
|
||||
case 'testnet4':
|
||||
prefix = 't';
|
||||
break;
|
||||
case 'signet':
|
||||
prefix = 's';
|
||||
break;
|
||||
}
|
||||
if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) {
|
||||
return `${amountShortenerPipe.transform(value / 100000000, 2)} ${prefix}BTC`;
|
||||
} else {
|
||||
if (prefix.length) {
|
||||
prefix += '-';
|
||||
}
|
||||
return `${amountShortenerPipe.transform(value, 2)} ${prefix}sats`;
|
||||
}
|
||||
}
|
||||
|
||||
export function insecureRandomUUID(): string {
|
||||
const hexDigits = '0123456789abcdef';
|
||||
const uuidLengths = [8, 4, 4, 4, 12];
|
||||
|
||||
@@ -70,6 +70,12 @@ export class GeolocationComponent implements OnChanges {
|
||||
if (this.type === 'node') {
|
||||
const city = this.data.city ? this.data.city : '';
|
||||
|
||||
// Handle city-states like Singapore or Hong Kong
|
||||
if (city && city === this.data?.country) {
|
||||
this.formattedLocation = `${this.data.country} ${getFlagEmoji(this.data.iso)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// City
|
||||
this.formattedLocation = `${city}`;
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
</div>
|
||||
@if (!enterpriseInfo?.footer_img) {
|
||||
<p class="explore-tagline-mobile">
|
||||
<ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>
|
||||
@if (officialMempoolSpace) {
|
||||
<ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>
|
||||
} @else {
|
||||
<ng-container i18n="shared.be-your-own-explorer">Be your own explorer</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">™</ng-template>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<div class="site-options language-selector d-flex justify-content-center align-items-center" [class]="{'services': isServicesPage}">
|
||||
@@ -27,29 +32,38 @@
|
||||
<div class="selector">
|
||||
<app-rate-unit-selector></app-rate-unit-selector>
|
||||
</div>
|
||||
<div class="selector d-none" [ngClass]="isServicesPage ? 'd-lg-flex' : 'd-md-flex'">
|
||||
<app-amount-selector></app-amount-selector>
|
||||
</div>
|
||||
@if (!env.customize?.theme) {
|
||||
<div class="selector d-none d-sm-flex">
|
||||
<div class="selector d-none" [ngClass]="isServicesPage ? 'd-lg-flex' : 'd-md-flex'">
|
||||
<app-theme-selector></app-theme-selector>
|
||||
</div>
|
||||
}
|
||||
<a *ngIf="stateService.isMempoolSpaceBuild" class="btn btn-purple sponsor d-none d-sm-flex justify-content-center" [routerLink]="['/login']">
|
||||
<a *ngIf="stateService.isMempoolSpaceBuild" class="btn btn-purple sponsor d-none justify-content-center" [ngClass]="isServicesPage ? 'd-lg-flex' : 'd-md-flex'" [routerLink]="['/login']">
|
||||
<span *ngIf="user" i18n="shared.my-account" class="nowrap">My Account</span>
|
||||
<span *ngIf="!user" i18n="shared.sign-in" class="nowrap">Sign In</span>
|
||||
</a>
|
||||
</div>
|
||||
@if (!env.customize?.theme) {
|
||||
<div class="selector d-flex d-sm-none justify-content-center ml-auto mr-auto mt-0">
|
||||
<app-theme-selector></app-theme-selector>
|
||||
<div class="selector d-flex justify-content-center ml-auto mr-auto mt-0" [ngClass]="isServicesPage ? 'd-lg-none' : 'd-md-none'">
|
||||
<app-amount-selector class="add-margin"></app-amount-selector>
|
||||
<app-theme-selector class="add-margin"></app-theme-selector>
|
||||
</div>
|
||||
}
|
||||
@if (!enterpriseInfo?.footer_img) {
|
||||
<a *ngIf="stateService.isMempoolSpaceBuild" class="btn btn-purple sponsor d-flex d-sm-none justify-content-center ml-auto mr-auto mt-0 mb-2" [routerLink]="['/login']">
|
||||
<a *ngIf="stateService.isMempoolSpaceBuild" class="btn btn-purple sponsor d-flex justify-content-center ml-auto mr-auto mt-0 mb-2" [ngClass]="isServicesPage ? 'd-lg-none' : 'd-md-none'" [routerLink]="['/login']">
|
||||
<span *ngIf="user" i18n="shared.my-account" class="nowrap">My Account</span>
|
||||
<span *ngIf="!user" i18n="shared.sign-in" class="nowrap">Sign In</span>
|
||||
</a>
|
||||
<p class="explore-tagline-desktop">
|
||||
<ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>
|
||||
@if (officialMempoolSpace) {
|
||||
<ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>
|
||||
} @else {
|
||||
<ng-container i18n="shared.be-your-own-explorer">Be your own explorer</ng-container>
|
||||
<ng-template [ngIf]="locale.substr(0, 2) === 'en'">™</ng-template>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,11 @@ footer .selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
footer .add-margin {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
footer .row.link-tree {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
@@ -154,7 +159,7 @@ footer .nowrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 951px) {
|
||||
@media (min-width: 1020px) {
|
||||
:host-context(.ltr-layout) .language-selector {
|
||||
float: right !important;
|
||||
}
|
||||
@@ -172,7 +177,24 @@ footer .nowrap {
|
||||
}
|
||||
|
||||
.services {
|
||||
@media (min-width: 951px) and (max-width: 1147px) {
|
||||
@media (min-width: 1300px) {
|
||||
:host-context(.ltr-layout) .language-selector {
|
||||
float: right !important;
|
||||
}
|
||||
:host-context(.rtl-layout) .language-selector {
|
||||
float: left !important;
|
||||
}
|
||||
|
||||
.explore-tagline-desktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.explore-tagline-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
:host-context(.ltr-layout) .services .language-selector {
|
||||
float: none !important;
|
||||
}
|
||||
@@ -248,7 +270,7 @@ footer .nowrap {
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
@media (max-width: 1019px) {
|
||||
|
||||
.main-logo {
|
||||
width: 220px;
|
||||
@@ -287,7 +309,7 @@ footer .nowrap {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1147px) {
|
||||
@media (max-width: 1300px) {
|
||||
|
||||
.services.main-logo {
|
||||
width: 220px;
|
||||
|
||||
425
frontend/src/app/shared/ord/inscription.utils.ts
Normal file
425
frontend/src/app/shared/ord/inscription.utils.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 HAUS HOPPE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
*/
|
||||
|
||||
// Adapted from https://github.com/ordpool-space/ordpool-parser/tree/ce04d7a5b6bb1cf37b9fdadd77ba430f5bd6e7d6/src
|
||||
// Utils functions to decode ord inscriptions
|
||||
|
||||
export const OP_FALSE = 0x00;
|
||||
export const OP_IF = 0x63;
|
||||
export const OP_0 = 0x00;
|
||||
|
||||
export const OP_PUSHBYTES_3 = 0x03; // 3 -- not an actual opcode, but used in documentation --> pushes the next 3 bytes onto the stack.
|
||||
export const OP_PUSHDATA1 = 0x4c; // 76 -- The next byte contains the number of bytes to be pushed onto the stack.
|
||||
export const OP_PUSHDATA2 = 0x4d; // 77 -- The next two bytes contain the number of bytes to be pushed onto the stack in little endian order.
|
||||
export const OP_PUSHDATA4 = 0x4e; // 78 -- The next four bytes contain the number of bytes to be pushed onto the stack in little endian order.
|
||||
export const OP_ENDIF = 0x68; // 104 -- Ends an if/else block.
|
||||
|
||||
export const OP_1NEGATE = 0x4f; // 79 -- The number -1 is pushed onto the stack.
|
||||
export const OP_RESERVED = 0x50; // 80 -- Transaction is invalid unless occuring in an unexecuted OP_IF branch
|
||||
export const OP_PUSHNUM_1 = 0x51; // 81 -- also known as OP_1
|
||||
export const OP_PUSHNUM_2 = 0x52; // 82 -- also known as OP_2
|
||||
export const OP_PUSHNUM_3 = 0x53; // 83 -- also known as OP_3
|
||||
export const OP_PUSHNUM_4 = 0x54; // 84 -- also known as OP_4
|
||||
export const OP_PUSHNUM_5 = 0x55; // 85 -- also known as OP_5
|
||||
export const OP_PUSHNUM_6 = 0x56; // 86 -- also known as OP_6
|
||||
export const OP_PUSHNUM_7 = 0x57; // 87 -- also known as OP_7
|
||||
export const OP_PUSHNUM_8 = 0x58; // 88 -- also known as OP_8
|
||||
export const OP_PUSHNUM_9 = 0x59; // 89 -- also known as OP_9
|
||||
export const OP_PUSHNUM_10 = 0x5a; // 90 -- also known as OP_10
|
||||
export const OP_PUSHNUM_11 = 0x5b; // 91 -- also known as OP_11
|
||||
export const OP_PUSHNUM_12 = 0x5c; // 92 -- also known as OP_12
|
||||
export const OP_PUSHNUM_13 = 0x5d; // 93 -- also known as OP_13
|
||||
export const OP_PUSHNUM_14 = 0x5e; // 94 -- also known as OP_14
|
||||
export const OP_PUSHNUM_15 = 0x5f; // 95 -- also known as OP_15
|
||||
export const OP_PUSHNUM_16 = 0x60; // 96 -- also known as OP_16
|
||||
|
||||
export const OP_RETURN = 0x6a; // 106 -- a standard way of attaching extra data to transactions is to add a zero-value output with a scriptPubKey consisting of OP_RETURN followed by data
|
||||
|
||||
//////////////////////////// Helper ///////////////////////////////
|
||||
|
||||
/**
|
||||
* Inscriptions may include fields before an optional body. Each field consists of two data pushes, a tag and a value.
|
||||
* Currently, there are six defined fields:
|
||||
*/
|
||||
export const knownFields = {
|
||||
// content_type, with a tag of 1, whose value is the MIME type of the body.
|
||||
content_type: 0x01,
|
||||
|
||||
// pointer, with a tag of 2, see pointer docs: https://docs.ordinals.com/inscriptions/pointer.html
|
||||
pointer: 0x02,
|
||||
|
||||
// parent, with a tag of 3, see provenance docs: https://docs.ordinals.com/inscriptions/provenance.html
|
||||
parent: 0x03,
|
||||
|
||||
// metadata, with a tag of 5, see metadata docs: https://docs.ordinals.com/inscriptions/metadata.html
|
||||
metadata: 0x05,
|
||||
|
||||
// metaprotocol, with a tag of 7, whose value is the metaprotocol identifier.
|
||||
metaprotocol: 0x07,
|
||||
|
||||
// content_encoding, with a tag of 9, whose value is the encoding of the body.
|
||||
content_encoding: 0x09,
|
||||
|
||||
// delegate, with a tag of 11, see delegate docs: https://docs.ordinals.com/inscriptions/delegate.html
|
||||
delegate: 0xb
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the value for a given field from an array of field objects.
|
||||
* It returns the value of the first object where the tag matches the specified field.
|
||||
*
|
||||
* @param fields - An array of objects containing tag and value properties.
|
||||
* @param field - The field number to search for.
|
||||
* @returns The value associated with the first matching field, or undefined if no match is found.
|
||||
*/
|
||||
export function getKnownFieldValue(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array | undefined {
|
||||
const knownField = fields.find(x =>
|
||||
x.tag === field);
|
||||
|
||||
if (knownField === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return knownField.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the values for a given field from an array of field objects.
|
||||
* It returns the values of all objects where the tag matches the specified field.
|
||||
*
|
||||
* @param fields - An array of objects containing tag and value properties.
|
||||
* @param field - The field number to search for.
|
||||
* @returns An array of Uint8Array values associated with the matching fields. If no matches are found, an empty array is returned.
|
||||
*/
|
||||
export function getKnownFieldValues(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array[] {
|
||||
const knownFields = fields.filter(x =>
|
||||
x.tag === field
|
||||
);
|
||||
|
||||
return knownFields.map(field => field.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the next position of the ordinal inscription mark (0063036f7264)
|
||||
* within the raw transaction data, starting from a given position.
|
||||
*
|
||||
* This function looks for a specific sequence of 6 bytes that represents the start of an ordinal inscription.
|
||||
* If the sequence is found, the function returns the index immediately following the inscription mark.
|
||||
* If the sequence is not found, the function returns -1, indicating no inscription mark was found.
|
||||
*
|
||||
* Note: This function uses a simple hardcoded approach based on the fixed length of the inscription mark.
|
||||
*
|
||||
* @returns The position immediately after the inscription mark, or -1 if not found.
|
||||
*/
|
||||
export function getNextInscriptionMark(raw: Uint8Array, startPosition: number): number {
|
||||
|
||||
// OP_FALSE
|
||||
// OP_IF
|
||||
// OP_PUSHBYTES_3: This pushes the next 3 bytes onto the stack.
|
||||
// 0x6f, 0x72, 0x64: These bytes translate to the ASCII string "ord"
|
||||
const inscriptionMark = new Uint8Array([OP_FALSE, OP_IF, OP_PUSHBYTES_3, 0x6f, 0x72, 0x64]);
|
||||
|
||||
for (let index = startPosition; index <= raw.length - 6; index++) {
|
||||
if (raw[index] === inscriptionMark[0] &&
|
||||
raw[index + 1] === inscriptionMark[1] &&
|
||||
raw[index + 2] === inscriptionMark[2] &&
|
||||
raw[index + 3] === inscriptionMark[3] &&
|
||||
raw[index + 4] === inscriptionMark[4] &&
|
||||
raw[index + 5] === inscriptionMark[5]) {
|
||||
return index + 6;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/////////////////////////////// Reader ///////////////////////////////
|
||||
|
||||
/**
|
||||
* Reads a specified number of bytes from a Uint8Array starting from a given pointer.
|
||||
*
|
||||
* @param raw - The Uint8Array from which bytes are to be read.
|
||||
* @param pointer - The position in the array from where to start reading.
|
||||
* @param n - The number of bytes to read.
|
||||
* @returns A tuple containing the read bytes as Uint8Array and the updated pointer position.
|
||||
*/
|
||||
export function readBytes(raw: Uint8Array, pointer: number, n: number): [Uint8Array, number] {
|
||||
const slice = raw.slice(pointer, pointer + n);
|
||||
return [slice, pointer + n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads data based on the Bitcoin script push opcode starting from a specified pointer in the raw data.
|
||||
* Handles different opcodes and direct push (where the opcode itself signifies the number of bytes to push).
|
||||
*
|
||||
* @param raw - The raw transaction data as a Uint8Array.
|
||||
* @param pointer - The current position in the raw data array.
|
||||
* @returns A tuple containing the read data as Uint8Array and the updated pointer position.
|
||||
*/
|
||||
export function readPushdata(raw: Uint8Array, pointer: number): [Uint8Array, number] {
|
||||
|
||||
let [opcodeSlice, newPointer] = readBytes(raw, pointer, 1);
|
||||
const opcode = opcodeSlice[0];
|
||||
|
||||
// Handle the special case of OP_0 (0x00) which pushes an empty array (interpreted as zero)
|
||||
// fixes #18
|
||||
if (opcode === OP_0) {
|
||||
return [new Uint8Array(), newPointer];
|
||||
}
|
||||
|
||||
// Handle the special case of OP_1NEGATE (-1)
|
||||
if (opcode === OP_1NEGATE) {
|
||||
// OP_1NEGATE pushes the value -1 onto the stack, represented as 0x81 in Bitcoin Script
|
||||
return [new Uint8Array([0x81]), newPointer];
|
||||
}
|
||||
|
||||
// Handle minimal push numbers OP_PUSHNUM_1 (0x51) to OP_PUSHNUM_16 (0x60)
|
||||
// which are used to push the values 0x01 (decimal 1) through 0x10 (decimal 16) onto the stack.
|
||||
// To get the value, we can subtract OP_RESERVED (0x50) from the opcode to get the value to be pushed.
|
||||
if (opcode >= OP_PUSHNUM_1 && opcode <= OP_PUSHNUM_16) {
|
||||
// Convert opcode to corresponding byte value
|
||||
const byteValue = opcode - OP_RESERVED;
|
||||
return [Uint8Array.from([byteValue]), newPointer];
|
||||
}
|
||||
|
||||
// Handle direct push of 1 to 75 bytes (OP_PUSHBYTES_1 to OP_PUSHBYTES_75)
|
||||
if (1 <= opcode && opcode <= 75) {
|
||||
return readBytes(raw, newPointer, opcode);
|
||||
}
|
||||
|
||||
let numBytes: number;
|
||||
switch (opcode) {
|
||||
case OP_PUSHDATA1: numBytes = 1; break;
|
||||
case OP_PUSHDATA2: numBytes = 2; break;
|
||||
case OP_PUSHDATA4: numBytes = 4; break;
|
||||
default:
|
||||
throw new Error(`Invalid push opcode ${opcode} at position ${pointer}`);
|
||||
}
|
||||
|
||||
let [dataSizeArray, nextPointer] = readBytes(raw, newPointer, numBytes);
|
||||
let dataSize = littleEndianBytesToNumber(dataSizeArray);
|
||||
return readBytes(raw, nextPointer, dataSize);
|
||||
}
|
||||
|
||||
//////////////////////////// Conversion ////////////////////////////
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array containing UTF-8 encoded data to a normal a UTF-16 encoded string.
|
||||
*
|
||||
* @param bytes - The Uint8Array containing UTF-8 encoded data.
|
||||
* @returns The corresponding UTF-16 encoded JavaScript string.
|
||||
*/
|
||||
export function bytesToUnicodeString(bytes: Uint8Array): string {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array to a string by treating each byte as a character code.
|
||||
* It avoids interpreting bytes as UTF-8 encoded sequences.
|
||||
* --> Again: it ignores UTF-8 encoding, which is necessary for binary content!
|
||||
*
|
||||
* Note: This method is different from just using `String.fromCharCode(...combinedData)` which can
|
||||
* cause a "Maximum call stack size exceeded" error for large arrays due to the limitation of
|
||||
* the spread operator in JavaScript. (previously the parser broke here, because of large content)
|
||||
*
|
||||
* @param bytes - The byte array to convert.
|
||||
* @returns The resulting string where each byte value is treated as a direct character code.
|
||||
*/
|
||||
export function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
let resultStr = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
resultStr += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return resultStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal string to a Uint8Array.
|
||||
*
|
||||
* @param hex - A string of hexadecimal characters.
|
||||
* @returns A Uint8Array representing the hex string.
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0, j = 0; i < hex.length; i += 2, j++) {
|
||||
bytes[j] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array to a hexadecimal string.
|
||||
*
|
||||
* @param bytes - A Uint8Array to convert.
|
||||
* @returns A string of hexadecimal characters representing the byte array.
|
||||
*/
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
if (!bytes) {
|
||||
return null;
|
||||
}
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a little-endian byte array to a JavaScript number.
|
||||
*
|
||||
* This function interprets the provided bytes in little-endian format, where the least significant byte comes first.
|
||||
* It constructs an integer value representing the number encoded by the bytes.
|
||||
*
|
||||
* @param byteArray - An array containing the bytes in little-endian format.
|
||||
* @returns The number represented by the byte array.
|
||||
*/
|
||||
export function littleEndianBytesToNumber(byteArray: Uint8Array): number {
|
||||
let number = 0;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
// Extract each byte from byteArray, shift it to the left by 8 * i bits, and combine it with number.
|
||||
// The shifting accounts for the little-endian format where the least significant byte comes first.
|
||||
number |= byteArray[i] << (8 * i);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates multiple Uint8Array objects into a single Uint8Array.
|
||||
*
|
||||
* @param arrays - An array of Uint8Array objects to concatenate.
|
||||
* @returns A new Uint8Array containing the concatenated results of the input arrays.
|
||||
*/
|
||||
export function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||
if (arrays.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
////////////////////////////// Inscription ///////////////////////////
|
||||
|
||||
export interface Inscription {
|
||||
body?: Uint8Array;
|
||||
is_cropped?: boolean;
|
||||
body_length?: number;
|
||||
content_type?: Uint8Array;
|
||||
content_type_str?: string;
|
||||
delegate_txid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts fields from the raw data until OP_0 is encountered.
|
||||
*
|
||||
* @param raw - The raw data to read.
|
||||
* @param pointer - The current pointer where the reading starts.
|
||||
* @returns An array of fields and the updated pointer position.
|
||||
*/
|
||||
export function extractFields(raw: Uint8Array, pointer: number): [{ tag: number; value: Uint8Array }[], number] {
|
||||
|
||||
const fields: { tag: number; value: Uint8Array }[] = [];
|
||||
let newPointer = pointer;
|
||||
let slice: Uint8Array;
|
||||
|
||||
while (newPointer < raw.length &&
|
||||
// normal inscription - content follows now
|
||||
(raw[newPointer] !== OP_0) &&
|
||||
// delegate - inscription has no further content and ends directly here
|
||||
(raw[newPointer] !== OP_ENDIF)
|
||||
) {
|
||||
|
||||
// tags are encoded by ord as single-byte data pushes, but are accepted by ord as either single-byte pushes, or as OP_NUM data pushes.
|
||||
// tags greater than or equal to 256 should be encoded as little endian integers with trailing zeros omitted.
|
||||
// see: https://github.com/ordinals/ord/issues/2505
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
const tag = slice.length === 1 ? slice[0] : littleEndianBytesToNumber(slice);
|
||||
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
const value = slice;
|
||||
|
||||
fields.push({ tag, value });
|
||||
}
|
||||
|
||||
return [fields, newPointer];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts inscription data starting from the current pointer.
|
||||
* @param raw - The raw data to read.
|
||||
* @param pointer - The current pointer where the reading starts.
|
||||
* @returns The parsed inscription or nullx
|
||||
*/
|
||||
export function extractInscriptionData(raw: Uint8Array, pointer: number): Inscription | null {
|
||||
|
||||
try {
|
||||
|
||||
let fields: { tag: number; value: Uint8Array }[];
|
||||
let newPointer: number;
|
||||
let slice: Uint8Array;
|
||||
|
||||
[fields, newPointer] = extractFields(raw, pointer);
|
||||
|
||||
// Now we are at the beginning of the body
|
||||
// (or at the end of the raw data if there's no body)
|
||||
if (newPointer < raw.length && raw[newPointer] === OP_0) {
|
||||
newPointer++; // Skip OP_0
|
||||
}
|
||||
|
||||
// Collect body data until OP_ENDIF
|
||||
const data: Uint8Array[] = [];
|
||||
while (newPointer < raw.length && raw[newPointer] !== OP_ENDIF) {
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
data.push(slice);
|
||||
}
|
||||
|
||||
const combinedLengthOfAllArrays = data.reduce((acc, curr) => acc + curr.length, 0);
|
||||
let combinedData = new Uint8Array(combinedLengthOfAllArrays);
|
||||
|
||||
// Copy all segments from data into combinedData, forming a single contiguous Uint8Array
|
||||
let idx = 0;
|
||||
for (const segment of data) {
|
||||
combinedData.set(segment, idx);
|
||||
idx += segment.length;
|
||||
}
|
||||
|
||||
const contentTypeRaw = getKnownFieldValue(fields, knownFields.content_type);
|
||||
let contentType: string;
|
||||
|
||||
if (!contentTypeRaw) {
|
||||
contentType = 'undefined';
|
||||
} else {
|
||||
contentType = bytesToUnicodeString(contentTypeRaw);
|
||||
}
|
||||
|
||||
return {
|
||||
content_type_str: contentType,
|
||||
body: combinedData.slice(0, 100_000), // Limit body to 100 kB for now
|
||||
is_cropped: combinedData.length > 100_000,
|
||||
body_length: combinedData.length,
|
||||
delegate_txid: getKnownFieldValue(fields, knownFields.delegate) ? bytesToHex(getKnownFieldValue(fields, knownFields.delegate).reverse()) : null
|
||||
};
|
||||
|
||||
} catch (ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
255
frontend/src/app/shared/ord/rune.utils.ts
Normal file
255
frontend/src/app/shared/ord/rune.utils.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Transaction } from '../../interfaces/electrs.interface';
|
||||
|
||||
export const U128_MAX_BIGINT = 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_ffffn;
|
||||
|
||||
export class RuneId {
|
||||
block: number;
|
||||
index: number;
|
||||
|
||||
constructor(block: number, index: number) {
|
||||
this.block = block;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.block}:${this.index}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Etching = {
|
||||
divisibility?: number;
|
||||
premine?: bigint;
|
||||
symbol?: string;
|
||||
terms?: {
|
||||
cap?: bigint;
|
||||
amount?: bigint;
|
||||
offset?: {
|
||||
start?: bigint;
|
||||
end?: bigint;
|
||||
};
|
||||
height?: {
|
||||
start?: bigint;
|
||||
end?: bigint;
|
||||
};
|
||||
};
|
||||
turbo?: boolean;
|
||||
name?: string;
|
||||
spacedName?: string;
|
||||
supply?: bigint;
|
||||
};
|
||||
|
||||
export type Edict = {
|
||||
id: RuneId;
|
||||
amount: bigint;
|
||||
output: number;
|
||||
};
|
||||
|
||||
export type Runestone = {
|
||||
mint?: RuneId;
|
||||
pointer?: number;
|
||||
edicts?: Edict[];
|
||||
etching?: Etching;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
fields: Record<number, bigint[]>;
|
||||
edicts: Edict[];
|
||||
}
|
||||
|
||||
export const UNCOMMON_GOODS: Etching = {
|
||||
divisibility: 0,
|
||||
premine: 0n,
|
||||
symbol: '⧉',
|
||||
terms: {
|
||||
cap: U128_MAX_BIGINT,
|
||||
amount: 1n,
|
||||
offset: {
|
||||
start: 0n,
|
||||
end: 0n,
|
||||
},
|
||||
height: {
|
||||
start: 840000n,
|
||||
end: 1050000n,
|
||||
},
|
||||
},
|
||||
turbo: false,
|
||||
name: 'UNCOMMONGOODS',
|
||||
spacedName: 'UNCOMMON•GOODS',
|
||||
supply: U128_MAX_BIGINT,
|
||||
};
|
||||
|
||||
enum Tag {
|
||||
Body = 0,
|
||||
Flags = 2,
|
||||
Rune = 4,
|
||||
Premine = 6,
|
||||
Cap = 8,
|
||||
Amount = 10,
|
||||
HeightStart = 12,
|
||||
HeightEnd = 14,
|
||||
OffsetStart = 16,
|
||||
OffsetEnd = 18,
|
||||
Mint = 20,
|
||||
Pointer = 22,
|
||||
Cenotaph = 126,
|
||||
|
||||
Divisibility = 1,
|
||||
Spacers = 3,
|
||||
Symbol = 5,
|
||||
Nop = 127,
|
||||
}
|
||||
|
||||
const Flag = {
|
||||
ETCHING: 1n,
|
||||
TERMS: 1n << 1n,
|
||||
TURBO: 1n << 2n,
|
||||
CENOTAPH: 1n << 127n,
|
||||
};
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
return new Uint8Array(hex.match(/.{2}/g).map((byte) => parseInt(byte, 16)));
|
||||
}
|
||||
|
||||
function decodeLEB128(bytes: Uint8Array): bigint[] {
|
||||
const integers: bigint[] = [];
|
||||
let index = 0;
|
||||
while (index < bytes.length) {
|
||||
let value = BigInt(0);
|
||||
let shift = 0;
|
||||
let byte: number;
|
||||
do {
|
||||
byte = bytes[index++];
|
||||
value |= BigInt(byte & 0x7f) << BigInt(shift);
|
||||
shift += 7;
|
||||
} while (byte & 0x80);
|
||||
integers.push(value);
|
||||
}
|
||||
return integers;
|
||||
}
|
||||
|
||||
function integersToMessage(integers: bigint[]): Message {
|
||||
const message = {
|
||||
fields: {},
|
||||
edicts: [],
|
||||
};
|
||||
let inBody = false;
|
||||
while (integers.length) {
|
||||
if (!inBody) {
|
||||
// The integers are interpreted as a sequence of tag/value pairs, with duplicate tags appending their value to the field value.
|
||||
const tag: Tag = Number(integers.shift());
|
||||
if (tag === Tag.Body) {
|
||||
inBody = true;
|
||||
} else {
|
||||
const value = integers.shift();
|
||||
if (message.fields[tag]) {
|
||||
message.fields[tag].push(value);
|
||||
} else {
|
||||
message.fields[tag] = [value];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If a tag with value zero is encountered, all following integers are interpreted as a series of four-integer edicts, each consisting of a rune ID block height, rune ID transaction index, amount, and output.
|
||||
const height = integers.shift();
|
||||
const txIndex = integers.shift();
|
||||
const amount = integers.shift();
|
||||
const output = integers.shift();
|
||||
message.edicts.push({
|
||||
id: new RuneId(Number(height), Number(txIndex)),
|
||||
amount,
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function parseRuneName(rune: bigint): string {
|
||||
let name = '';
|
||||
rune += 1n;
|
||||
while (rune > 0n) {
|
||||
name = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Number((rune - 1n) % 26n)] + name;
|
||||
rune = (rune - 1n) / 26n;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function spaceRuneName(name: string, spacers: bigint): string {
|
||||
let i = 0;
|
||||
let spacedName = '';
|
||||
while (spacers > 0n || i < name.length) {
|
||||
spacedName += name[i];
|
||||
if (spacers & 1n) {
|
||||
spacedName += '•';
|
||||
}
|
||||
if (spacers > 0n) {
|
||||
spacers >>= 1n;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return spacedName;
|
||||
}
|
||||
|
||||
function messageToRunestone(message: Message): Runestone {
|
||||
let etching: Etching | undefined;
|
||||
let mint: RuneId | undefined;
|
||||
let pointer: number | undefined;
|
||||
|
||||
const flags = message.fields[Tag.Flags]?.[0] || 0n;
|
||||
if (flags & Flag.ETCHING) {
|
||||
const hasTerms = (flags & Flag.TERMS) > 0n;
|
||||
const isTurbo = (flags & Flag.TURBO) > 0n;
|
||||
const name = parseRuneName(message.fields[Tag.Rune]?.[0] ?? 0n);
|
||||
etching = {
|
||||
divisibility: Number(message.fields[Tag.Divisibility]?.[0] ?? 0n),
|
||||
premine: message.fields[Tag.Premine]?.[0],
|
||||
symbol: message.fields[Tag.Symbol]?.[0] ? String.fromCodePoint(Number(message.fields[Tag.Symbol][0])) : '¤',
|
||||
terms: hasTerms ? {
|
||||
cap: message.fields[Tag.Cap]?.[0],
|
||||
amount: message.fields[Tag.Amount]?.[0],
|
||||
offset: {
|
||||
start: message.fields[Tag.OffsetStart]?.[0],
|
||||
end: message.fields[Tag.OffsetEnd]?.[0],
|
||||
},
|
||||
height: {
|
||||
start: message.fields[Tag.HeightStart]?.[0],
|
||||
end: message.fields[Tag.HeightEnd]?.[0],
|
||||
},
|
||||
} : undefined,
|
||||
turbo: isTurbo,
|
||||
name,
|
||||
spacedName: spaceRuneName(name, message.fields[Tag.Spacers]?.[0] ?? 0n),
|
||||
};
|
||||
etching.supply = (
|
||||
(etching.terms?.cap ?? 0n) * (etching.terms?.amount ?? 0n)
|
||||
) + (etching.premine ?? 0n);
|
||||
}
|
||||
const mintField = message.fields[Tag.Mint];
|
||||
if (mintField) {
|
||||
mint = new RuneId(Number(mintField[0]), Number(mintField[1]));
|
||||
}
|
||||
const pointerField = message.fields[Tag.Pointer];
|
||||
if (pointerField) {
|
||||
pointer = Number(pointerField[0]);
|
||||
}
|
||||
return {
|
||||
mint,
|
||||
pointer,
|
||||
edicts: message.edicts,
|
||||
etching,
|
||||
};
|
||||
}
|
||||
|
||||
export function decipherRunestone(tx: Transaction): Runestone | void {
|
||||
const payload = tx.vout.find((vout) => vout.scriptpubkey.startsWith('6a5d'))?.scriptpubkey_asm.replace(/OP_\w+|\s/g, '');
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const integers = decodeLEB128(hexToBytes(payload));
|
||||
const message = integersToMessage(integers);
|
||||
return messageToRunestone(message);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class BitcoinsatoshisPipe implements PipeTransform {
|
||||
|
||||
constructor(private sanitizer: DomSanitizer) { }
|
||||
|
||||
transform(value: string): SafeHtml {
|
||||
transform(value: string, firstPartClass?: string): SafeHtml {
|
||||
const newValue = this.insertSpaces(parseFloat(value || '0').toFixed(8));
|
||||
const position = (newValue || '0').search(/[1-9]/);
|
||||
|
||||
@@ -16,7 +16,7 @@ export class BitcoinsatoshisPipe implements PipeTransform {
|
||||
const secondPart = newValue.slice(position);
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustHtml(
|
||||
`<span class="text-secondary">${firstPart}</span>${secondPart}`
|
||||
`<span class="${firstPartClass ? firstPartClass : 'text-secondary'}">${firstPart}</span>${secondPart}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { LanguageSelectorComponent } from '../components/language-selector/langu
|
||||
import { FiatSelectorComponent } from '../components/fiat-selector/fiat-selector.component';
|
||||
import { RateUnitSelectorComponent } from '../components/rate-unit-selector/rate-unit-selector.component';
|
||||
import { ThemeSelectorComponent } from '../components/theme-selector/theme-selector.component';
|
||||
import { AmountSelectorComponent } from '../components/amount-selector/amount-selector.component';
|
||||
import { BrowserOnlyDirective } from './directives/browser-only.directive';
|
||||
import { ServerOnlyDirective } from './directives/server-only.directive';
|
||||
import { ColoredPriceDirective } from './directives/colored-price.directive';
|
||||
@@ -101,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';
|
||||
@@ -131,6 +133,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
||||
FiatSelectorComponent,
|
||||
ThemeSelectorComponent,
|
||||
RateUnitSelectorComponent,
|
||||
AmountSelectorComponent,
|
||||
ScriptpubkeyTypePipe,
|
||||
RelativeUrlPipe,
|
||||
NoSanitizePipe,
|
||||
@@ -227,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
||||
AccelerationStatsComponent,
|
||||
PendingStatsComponent,
|
||||
AccelerationSparklesComponent,
|
||||
OrdDataComponent,
|
||||
HttpErrorComponent,
|
||||
TwitterWidgetComponent,
|
||||
FaucetComponent,
|
||||
@@ -278,6 +282,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
||||
FiatSelectorComponent,
|
||||
RateUnitSelectorComponent,
|
||||
ThemeSelectorComponent,
|
||||
AmountSelectorComponent,
|
||||
ScriptpubkeyTypePipe,
|
||||
RelativeUrlPipe,
|
||||
Hex2asciiPipe,
|
||||
@@ -358,10 +363,12 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
||||
AccelerationStatsComponent,
|
||||
PendingStatsComponent,
|
||||
AccelerationSparklesComponent,
|
||||
OrdDataComponent,
|
||||
HttpErrorComponent,
|
||||
TwitterWidgetComponent,
|
||||
TwitterLogin,
|
||||
BitcoinInvoiceComponent,
|
||||
BitcoinsatoshisPipe,
|
||||
|
||||
MempoolBlockOverviewComponent,
|
||||
ClockchainComponent,
|
||||
|
||||
Reference in New Issue
Block a user