Merge branch 'master' into nymkappa/square-errors

This commit is contained in:
nymkappa
2025-01-20 17:23:15 +09:00
129 changed files with 5887 additions and 929 deletions

View File

@@ -1,4 +1,4 @@
<div class="box card w-100" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
<div class="box card w-100 accelerate-checkout-inner" [class.input-disabled]="isCheckoutLocked > 0" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
@if (accelerateError) {
@if (accelerateError.includes('Payment declined')) {
<div class="row mb-1 text-center">
@@ -369,7 +369,7 @@
<div class="row text-center justify-content-center mx-2">
<p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
</div>
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) {
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
<div class="row">
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p>
@@ -492,6 +492,11 @@
</div>
}
</div>
@if (isTokenizing > 0) {
<div class="d-flex flex-row justify-content-center">
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div>
</div>

View File

@@ -8,6 +8,13 @@
color: var(--green)
}
.accelerate-checkout-inner {
&.input-disabled {
pointer-events: none;
opacity: 0.75;
}
}
.paymentMethod {
padding: 10px;
background-color: var(--secondary);

View File

@@ -2,7 +2,7 @@
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '@app/services/services-api.service';
import { md5, insecureRandomUUID } from '@app/shared/common.utils';
import { md5 } from '@app/shared/common.utils';
import { StateService } from '@app/services/state.service';
import { AudioService } from '@app/services/audio.service';
import { ETA, EtaService } from '@app/services/eta.service';
@@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
calculating = true;
processing = false;
isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
isTokenizing = 0; // reference counter, 0 = false, >0 = true
selectedOption: 'wait' | 'accel';
cantPayReason = '';
quoteError = ''; // error fetching estimate or initial data
@@ -94,7 +96,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
auth: IAuth | null = null;
// accelerator stuff
accelerationUUID: string;
accelerationSubscription: Subscription;
difficultySubscription: Subscription;
estimateSubscription: Subscription;
@@ -138,7 +139,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private enterpriseService: EnterpriseService,
) {
this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1;
this.accelerationUUID = insecureRandomUUID();
// Check if Apple Pay available
// https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
@@ -156,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerateError = null;
this.timePaid = 0;
this.btcpayInvoiceFailed = false;
this.moveToStep('summary');
this.moveToStep('summary', true);
} else {
this.auth = auth;
}
@@ -165,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('cash_request_id')) { // Redirected from cashapp
this.moveToStep('processing');
this.moveToStep('processing', true);
this.insertSquare();
this.setupSquare();
} else {
this.moveToStep('summary');
this.moveToStep('summary', true);
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
@@ -194,14 +194,18 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
if (changes.accelerating && this.accelerating) {
if (this.step === 'processing' || this.step === 'paid') {
this.moveToStep('success');
this.moveToStep('success', true);
} else { // Edge case where the transaction gets accelerated by someone else or on another session
this.closeModal();
}
}
}
moveToStep(step: CheckoutStep): void {
moveToStep(step: CheckoutStep, force: boolean = false): void {
if (this.isCheckoutLocked > 0 && !force) {
return;
}
this.processing = false;
this._step = step;
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
@@ -243,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
closeModal(): void {
this.completed.emit(true);
this.moveToStep('summary');
this.moveToStep('summary', true);
}
/**
@@ -387,7 +391,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerationSubscription = this.servicesApiService.accelerate$(
this.tx.txid,
this.userBid,
this.accelerationUUID
).subscribe({
next: () => {
this.processing = false;
@@ -395,7 +398,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.audioService.playSound('ascend-chime-cartoon');
this.showSuccess = true;
this.estimateSubscription.unsubscribe();
this.moveToStep('paid');
this.moveToStep('paid', true);
},
error: (response) => {
this.processing = false;
@@ -505,57 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
this.loadingApplePay = false;
applePayButton.addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault();
const tokenResult = await this.applePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
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());
this.servicesApiService.accelerateWithApplePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID,
costUSD
).subscribe({
next: () => {
try {
// lock the checkout UI and show a loading spinner until the square modals are finished
this.isCheckoutLocked++;
this.isTokenizing++;
const tokenResult = await this.applePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
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;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
return;
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
// keep checkout in loading state until the acceleration request completes
this.isTokenizing++;
this.isCheckoutLocked++;
this.servicesApiService.accelerateWithApplePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD
).subscribe({
next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
}
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
this.moveToStep('paid', true);
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
throw new Error(errorMessage);
} finally {
// always unlock the checkout once we're finished
this.isTokenizing--;
this.isCheckoutLocked--;
}
});
} catch (e) {
@@ -605,57 +626,84 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault();
const tokenResult = await this.googlePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
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());
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID,
costUSD
).subscribe({
next: () => {
try {
// lock the checkout UI and show a loading spinner until the square modals are finished
this.isCheckoutLocked++;
this.isTokenizing++;
const tokenResult = await this.googlePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
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;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
return;
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
if (!verificationToken || !verificationToken.token) {
console.error(`SCA verification failed`);
this.accelerateError = 'SCA Verification Failed. Payment Declined.';
this.processing = false;
return;
}
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
// keep checkout in loading state until the acceleration request completes
this.isCheckoutLocked++;
this.isTokenizing++;
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
verificationToken.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD,
verificationToken.userChallenged
).subscribe({
next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
this.moveToStep('paid', true);
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
this.isTokenizing--;
this.isCheckoutLocked--;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
throw new Error(errorMessage);
} finally {
// always unlock the checkout once we're finished
this.isTokenizing--;
this.isCheckoutLocked--;
}
});
}
@@ -712,7 +760,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
tokenResult.token,
tokenResult.details.cashAppPay.cashtag,
tokenResult.details.cashAppPay.referenceId,
this.accelerationUUID,
costUSD
).subscribe({
next: () => {
@@ -723,7 +770,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.cashAppPay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
this.moveToStep('paid', true);
if (window.history.replaceState) {
const urlParams = new URLSearchParams(window.location.search);
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
@@ -748,6 +795,32 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
);
}
/**
* https://developer.squareup.com/docs/sca-overview
*/
async $verifyBuyer(payments, token, details, amount): Promise<{token: string, userChallenged: boolean}> {
const verificationDetails = {
amount: amount,
currencyCode: 'USD',
intent: 'CHARGE',
billingContact: {
givenName: details.card?.billing?.givenName,
familyName: details.card?.billing?.familyName,
phone: details.card?.billing?.phone,
addressLines: details.card?.billing?.addressLines,
city: details.card?.billing?.city,
state: details.card?.billing?.state,
countryCode: details.card?.billing?.countryCode,
},
};
const verificationResults = await payments.verifyBuyer(
token,
verificationDetails,
);
return verificationResults;
}
/**
* BTCPay
*/
@@ -771,7 +844,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
this.estimateSubscription.unsubscribe();
this.moveToStep('paid');
this.moveToStep('paid', true);
}
isLoggedIn(): boolean {

View File

@@ -1,6 +1,6 @@
<div class="acceleration-timeline box" [class.lower-padding]="!tx.status.confirmed">
<div class="timeline-wrapper">
@if (!tx.status.confirmed) {
@if (!tx.status.confirmed || canceled) {
<div class="timeline">
<div class="intervals">
<div class="node-spacer"></div>
@@ -8,7 +8,7 @@
<div class="node-spacer"></div>
<div class="interval">
<div class="interval-time">
@if (eta) {
@if (eta && !canceled) {
~<app-time [time]="eta?.wait / 1000"></app-time>
}
</div>
@@ -19,16 +19,20 @@
<div class="node-spacer"></div>
<div class="interval-spacer"></div>
<div class="node">
<div class="acc-to-confirmed right go-faster"></div>
<div class="acc-to-confirmed right go-faster" [class.no-animation]="canceled"></div>
</div>
<div class="interval-spacer">
</div>
<div class="node" [id]="'confirmed'">
<div class="acc-to-confirmed left go-faster"></div>
<div class="acc-to-confirmed left go-faster" [class.no-animation]="canceled"></div>
<div class="shape-border waiting">
<div class="shape"></div>
</div>
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
@if (canceled) {
<div class="status"><span class="badge badge-danger" i18n="accelerator.canceled">Canceled</span></div>
} @else {
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
}
</div>
</div>
</div>
@@ -45,9 +49,9 @@
<div class="interval">
<div class="interval-time">
@if (tx.status.confirmed) {
<div class="interval-time">
<app-time [time]="acceleratedToMined"></app-time>
</div>
<app-time [time]="acceleratedToMined"></app-time>
} @else if (eta && canceled) {
~<app-time [time]="eta?.wait / 1000"></app-time>
}
</div>
</div>
@@ -71,42 +75,42 @@
<div class="interval-spacer">
<div class="seen-to-acc"></div>
</div>
<div class="node" [class.accelerated]="!tx.status.confirmed" [id]="'accelerated'">
<div class="node" [class.accelerated]="!tx.status.confirmed && !canceled" [id]="'accelerated'">
<div class="seen-to-acc left"></div>
@if (tx.status.confirmed) {
@if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed right"></div>
} @else {
<div class="seen-to-acc right"></div>
}
<div class="shape-border hovering" (pointerover)="onHover($event, 'accelerated');" (pointerout)="onBlur($event);">
<div class="shape"></div>
@if (!tx.status.confirmed) {
<div class="connector down loading"></div>
@if (!tx.status.confirmed || canceled) {
<div class="connector down" [class.loading]="!canceled"></div>
}
</div>
@if (tx.status.confirmed) {
@if (tx.status.confirmed && !canceled) {
<div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div>
}
<div class="time" [class.no-margin]="!tx.status.confirmed" [class.offset-left]="!tx.status.confirmed">
<div class="time" [class.no-margin]="!tx.status.confirmed || canceled" [class.offset-left]="!tx.status.confirmed || canceled">
@if (!tx.status.confirmed) {
<span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }}
}
@if (useAbsoluteTime) {
<span>{{ acceleratedAt * 1000 | date }}</span>
} @else {
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed"></app-time>
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed || canceled"></app-time>
}
</div>
</div>
<div class="interval-spacer">
@if (tx.status.confirmed) {
@if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed"></div>
} @else {
<div class="seen-to-acc"></div>
}
</div>
<div class="node" [class.selected]="tx.status.confirmed" [id]="'confirmed'">
@if (tx.status.confirmed) {
@if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed left"></div>
} @else {
<div class="seen-to-acc left"></div>

View File

@@ -129,6 +129,9 @@
margin-left: calc(-4em + 5px);
animation: goFasterLeft 0.8s infinite linear;
}
&.no-animation {
animation: none;
}
}
&.left {

View File

@@ -15,6 +15,7 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() tx: Transaction;
@Input() accelerationInfo: Acceleration;
@Input() eta: ETA;
@Input() canceled: boolean;
now: number;
accelerateRatio: number;

View File

@@ -46,6 +46,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
aggregatedHistory$: Observable<any>;
statsSubscription: Subscription;
aggregatedHistorySubscription: Subscription;
fragmentSubscription: Subscription;
isLoading = true;
formatNumber = formatNumber;
timespan = '';
@@ -79,8 +81,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
}
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.route.fragment.subscribe((fragment) => {
this.fragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
}
@@ -113,7 +115,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
share(),
);
this.aggregatedHistory$.subscribe();
this.aggregatedHistorySubscription = this.aggregatedHistory$.subscribe();
}
ngOnChanges(changes: SimpleChanges): void {
@@ -335,8 +337,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
}
ngOnDestroy(): void {
if (this.statsSubscription) {
this.statsSubscription.unsubscribe();
}
this.aggregatedHistorySubscription?.unsubscribe();
this.fragmentSubscription?.unsubscribe();
this.statsSubscription?.unsubscribe();
}
}

View File

@@ -4,7 +4,7 @@
<div class="clearfix"></div>
<div class="acceleration-list">
<div class="acceleration-list" *ngIf="{ accelerations: accelerationList$ | async } as state">
<table *ngIf="nonEmptyAccelerations; else noData" class="table table-borderless table-fixed">
<thead>
<th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th>
@@ -21,8 +21,8 @@
<th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th>
</ng-container>
</thead>
<tbody *ngIf="accelerationList$ | async as accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tr *ngFor="let acceleration of accelerations; let i= index;">
<tbody *ngIf="state.accelerations && nonEmptyAccelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tr *ngFor="let acceleration of state.accelerations; let i= index;">
<td class="txid text-left">
<a [routerLink]="['/tx' | relativeUrl, acceleration.txid]">
<app-truncate [text]="acceleration.txid" [lastChars]="5"></app-truncate>

View File

@@ -10,7 +10,6 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip
import { StateService } from '@app/services/state.service';
import { PriceService } from '@app/services/price.service';
import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe';
import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe';
const periodSeconds = {
'1d': (60 * 60 * 24),
@@ -45,14 +44,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() right: number | string = 10;
@Input() left: number | string = 70;
@Input() widget: boolean = false;
@Input() defaultFiat: boolean = false;
@Input() showLegend: boolean = true;
@Input() showYAxis: boolean = true;
adjustedLeft: number;
adjustedRight: number;
data: any[] = [];
fiatData: any[] = [];
hoverData: any[] = [];
conversions: any;
allowZoom: boolean = false;
initialRight = this.right;
initialLeft = this.left;
selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false };
subscription: Subscription;
@@ -77,7 +80,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
private relativeUrlPipe: RelativeUrlPipe,
private priceService: PriceService,
private fiatCurrencyPipe: FiatCurrencyPipe,
private fiatShortenerPipe: FiatShortenerPipe,
private zone: NgZone,
) {}
@@ -86,6 +88,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!this.addressSummary$ && (!this.address || !this.stats)) {
return;
}
if (changes.defaultFiat) {
this.selected['Fiat'] = !!this.defaultFiat;
}
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
if (this.subscription) {
this.subscription.unsubscribe();
@@ -118,7 +123,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} else if (this.conversions && this.conversions['USD']) {
price = this.conversions['USD'];
}
return { ...item, price: price }
return { ...item, price: price };
});
}
}),
@@ -147,7 +152,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!summary) {
return;
}
const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0);
let runningTotal = total;
const processData = summary.map(d => {
@@ -161,7 +166,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
d
};
}).reverse();
this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]);
this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]);
@@ -179,6 +184,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue);
this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = {
color: [
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@@ -194,10 +202,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
grid: {
top: 20,
bottom: this.allowZoom ? 65 : 20,
right: this.right,
left: this.left,
right: this.adjustedRight,
left: this.adjustedLeft,
},
legend: !this.stateService.isAnyTestnet() ? {
legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? {
data: [
{
name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`,
@@ -245,21 +253,22 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
let tooltip = '<div>';
const hasTx = data[0].data[2].txid;
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">
<div><b>${date}</b></div>`;
if (hasTx) {
const header = data.length === 1
? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}`
: `${data.length} transactions`;
tooltip += `<span><b>${header}</b></span>`;
tooltip += `<div><b>${header}</b></div>`;
}
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">`;
const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal);
const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD');
const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0);
const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0);
const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)');
@@ -291,7 +300,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
}
}
tooltip += `</div><span>${date}</span></div>`;
tooltip += `</div></div>`;
return tooltip;
}.bind(this)
},
@@ -307,22 +316,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'value',
position: 'left',
axisLabel: {
show: this.showYAxis,
color: 'rgb(110, 112, 121)',
formatter: (val): string => {
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
if (valSpan > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`;
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`;
}
else if (valSpan > 1_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`;
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`;
} else if (valSpan > 100_000_000) {
return `${(val / 100_000_000).toFixed(1)} BTC`;
} else if (valSpan > 10_000_000) {
return `${(val / 100_000_000).toFixed(2)} BTC`;
} else if (valSpan > 1_000_000) {
if (maxValue > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 3, undefined, true)} BTC`;
}
return `${(val / 100_000_000).toFixed(3)} BTC`;
} else {
return `${this.amountShortenerPipe.transform(val, 0)} sats`;
return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`;
}
}
},
@@ -334,9 +347,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
{
type: 'value',
axisLabel: {
show: this.showYAxis,
color: 'rgb(110, 112, 121)',
formatter: function(val) {
return this.fiatShortenerPipe.transform(val, null, 'USD');
return `$${this.amountShortenerPipe.transform(val, 3, undefined, true, true)}`;
}.bind(this)
},
splitLine: {
@@ -390,8 +404,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'slider',
brushSelect: false,
realtime: true,
left: this.left,
right: this.right,
left: this.adjustedLeft,
right: this.adjustedRight,
selectedDataBackground: {
lineStyle: {
color: '#fff',
@@ -404,7 +418,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
onChartClick(e) {
if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) {
this.zone.run(() => {
this.zone.run(() => {
const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`);
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
window.open(url);
@@ -421,26 +435,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
onLegendSelectChanged(e) {
this.selected = e.selected;
this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight;
this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40;
this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = {
grid: {
right: this.right,
left: this.left,
right: this.adjustedRight,
left: this.adjustedLeft,
},
legend: {
selected: this.selected,
},
dataZoom: this.allowZoom ? [{
left: this.left,
right: this.right,
left: this.adjustedLeft,
right: this.adjustedRight,
}, {
left: this.left,
right: this.right,
left: this.adjustedLeft,
right: this.adjustedRight,
}] : undefined
};
if (this.chartInstance) {
this.chartInstance.setOption(this.chartOptions);
}
@@ -464,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
}
extendSummary(summary) {
let extendedSummary = summary.slice();
const extendedSummary = summary.slice();
// Add a point at today's date to make the graph end at the current time
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
extendedSummary.reverse();
let oneHour = 60 * 60;
let maxTime = Date.now() / 1000;
const oneHour = 60 * 60;
// Fill gaps longer than interval
for (let i = 0; i < extendedSummary.length - 1; i++) {
let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour);
if (extendedSummary[i].time > maxTime) {
extendedSummary[i].time = maxTime - 30;
}
maxTime = extendedSummary[i].time;
const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour);
if (hours > 1) {
for (let j = 1; j < hours; j++) {
let newTime = extendedSummary[i].time + oneHour * j;
const newTime = extendedSummary[i].time - oneHour * j;
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
}
i += hours - 1;
}
}
return extendedSummary.reverse();
return extendedSummary;
}
}

View File

@@ -41,7 +41,7 @@ export class AppComponent implements OnInit {
@HostListener('document:keydown', ['$event'])
handleKeyboardEvents(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement) {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
// prevent arrow key horizontal scrolling

View File

@@ -172,13 +172,19 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
ngOnDestroy(): void {
if (this.animationFrameRequest) {
cancelAnimationFrame(this.animationFrameRequest);
clearTimeout(this.animationHeartBeat);
}
clearTimeout(this.animationHeartBeat);
if (this.canvas) {
this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
this.themeChangedSubscription?.unsubscribe();
}
if (this.scene) {
this.scene.destroy();
}
this.vertexArray.destroy();
this.vertexArray = null;
this.themeChangedSubscription?.unsubscribe();
this.searchSubscription?.unsubscribe();
}
clear(direction): void {
@@ -447,7 +453,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
}
this.applyQueuedUpdates();
// skip re-render if there's no change to the scene
if (this.scene && this.gl) {
if (this.scene && this.gl && this.vertexArray) {
/* SET UP SHADER UNIFORMS */
// screen dimensions
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
@@ -489,9 +495,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
this.doRun();
} else {
if (this.animationHeartBeat) {
clearTimeout(this.animationHeartBeat);
}
clearTimeout(this.animationHeartBeat);
this.animationHeartBeat = window.setTimeout(() => {
this.start();
}, 1000);

View File

@@ -19,6 +19,7 @@ export class FastVertexArray {
freeSlots: number[];
lastSlot: number;
dirty = false;
destroyed = false;
constructor(length, stride) {
this.length = length;
@@ -32,6 +33,9 @@ export class FastVertexArray {
}
insert(sprite: TxSprite): number {
if (this.destroyed) {
return;
}
this.count++;
let position;
@@ -45,11 +49,14 @@ export class FastVertexArray {
}
}
this.sprites[position] = sprite;
return position;
this.dirty = true;
return position;
}
remove(index: number): void {
if (this.destroyed) {
return;
}
this.count--;
this.clearData(index);
this.freeSlots.push(index);
@@ -61,20 +68,26 @@ export class FastVertexArray {
}
setData(index: number, dataChunk: number[]): void {
if (this.destroyed) {
return;
}
this.data.set(dataChunk, (index * this.stride));
this.dirty = true;
}
clearData(index: number): void {
private clearData(index: number): void {
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
this.dirty = true;
}
getData(index: number): Float32Array {
if (this.destroyed) {
return;
}
return this.data.subarray(index, this.stride);
}
expand(): void {
private expand(): void {
this.length *= 2;
const newData = new Float32Array(this.length * this.stride);
newData.set(this.data);
@@ -82,7 +95,7 @@ export class FastVertexArray {
this.dirty = true;
}
compact(): void {
private compact(): void {
// New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512)
const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count))));
if (newLength !== this.length) {
@@ -110,4 +123,13 @@ export class FastVertexArray {
getVertexData(): Float32Array {
return this.data;
}
destroy(): void {
this.data = null;
this.sprites = null;
this.freeSlots = null;
this.lastSlot = 0;
this.dirty = false;
this.destroyed = true;
}
}

View File

@@ -116,7 +116,7 @@ export class BlockViewComponent implements OnInit, OnDestroy {
this.isLoadingBlock = false;
this.isLoadingOverview = true;
}),
shareReplay(1)
shareReplay({ bufferSize: 1, refCount: true })
);
this.overviewSubscription = block$.pipe(
@@ -176,5 +176,8 @@ export class BlockViewComponent implements OnInit, OnDestroy {
if (this.queryParamsSubscription) {
this.queryParamsSubscription.unsubscribe();
}
if (this.blockGraph) {
this.blockGraph.destroy();
}
}
}

View File

@@ -117,7 +117,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.openGraphService.waitOver('block-data-' + this.rawId);
}),
throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1)
shareReplay({ bufferSize: 1, refCount: true })
);
this.overviewSubscription = block$.pipe(

View File

@@ -1,8 +1,8 @@
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
import { ElectrsApiService } from '@app/services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter, take } from 'rxjs/operators';
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
import { StateService } from '@app/services/state.service';
import { SeoService } from '@app/services/seo.service';
@@ -68,6 +68,7 @@ export class BlockComponent implements OnInit, OnDestroy {
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected';
currentQueryParams: Params;
overviewSubscription: Subscription;
accelerationsSubscription: Subscription;
@@ -80,8 +81,8 @@ export class BlockComponent implements OnInit, OnDestroy {
timeLtr: boolean;
childChangeSubscription: Subscription;
auditPrefSubscription: Subscription;
isAuditEnabledSubscription: Subscription;
oobSubscription: Subscription;
priceSubscription: Subscription;
blockConversion: Price;
@@ -118,7 +119,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.setAuditAvailable(this.auditSupported);
if (this.auditSupported) {
this.isAuditEnabledFromParam().subscribe(auditParam => {
this.isAuditEnabledSubscription = this.isAuditEnabledFromParam().subscribe(auditParam => {
if (this.auditParamEnabled) {
this.auditModeEnabled = auditParam;
} else {
@@ -281,7 +282,7 @@ export class BlockComponent implements OnInit, OnDestroy {
}
}),
throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1)
shareReplay({ bufferSize: 1, refCount: true })
);
this.overviewSubscription = this.block$.pipe(
@@ -363,6 +364,7 @@ export class BlockComponent implements OnInit, OnDestroy {
.subscribe((network) => this.network = network);
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
this.currentQueryParams = params;
if (params.showDetails === 'true') {
this.showDetails = true;
} else {
@@ -414,6 +416,7 @@ export class BlockComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.stateService.markBlock$.next({});
this.overviewSubscription?.unsubscribe();
this.accelerationsSubscription?.unsubscribe();
this.keyNavigationSubscription?.unsubscribe();
this.blocksSubscription?.unsubscribe();
this.cacheBlocksSubscription?.unsubscribe();
@@ -421,8 +424,16 @@ export class BlockComponent implements OnInit, OnDestroy {
this.queryParamsSubscription?.unsubscribe();
this.timeLtrSubscription?.unsubscribe();
this.childChangeSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe();
this.auditPrefSubscription?.unsubscribe();
this.isAuditEnabledSubscription?.unsubscribe();
this.oobSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe();
this.blockGraphProjected.forEach(graph => {
graph.destroy();
});
this.blockGraphActual.forEach(graph => {
graph.destroy();
});
}
// TODO - Refactor this.fees/this.reward for liquid because it is not
@@ -733,19 +744,18 @@ export class BlockComponent implements OnInit, OnDestroy {
toggleAuditMode(): void {
this.stateService.hideAudit.next(this.auditModeEnabled);
this.route.queryParams.subscribe(params => {
const queryParams = { ...params };
delete queryParams['audit'];
const queryParams = { ...this.currentQueryParams };
delete queryParams['audit'];
let newUrl = this.router.url.split('?')[0];
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) {
newUrl += '?' + queryString;
}
this.location.replaceState(newUrl);
});
let newUrl = this.router.url.split('?')[0];
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) {
newUrl += '?' + queryString;
}
this.location.replaceState(newUrl);
// avoid duplicate subscriptions
this.auditPrefSubscription?.unsubscribe();
this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => {
this.auditModeEnabled = !hide;
this.showAudit = this.auditAvailable && this.auditModeEnabled;
@@ -762,7 +772,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.route.queryParams.pipe(
map(params => {
this.auditParamEnabled = 'audit' in params;
return this.auditParamEnabled ? !(params['audit'] === 'false') : true;
})
);

View File

@@ -49,7 +49,7 @@
</div>
</td>
<td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="block.timestamp" [hideTimeSince]="true"></app-timestamp>
</td>
<td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
<a

View File

@@ -1,15 +1,17 @@
<ng-template [ngIf]="button" [ngIfElse]="btnLink">
<button #btn [attr.data-clipboard-text]="text" [class]="class" type="button" [disabled]="text === ''">
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;top: -2px;left: 1px;">
<button [class]="class" type="button" [disabled]="text === ''" style="box-shadow: none;" (click)="copyText()">
<span style="position: relative;top: -2px;left: 1px;">
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
<span *ngIf="showMessage" class="copied-message" style="top: 29px; left: -23.5px;">{{ copiedMessage }}</span>
</span>
</button>
</ng-template>
<ng-template #btnLink>
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0 {{ leftPadding ? 'padding' : '' }}" [attr.data-clipboard-text]="text">
<span style="position: relative;">
<button class="btn btn-sm btn-link pt-0 {{ leftPadding ? 'padding' : '' }}" style="box-shadow: none;" (click)="copyText()">
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
</button>
<span *ngIf="showMessage" class="copied-message" style="top: 29px; left: -23.5px;">{{ copiedMessage }}</span>
</span>
</ng-template>

View File

@@ -7,7 +7,19 @@
padding-left: 0.4rem;
}
img {
position: relative;
left: -3px;
}
.copied-message {
background: color-mix(in srgb, var(--active-bg) 95%, transparent);
color: var(--fg);
font-family: sans-serif;
font-size: .8rem;
font-weight: 400;
text-decoration: none;
text-align: left;
padding: .6em .75rem;
border-radius: 4px;
position: absolute;
white-space: nowrap;
box-shadow: 0 .5rem 1rem -.5rem #000;
z-index: 1000;
opacity: .9;
}

View File

@@ -1,6 +1,4 @@
import { Component, ViewChild, ElementRef, AfterViewInit, Input, ChangeDetectionStrategy } from '@angular/core';
import * as ClipboardJS from 'clipboard';
import * as tlite from 'tlite';
import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
@Component({
selector: 'app-clipboard',
@@ -8,15 +6,14 @@ import * as tlite from 'tlite';
styleUrls: ['./clipboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClipboardComponent implements AfterViewInit {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
export class ClipboardComponent {
@Input() button = false;
@Input() class = 'btn btn-secondary ml-1';
@Input() size: 'small' | 'normal' | 'large' = 'normal';
@Input() text: string;
@Input() leftPadding = true;
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
showMessage = false;
widths = {
small: '10',
@@ -24,22 +21,40 @@ export class ClipboardComponent implements AfterViewInit {
large: '18',
};
clipboard: any;
constructor(
private cd: ChangeDetectorRef,
) { }
constructor() { }
ngAfterViewInit() {
this.clipboard = new ClipboardJS(this.btn.nativeElement);
this.clipboard.on('success', () => {
tlite.show(this.buttonWrapper.nativeElement);
setTimeout(() => {
tlite.hide(this.buttonWrapper.nativeElement);
}, 1000);
});
async copyText() {
if (this.text && !this.showMessage) {
try {
await this.copyToClipboard(this.text);
this.showMessage = true;
this.cd.markForCheck();
setTimeout(() => {
this.showMessage = false;
this.cd.markForCheck();
}, 1000);
} catch (error) {
console.error('Clipboard copy failed:', error);
}
}
}
onDestroy() {
this.clipboard.destroy();
async copyToClipboard(text: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
// Use the 'out of viewport hidden text area' trick on non-secure contexts
const textarea = document.createElement('textarea');
textarea.value = this.text;
textarea.style.opacity = '0';
textarea.setAttribute('readonly', 'true'); // Don't trigger keyboard on mobile
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
}
}

View File

@@ -238,7 +238,7 @@
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [height]="graphHeight"></app-address-graph>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight"></app-address-graph>
</div>
</div>
</div>
@@ -281,9 +281,11 @@
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="card">
<div class="card-body">
<span class="title-link">
<a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/wallet/' + widget.props.wallet | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
</span>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
</div>
</div>

View File

@@ -162,6 +162,9 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
this.cacheBlocksSubscription?.unsubscribe();
this.networkChangedSubscription?.unsubscribe();
this.queryParamsSubscription?.unsubscribe();
this.blockGraphs.forEach(graph => {
graph.destroy();
});
}
shiftTestBlocks(): void {

View File

@@ -56,8 +56,7 @@
</ng-template>
</td>
<td class="timestamp text-left">
&lrm;{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="utxo.blocktime"></app-time>)</i></div>
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="utxo.blocktime"></app-timestamp>
</td>
<td class="expires-in text-left" [ngStyle]="{ 'color': getGradientColor(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) }">
{{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} <span i18n="shared.blocks" class="symbol">blocks</span>

View File

@@ -53,8 +53,7 @@
</ng-container>
</td>
<td class="timestamp text-left">
&lrm;{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="peg.blocktime"></app-time>)</i></div>
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="peg.blocktime"></app-timestamp>
</td>
<td class="amount text-right" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0, 'glow-effect': peg.amount < 0 && peg.bitcoinaddress && !peg.bitcointxid}">
<app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount>

View File

@@ -120,6 +120,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
}
ngOnDestroy(): void {
this.blockGraph?.destroy();
this.blockSub.unsubscribe();
this.timeLtrSubscription.unsubscribe();
this.websocketService.stopTrackMempoolBlock();

View File

@@ -10,7 +10,7 @@
<h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1>
</div>
<div class="box">
<div class="box pool-details">
<div class="row">
<div class="col-lg-6">
@@ -173,7 +173,119 @@
<div class="spinner-border text-light"></div>
</div>
<!-- Stratum Job -->
<ng-container *ngIf="(job$ | async) as job;">
<h2 i18n="pool.next_block">Next block</h2>
<div class="box mb-3">
<div class="row" >
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>
<table class="job-table table table-xs table-borderless table-fixed table-data">
<thead>
<tr>
<th class="data-title clip text-center height" i18n="latest-blocks.height">Height</th>
<th class="data-title clip text-center expected" i18n="next-block.expected-time">Expected</th>
<th class="data-title clip text-center reward" i18n="latest-blocks.reward">Reward</th>
<th class="data-title clip text-center timestamp" i18n="next-block.timestamp">Timestamp</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center height">
{{ job.height }}
</td>
<td class="text-center expected">
<ng-container *ngIf="(expectedBlockTime$ | async) as expectedBlockTime; else expectedPlaceholder">
<app-time kind="until" [time]="expectedBlockTime" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
</ng-container>
<ng-template #expectedPlaceholder>~</ng-template>
</td>
<td class="text-center reward">
<app-amount [satoshis]="job.reward"></app-amount>
</td>
<td class="text-center timestamp">
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.timestamp" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td>
<table class="job-table table table-xs table-borderless table-fixed table-data">
<thead>
<tr>
<th class="data-title clip text-center coinbase" i18n="latest-blocks.coinbasetag">Coinbase tag</th>
<th class="data-title clip text-center clean" i18n="next-block.clean">Clean</th>
<th class="data-title clip text-center prevhash" i18n="next-block.prevhash">Prevhash</th>
<th class="data-title clip text-center job-received" i18n="next-block.job-received">Job Received</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center coinbase">
{{ job.scriptsig | hex2ascii }}
</td>
<td class="text-center clean">
@if (job.cleanJobs) {
<fa-icon [icon]="['fas', 'check-circle']" [fixedWidth]="true"></fa-icon>
} @else {
<fa-icon [icon]="['fas', 'times-circle']" [fixedWidth]="true"></fa-icon>
}
</td>
<td class="text-center prevhash">
<a [routerLink]="['/block' | relativeUrl, job.prevHash]">
<app-truncate [text]="job.prevHash" [lastChars]="8"></app-truncate>
</a>
</td>
<td class="text-center job-received">
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.received / 1000" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td>
<table class="stratum-table">
<thead>
<tr>
<th class="data-title clip text-center" [attr.colspan]="Math.max(job.merkleBranches.length, 12)">
<a class="title-link" href="" [routerLink]="['/stratum' | relativeUrl]">
Merkle Branches
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
</th>
</tr>
</thead>
<tbody>
<tr>
@for (branch of job.merkleBranches; track $index) {
<td class="merkle" [style.background-color]="branch ? '#' + branch.slice(0, 6) : ''"></td>
}
@for (_ of [].constructor(Math.max(0, 12 - job.merkleBranches.length)); track $index) {
<td class="merkle empty-branch"></td>
}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</ng-container>
<!-- Blocks list -->
<h2 i18n="master-page.blocks">Blocks</h2>
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
[infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<ng-container *ngIf="blocks$ | async as blocks; else skeleton">
@@ -194,7 +306,7 @@
<a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a>
</td>
<td class="timestamp">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="block.timestamp" [hideTimeSince]="true"></app-timestamp>
</td>
<td class="mined">
<app-time kind="since" [time]="block.timestamp" [fastRender]="true" [showTooltip]="true"></app-time>

View File

@@ -49,111 +49,110 @@ div.scrollable {
max-height: 75px;
}
.box {
padding-bottom: 5px;
.pool-details {
@media (min-width: 767.98px) {
min-height: 187px;
}
}
.label {
width: 25%;
@media (min-width: 767.98px) {
vertical-align: middle;
.label {
width: 25%;
@media (min-width: 767.98px) {
vertical-align: middle;
}
@media (max-width: 767.98px) {
font-weight: bold;
}
}
@media (max-width: 767.98px) {
font-weight: bold;
.label.addresses {
vertical-align: top;
padding-top: 25px;
}
.addresses-data {
vertical-align: top;
font-family: monospace;
font-size: 14px;
}
}
.label.addresses {
vertical-align: top;
padding-top: 25px;
}
.addresses-data {
vertical-align: top;
font-family: monospace;
font-size: 14px;
}
.data {
text-align: right;
padding-left: 5%;
@media (max-width: 992px) {
text-align: left;
padding-left: 12px;
}
@media (max-width: 450px) {
.data {
text-align: right;
padding-left: 5%;
@media (max-width: 992px) {
text-align: left;
padding-left: 12px;
}
@media (max-width: 450px) {
text-align: right;
}
}
}
.progress {
background-color: var(--secondary);
}
.progress {
background-color: var(--secondary);
}
.coinbase {
width: 20%;
@media (max-width: 875px) {
display: none;
}
}
.height {
width: 10%;
}
.timestamp {
@media (max-width: 875px) {
padding-left: 50px;
}
@media (max-width: 685px) {
display: none;
}
}
.mined {
width: 13%;
@media (max-width: 1100px) {
display: none;
}
}
.txs {
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
}
.size {
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
@media (max-width: 875px) {
.coinbase {
width: 20%;
@media (max-width: 875px) {
display: none;
}
}
@media (max-width: 650px) {
width: 20%;
}
@media (max-width: 450px) {
display: none;
}
}
.scriptmessage {
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
vertical-align: middle;
width: auto;
text-align: left;
.height {
width: 10%;
}
.timestamp {
@media (max-width: 875px) {
padding-left: 50px;
}
@media (max-width: 685px) {
display: none;
}
}
.mined {
width: 13%;
@media (max-width: 1100px) {
display: none;
}
}
.txs {
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
}
.size {
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
@media (max-width: 875px) {
width: 20%;
}
@media (max-width: 650px) {
width: 20%;
}
@media (max-width: 450px) {
display: none;
}
}
.scriptmessage {
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
vertical-align: middle;
width: auto;
text-align: left;
}
}
.skeleton-loader {
@@ -214,4 +213,55 @@ div.scrollable {
.taller-row {
height: 75px;
}
.stratum-table {
width: 100%;
.merkle {
width: 100px;
}
.empty-branch {
outline: solid 1px white;
outline-offset: -1px;
&::after {
content: "";
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background: linear-gradient(to top left, transparent, transparent 48%, white 49%, white 51%, transparent 52%, transparent);
}
}
td {
position: relative;
height: 2em;
}
}
.job-table {
td, th {
width: 25%;
max-width: 25%;
min-width: 25%;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.1rem 0.2rem;
}
@media (max-width: 767.98px) {
.expected, .timestamp, .clean, .job-received {
display: none;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
text-decoration: none;
color: inherit;
}

View File

@@ -10,6 +10,9 @@ import { selectPowerOfTen } from '@app/bitcoin.utils';
import { formatNumber } from '@angular/common';
import { SeoService } from '@app/services/seo.service';
import { HttpErrorResponse } from '@angular/common/http';
import { StratumJob } from '../../interfaces/websocket.interface';
import { WebsocketService } from '../../services/websocket.service';
import { MiningService } from '../../services/mining.service';
interface AccelerationTotal {
cost: number,
@@ -27,12 +30,16 @@ export class PoolComponent implements OnInit {
@Input() left: number | string = 75;
gfg = true;
stratumEnabled = this.stateService.env.STRATUM_ENABLED;
formatNumber = formatNumber;
Math = Math;
slugSubscription: Subscription;
poolStats$: Observable<PoolStat>;
blocks$: Observable<BlockExtended[]>;
oobFees$: Observable<AccelerationTotal[]>;
job$: Observable<StratumJob | null>;
expectedBlockTime$: Observable<number>;
isLoading = true;
error: HttpErrorResponse | null = null;
@@ -53,6 +60,8 @@ export class PoolComponent implements OnInit {
private apiService: ApiService,
private route: ActivatedRoute,
public stateService: StateService,
private websocketService: WebsocketService,
private miningService: MiningService,
private seoService: SeoService,
) {
this.auditAvailable = this.stateService.env.AUDIT;
@@ -62,7 +71,7 @@ export class PoolComponent implements OnInit {
this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
this.isLoading = true;
this.blocks = [];
this.chartOptions = {};
this.chartOptions = {};
this.slug = slug;
this.initializeObservables();
});
@@ -129,6 +138,31 @@ export class PoolComponent implements OnInit {
}),
filter(oob => oob.length === 3 && oob[2].count > 0)
);
if (this.stratumEnabled) {
this.job$ = combineLatest([
this.poolStats$.pipe(
tap((poolStats) => {
this.websocketService.startTrackStratum(poolStats.pool.unique_id);
})
),
this.stateService.stratumJobs$
]).pipe(
map(([poolStats, jobs]) => {
return jobs[poolStats.pool.unique_id];
})
);
this.expectedBlockTime$ = combineLatest([
this.miningService.getMiningStats('1w'),
this.poolStats$,
this.stateService.difficultyAdjustment$
]).pipe(
map(([miningStats, poolStat, da]) => {
return (da.timeAvg / ((poolStat.estimatedHashrate || 0) / (miningStats.lastEstimatedHashrate * 1_000_000_000_000_000_000))) + Date.now() + da.timeOffset;
})
);
}
}
prepareChartOptions(hashrate, share) {

View File

@@ -0,0 +1,34 @@
.accept-results {
td, th {
&.allowed {
width: 10%;
text-align: center;
}
&.txid {
width: 50%;
}
&.rate {
width: 20%;
text-align: right;
white-space: wrap;
}
&.reason {
width: 20%;
text-align: right;
white-space: wrap;
}
}
@media (max-width: 950px) {
table-layout: auto;
td, th {
&.allowed {
width: 100px;
}
&.txid {
max-width: 200px;
}
}
}
}

View File

@@ -19,6 +19,9 @@
<th class="rtt only-small">RTT</th>
<th class="rtt only-large">RTT</th>
<th class="height">Height</th>
<th class="frontend only-large">Front</th>
<th class="backend only-large">Back</th>
<th class="electrs only-large">Electrs</th>
</tr>
<tr *ngFor="let host of hosts; let i = index; trackBy: trackByFn">
<td class="rank">{{ i + 1 }}</td>
@@ -28,6 +31,15 @@
<td class="rtt only-small">{{ (host.rtt / 1000) | number : '1.1-1' }} {{ host.rtt == null ? '' : 's'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="rtt only-large">{{ host.rtt | number : '1.0-0' }} {{ host.rtt == null ? '' : 'ms'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="height">{{ host.latestHeight }} {{ !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '')) }}</td>
<ng-container *ngFor="let type of ['frontend', 'backend', 'electrs']">
<td class="{{type}} only-large" [style.background-color]="host.hashes?.[type] ? '#' + host.hashes[type].slice(0, 6) : ''">
@if (host.hashes?.[type]) {
<a [style.color]="'white'" href="https://github.com/mempool/{{type === 'electrs' ? 'electrs' : 'mempool'}}/commit/{{ host.hashes[type] }}" target="_blank">{{ host.hashes[type].slice(0, 8) || '?' }}</a>
} @else {
<span>?</span>
}
</td>
</ng-container>
</tr>
</tbody>
</table>

View File

@@ -9,7 +9,7 @@
}
.status-panel {
max-width: 720px;
max-width: 1000px;
margin: auto;
padding: 1em;
background: var(--box-bg);

View File

@@ -0,0 +1,49 @@
<div class="container-xl" style="min-height: 335px">
<h1 class="float-left" i18n="master-page.blocks">Stratum Jobs</h1>
<div class="clearfix"></div>
<div style="min-height: 295px">
<table *ngIf="poolsReady && (rows$ | async) as rows;" class="stratum-table">
<thead>
<tr>
<td class="height">Height</td>
<td class="reward">Reward</td>
<td class="tag">Coinbase Tag</td>
<td class="merkle" [attr.colspan]="rows[0]?.merkleCells?.length || 4">
Merkle Branches
</td>
<td class="pool">Pool</td>
</tr>
</thead>
<tbody>
@for (row of rows; track row.job.pool) {
<tr>
<td class="height">
{{ row.job.height }}
</td>
<td class="reward">
<app-amount [satoshis]="row.job.reward"></app-amount>
</td>
<td class="tag">
{{ row.job.tag }}
</td>
@for (cell of row.merkleCells; track $index) {
<td class="merkle" [style.background-color]="cell.hash ? '#' + cell.hash.slice(0, 6) : ''">
<div class="pipe-segment" [class]="pipeToClass(cell.type)"></div>
</td>
}
<td class="pool">
@if (pools[row.job.pool]) {
<a class="badge" [routerLink]="[('/mining/pool/' + pools[row.job.pool].slug) | relativeUrl]">
<img class="pool-logo" [src]="'/resources/mining-pools/' + pools[row.job.pool].slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pools[row.job.pool].name + ' mining pool'">
{{ pools[row.job.pool].name}}
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,108 @@
.stratum-table {
width: 100%;
}
td {
position: relative;
height: 2em;
&.height, &.reward, &.tag {
padding: 0 5px;
}
&.tag {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.pool {
padding-left: 5px;
padding-right: 20px;
}
&.merkle {
width: 100px;
.pipe-segment {
position: absolute;
border-color: white;
box-sizing: content-box;
&.vertical {
top: 0;
right: 0;
width: 50%;
height: 100%;
border-left: solid 4px;
}
&.horizontal {
bottom: 0;
left: 0;
width: 100%;
height: 50%;
border-top: solid 4px;
}
&.branch-top {
bottom: 0;
right: 0;
width: 100%;
height: 50%;
border-top: solid 4px;
&::after {
content: "";
position: absolute;
box-sizing: content-box;
top: -4px;
right: 0px;
bottom: 0;
width: 50%;
border-top: solid 4px;
border-left: solid 4px;
border-top-left-radius: 5px;
}
}
&.branch-mid {
bottom: 0;
right: 0px;
width: 50%;
height: 100%;
border-left: solid 4px;
&::after {
content: "";
position: absolute;
box-sizing: content-box;
top: -4px;
left: -4px;
width: 100%;
height: 50%;
border-bottom: solid 4px;
border-left: solid 4px;
border-bottom-left-radius: 5px;
}
}
&.branch-end {
top: -4px;
right: 0;
width: 50%;
height: 50%;
border-bottom-left-radius: 5px;
border-bottom: solid 4px;
border-left: solid 4px;
}
}
}
}
.badge {
position: relative;
color: #FFF;
}
.pool-logo {
width: 15px;
height: 15px;
position: relative;
top: -1px;
margin-right: 2px;
}

View File

@@ -0,0 +1,224 @@
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { StateService } from '../../../services/state.service';
import { WebsocketService } from '../../../services/websocket.service';
import { map, Observable } from 'rxjs';
import { StratumJob } from '../../../interfaces/websocket.interface';
import { MiningService } from '../../../services/mining.service';
import { SinglePoolStats } from '../../../interfaces/node-api.interface';
type MerkleCellType = ' ' | '┬' | '├' | '└' | '│' | '─' | 'leaf';
interface TaggedStratumJob extends StratumJob {
tag: string;
merkleBranchIds: string[];
}
interface MerkleCell {
hash: string;
type: MerkleCellType;
job?: TaggedStratumJob;
}
interface MerkleTree {
hash?: string;
job: string;
size: number;
children?: MerkleTree[];
}
interface PoolRow {
job: TaggedStratumJob;
merkleCells: MerkleCell[];
}
function parseTag(scriptSig: string): string {
const hex = scriptSig.slice(8).replace(/6d6d.{64}/, '');
const bytes: number[] = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16));
}
// eslint-disable-next-line no-control-regex
const ascii = new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '').replace(/[\x00-\x1F\x7F-\x9F]/g, '');
if (ascii.includes('/ViaBTC/')) {
return '/ViaBTC/';
} else if (ascii.includes('SpiderPool/')) {
return 'SpiderPool/';
}
return (ascii.match(/\/.*\//)?.[0] || ascii).trim();
}
function getMerkleBranchIds(merkleBranches: string[], numBranches: number): string[] {
let lastHash = '';
const ids: string[] = [];
for (let i = 0; i < numBranches; i++) {
if (merkleBranches[i]) {
lastHash = merkleBranches[i];
}
ids.push(`${i}-${lastHash}`);
}
return ids;
}
@Component({
selector: 'app-stratum-list',
templateUrl: './stratum-list.component.html',
styleUrls: ['./stratum-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StratumList implements OnInit, OnDestroy {
rows$: Observable<PoolRow[]>;
pools: { [id: number]: SinglePoolStats } = {};
poolsReady: boolean = false;
constructor(
private stateService: StateService,
private websocketService: WebsocketService,
private miningService: MiningService,
private cd: ChangeDetectorRef,
) {}
ngOnInit(): void {
this.websocketService.want(['stats', 'blocks', 'mempool-blocks']);
this.miningService.getPools().subscribe(pools => {
this.pools = {};
for (const pool of pools) {
this.pools[pool.unique_id] = pool;
}
this.poolsReady = true;
this.cd.markForCheck();
});
this.rows$ = this.stateService.stratumJobs$.pipe(
map((jobs) => this.processJobs(jobs)),
);
this.websocketService.startTrackStratum('all');
}
processJobs(rawJobs: Record<string, StratumJob>): PoolRow[] {
const numBranches = Math.max(...Object.values(rawJobs).map(job => job.merkleBranches.length));
const jobs: Record<string, TaggedStratumJob> = {};
for (const [id, job] of Object.entries(rawJobs)) {
jobs[id] = { ...job, tag: parseTag(job.scriptsig), merkleBranchIds: getMerkleBranchIds(job.merkleBranches, numBranches) };
}
if (Object.keys(jobs).length === 0) {
return [];
}
let trees: MerkleTree[] = Object.keys(jobs).map(job => ({
job,
size: 1,
}));
// build tree from bottom up
for (let col = numBranches - 1; col >= 0; col--) {
const groups: Record<string, MerkleTree[]> = {};
for (const tree of trees) {
const branchId = jobs[tree.job].merkleBranchIds[col];
if (!groups[branchId]) {
groups[branchId] = [];
}
groups[branchId].push(tree);
}
trees = Object.values(groups).map(group => ({
hash: jobs[group[0].job].merkleBranches[col],
job: group[0].job,
children: group,
size: group.reduce((acc, tree) => acc + tree.size, 0),
}));
}
// initialize grid of cells
const rows: (MerkleCell | null)[][] = [];
for (let i = 0; i < Object.keys(jobs).length; i++) {
const row: (MerkleCell | null)[] = [];
for (let j = 0; j <= numBranches; j++) {
row.push(null);
}
rows.push(row);
}
// fill in the cells
let colTrees = [trees.sort((a, b) => {
if (a.size !== b.size) {
return b.size - a.size;
}
return a.job.localeCompare(b.job);
})];
for (let col = 0; col <= numBranches; col++) {
let row = 0;
const nextTrees: MerkleTree[][] = [];
for (let g = 0; g < colTrees.length; g++) {
for (let t = 0; t < colTrees[g].length; t++) {
const tree = colTrees[g][t];
const isFirstTree = (t === 0);
const isLastTree = (t === colTrees[g].length - 1);
for (let i = 0; i < tree.size; i++) {
const isFirstCell = (i === 0);
const isLeaf = (col === numBranches);
rows[row][col] = {
hash: tree.hash,
job: isLeaf ? jobs[tree.job] : undefined,
type: 'leaf',
};
if (col > 0) {
rows[row][col - 1].type = getCellType(isFirstCell, isFirstTree, isLastTree);
}
row++;
}
if (tree.children) {
nextTrees.push(tree.children.sort((a, b) => {
if (a.size !== b.size) {
return b.size - a.size;
}
return a.job.localeCompare(b.job);
}));
}
}
}
colTrees = nextTrees;
}
return rows.map(row => ({
job: row[row.length - 1].job,
merkleCells: row.slice(0, -1),
}));
}
pipeToClass(type: MerkleCellType): string {
return {
' ': 'empty',
'┬': 'branch-top',
'├': 'branch-mid',
'└': 'branch-end',
'│': 'vertical',
'─': 'horizontal',
'leaf': 'leaf'
}[type];
}
ngOnDestroy(): void {
this.websocketService.stopTrackStratum();
}
}
function getCellType(isFirstCell, isFirstTree, isLastTree): MerkleCellType {
if (isFirstCell) {
if (isFirstTree) {
if (isLastTree) {
return '─';
} else {
return '┬';
}
} else if (isLastTree) {
return '└';
} else {
return '├';
}
} else {
if (isLastTree) {
return ' ';
} else {
return '│';
}
}
}

View File

@@ -0,0 +1,8 @@
<div [formGroup]="timezoneForm" class="text-small text-center">
<select formControlName="mode" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 110px;" (change)="changeMode()">
<option value="local">UTC{{ localTimezoneOffset !== '+0' ? localTimezoneOffset : '' }} {{ localTimezoneName ? '- ' + localTimezoneName : '' }}</option>
<option value="+0" *ngIf="localTimezoneOffset !== '+0'">UTC - Greenwich Mean Time (GMT)</option>
<option disabled>────</option>
<option *ngFor="let timezone of timezones" [value]="timezone.offset">UTC{{ timezone.offset !== '+0' ? timezone.offset : '' }} - {{ timezone.name }}</option>
</select>
</div>

View File

@@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { StorageService } from '@app/services/storage.service';
import { StateService } from '@app/services/state.service';
import { timezones } from '@app/app.constants';
@Component({
selector: 'app-timezone-selector',
templateUrl: './timezone-selector.component.html',
styleUrls: ['./timezone-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimezoneSelectorComponent implements OnInit {
timezoneForm: UntypedFormGroup;
timezones = timezones;
localTimezoneOffset: string = '';
localTimezoneName: string;
constructor(
private formBuilder: UntypedFormBuilder,
private stateService: StateService,
private storageService: StorageService,
) { }
ngOnInit() {
this.setLocalTimezone();
this.timezoneForm = this.formBuilder.group({
mode: ['local'],
});
this.stateService.timezone$.subscribe((mode) => {
this.timezoneForm.get('mode')?.setValue(mode);
});
}
changeMode() {
const newMode = this.timezoneForm.get('mode')?.value;
this.storageService.setValue('timezone-preference', newMode);
this.stateService.timezone$.next(newMode);
}
setLocalTimezone() {
const offset = new Date().getTimezoneOffset();
const sign = offset <= 0 ? "+" : "-";
const absOffset = Math.abs(offset);
const hours = String(Math.floor(absOffset / 60));
const minutes = String(absOffset % 60).padStart(2, '0');
if (minutes === '00') {
this.localTimezoneOffset = `${sign}${hours}`;
} else {
this.localTimezoneOffset = `${sign}${hours.padStart(2, '0')}:${minutes}`;
}
const timezone = this.timezones.find(tz => tz.offset === this.localTimezoneOffset);
this.timezones = this.timezones.filter(tz => tz.offset !== this.localTimezoneOffset && tz.offset !== '+0');
this.localTimezoneName = timezone ? timezone.name : '';
}
}

View File

@@ -88,7 +88,7 @@
<div class="field narrower mt-2">
<div class="label" i18n="transaction.confirmed-at">Confirmed at</div>
<div class="value">
&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }}
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="tx.status.block_time" [hideTimeSince]="true"></app-timestamp>
<div class="lg-inline">
<i class="symbol">(<app-time kind="since" [time]="tx.status.block_time" [fastRender]="true" [showTooltip]="true"></app-time>)</i>
</div>

View File

@@ -61,10 +61,7 @@
<tr>
<td i18n="block.timestamp">Timestamp</td>
<td>
&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}
<div class="lg-inline">
<i class="symbol">(<app-time kind="since" [time]="tx.status.block_time" [fastRender]="true"></app-time>)</i>
</div>
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="tx.status.block_time"></app-timestamp>
</td>
</tr>
} @else {
@@ -217,10 +214,10 @@
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span>
@if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) {
@if (isAcceleration && 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.sats">sats</span>
}
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0)"></app-fiat></span>
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + (isAcceleration ? ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0) : 0)"></app-fiat></span>
</td>
</tr>
} @else {
@@ -247,7 +244,7 @@
<ng-template #effectiveRateRow>
@if (!isLoadingTx) {
@if ((cpfpInfo && hasEffectiveFeeRate) || accelerationInfo) {
@if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) {
<tr>
@if (isAcceleration) {
<td i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
@@ -280,7 +277,7 @@
<ng-template #acceleratingRow>
<tr>
<td rowspan="2" colspan="2" style="padding: 0;">
<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>
<app-active-acceleration-box [acceleratedBy]="tx.acceleratedBy" [effectiveFeeRate]="tx.effectiveFeePerVsize" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="toggleCpfp()" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
</td>
</tr>
<tr></tr>

View File

@@ -29,7 +29,6 @@ export class TransactionDetailsComponent implements OnInit {
@Input() hasEffectiveFeeRate: boolean;
@Input() cpfpInfo: CpfpInfo;
@Input() hasCpfp: boolean;
@Input() showCpfpDetails: boolean;
@Input() accelerationInfo: Acceleration;
@Input() acceleratorAvailable: boolean;
@Input() accelerateCtaType: string;
@@ -51,7 +50,7 @@ export class TransactionDetailsComponent implements OnInit {
this.accelerateClicked.emit(true);
}
toggleCpfp(): void {
toggleCpfp(): void {
this.toggleCpfp$.emit();
}
}

View File

@@ -24,6 +24,7 @@
[height]="tx?.status?.block_height"
[replaced]="replaced"
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
[cached]="isCached"
></app-confirmations>
</div>
</ng-container>
@@ -52,7 +53,6 @@
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
[cpfpInfo]="cpfpInfo"
[hasCpfp]="hasCpfp"
[showCpfpDetails]="showCpfpDetails"
[accelerationInfo]="accelerationInfo"
[replaced]="replaced"
[isCached]="isCached"
@@ -166,12 +166,12 @@
<br>
</ng-container>
<ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && isAcceleration">
<ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && (isAcceleration || accelerationCanceled)">
<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" [acceleratedAt]="tx.acceleratedAt" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)"></app-acceleration-timeline>
<app-acceleration-timeline [transactionTime]="transactionTime" [acceleratedAt]="tx.acceleratedAt" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)" [canceled]="accelerationCanceled"></app-acceleration-timeline>
<br>
</ng-container>

View File

@@ -107,6 +107,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
pool: Pool | null;
auditStatus: TxAuditStatus | null;
isAcceleration: boolean = false;
accelerationCanceled: boolean = false;
filters: Filter[] = [];
showCpfpDetails = false;
miningStats: MiningStats;
@@ -239,7 +240,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
retry({ count: 2, delay: 2000 }),
// Try again until we either get a valid response, or the transaction is confirmed
repeat({ delay: 2000 }),
filter((transactionTimes) => transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed),
filter((transactionTimes) => transactionTimes?.[0] > 0 || this.tx.status?.confirmed),
take(1),
)),
)
@@ -360,16 +361,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
).subscribe((accelerationHistory) => {
for (const acceleration of accelerationHistory) {
if (acceleration.txid === this.txId) {
if (acceleration.status === 'completed' || acceleration.status === 'completed_provisional') {
if (acceleration.pools.includes(acceleration.minedByPoolUniqueId)) {
const boostCost = acceleration.boostCost || acceleration.bidBoost;
acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize;
acceleration.boost = boostCost;
this.tx.acceleratedAt = acceleration.added;
this.accelerationInfo = acceleration;
} else {
this.tx.feeDelta = undefined;
}
if ((acceleration.status === 'completed' || acceleration.status === 'completed_provisional') && acceleration.pools.includes(acceleration.minedByPoolUniqueId)) {
const boostCost = acceleration.boostCost || acceleration.bidBoost;
acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize;
acceleration.boost = boostCost;
this.tx.acceleratedAt = acceleration.added;
this.accelerationInfo = acceleration;
}
if (acceleration.status === 'failed' || acceleration.status === 'failed_provisional') {
this.accelerationCanceled = true;
this.tx.acceleratedAt = acceleration.added;
this.accelerationInfo = acceleration;
}
this.waitingForAccelerationInfo = false;
this.setIsAccelerated();
@@ -878,6 +880,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
this.tx.acceleratedAt = cpfpInfo.acceleratedAt;
this.tx.feeDelta = cpfpInfo.feeDelta;
this.accelerationCanceled = false;
this.setIsAccelerated(firstCpfp);
} else if (cpfpInfo.acceleratedAt) { // Acceleration was cancelled: reset acceleration state
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
this.tx.acceleratedAt = cpfpInfo.acceleratedAt;
this.tx.feeDelta = cpfpInfo.feeDelta;
this.accelerationCanceled = true;
this.setIsAccelerated(firstCpfp);
}
@@ -901,7 +910,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
setIsAccelerated(initialState: boolean = false) {
this.isAcceleration = ((this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id))));
this.isAcceleration =
(
(this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) ||
(this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))
) &&
!this.accelerationCanceled;
if (this.isAcceleration) {
if (initialState) {
this.accelerationFlowCompleted = true;

View File

@@ -6,7 +6,7 @@
<app-truncate [text]="tx.txid"></app-truncate>
</a>
<div>
<ng-template [ngIf]="tx.status.confirmed">&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</ng-template>
<ng-template [ngIf]="tx.status.confirmed"><app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="tx.status.block_time" [hideTimeSince]="true"></app-timestamp></ng-template>
<ng-template [ngIf]="!tx.status.confirmed && tx.firstSeen">
<i><app-time kind="since" [time]="tx.firstSeen" [fastRender]="true" [showTooltip]="true"></app-time></i>
</ng-template>
@@ -81,7 +81,7 @@
</ng-container>
</div>
</td>
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000 || vin.isInscription}">
<td class="text-right nowrap amount" [class]="{large: tx.largeInput}">
<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">
@@ -257,7 +257,7 @@
</ng-template>
</ng-template>
</td>
<td class="text-right nowrap amount" [class]="{large: vout?.value > 1000000000}">
<td class="text-right nowrap amount" [class]="{large: tx.largeOutput}">
<ng-template [ngIf]="vout.asset && vout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
<div *ngIf="assetsMinimal && assetsMinimal[vout.asset] else assetNotFound">
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vout }"></ng-container>

View File

@@ -202,12 +202,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
for (const address of this.addresses) {
switch (address.length) {
case 130: {
if (v.scriptpubkey === '21' + address + 'ac') {
if (v.scriptpubkey === '41' + address + 'ac') {
return v.value;
}
} break;
case 66: {
if (v.scriptpubkey === '41' + address + 'ac') {
if (v.scriptpubkey === '21' + address + 'ac') {
return v.value;
}
} break;
@@ -224,12 +224,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
for (const address of this.addresses) {
switch (address.length) {
case 130: {
if (v.prevout?.scriptpubkey === '21' + address + 'ac') {
if (v.prevout?.scriptpubkey === '41' + address + 'ac') {
return v.prevout?.value;
}
} break;
case 66: {
if (v.prevout?.scriptpubkey === '41' + address + 'ac') {
if (v.prevout?.scriptpubkey === '21' + address + 'ac') {
return v.prevout?.value;
}
} break;
@@ -258,6 +258,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
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;
tx.largeInput = true;
}
}
}
@@ -268,6 +269,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
}
tx.largeInput = tx.largeInput || tx.vin.some(vin => (vin?.prevout?.value > 1000000000));
tx.largeOutput = tx.vout.some(vout => (vout?.value > 1000000000));
});
if (this.blockTime && this.transactions?.length && this.currency) {
@@ -351,8 +355,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.electrsApiService.getTransaction$(tx.txid)
.subscribe((newTx) => {
tx['@vinLoaded'] = true;
let temp = tx.vin;
tx.vin = newTx.vin;
tx.fee = newTx.fee;
for (const [index, vin] of temp.entries()) {
newTx.vin[index].isInscription = vin.isInscription;
}
this.ref.markForCheck();
});
}

View File

@@ -0,0 +1,31 @@
<div class="box preview-box" *ngIf="(walletAddresses$ | async) as walletAddresses">
<app-preview-title>
<span i18n="shared.wallet">Wallet</span>
</app-preview-title>
<div>
<div class="table-col">
<table class="table table-borderless dual-col-striped table-fixed wallet-table" *ngIf="(walletStats$ | async) as walletStats">
<tbody>
<tr>
<td i18n="address.number-addresses">Addresses</td>
<td class="wrap-cell">{{ addressStrings.length }}</td>
<td class="spacer"></td>
<td i18n="address.utxos">UTXOs</td>
<td class="wrap-cell">{{ walletStats.utxos }}</td>
</tr>
<tr>
<td i18n="wallet.balance-btc">Balance (BTC)</td>
<td class="wrap-cell"><app-amount [satoshis]="walletStats.balance" [noFiat]="true" [digitsInfo]="walletStats.balance > 1_000_000_000 ? '1.4-4' : '1.8-8'"></app-amount></td>
<td class="spacer"></td>
<td i18n="wallet.balance-usd">Balance (USD)</td>
<td class="wrap-cell"><span class="fiat"><app-fiat [value]="walletStats.balance"></app-fiat></span></td>
</tr>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col-md graph-col">
<app-address-graph [addressSummary$]="walletSummary$" period="all" [widget]="true" [defaultFiat]="true" [height]="330" [left]="-40" [right]="-40" [showLegend]="false" [showYAxis]="false"/>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
.title-wrapper {
padding: 0 15px;
}
.graph-col {
height: 350px;
text-align: center;
padding: 0;
margin-left: 2px;
margin-right: 15px;
}
.table-col {
overflow: hidden;
}
.table {
font-size: 32px;
::ng-deep .symbol {
font-size: 24px;
}
.spacer {
background: none;
}
}
.fiat {
display: block;
}

View File

@@ -0,0 +1,245 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators';
import { Address, AddressTxSummary, ChainStats, Transaction } from '@interfaces/electrs.interface';
import { StateService } from '@app/services/state.service';
import { ApiService } from '@app/services/api.service';
import { of, Observable, Subscription } from 'rxjs';
import { SeoService } from '@app/services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { WalletAddress } from '@interfaces/node-api.interface';
import { OpenGraphService } from '../../services/opengraph.service';
import { WebsocketService } from '../../services/websocket.service';
class WalletStats implements ChainStats {
addresses: string[];
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
constructor (stats: ChainStats[], addresses: string[]) {
Object.assign(this, stats.reduce((acc, stat) => {
acc.funded_txo_count += stat.funded_txo_count;
acc.funded_txo_sum += stat.funded_txo_sum;
acc.spent_txo_count += stat.spent_txo_count;
acc.spent_txo_sum += stat.spent_txo_sum;
return acc;
}, {
funded_txo_count: 0,
funded_txo_sum: 0,
spent_txo_count: 0,
spent_txo_sum: 0,
tx_count: 0,
})
);
this.addresses = addresses;
}
public addTx(tx: Transaction): void {
for (const vin of tx.vin) {
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
this.spendTxo(vin.prevout.value);
}
}
for (const vout of tx.vout) {
if (this.addresses.includes(vout.scriptpubkey_address)) {
this.fundTxo(vout.value);
}
}
this.tx_count++;
}
public removeTx(tx: Transaction): void {
for (const vin of tx.vin) {
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
this.unspendTxo(vin.prevout.value);
}
}
for (const vout of tx.vout) {
if (this.addresses.includes(vout.scriptpubkey_address)) {
this.unfundTxo(vout.value);
}
}
this.tx_count--;
}
private fundTxo(value: number): void {
this.funded_txo_sum += value;
this.funded_txo_count++;
}
private unfundTxo(value: number): void {
this.funded_txo_sum -= value;
this.funded_txo_count--;
}
private spendTxo(value: number): void {
this.spent_txo_sum += value;
this.spent_txo_count++;
}
private unspendTxo(value: number): void {
this.spent_txo_sum -= value;
this.spent_txo_count--;
}
get balance(): number {
return this.funded_txo_sum - this.spent_txo_sum;
}
get totalReceived(): number {
return this.funded_txo_sum;
}
get utxos(): number {
return this.funded_txo_count - this.spent_txo_count;
}
}
@Component({
selector: 'app-wallet-preview',
templateUrl: './wallet-preview.component.html',
styleUrls: ['./wallet-preview.component.scss']
})
export class WalletPreviewComponent implements OnInit, OnDestroy {
network = '';
addresses: Address[] = [];
addressStrings: string[] = [];
walletName: string;
isLoadingWallet = true;
wallet$: Observable<Record<string, WalletAddress>>;
walletAddresses$: Observable<Record<string, Address>>;
walletSummary$: Observable<AddressTxSummary[]>;
walletStats$: Observable<WalletStats>;
error: any;
walletSubscription: Subscription;
collapseAddresses: boolean = true;
fullyLoaded = false;
txCount = 0;
received = 0;
sent = 0;
chainBalance = 0;
constructor(
private route: ActivatedRoute,
private stateService: StateService,
private apiService: ApiService,
private seoService: SeoService,
private websocketService: WebsocketService,
private openGraphService: OpenGraphService,
) { }
ngOnInit(): void {
this.websocketService.want(['blocks', 'stats']);
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.wallet$ = this.route.paramMap.pipe(
map((params: ParamMap) => params.get('wallet') as string),
tap((walletName: string) => {
this.walletName = walletName;
this.openGraphService.waitFor('wallet-addresses-' + this.walletName);
this.openGraphService.waitFor('wallet-data-' + this.walletName);
this.openGraphService.waitFor('wallet-txs-' + this.walletName);
this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`);
}),
switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe(
catchError((err) => {
this.error = err;
this.seoService.logSoft404();
console.log(err);
this.openGraphService.fail('wallet-addresses-' + this.walletName);
this.openGraphService.fail('wallet-data-' + this.walletName);
this.openGraphService.fail('wallet-txs-' + this.walletName);
return of({});
})
)),
shareReplay(1),
);
this.walletAddresses$ = this.wallet$.pipe(
map(wallet => {
const walletInfo: Record<string, Address> = {};
for (const address of Object.keys(wallet)) {
walletInfo[address] = {
address,
chain_stats: wallet[address].stats,
mempool_stats: {
funded_txo_count: 0,
funded_txo_sum: 0,
spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0
},
};
}
return walletInfo;
}),
tap(() => {
this.isLoadingWallet = false;
})
);
this.walletSubscription = this.walletAddresses$.subscribe(wallet => {
this.addressStrings = Object.keys(wallet);
this.addresses = Object.values(wallet);
this.openGraphService.waitOver('wallet-addresses-' + this.walletName);
});
this.walletSummary$ = this.wallet$.pipe(
map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))),
tap(() => {
this.openGraphService.waitOver('wallet-txs-' + this.walletName);
})
);
this.walletStats$ = this.wallet$.pipe(
switchMap(wallet => {
const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet));
return this.stateService.walletTransactions$.pipe(
startWith([]),
scan((stats, newTransactions) => {
for (const tx of newTransactions) {
stats.addTx(tx);
}
return stats;
}, walletStats),
);
}),
tap(() => {
this.openGraphService.waitOver('wallet-data-' + this.walletName);
})
);
}
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
const transactions = new Map<string, AddressTxSummary>();
for (const tx of walletTransactions) {
if (transactions.has(tx.txid)) {
transactions.get(tx.txid).value += tx.value;
} else {
transactions.set(tx.txid, tx);
}
}
return Array.from(transactions.values()).sort((a, b) => {
if (a.height === b.height) {
return b.tx_position - a.tx_position;
}
return b.height - a.height;
});
}
normalizeAddress(address: string): string {
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) {
return address.toLowerCase();
} else {
return address;
}
}
ngOnDestroy(): void {
this.walletSubscription.unsubscribe();
}
}

View File

@@ -1,6 +1,6 @@
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
<div class="title-address">
<h1 i18n="shared.wallet">Wallet</h1>
<h1>{{ walletName }}</h1>
</div>
<div class="clearfix"></div>
@@ -74,6 +74,36 @@
</ng-container>
</ng-container>
<br>
<div class="title-tx">
<h2 class="text-left" i18n="address.transactions">Transactions</h2>
</div>
<app-transactions-list [transactions]="transactions" [showConfirmations]="true" [addresses]="addressStrings" (loadMore)="loadMore()"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="header-bg box">
<div class="row" style="height: 107px;">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</ng-template>
<ng-template [ngIf]="retryLoadMore">
<br>
<button type="button" class="btn btn-outline-info btn-sm" (click)="loadMore()"><fa-icon [icon]="['fas', 'redo-alt']" [fixedWidth]="true"></fa-icon></button>
</ng-template>
</div>
<ng-template #loadingTemplate>
<div class="box" *ngIf="!error; else errorTemplate">

View File

@@ -9,6 +9,8 @@ import { of, Observable, Subscription } from 'rxjs';
import { SeoService } from '@app/services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { WalletAddress } from '@interfaces/node-api.interface';
import { ElectrsApiService } from '@app/services/electrs-api.service';
import { AudioService } from '@app/services/audio.service';
class WalletStats implements ChainStats {
addresses: string[];
@@ -24,6 +26,7 @@ class WalletStats implements ChainStats {
acc.funded_txo_sum += stat.funded_txo_sum;
acc.spent_txo_count += stat.spent_txo_count;
acc.spent_txo_sum += stat.spent_txo_sum;
acc.tx_count += stat.tx_count;
return acc;
}, {
funded_txo_count: 0,
@@ -109,12 +112,17 @@ export class WalletComponent implements OnInit, OnDestroy {
addressStrings: string[] = [];
walletName: string;
isLoadingWallet = true;
isLoadingTransactions = true;
transactions: Transaction[];
totalTransactionCount: number;
retryLoadMore = false;
wallet$: Observable<Record<string, WalletAddress>>;
walletAddresses$: Observable<Record<string, Address>>;
walletSummary$: Observable<AddressTxSummary[]>;
walletStats$: Observable<WalletStats>;
error: any;
walletSubscription: Subscription;
transactionSubscription: Subscription;
collapseAddresses: boolean = true;
@@ -129,6 +137,8 @@ export class WalletComponent implements OnInit, OnDestroy {
private websocketService: WebsocketService,
private stateService: StateService,
private apiService: ApiService,
private electrsApiService: ElectrsApiService,
private audioService: AudioService,
private seoService: SeoService,
) { }
@@ -172,6 +182,21 @@ export class WalletComponent implements OnInit, OnDestroy {
}),
switchMap(initial => this.stateService.walletTransactions$.pipe(
startWith(null),
tap((transactions) => {
if (!transactions?.length) {
return;
}
for (const transaction of transactions) {
const tx = this.transactions.find((t) => t.txid === transaction.txid);
if (tx) {
tx.status = transaction.status;
} else {
this.transactions.unshift(transaction);
}
}
this.transactions = this.transactions.slice();
this.audioService.playSound('magic');
}),
scan((wallet, walletTransactions) => {
for (const tx of (walletTransactions || [])) {
const funded: Record<string, number> = {};
@@ -267,8 +292,57 @@ export class WalletComponent implements OnInit, OnDestroy {
return stats;
}, walletStats),
);
}),
})
);
this.transactionSubscription = this.wallet$.pipe(
switchMap(wallet => {
const addresses = Object.keys(wallet).map(addr => this.normalizeAddress(addr));
return this.electrsApiService.getAddressesTransactions$(addresses);
}),
map(transactions => {
// only confirmed transactions supported for now
return transactions.filter(tx => tx.status.confirmed).sort((a, b) => b.status.block_height - a.status.block_height);
}),
catchError((error) => {
console.log(error);
this.error = error;
this.seoService.logSoft404();
this.isLoadingWallet = false;
return of([]);
})
).subscribe((transactions: Transaction[] | null) => {
if (!transactions) {
return;
}
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore(): void {
if (this.isLoadingTransactions || this.fullyLoaded) {
return;
}
this.isLoadingTransactions = true;
this.retryLoadMore = false;
this.electrsApiService.getAddressesTransactions$(this.addressStrings, this.transactions[this.transactions.length - 1].txid)
.subscribe((transactions: Transaction[]) => {
if (transactions && transactions.length) {
this.transactions = this.transactions.concat(transactions.sort((a, b) => b.status.block_height - a.status.block_height));
} else {
this.fullyLoaded = true;
}
this.isLoadingTransactions = false;
},
(error) => {
this.isLoadingTransactions = false;
this.retryLoadMore = true;
// In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page.
if (error.status === 422) {
window.location.reload();
}
});
}
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
@@ -299,5 +373,6 @@ export class WalletComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.websocketService.stopTrackingWallet();
this.walletSubscription.unsubscribe();
this.transactionSubscription.unsubscribe();
}
}