mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge d29862d513 into 79e79bcadb
This commit is contained in:
commit
cf2f6d36ab
7 changed files with 990 additions and 1 deletions
193
frontend/src/app/components/payment/payment.component.html
Normal file
193
frontend/src/app/components/payment/payment.component.html
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<div class="container-xl">
|
||||
|
||||
@if (!error || loadingCachedTx) {
|
||||
|
||||
<div class="title-block">
|
||||
<h1 i18n="shared.bitcoin.payment">Bitcoin payment</h1>
|
||||
<span class="tx-link">
|
||||
<span class="txid">
|
||||
<app-truncate [text]="txId" [lastChars]="12" [link]="['/tx/' | relativeUrl, txId]">
|
||||
<app-clipboard [text]="txId"></app-clipboard>
|
||||
</app-truncate>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
@if (isMobile) {
|
||||
<div class="col-sm">
|
||||
<ng-container *ngTemplateOutlet="amountSection"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="trackerPanel"></ng-container>
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<ng-container *ngTemplateOutlet="rows"></ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="col-sm">
|
||||
<ng-container *ngTemplateOutlet="trackerPanel"></ng-container>
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<ng-container *ngTemplateOutlet="rows"></ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-sm amount-side">
|
||||
<ng-container *ngTemplateOutlet="amountSection"></ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-link">
|
||||
<button type="button" class="btn btn-sm btn-outline-info" (click)="viewFullDetails()">
|
||||
<span i18n="transaction.view-full-details">View full transaction details</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
@if (error && !loadingCachedTx) {
|
||||
<app-http-error [error]="error">
|
||||
<span i18n="transaction.error.loading-transaction-data">Error loading transaction data.</span>
|
||||
</app-http-error>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<ng-template #amountSection>
|
||||
<div style="position: relative;">
|
||||
@if (viewAmountMode$ | async; as mode) {
|
||||
<div class="toggler">
|
||||
<a href="" (click)="changeMode('btc')" class="toggler-option"
|
||||
[class.inactive]="mode === 'sats'"><small>BTC</small></a>
|
||||
<span style="color: var(--transparent-fg); font-size: 8px"> | </span>
|
||||
<a href="" (click)="changeMode('sats')" class="toggler-option"
|
||||
[class.inactive]="mode !== 'sats'"><small>Sats</small></a>
|
||||
</div>
|
||||
<div class="amount-col">
|
||||
<span class="text" i18n="transaction.amount|Amount">Amount</span>
|
||||
<a class="amount" href="" (click)="changeMode(mode === 'sats' ? 'btc' : 'sats')">
|
||||
<app-amount [satoshis]="amount" [blockConversion]="tx.price" [noFiat]="true"></app-amount>
|
||||
</a>
|
||||
<app-fiat class="fiat" [value]="amount" [blockConversion]="tx.price"></app-fiat>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #rows>
|
||||
<ng-container *ngTemplateOutlet="addressRow"></ng-container><!--
|
||||
<ng-container *ngTemplateOutlet="amountRow"></ng-container> -->
|
||||
<ng-container *ngTemplateOutlet="statusRow"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #addressRow>
|
||||
@if (isValidView) {
|
||||
<tr>
|
||||
<td class="td-width" i18n="shared.address">Address</td>
|
||||
<td>
|
||||
<app-truncate class="address" [text]="destination" [lastChars]="4"
|
||||
[link]="['/address/' | relativeUrl, destination]"></app-truncate>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #amountRow>
|
||||
@if (tx) {
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.amount|Amount">Amount</td>
|
||||
<td class="wrap-cell">
|
||||
<app-amount [satoshis]="amount" [blockConversion]="tx.price" [noFiat]="true"></app-amount>
|
||||
<span class="fiat">
|
||||
<app-fiat [value]="amount" [blockConversion]="tx.price"></app-fiat>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #statusRow>
|
||||
@if (tx && !replaced) {
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.status|Transaction Status">Status</td>
|
||||
<td>
|
||||
@if (!tx.status?.confirmed) {
|
||||
<ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton">
|
||||
@if (eta.blocks >= 7) {
|
||||
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
|
||||
} @else {
|
||||
<app-time kind="until" [time]="eta.time" [fastRender]="false"></app-time>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-template #etaSkeleton>
|
||||
<span class="skeleton-loader"></span>
|
||||
</ng-template>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="confs"></ng-container>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @else if (replaced) {
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #confs>
|
||||
@if (settled) {
|
||||
<span class="settled" i18n="transaction.status.settled|Settled">Settled</span>
|
||||
} @else if (isValidView && confsRequired > 1) {
|
||||
<span class="confs-cell">
|
||||
<span>
|
||||
<span>{{ confirmations }} <span i18n="shared.of">of</span> {{ confsRequired }} </span><span i18n="shared.confirmations">Confirmations</span>
|
||||
</span>
|
||||
<ng-container *ngTemplateOutlet="confsProgress"></ng-container>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="skeleton-loader"></span>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #confsProgress>
|
||||
<span class="confs">
|
||||
@for (conf of confsRequiredArr; track $index) {
|
||||
<span>
|
||||
<svg width="25px" height="25px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="confirmation" [class.pending]="($index + 1) > confirmations"
|
||||
[class.animate]="($index + 1) === (confirmations + 1)" d="M23.05 23.05V488.9H488.9V23.05H23.05z" />
|
||||
</svg>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #trackerPanel>
|
||||
@if (replaced) {
|
||||
<div class="alert-replaced" role="alert">
|
||||
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
|
||||
<a [routerLink]="['/tx/' | relativeUrl, latestReplacement]" [queryParams]="{ mode: 'status' }">{{ latestReplacement
|
||||
}}</a>
|
||||
</div>
|
||||
} @else if (tx && !isCached) {
|
||||
<div class="tracker-bar">
|
||||
<app-tracker-bar [stage]="trackerStage"></app-tracker-bar>
|
||||
</div>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #skeletonRow>
|
||||
<tr>
|
||||
<td colspan="2"><span class="skeleton-loader"></span></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
242
frontend/src/app/components/payment/payment.component.scss
Normal file
242
frontend/src/app/components/payment/payment.component.scss
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
|
||||
.title-block {
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
|
||||
@media (min-width: 650px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
margin-right: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: baseline;
|
||||
width: 0;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
@media (min-width: 651px) {
|
||||
flex-grow: 1;
|
||||
margin-right: 1em;
|
||||
top: 1px;
|
||||
position: relative;
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
width: 100%;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.txid {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.container-buttons {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@media (min-width: 850px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.td-width {
|
||||
width: 150px;
|
||||
@media (max-width: 768px) {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
tr td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
@media (min-width: 576px) {
|
||||
padding: 0.75rem 0.75rem;
|
||||
}
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
@media (min-width: 850px) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&.wrap-cell {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.address ::ng-deep .truncate {
|
||||
justify-content: end !important;
|
||||
@media (min-width: 850px) {
|
||||
justify-content: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fiat {
|
||||
display: block;
|
||||
@media (min-width: 768px) {
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.details-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tracker-bar {
|
||||
padding: 1em 0.5em 0;
|
||||
margin-bottom: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settled {
|
||||
color: var(--green);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.confs-cell {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (min-width: 850px) {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
.confs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.confirmation {
|
||||
fill: var(--green);
|
||||
fill-opacity: 0.8;
|
||||
|
||||
&.pending {
|
||||
fill-opacity: 0.25;
|
||||
&.animate {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-replaced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--red);
|
||||
margin-bottom: 1em;
|
||||
padding: 0.5em;
|
||||
border-radius: 0.5em;
|
||||
|
||||
@media (max-width: 850px) {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-side {
|
||||
@media (min-width: 850px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1.5rem 1rem;
|
||||
background-color: var(--stat-box-bg);
|
||||
|
||||
.text {
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--transparent-fg);
|
||||
}
|
||||
|
||||
app-amount {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.fiat {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
color: var(--transparent-fg);
|
||||
}
|
||||
}
|
||||
|
||||
.toggler {
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: 3px;
|
||||
text-align: right;
|
||||
|
||||
@media (max-width: 850px) {
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggler-option {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
color: var(--transparent-fg);
|
||||
}
|
||||
|
||||
.amount {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
fill-opacity: 0.30;
|
||||
}
|
||||
50% {
|
||||
fill-opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
fill-opacity: 0.30;
|
||||
}
|
||||
}
|
||||
482
frontend/src/app/components/payment/payment.component.ts
Normal file
482
frontend/src/app/components/payment/payment.component.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
import { Component, OnInit, OnDestroy, HostListener, ChangeDetectorRef, Inject } from '@angular/core';
|
||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
||||
import { switchMap, filter, catchError, map, startWith, distinctUntilChanged, tap } from 'rxjs/operators';
|
||||
import { Transaction } from '@interfaces/electrs.interface';
|
||||
import { of, merge, Subscription, Observable, combineLatest, BehaviorSubject, Subject } from 'rxjs';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { AudioService } from '@app/services/audio.service';
|
||||
import { CacheService } from '@app/services/cache.service';
|
||||
import { WebsocketService } from '@app/services/websocket.service';
|
||||
import { SeoService } from '@app/services/seo.service';
|
||||
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
||||
import { getUnacceleratedFeeRate } from '@app/shared/transaction.utils';
|
||||
import { BlockExtended, MempoolPosition, AccelerationPosition, RbfTree } from '@interfaces/node-api.interface';
|
||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||
import { ZONE_SERVICE } from '@app/injection-tokens';
|
||||
import { MiningService, MiningStats } from '@app/services/mining.service';
|
||||
import { ETA, EtaService } from '@app/services/eta.service';
|
||||
import { getRegex } from '@app/shared/regex.utils';
|
||||
import { TrackerStage } from '@components/tracker/tracker-bar.component';
|
||||
import { ApiService } from '@app/services/api.service';
|
||||
import { StorageService } from '@app/services/storage.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-payment',
|
||||
templateUrl: './payment.component.html',
|
||||
styleUrls: ['./payment.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class PaymentComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
tx: Transaction;
|
||||
txId: string;
|
||||
mempoolPosition: MempoolPosition;
|
||||
accelerationPositions: AccelerationPosition[];
|
||||
latestBlock: BlockExtended;
|
||||
isLoadingTx = true;
|
||||
error: any = undefined;
|
||||
waitingForTransaction = false;
|
||||
isMobile: boolean;
|
||||
isValidView = false;
|
||||
rbfTransaction: Transaction | null;
|
||||
|
||||
destination = '';
|
||||
confsRequired = 1;
|
||||
amount = 0;
|
||||
confirmations = 0;
|
||||
settled = false;
|
||||
replaced = false;
|
||||
trackerStage: TrackerStage = 'waiting';
|
||||
|
||||
miningStats: MiningStats;
|
||||
ETA$: Observable<ETA | null>;
|
||||
txChanged$ = new BehaviorSubject<boolean>(false);
|
||||
isAccelerated$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
isCached: boolean;
|
||||
rbfInfo: RbfTree;
|
||||
fetchRbfHistory$ = new Subject<string>();
|
||||
fetchCachedTx$ = new Subject<string>();
|
||||
loadingCachedTx: boolean;
|
||||
|
||||
subscription: Subscription;
|
||||
networkChangedSubscription: Subscription;
|
||||
blocksSubscription: Subscription;
|
||||
mempoolPositionSubscription: Subscription;
|
||||
txConfirmedSubscription: Subscription;
|
||||
txReplacedSubscription: Subscription;
|
||||
fetchRbfSubscription: Subscription;
|
||||
fetchCachedTxSubscription: Subscription;
|
||||
txRbfInfoSubscription: Subscription;
|
||||
latestReplacement: string;
|
||||
amountMode: string;
|
||||
viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private relativeUrlPipe: RelativeUrlPipe,
|
||||
private apiService: ApiService,
|
||||
private electrsApiService: ElectrsApiService,
|
||||
public stateService: StateService,
|
||||
private audioService: AudioService,
|
||||
private cacheService: CacheService,
|
||||
private websocketService: WebsocketService,
|
||||
private seoService: SeoService,
|
||||
private miningService: MiningService,
|
||||
private etaService: EtaService,
|
||||
private storageService: StorageService,
|
||||
private cd: ChangeDetectorRef,
|
||||
@Inject(ZONE_SERVICE) private zoneService: any,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.onResize();
|
||||
this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable();
|
||||
|
||||
if (!this.stateService.isLiquid()) {
|
||||
this.miningService.getMiningStats('1m').subscribe(stats => {
|
||||
this.miningStats = stats;
|
||||
});
|
||||
}
|
||||
|
||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||
|
||||
this.networkChangedSubscription = this.stateService.networkChanged$.subscribe((network) => {
|
||||
this.network = network;
|
||||
});
|
||||
|
||||
this.blocksSubscription = this.stateService.blocks$.subscribe((blocks) => {
|
||||
this.latestBlock = blocks[0];
|
||||
this.updateConfirmations();
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
|
||||
this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
|
||||
if (txPosition && txPosition.txid === this.txId && txPosition.position) {
|
||||
this.mempoolPosition = txPosition.position;
|
||||
this.accelerationPositions = txPosition.accelerationPositions;
|
||||
this.isAccelerated$.next(!!(this.tx?.acceleration || txPosition.position?.accelerated));
|
||||
if (this.tx && !this.tx.status.confirmed) {
|
||||
this.markBlock();
|
||||
}
|
||||
} else {
|
||||
this.mempoolPosition = null;
|
||||
this.accelerationPositions = null;
|
||||
this.isAccelerated$.next(false);
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchRbfSubscription = this.fetchRbfHistory$
|
||||
.pipe(
|
||||
switchMap((txId) =>
|
||||
this.apiService
|
||||
.getRbfHistory$(txId)
|
||||
),
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((rbfResponse) => {
|
||||
this.rbfInfo = rbfResponse?.replacements;
|
||||
if (this.rbfInfo) {
|
||||
// link to the latest pending version
|
||||
this.latestReplacement = this.rbfInfo.tx.txid;
|
||||
// or traverse the rbf tree to find a confirmed version
|
||||
if (this.rbfInfo.mined) {
|
||||
const stack = [this.rbfInfo];
|
||||
let found = false;
|
||||
while (stack.length && !found) {
|
||||
const top = stack.pop();
|
||||
if (top?.tx.mined) {
|
||||
found = true;
|
||||
this.latestReplacement = top.tx.txid;
|
||||
break;
|
||||
} else {
|
||||
stack.push(...top.replaces);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchCachedTxSubscription = this.fetchCachedTx$
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.loadingCachedTx = true;
|
||||
}),
|
||||
switchMap((txId) =>
|
||||
this.apiService
|
||||
.getRbfCachedTx$(txId)
|
||||
),
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((tx) => {
|
||||
this.loadingCachedTx = false;
|
||||
if (!tx) {
|
||||
this.seoService.logSoft404();
|
||||
return;
|
||||
}
|
||||
this.seoService.clearSoft404();
|
||||
|
||||
if (!this.tx) {
|
||||
this.tx = tx;
|
||||
this.isCached = true;
|
||||
if (tx.fee === undefined) {
|
||||
this.tx.fee = 0;
|
||||
}
|
||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||
this.isLoadingTx = false;
|
||||
this.error = undefined;
|
||||
this.waitingForTransaction = false;
|
||||
|
||||
if (!this.isValidDestination(tx)) {
|
||||
this.viewFullDetails();
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchRbfHistory$.next(this.tx.txid);
|
||||
this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => {
|
||||
if (this.tx) {
|
||||
this.rbfInfo = rbfInfo;
|
||||
}
|
||||
});
|
||||
this.setAmount();
|
||||
this.txChanged$.next(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscription = this.zoneService.wrapObservable(this.route.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
this.txId = params.get('id');
|
||||
this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`);
|
||||
const network = this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ? 'Liquid' : 'Bitcoin';
|
||||
const seoDescription = seoDescriptionNetwork(this.stateService.network);
|
||||
this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${network}${seoDescription} transaction with txid ${this.txId}.`);
|
||||
this.resetTransaction();
|
||||
|
||||
return merge(
|
||||
of(true),
|
||||
this.stateService.connectionState$.pipe(
|
||||
filter((state) => state === 2 && this.tx && !this.tx.status?.confirmed)
|
||||
)
|
||||
);
|
||||
}),
|
||||
switchMap(() => {
|
||||
let transactionObservable$: Observable<Transaction>;
|
||||
const cached = this.cacheService.getTxFromCache(this.txId);
|
||||
if (cached && cached.fee !== -1) {
|
||||
transactionObservable$ = of(cached);
|
||||
} else {
|
||||
transactionObservable$ = this.electrsApiService
|
||||
.getTransaction$(this.txId)
|
||||
.pipe(catchError(this.handleLoadElectrsTransactionError.bind(this)));
|
||||
}
|
||||
return merge(transactionObservable$, this.stateService.mempoolTransactions$);
|
||||
}),
|
||||
))
|
||||
.subscribe((tx: Transaction) => {
|
||||
if (!tx) {
|
||||
this.fetchCachedTx$.next(this.txId);
|
||||
this.seoService.logSoft404();
|
||||
return;
|
||||
}
|
||||
this.seoService.clearSoft404();
|
||||
|
||||
this.tx = tx;
|
||||
this.isCached = false;
|
||||
this.isAccelerated$.next(!!(tx.acceleration || this.mempoolPosition?.accelerated));
|
||||
if (tx.fee === undefined) {
|
||||
this.tx.fee = 0;
|
||||
}
|
||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||
this.isLoadingTx = false;
|
||||
this.error = undefined;
|
||||
this.waitingForTransaction = false;
|
||||
|
||||
// The payment view only makes sense for a real output of this transaction.
|
||||
// Otherwise fall back to the full transaction page.
|
||||
if (!this.isValidDestination(tx)) {
|
||||
this.viewFullDetails();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tx.status.confirmed) {
|
||||
this.trackerStage = 'pending';
|
||||
} else {
|
||||
this.trackerStage = 'confirmed';
|
||||
}
|
||||
|
||||
this.websocketService.startTrackTransaction(tx.txid);
|
||||
this.setAmount();
|
||||
this.updateConfirmations();
|
||||
this.txChanged$.next(true);
|
||||
this.markBlock();
|
||||
|
||||
this.cd.detectChanges();
|
||||
},
|
||||
(error) => {
|
||||
this.error = error;
|
||||
this.seoService.logSoft404();
|
||||
this.isLoadingTx = false;
|
||||
});
|
||||
|
||||
this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
|
||||
if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) {
|
||||
this.tx.status = {
|
||||
confirmed: true,
|
||||
block_height: block.height,
|
||||
block_hash: block.id,
|
||||
block_time: block.timestamp,
|
||||
};
|
||||
this.txChanged$.next(true);
|
||||
this.updateConfirmations();
|
||||
this.stateService.markBlock$.next({ blockHeight: block.height });
|
||||
this.trackerStage = 'confirmed';
|
||||
this.audioService.playSound('magic');
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTx) => {
|
||||
if (!this.tx) {
|
||||
this.error = new Error();
|
||||
this.loadingCachedTx = false;
|
||||
this.waitingForTransaction = false;
|
||||
}
|
||||
|
||||
this.rbfTransaction = rbfTx;
|
||||
this.replaced = true;
|
||||
this.trackerStage = 'replaced';
|
||||
if (!this.rbfInfo && rbfTx) {
|
||||
this.latestReplacement = rbfTx.txid;
|
||||
}
|
||||
this.stateService.markBlock$.next({});
|
||||
|
||||
if (rbfTx && !this.tx) {
|
||||
this.fetchCachedTx$.next(this.txId);
|
||||
}
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
|
||||
this.ETA$ = combineLatest([
|
||||
this.stateService.mempoolTxPosition$.pipe(startWith(null)),
|
||||
this.stateService.mempoolBlocks$.pipe(startWith(null)),
|
||||
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
|
||||
this.isAccelerated$,
|
||||
this.txChanged$,
|
||||
]).pipe(
|
||||
map(([position, mempoolBlocks, da, isAccelerated]) => {
|
||||
if (!this.tx || this.tx.status?.confirmed || !position || position.txid !== this.tx.txid) {
|
||||
return null;
|
||||
}
|
||||
return this.etaService.calculateETA(
|
||||
this.network,
|
||||
this.tx,
|
||||
mempoolBlocks,
|
||||
position,
|
||||
da,
|
||||
this.miningStats,
|
||||
isAccelerated,
|
||||
this.accelerationPositions,
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged((prev: ETA | null, curr: ETA | null) => {
|
||||
return prev === curr || (prev && curr && prev.time === curr.time && prev.blocks === curr.blocks);
|
||||
}),
|
||||
tap((eta) => {
|
||||
if (this.replaced) {
|
||||
this.trackerStage = 'replaced';
|
||||
} else if (eta?.blocks === 0) {
|
||||
this.trackerStage = 'next';
|
||||
} else if (eta?.blocks < 3){
|
||||
this.trackerStage = 'soon';
|
||||
} else {
|
||||
this.trackerStage = 'pending';
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isValidDestination(tx: Transaction): boolean {
|
||||
const network = (this.network || 'mainnet') as any;
|
||||
if (!this.destination || !getRegex('address', network).test(this.destination)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isValidView = tx?.vout?.some(vout => vout.scriptpubkey_address === this.destination);
|
||||
return this.isValidView;
|
||||
}
|
||||
|
||||
setAmount(): void {
|
||||
this.amount = (this.tx?.vout || []).reduce((total, vout) => {
|
||||
return vout.scriptpubkey_address === this.destination ? total + vout.value : total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
updateConfirmations(): void {
|
||||
if (this.tx?.status?.confirmed && this.latestBlock?.height != null) {
|
||||
this.confirmations = Math.max(1, this.latestBlock.height - this.tx.status.block_height + 1);
|
||||
} else {
|
||||
this.confirmations = 0;
|
||||
}
|
||||
this.settled = this.confirmations >= this.confsRequired;
|
||||
}
|
||||
|
||||
markBlock(): void {
|
||||
if (this.tx?.status?.confirmed) {
|
||||
this.stateService.markBlock$.next({ blockHeight: this.tx.status.block_height });
|
||||
} else if (this.tx) {
|
||||
const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated);
|
||||
this.stateService.markBlock$.next({
|
||||
txid: this.tx.txid,
|
||||
txFeePerVSize,
|
||||
mempoolPosition: this.mempoolPosition,
|
||||
accelerationPositions: this.accelerationPositions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
viewFullDetails(): void {
|
||||
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId]);
|
||||
}
|
||||
|
||||
get confsRequiredArr(): number[] {
|
||||
return Array(this.confsRequired);
|
||||
}
|
||||
|
||||
handleLoadElectrsTransactionError(error: any): Observable<any> {
|
||||
if (error.status === 404 && /^[a-fA-F0-9]{64}$/.test(this.txId)) {
|
||||
this.websocketService.startMultiTrackTransaction(this.txId);
|
||||
this.waitingForTransaction = true;
|
||||
}
|
||||
this.error = error;
|
||||
this.seoService.logSoft404();
|
||||
this.isLoadingTx = false;
|
||||
return of(false);
|
||||
}
|
||||
|
||||
getFragment(destination: string, confs: number = 1): string {
|
||||
return `destination=${destination}&confs=${confs}`;
|
||||
}
|
||||
|
||||
resetTransaction(): void {
|
||||
const fragmentParams = new URLSearchParams(this.route.snapshot.fragment || '');
|
||||
this.destination = fragmentParams.get('destination') || '';
|
||||
const parsedConfs = Math.min(Math.ceil(parseInt((fragmentParams.get('confs') || '1'), 10)), 6); // Capped to 6 confs
|
||||
this.confsRequired = (!isNaN(parsedConfs) && parsedConfs >= 1) ? parsedConfs : 1;
|
||||
|
||||
this.error = undefined;
|
||||
this.tx = null;
|
||||
this.txChanged$.next(true);
|
||||
this.waitingForTransaction = false;
|
||||
this.isLoadingTx = true;
|
||||
this.isValidView = false;
|
||||
this.replaced = false;
|
||||
this.rbfTransaction = null;
|
||||
this.rbfInfo = null;
|
||||
this.latestReplacement = '';
|
||||
this.isCached = false;
|
||||
this.loadingCachedTx = false;
|
||||
this.trackerStage = 'waiting';
|
||||
this.amount = 0;
|
||||
this.confirmations = 0;
|
||||
this.settled = false;
|
||||
this.mempoolPosition = null;
|
||||
this.accelerationPositions = null;
|
||||
this.isAccelerated$.next(false);
|
||||
this.txRbfInfoSubscription?.unsubscribe();
|
||||
this.leaveTransaction();
|
||||
}
|
||||
|
||||
leaveTransaction(): void {
|
||||
this.websocketService.stopTrackingTransaction();
|
||||
this.stateService.markBlock$.next({});
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(): void {
|
||||
this.isMobile = window.innerWidth < 850;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.fetchRbfSubscription?.unsubscribe();
|
||||
this.fetchCachedTxSubscription?.unsubscribe();
|
||||
this.subscription?.unsubscribe();
|
||||
this.networkChangedSubscription?.unsubscribe();
|
||||
this.blocksSubscription?.unsubscribe();
|
||||
this.mempoolPositionSubscription?.unsubscribe();
|
||||
this.txConfirmedSubscription?.unsubscribe();
|
||||
this.txReplacedSubscription?.unsubscribe();
|
||||
this.leaveTransaction();
|
||||
}
|
||||
|
||||
changeMode(mode: 'btc' | 'sats'): boolean {
|
||||
this.storageService.setValue('view-amount-mode', mode);
|
||||
this.stateService.viewAmountMode$.next(mode);
|
||||
this.amountMode = mode;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
50
frontend/src/app/components/payment/payment.module.ts
Normal file
50
frontend/src/app/components/payment/payment.module.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { SharedModule } from '@app/shared/shared.module';
|
||||
import { PaymentComponent } from '@components/payment/payment.component';
|
||||
import { TrackerModule } from '@components/tracker/tracker.module';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: '/',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: PaymentComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes)
|
||||
],
|
||||
exports: [
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class PaymentRoutingModule { }
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
PaymentRoutingModule,
|
||||
SharedModule,
|
||||
TrackerModule
|
||||
],
|
||||
declarations: [
|
||||
PaymentComponent
|
||||
]
|
||||
})
|
||||
export class PaymentModule { }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -40,6 +40,9 @@ export class TrackerRoutingModule { }
|
|||
declarations: [
|
||||
TrackerComponent,
|
||||
TrackerBarComponent,
|
||||
],
|
||||
exports: [
|
||||
TrackerBarComponent
|
||||
]
|
||||
})
|
||||
export class TrackerModule { }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { ServerStatusComponent } from '@components/server-health/server-status.c
|
|||
import { FaucetComponent } from '@components/faucet/faucet.component';
|
||||
import { SimpleProofWidgetComponent } from '@components/simpleproof-widget/simpleproof-widget.component';
|
||||
import { SimpleProofCuboWidgetComponent } from '@components/simpleproof-widget/simpleproof-cubo-widget.component';
|
||||
import { PaymentGuard } from '@app/route-guards';
|
||||
|
||||
const browserWindow = window || {};
|
||||
// @ts-ignore
|
||||
|
|
@ -93,6 +94,15 @@ const routes: Routes = [
|
|||
{
|
||||
path: 'tx',
|
||||
component: StartComponent,
|
||||
canMatch: [PaymentGuard],
|
||||
runGuardsAndResolvers: 'always',
|
||||
data: { preload: false, networkSpecific: true },
|
||||
loadChildren: () => import('@components/payment/payment.module').then(m => m.PaymentModule),
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
component: StartComponent,
|
||||
runGuardsAndResolvers: 'always',
|
||||
data: { preload: true, networkSpecific: true },
|
||||
loadChildren: () => import('@components/transaction/transaction.module').then(m => m.TransactionModule),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,10 +14,19 @@ class GuardService {
|
|||
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
||||
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
||||
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
|
||||
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path));
|
||||
return (!this.paymentGuard() && (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)));
|
||||
}
|
||||
|
||||
paymentGuard(route?: Route, segments?: UrlSegment[]): boolean {
|
||||
const fragmentParams = new URLSearchParams(this.router.getCurrentNavigation()?.extractedUrl.fragment || '');
|
||||
return (fragmentParams.has('destination'));
|
||||
}
|
||||
}
|
||||
|
||||
export const TrackerGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
|
||||
return inject(GuardService).trackerGuard(route, segments);
|
||||
};
|
||||
|
||||
export const PaymentGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
|
||||
return inject(GuardService).paymentGuard(route, segments);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue