From 025b0585b483168f2ccfffd33e204f7ecfad8c71 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 27 Nov 2024 18:11:56 +0100 Subject: [PATCH] Preview transaction from raw data --- .../transaction-raw.component.html | 190 +++++++++++ .../transaction-raw.component.scss | 194 ++++++++++++ .../transaction/transaction-raw.component.ts | 296 ++++++++++++++++++ .../transaction/transaction.module.ts | 6 + .../transactions-list.component.ts | 12 +- frontend/src/app/route-guards.ts | 2 +- .../global-footer.component.html | 1 + .../truncate/truncate.component.html | 2 +- .../truncate/truncate.component.scss | 6 + .../components/truncate/truncate.component.ts | 1 + 10 files changed, 704 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.html create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.scss create mode 100644 frontend/src/app/components/transaction/transaction-raw.component.ts diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html new file mode 100644 index 000000000..15293e2dd --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -0,0 +1,190 @@ +
+ + @if (!transaction) { + +

Preview Transaction

+ +
+
+ +
+ + + +

Error decoding transaction, reason: {{ error }}

+
+ } + + @if (transaction && !error && !isLoading) { +
+

Preview Transaction

+ + + + + + + + + +
+ + + +
+ +
+ +

{{ errorBroadcast }}

+ +
+ + @if (!hasPrevouts) { +
+ This transaction is missing prevouts data. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }} +
+ } + + + +
+ + +
+

Flow

+
+ + + +
+ +
+
+ + +
+
+ + + + +
+
+ +
+
+ + + + +
+
+

Inputs & Outputs

+
+ +
+ + +
+
+ + + + +
+

Details

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
Size
Virtual size
Adjusted vsize + + + +
Weight
+
+
+ + + + + + + + + + + + + + + + + + + +
Version
Locktime
Sigops + + + +
Transaction hex
+
+
+
+ } + + @if (isLoading) { +
+
+

Loading transaction prevouts ({{ prevoutsLoadedCount }} / {{ prevoutsCount }})

+
+ } +
\ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.scss b/frontend/src/app/components/transaction/transaction-raw.component.scss new file mode 100644 index 000000000..5bbe5601e --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.scss @@ -0,0 +1,194 @@ +.label { + margin: 0 5px; +} + +.container-buttons { + align-self: center; +} + +.title-block { + flex-wrap: wrap; + align-items: baseline; + @media (min-width: 650px) { + flex-direction: row; + } + h1 { + margin: 0rem; + 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-right: 0px; + margin-bottom: 0px; + margin-top: 8px; + @media (min-width: 651px) { + flex-grow: 1; + margin-bottom: 0px; + 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-xl { + margin-bottom: 40px; +} + +.row { + flex-direction: column; + @media (min-width: 850px) { + flex-direction: row; + } +} + +.box.hidden { + visibility: hidden; + height: 0px; + padding-top: 0px; + padding-bottom: 0px; + margin-top: 0px; + margin-bottom: 0px; +} + +.graph-container { + position: relative; + width: 100%; + background: var(--stat-box-bg); + padding: 10px 0; + padding-bottom: 0; +} + +.toggle-wrapper { + width: 100%; + text-align: center; + margin: 1.25em 0 0; +} + +.graph-toggle { + margin: auto; +} + +.table { + 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; + } + } + .btn { + display: block; + } + + &.wrap-cell { + white-space: normal; + } + } +} + +.effective-fee-container { + display: block; + @media (min-width: 768px){ + display: inline-block; + } + @media (max-width: 425px){ + display: flex; + flex-direction: column; + } +} + +.effective-fee-rating { + @media (max-width: 767px){ + margin-right: 0px !important; + } +} + +.title { + h2 { + line-height: 1; + margin: 0; + padding-bottom: 5px; + } +} + +.btn-outline-info { + margin-top: 5px; + @media (min-width: 768px){ + margin-top: 0px; + } +} + +.flow-toggle { + margin-top: -5px; + margin-left: 10px; + @media (min-width: 768px){ + display: inline-block; + margin-top: 0px; + margin-bottom: 0px; + } +} + +.subtitle-block { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + + .title { + flex-shrink: 0; + } + + .title-buttons { + flex-shrink: 1; + text-align: right; + .btn { + margin-top: 0; + margin-bottom: 8px; + margin-left: 8px; + } + } +} + +.cpfp-details { + .txids { + width: 60%; + } + + @media (max-width: 500px) { + .txids { + width: 40%; + } + } +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} + +.no-cursor { + cursor: default !important; + pointer-events: none; +} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts new file mode 100644 index 000000000..cac7b595f --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -0,0 +1,296 @@ +import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Transaction } from '@interfaces/electrs.interface'; +import { StateService } from '../../services/state.service'; +import { Filter, toFilters } from '../../shared/filters.utils'; +import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; +import { ETA, EtaService } from '../../services/eta.service'; +import { combineLatest, firstValueFrom, map, Observable, startWith, Subscription } from 'rxjs'; +import { WebsocketService } from '../../services/websocket.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { SeoService } from '../../services/seo.service'; +import { seoDescriptionNetwork } from '@app/shared/common.utils'; +import { ApiService } from '../../services/api.service'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; + +@Component({ + selector: 'app-transaction-raw', + templateUrl: './transaction-raw.component.html', + styleUrls: ['./transaction-raw.component.scss'], +}) +export class TransactionRawComponent implements OnInit, OnDestroy { + + pushTxForm: UntypedFormGroup; + isLoading: boolean; + offlineMode: boolean = false; + transaction: Transaction; + error: string; + errorPrevouts: string; + hasPrevouts: boolean; + prevoutsLoadedCount: number = 0; + prevoutsCount: number; + isLoadingBroadcast: boolean; + errorBroadcast: string; + successBroadcast: boolean; + + isMobile: boolean; + @ViewChild('graphContainer') + graphContainer: ElementRef; + graphExpanded: boolean = false; + graphWidth: number = 1068; + graphHeight: number = 360; + inOutLimit: number = 150; + maxInOut: number = 0; + flowPrefSubscription: Subscription; + hideFlow: boolean = this.stateService.hideFlow.value; + flowEnabled: boolean; + adjustedVsize: number; + filters: Filter[] = []; + showCpfpDetails = false; + ETA$: Observable; + mempoolBlocksSubscription: Subscription; + + constructor( + public route: ActivatedRoute, + public router: Router, + public stateService: StateService, + public etaService: EtaService, + public electrsApi: ElectrsApiService, + public websocketService: WebsocketService, + public formBuilder: UntypedFormBuilder, + public cd: ChangeDetectorRef, + public seoService: SeoService, + public apiService: ApiService, + public relativeUrlPipe: RelativeUrlPipe, + ) {} + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@meta.title.preview-tx:Preview Transaction`); + this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`); + this.websocketService.want(['blocks', 'mempool-blocks']); + this.pushTxForm = this.formBuilder.group({ + txRaw: ['', Validators.required], + }); + } + + async decodeTransaction(): Promise { + this.resetState(); + this.isLoading = true; + try { + const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network); + await this.fetchPrevouts(tx); + this.processTransaction(tx); + } catch (error) { + this.error = error.message; + } finally { + this.isLoading = false; + } + } + + async fetchPrevouts(transaction: Transaction): Promise { + if (this.offlineMode) { + return; + } + + this.prevoutsCount = transaction.vin.filter(input => !input.is_coinbase).length; + if (this.prevoutsCount === 0) { + this.hasPrevouts = true; + return; + } + + const txsToFetch: { [txid: string]: number } = transaction.vin.reduce((acc, input) => { + if (!input.is_coinbase) { + acc[input.txid] = (acc[input.txid] || 0) + 1; + } + return acc; + }, {} as { [txid: string]: number }); + + try { + + if (Object.keys(txsToFetch).length > 20) { + throw new Error($localize`:@@transaction.too-many-prevouts:Too many transactions to fetch (${Object.keys(txsToFetch).length})`); + } + + const fetchedTransactions = await Promise.all( + Object.keys(txsToFetch).map(txid => + firstValueFrom(this.electrsApi.getTransaction$(txid)) + .then(response => { + this.prevoutsLoadedCount += txsToFetch[txid]; + this.cd.markForCheck(); + return response; + }) + ) + ); + + const transactionsMap = fetchedTransactions.reduce((acc, transaction) => { + acc[transaction.txid] = transaction; + return acc; + }, {} as { [txid: string]: any }); + + const prevouts = transaction.vin.map((input, index) => ({ index, prevout: transactionsMap[input.txid]?.vout[input.vout] || null})); + + transaction.vin = transaction.vin.map((input, index) => { + if (!input.is_coinbase) { + input.prevout = prevouts.find(p => p.index === index)?.prevout; + addInnerScriptsToVin(input); + } + return input; + }); + this.hasPrevouts = true; + } catch (error) { + this.errorPrevouts = error.message; + } + } + + processTransaction(tx: Transaction): void { + this.transaction = tx; + + if (this.hasPrevouts) { + this.transaction.fee = this.transaction.vin.some(input => input.is_coinbase) + ? 0 + : this.transaction.vin.reduce((fee, input) => { + return fee + (input.prevout?.value || 0); + }, 0) - this.transaction.vout.reduce((sum, output) => sum + output.value, 0); + this.transaction.feePerVsize = this.transaction.fee / (this.transaction.weight / 4); + } + + this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network); + this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; + this.transaction.sigops = countSigops(this.transaction); + if (this.transaction.sigops >= 0) { + this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); + } + + this.setupGraph(); + this.setFlowEnabled(); + this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => { + this.hideFlow = !!hide; + this.setFlowEnabled(); + }); + this.setGraphSize(); + + this.ETA$ = combineLatest([ + this.stateService.mempoolTxPosition$.pipe(startWith(null)), + this.stateService.mempoolBlocks$.pipe(startWith(null)), + this.stateService.difficultyAdjustment$.pipe(startWith(null)), + ]).pipe( + map(([position, mempoolBlocks, da]) => { + return this.etaService.calculateETA( + this.stateService.network, + this.transaction, + mempoolBlocks, + position, + da, + null, + null, + null + ); + }) + ); + + this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => { + if (this.transaction) { + this.stateService.markBlock$.next({ + txid: this.transaction.txid, + txFeePerVSize: this.transaction.feePerVsize, + }); + } + }); + } + + async postTx(): Promise { + this.isLoadingBroadcast = true; + this.errorBroadcast = null; + return new Promise((resolve, reject) => { + this.apiService.postTransaction$(this.pushTxForm.get('txRaw').value) + .subscribe((result) => { + this.isLoadingBroadcast = false; + this.successBroadcast = true; + resolve(result); + }, + (error) => { + if (typeof error.error === 'string') { + const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"'); + this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error); + } else if (error.message) { + this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message; + } + this.isLoadingBroadcast = false; + reject(this.error); + }); + }); + } + + resetState() { + this.transaction = null; + this.error = null; + this.errorPrevouts = null; + this.errorBroadcast = null; + this.successBroadcast = false; + this.isLoading = false; + this.adjustedVsize = null; + this.filters = []; + this.hasPrevouts = false; + this.prevoutsLoadedCount = 0; + this.prevoutsCount = 0; + this.stateService.markBlock$.next({}); + this.mempoolBlocksSubscription?.unsubscribe(); + } + + resetForm() { + this.resetState(); + this.pushTxForm.reset(); + } + + @HostListener('window:resize', ['$event']) + setGraphSize(): void { + this.isMobile = window.innerWidth < 850; + if (this.graphContainer?.nativeElement && this.stateService.isBrowser) { + setTimeout(() => { + if (this.graphContainer?.nativeElement?.clientWidth) { + this.graphWidth = this.graphContainer.nativeElement.clientWidth; + } else { + setTimeout(() => { this.setGraphSize(); }, 1); + } + }, 1); + } else { + setTimeout(() => { this.setGraphSize(); }, 1); + } + } + + setupGraph() { + this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1)); + this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80); + } + + toggleGraph() { + const showFlow = !this.flowEnabled; + this.stateService.hideFlow.next(!showFlow); + } + + setFlowEnabled() { + this.flowEnabled = !this.hideFlow; + } + + expandGraph() { + this.graphExpanded = true; + this.graphHeight = this.maxInOut * 15; + } + + collapseGraph() { + this.graphExpanded = false; + this.graphHeight = Math.min(360, this.maxInOut * 80); + } + + onOfflineModeChange(e): void { + this.offlineMode = !e.target.checked; + } + + ngOnDestroy(): void { + this.mempoolBlocksSubscription?.unsubscribe(); + this.flowPrefSubscription?.unsubscribe(); + this.stateService.markBlock$.next({}); + } + +} diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts index 80de0cf40..58b6493d8 100644 --- a/frontend/src/app/components/transaction/transaction.module.ts +++ b/frontend/src/app/components/transaction/transaction.module.ts @@ -9,6 +9,7 @@ import { TransactionExtrasModule } from '@components/transaction/transaction-ext import { GraphsModule } from '@app/graphs/graphs.module'; import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component'; import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component'; +import { TransactionRawComponent } from '@components/transaction/transaction-raw.component'; const routes: Routes = [ { @@ -16,6 +17,10 @@ const routes: Routes = [ redirectTo: '/', pathMatch: 'full', }, + { + path: 'preview', + component: TransactionRawComponent, + }, { path: ':id', component: TransactionComponent, @@ -49,6 +54,7 @@ export class TransactionRoutingModule { } TransactionDetailsComponent, AccelerateCheckout, AccelerateFeeGraphComponent, + TransactionRawComponent, ], exports: [ TransactionComponent, diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index b07546e5e..d1764442e 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -37,6 +37,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Input() addresses: string[] = []; @Input() rowLimit = 12; @Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block + @Input() txPreview = false; @Output() loadMore = new EventEmitter(); @@ -81,7 +82,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.refreshOutspends$ .pipe( switchMap((txIds) => { - if (!this.cached) { + if (!this.cached && !this.txPreview) { // break list into batches of 50 (maximum supported by esplora) const batches = []; for (let i = 0; i < txIds.length; i += 50) { @@ -119,7 +120,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { ), this.refreshChannels$ .pipe( - filter(() => this.stateService.networkSupportsLightning()), + filter(() => this.stateService.networkSupportsLightning() && !this.txPreview), switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)), catchError((error) => { // handle 404 @@ -187,7 +188,10 @@ export class TransactionsListComponent implements OnInit, OnChanges { } this.transactionsLength = this.transactions.length; - this.cacheService.setTxCache(this.transactions); + + if (!this.txPreview) { + this.cacheService.setTxCache(this.transactions); + } const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length; this.transactions.forEach((tx) => { @@ -347,7 +351,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } loadMoreInputs(tx: Transaction): void { - if (!tx['@vinLoaded']) { + if (!tx['@vinLoaded'] && !this.txPreview) { this.electrsApiService.getTransaction$(tx.txid) .subscribe((newTx) => { tx['@vinLoaded'] = true; diff --git a/frontend/src/app/route-guards.ts b/frontend/src/app/route-guards.ts index 780e997db..81cbf03ae 100644 --- a/frontend/src/app/route-guards.ts +++ b/frontend/src/app/route-guards.ts @@ -14,7 +14,7 @@ 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'].includes(path[1].path)); + return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)); } } diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index d82bb8062..edce7bb88 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -76,6 +76,7 @@

Recent Blocks

Broadcast Transaction

Test Transaction

+

Preview Transaction

Connect to our Nodes

API Documentation

diff --git a/frontend/src/app/shared/components/truncate/truncate.component.html b/frontend/src/app/shared/components/truncate/truncate.component.html index 066f83244..b7e31483e 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.html +++ b/frontend/src/app/shared/components/truncate/truncate.component.html @@ -1,6 +1,6 @@ - + diff --git a/frontend/src/app/shared/components/truncate/truncate.component.scss b/frontend/src/app/shared/components/truncate/truncate.component.scss index 8c22dd836..739376ed2 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.scss +++ b/frontend/src/app/shared/components/truncate/truncate.component.scss @@ -37,6 +37,12 @@ max-width: 300px; overflow: hidden; } + + .disabled { + pointer-events: none; + opacity: 0.8; + color: #fff; + } } @media (max-width: 567px) { diff --git a/frontend/src/app/shared/components/truncate/truncate.component.ts b/frontend/src/app/shared/components/truncate/truncate.component.ts index 589f7aa36..f9ab34ee9 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.ts +++ b/frontend/src/app/shared/components/truncate/truncate.component.ts @@ -15,6 +15,7 @@ export class TruncateComponent { @Input() maxWidth: number = null; @Input() inline: boolean = false; @Input() textAlign: 'start' | 'end' = 'start'; + @Input() disabled: boolean = false; rtl: boolean; constructor(