diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index fd04707b5..ac848d4a4 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -162,6 +162,7 @@ class BitcoinRoutes { adjustedVsize: tx.adjustedVsize, acceleration: tx.acceleration, acceleratedBy: tx.acceleratedBy || undefined, + acceleratedAt: tx.acceleratedAt || undefined, }); return; } diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index a144515dc..e655601e5 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -452,12 +452,14 @@ class MempoolBlocks { } mempoolTx.acceleration = true; mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools; + mempoolTx.acceleratedAt = acceleration?.added; for (const ancestor of mempoolTx.ancestors || []) { if (!mempool[ancestor.txid].acceleration) { mempool[ancestor.txid].cpfpDirty = true; } mempool[ancestor.txid].acceleration = true; mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy; + mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt; isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy; } } else { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 3c8c06ecf..8d1a85994 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -822,6 +822,7 @@ class WebsocketHandler { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, }, accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }; @@ -862,6 +863,7 @@ class WebsocketHandler { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, }; if (!mempoolTx.cpfpChecked) { calculateCpfp(mempoolTx, newMempool); @@ -1139,6 +1141,7 @@ class WebsocketHandler { ...mempoolTx.position, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, }, accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid), }); @@ -1160,6 +1163,7 @@ class WebsocketHandler { }, accelerated: mempoolTx.acceleration || undefined, acceleratedBy: mempoolTx.acceleratedBy || undefined, + acceleratedAt: mempoolTx.acceleratedAt || undefined, }; } } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 2d3c7a7c0..34375604e 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -112,6 +112,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction { }; acceleration?: boolean; acceleratedBy?: number[]; + acceleratedAt?: number; replacement?: boolean; uid?: number; flags?: number; @@ -434,7 +435,7 @@ export interface OptimizedStatistic { export interface TxTrackingInfo { replacedBy?: string, - position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[] }, + position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number }, cpfp?: { ancestors?: Ancestor[], bestDescendant?: Ancestor | null, @@ -446,6 +447,7 @@ export interface TxTrackingInfo { utxoSpent?: { [vout: number]: { vin: number, txid: string } }, accelerated?: boolean, acceleratedBy?: number[], + acceleratedAt?: number, confirmed?: boolean } diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html new file mode 100644 index 000000000..6d3591fb9 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.html @@ -0,0 +1,78 @@ +
+
+
+
+
+
+
+ +
+
+
+
+
+ @if (eta) { + ~ + } @else if (tx.status.block_time) { + + } +
+
+
+
+ +
+
+
+
+ +
+
+
Sent
+
+ +
+
+
+
+
+
+
+
+ +
+
+
Accelerated
+
+ +
+
+
+
+
+
+
+ +
+
+
Mined
+
+ @if (tx.status.block_time) { + + } @else if (eta) { + + } +
+
+
+
+ + +
+
+ + +
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss new file mode 100644 index 000000000..d0338ec84 --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.scss @@ -0,0 +1,197 @@ +.acceleration-timeline { + position: relative; + width: 100%; + padding: 1em 0; + + &::after, &::before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 2em; + z-index: 2; + } + + &::before { + left: 0; + background: linear-gradient(to right, var(--box-bg), var(--box-bg), transparent); + } + + &::after { + right: 0; + background: linear-gradient(to left, var(--box-bg), var(--box-bg), transparent); + } + + .timeline-wrapper { + position: relative; + width: calc(100% - 2em); + margin: auto; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .intervals, .nodes { + min-width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + text-align: center; + + .node, .node-spacer { + width: 6em; + min-width: 6em; + flex-grow: 1; + } + + .interval, .interval-spacer { + width: 8em; + min-width: 5em; + max-width: 8em; + height: 32px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-end; + } + + .interval { + overflow: visible; + } + + .interval-time { + font-size: 12px; + line-height: 16px; + white-space: nowrap; + } + } + + .node, .interval-spacer { + position: relative; + .seen-to-acc { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: var(--primary); + border-radius: 5px; + + &.loading { + animation: standardPulse 1s infinite; + } + + &.left { + right: 50%; + } + + &.right { + left: 50%; + } + } + + .acc-to-confirmed { + position: absolute; + height: 10px; + left: -5px; + right: -5px; + top: 0; + transform: translateY(-50%); + background: var(--tertiary); + border-radius: 5px; + + &.loading { + animation: acceleratePulse 1s infinite; + } + + &.left { + right: 50%; + } + &.right { + left: 50%; + } + } + + } + + .nodes { + position: relative; + margin-top: 1em; + .node { + .shape-border { + display: block; + margin: auto; + height: calc(1em + 8px); + width: calc(1em + 8px); + margin-bottom: -8px; + transform: translateY(-50%); + border-radius: 50%; + padding: 2px; + background: transparent; + transition: background-color 300ms, padding 300ms; + + .shape { + width: 100%; + height: 100%; + border-radius: 50%; + background: white; + transition: background-color 300ms, border 300ms; + } + + &.sent-selected { + .shape { + background: var(--primary); + } + } + + &.accelerated-selected { + .shape { + background: var(--tertiary); + } + } + + &.mined-selected { + .shape { + background: var(--success); + } + } + } + + .status { + margin-top: -64px; + + .badge.badge-accelerated { + background-color: var(--tertiary); + color: white; + } + } + + .time { + margin-top: 33px; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + } + } + } +} + +@keyframes acceleratePulse { + 0% { background-color: var(--tertiary) } + 50% { background-color: var(--mainnet-alt) } + 100% { background-color: var(--tertiary) } +} + +@keyframes standardPulse { + 0% { background-color: var(--primary) } + 50% { background-color: var(--secondary) } + 100% { background-color: var(--primary) } + +} \ No newline at end of file diff --git a/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts new file mode 100644 index 000000000..d40215c1d --- /dev/null +++ b/frontend/src/app/components/acceleration-timeline/acceleration-timeline.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core'; +import { ETA } from '../../services/eta.service'; +import { Transaction } from '../../interfaces/electrs.interface'; + +@Component({ + selector: 'app-acceleration-timeline', + templateUrl: './acceleration-timeline.component.html', + styleUrls: ['./acceleration-timeline.component.scss'], +}) +export class AccelerationTimelineComponent implements OnInit, OnChanges { + @Input() transactionTime: number; + @Input() tx: Transaction; + @Input() eta: ETA; + + acceleratedAt: number; + dir: 'rtl' | 'ltr' = 'ltr'; + + constructor( + @Inject(LOCALE_ID) private locale: string, + ) { + if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { + this.dir = 'rtl'; + } + } + + ngOnInit(): void { + this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000; + } + + ngOnChanges(changes): void { + } + +} diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 8967e03b8..0f9a4d9c4 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -152,9 +152,18 @@
+ +
+

Acceleration Timeline

+
+
+ +
+
+
-

RBF History

+

RBF Timeline

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 3c316f879..d2cc0789d 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -326,7 +326,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { 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; this.setIsAccelerated(); } @@ -777,6 +777,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (cpfpInfo.acceleration) { this.tx.acceleration = cpfpInfo.acceleration; this.tx.acceleratedBy = cpfpInfo.acceleratedBy; + this.tx.acceleratedAt = cpfpInfo.acceleratedAt; this.setIsAccelerated(firstCpfp); } diff --git a/frontend/src/app/docs/api-docs/api-docs-data.ts b/frontend/src/app/docs/api-docs/api-docs-data.ts index b799d79a2..0bcc414e9 100644 --- a/frontend/src/app/docs/api-docs/api-docs-data.ts +++ b/frontend/src/app/docs/api-docs/api-docs-data.ts @@ -6176,10 +6176,10 @@ export const restApiDocsData = [ type: "endpoint", category: "transactions", httpRequestMethod: "GET", - fragment: "get-transaction-rbf-history", - title: "GET Transaction RBF History", + fragment: "get-transaction-rbf-timeline", + title: "GET Transaction RBF Timeline", description: { - default: "Returns the RBF tree history of a transaction." + default: "Returns the RBF tree timeline of a transaction." }, urlString: "v1/tx/:txId/rbf", showConditions: bitcoinNetworks, diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 726649090..942386d8f 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -21,6 +21,7 @@ export interface Transaction { cpfpChecked?: boolean; acceleration?: boolean; acceleratedBy?: number[]; + acceleratedAt?: number; deleteAfter?: number; _unblinded?: any; _deduced?: boolean; diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index c3c7c9efe..eb5d3ba94 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -30,6 +30,7 @@ export interface CpfpInfo { adjustedVsize?: number; acceleration?: boolean; acceleratedBy?: number[]; + acceleratedAt?: number; } export interface RbfInfo { diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index c060bbbd2..868cb1bd9 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -66,6 +66,7 @@ import { DifficultyMiningComponent } from '../components/difficulty-mining/diffi import { BalanceWidgetComponent } from '../components/balance-widget/balance-widget.component'; import { AddressTransactionsWidgetComponent } from '../components/address-transactions-widget/address-transactions-widget.component'; import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component'; +import { AccelerationTimelineComponent } from '../components/acceleration-timeline/acceleration-timeline.component'; import { RbfTimelineTooltipComponent } from '../components/rbf-timeline/rbf-timeline-tooltip.component'; import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; import { TestTransactionsComponent } from '../components/test-transactions/test-transactions.component'; @@ -177,6 +178,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BalanceWidgetComponent, AddressTransactionsWidgetComponent, RbfTimelineComponent, + AccelerationTimelineComponent, RbfTimelineTooltipComponent, PushTransactionComponent, TestTransactionsComponent, @@ -316,6 +318,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BalanceWidgetComponent, AddressTransactionsWidgetComponent, RbfTimelineComponent, + AccelerationTimelineComponent, RbfTimelineTooltipComponent, PushTransactionComponent, TestTransactionsComponent,