diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index b8c86bbe2..c01a6170f 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -34,6 +34,8 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) + .get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements) + .get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { try { @@ -653,6 +655,24 @@ class BitcoinRoutes { } } + private async getRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfChains(false); + res.json(result); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async getFullRbfReplacements(req: Request, res: Response) { + try { + const result = rbfCache.getRbfChains(true); + res.json(result); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getCachedTx(req: Request, res: Response) { try { const result = rbfCache.getTx(req.params.txId); diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 1a0e0f7d5..17eb53e12 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -73,6 +73,33 @@ class RbfCache { return this.rbfChains.get(this.chainMap.get(txId) || '') || []; } + // get a paginated list of RbfChains + // ordered by most recent replacement time + public getRbfChains(onlyFullRbf: boolean, after?: string): RbfChain[] { + const limit = 25; + const chains: RbfChain[] = []; + const used = new Set(); + const replacements: string[][] = Array.from(this.replacedBy).reverse(); + const afterChain = after ? this.chainMap.get(after) : null; + let ready = !afterChain; + for (let i = 0; i < replacements.length && chains.length <= limit - 1; i++) { + const txid = replacements[i][1]; + const chainRoot = this.chainMap.get(txid) || ''; + if (chainRoot === afterChain) { + ready = true; + } else if (ready) { + if (!used.has(chainRoot)) { + const chain = this.rbfChains.get(chainRoot); + used.add(chainRoot); + if (chain && (!onlyFullRbf || chain.slice(0, -1).some(entry => !entry.tx.rbf))) { + chains.push(chain); + } + } + } + } + return chains; + } + // get map of rbf chains that have been updated since the last call public getRbfChanges(): { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} { const changes: { chains: {[root: string]: RbfChain }, map: { [txid: string]: string }} = { @@ -92,6 +119,20 @@ class RbfCache { return changes; } + public mined(txid): void { + const chainRoot = this.chainMap.get(txid) + if (chainRoot && this.rbfChains.has(chainRoot)) { + const chain = this.rbfChains.get(chainRoot); + if (chain) { + const chainEntry = chain.find(entry => entry.tx.txid === txid); + if (chainEntry) { + chainEntry.mined = true; + } + this.dirtyChains.add(chainRoot); + } + } + this.evict(txid); + } // flag a transaction as removed from the mempool public evict(txid): void { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 695b79f2b..71ed473a8 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -140,6 +140,14 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { + if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { + client['track-rbf'] = parsedMessage['track-rbf']; + } else { + client['track-rbf'] = false; + } + } + if (parsedMessage.action === 'init') { const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); if (!_blocks) { @@ -279,6 +287,12 @@ class WebsocketHandler { const da = difficultyAdjustment.getDifficultyAdjustment(); memPool.handleRbfTransactions(rbfTransactions); const rbfChanges = rbfCache.getRbfChanges(); + let rbfReplacements; + let fullRbfReplacements; + if (Object.keys(rbfChanges.chains).length) { + rbfReplacements = rbfCache.getRbfChains(false); + fullRbfReplacements = rbfCache.getRbfChains(true); + } const recommendedFees = feeApi.getRecommendedFee(); this.wss.clients.forEach(async (client) => { @@ -428,6 +442,13 @@ class WebsocketHandler { } } + console.log(client['track-rbf']); + if (client['track-rbf'] === 'all' && rbfReplacements) { + response['rbfLatest'] = rbfReplacements; + } else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) { + response['rbfLatest'] = fullRbfReplacements; + } + if (Object.keys(response).length) { client.send(JSON.stringify(response)); } @@ -506,7 +527,7 @@ class WebsocketHandler { // Update mempool to remove transactions included in the new block for (const txId of txIds) { delete _memPool[txId]; - rbfCache.evict(txId); + rbfCache.mined(txId); } if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 90ea84a82..06334c5b5 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { TrademarkPolicyComponent } from './components/trademark-policy/trademar import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; +import { RbfList } from './components/rbf-list/rbf-list.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; @@ -56,6 +57,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -162,6 +167,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent @@ -264,6 +273,10 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, + { + path: 'rbf', + component: RbfList, + }, { path: 'terms-of-service', component: TermsOfServiceComponent diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.html b/frontend/src/app/components/rbf-list/rbf-list.component.html new file mode 100644 index 000000000..427ab3acf --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.html @@ -0,0 +1,61 @@ +
+

RBF Replacements

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

there are no replacements in the mempool yet!

+
+
+ + +
+ +
diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.scss b/frontend/src/app/components/rbf-list/rbf-list.component.scss new file mode 100644 index 000000000..fa8ebc1f1 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.scss @@ -0,0 +1,51 @@ +.spinner-border { + height: 25px; + width: 25px; + margin-top: 13px; +} + +.rbf-chains { + .info { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + margin: 0; + + .type { + .badge { + margin-left: .5em; + } + } + } + + .chain { + margin-bottom: 1em; + } + + .txids { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + margin-bottom: 2px; + + .txid { + flex-basis: 0; + flex-grow: 1; + + &.right { + text-align: right; + } + } + } + + .timeline-wrapper.mined { + border: solid 4px #1a9436; + } + + .no-replacements { + margin: 1em; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-list/rbf-list.component.ts b/frontend/src/app/components/rbf-list/rbf-list.component.ts new file mode 100644 index 000000000..b40dbaf16 --- /dev/null +++ b/frontend/src/app/components/rbf-list/rbf-list.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit, ChangeDetectionStrategy, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { WebsocketService } from 'src/app/services/websocket.service'; +import { RbfInfo } from '../../interfaces/node-api.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; + +@Component({ + selector: 'app-rbf-list', + templateUrl: './rbf-list.component.html', + styleUrls: ['./rbf-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RbfList implements OnInit, OnDestroy { + rbfChains$: Observable; + fromChainSubject = new BehaviorSubject(null); + urlFragmentSubscription: Subscription; + fullRbfEnabled: boolean; + fullRbf: boolean; + isLoading = true; + firstChainId: string; + lastChainId: string; + + constructor( + private route: ActivatedRoute, + private router: Router, + private apiService: ApiService, + public stateService: StateService, + private websocketService: WebsocketService, + ) { + this.fullRbfEnabled = stateService.env.FULL_RBF_ENABLED; + } + + ngOnInit(): void { + this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { + this.fullRbf = (fragment === 'fullrbf'); + this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all'); + this.fromChainSubject.next(this.firstChainId); + }); + + this.rbfChains$ = merge( + this.fromChainSubject.pipe( + switchMap((fromChainId) => { + return this.apiService.getRbfList$(this.fullRbf, fromChainId || undefined) + }), + catchError((e) => { + return EMPTY; + }) + ), + this.stateService.rbfLatest$ + ) + .pipe( + tap((result: RbfInfo[][]) => { + this.isLoading = false; + if (result && result.length && result[0].length) { + this.lastChainId = result[result.length - 1][0].tx.txid; + } + }) + ); + } + + toggleFullRbf(event) { + this.router.navigate([], { + relativeTo: this.route, + fragment: this.fullRbf ? null : 'fullrbf' + }); + } + + isFullRbf(chain: RbfInfo[]): boolean { + return chain.slice(0, -1).some(entry => !entry.tx.rbf); + } + + isMined(chain: RbfInfo[]): boolean { + return chain.some(entry => entry.mined); + } + + // pageChange(page: number) { + // this.fromChainSubject.next(this.lastChainId); + // } + + ngOnDestroy(): void { + this.websocketService.stopTrackRbf(); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html index a7b96f000..13f5a567c 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -1,4 +1,4 @@ -
+
@@ -15,7 +15,7 @@
-
+
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss index af0e75744..1be6a7628 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -126,6 +126,12 @@ } } + &.mined { + .shape-border { + background: #1a9436; + } + } + .shape-border:hover { padding: 0px; .shape { diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts index b053158b4..0b65f703b 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -12,6 +12,7 @@ import { ApiService } from '../../services/api.service'; export class RbfTimelineComponent implements OnInit, OnChanges { @Input() replacements: RbfInfo[]; @Input() txid: string; + mined: boolean; dir: 'rtl' | 'ltr' = 'ltr'; @@ -27,10 +28,10 @@ export class RbfTimelineComponent implements OnInit, OnChanges { } ngOnInit(): void { - + this.mined = this.replacements.some(entry => entry.mined); } ngOnChanges(): void { - + this.mined = this.replacements.some(entry => entry.mined); } } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 442fb73ce..420c8bdaf 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -28,7 +28,8 @@ export interface CpfpInfo { export interface RbfInfo { tx: RbfTransaction, - time: number + time: number, + mined?: boolean, } export interface DifficultyAdjustment { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index aa0834cf8..c7e2f60fd 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -17,6 +17,7 @@ export interface WebsocketResponse { rbfTransaction?: ReplacedTransaction; txReplaced?: ReplacedTransaction; rbfInfo?: RbfInfo[]; + rbfLatest?: RbfInfo[][]; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; @@ -27,6 +28,7 @@ export interface WebsocketResponse { 'track-address'?: string; 'track-asset'?: string; 'track-mempool-block'?: number; + 'track-rbf'?: string; 'watch-mempool'?: boolean; 'track-bisq-market'?: string; } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index fda957a8a..bdba538ef 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -132,6 +132,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached'); } + getRbfList$(fullRbf: boolean, after?: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); + } + listLiquidPegsMonth$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index dbb269945..1b0a65d95 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -99,6 +99,7 @@ export class StateService { mempoolBlockDelta$ = new Subject(); txReplaced$ = new Subject(); txRbfInfo$ = new Subject(); + rbfLatest$ = new Subject(); utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); mempoolTransactions$ = new Subject(); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 826716db2..9e473d24c 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -28,6 +28,7 @@ export class WebsocketService { private isTrackingTx = false; private trackingTxId: string; private isTrackingMempoolBlock = false; + private isTrackingRbf = false; private trackingMempoolBlock: number; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -173,6 +174,16 @@ export class WebsocketService { this.isTrackingMempoolBlock = false } + startTrackRbf(mode: 'all' | 'fullRbf') { + this.websocketSubject.next({ 'track-rbf': mode }); + this.isTrackingRbf = true; + } + + stopTrackRbf() { + this.websocketSubject.next({ 'track-rbf': 'stop' }); + this.isTrackingRbf = false; + } + startTrackBisqMarket(market: string) { this.websocketSubject.next({ 'track-bisq-market': market }); } @@ -261,6 +272,10 @@ export class WebsocketService { this.stateService.txRbfInfo$.next(response.rbfInfo); } + if (response.rbfLatest) { + this.stateService.rbfLatest$.next(response.rbfLatest); + } + if (response.txReplaced) { this.stateService.txReplaced$.next(response.txReplaced); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 7313ec8e3..ec601964a 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, - faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons'; + faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowRight, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MasterPageComponent } from '../components/master-page/master-page.component'; import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component'; @@ -73,6 +73,7 @@ import { AssetCirculationComponent } from '../components/asset-circulation/asset import { AmountShortenerPipe } from '../shared/pipes/amount-shortener.pipe'; import { DifficultyAdjustmentsTable } from '../components/difficulty-adjustments-table/difficulty-adjustments-table.components'; import { BlocksList } from '../components/blocks-list/blocks-list.component'; +import { RbfList } from '../components/rbf-list/rbf-list.component'; import { RewardStatsComponent } from '../components/reward-stats/reward-stats.component'; import { DataCyDirective } from '../data-cy.directive'; import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; @@ -153,6 +154,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert. AmountShortenerPipe, DifficultyAdjustmentsTable, BlocksList, + RbfList, DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, @@ -313,6 +315,7 @@ export class SharedModule { library.addIcons(faDownload); library.addIcons(faQrcode); library.addIcons(faArrowRightArrowLeft); + library.addIcons(faArrowRight); library.addIcons(faExchangeAlt); } }