Detect RBF-transactions and offer to track them.

fixes #78
This commit is contained in:
softsimon 2020-06-08 18:55:53 +07:00
parent f0b0fc3f4b
commit 8b6a681614
No known key found for this signature in database
GPG Key ID: 488D7DCFB5A430D7
11 changed files with 74 additions and 19 deletions

View File

@ -25,4 +25,26 @@ export class Common {
arr.push(transactions[lastindex].feePerVsize); arr.push(transactions[lastindex].feePerVsize);
return arr; return arr;
} }
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
const matches: { [txid: string]: TransactionExtended } = {};
deleted
// The replaced tx must have at least one input with nSequence < maxint-1 (Thats the opt-in)
.filter((tx) => tx.vin.some((vin) => vin.sequence < 0xfffffffe))
.forEach((deletedTx) => {
const foundMatches = added.find((addedTx) => {
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
return addedTx.fee > deletedTx.fee
// The new transaction must pay more fee per kB than the replaced tx.
&& addedTx.feePerVsize > deletedTx.feePerVsize
// Spends one or more of the same inputs
&& deletedTx.vin.some((deletedVin) =>
addedTx.vin.some((vin) => vin.txid === deletedVin.txid));
});
if (foundMatches) {
matches[deletedTx.txid] = foundMatches;
}
});
return matches;
}
} }

View File

@ -49,9 +49,9 @@ class MempoolBlocks {
transactions.push(tx); transactions.push(tx);
} else { } else {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockVSize, mempoolBlocks.length)); mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockVSize, mempoolBlocks.length));
blockVSize = 0; blockVSize = tx.vsize;
blockSize = 0; blockSize = tx.size;
transactions = []; transactions = [tx];
} }
}); });
if (transactions.length) { if (transactions.length) {

View File

@ -123,31 +123,30 @@ class Mempool {
} }
} }
// Replace mempool to clear already confirmed transactions // Replace mempool to clear deleted transactions
const newMempool = {}; const newMempool = {};
transactions.forEach((tx) => { const deletedTransactions: Transaction[] = [];
if (this.mempoolCache[tx]) { for (const tx in this.mempoolCache) {
if (transactions.indexOf(tx) > -1) {
newMempool[tx] = this.mempoolCache[tx]; newMempool[tx] = this.mempoolCache[tx];
} else { } else {
hasChange = true; deletedTransactions.push(this.mempoolCache[tx]);
}
} }
});
if (!this.inSync && transactions.length === Object.keys(newMempool).length) { if (!this.inSync && transactions.length === Object.keys(newMempool).length) {
this.inSync = true; this.inSync = true;
console.log('The mempool is now in sync!'); console.log('The mempool is now in sync!');
} }
console.log(`New mempool size: ${Object.keys(newMempool).length} Change: ${diff}`); if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
this.mempoolCache = newMempool; this.mempoolCache = newMempool;
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
if (hasChange && this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempoolCache, newTransactions);
} }
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
console.log(`New mempool size: ${Object.keys(newMempool).length} Change: ${diff}`);
console.log('Mempool updated in ' + time / 1000 + ' seconds'); console.log('Mempool updated in ' + time / 1000 + ' seconds');
} catch (err) { } catch (err) {
console.log('getRawMempool error.', err); console.log('getRawMempool error.', err);

View File

@ -8,6 +8,7 @@ import backendInfo from './backend-info';
import mempoolBlocks from './mempool-blocks'; import mempoolBlocks from './mempool-blocks';
import fiatConversion from './fiat-conversion'; import fiatConversion from './fiat-conversion';
import * as os from 'os'; import * as os from 'os';
import { Common } from './common';
class WebsocketHandler { class WebsocketHandler {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@ -121,7 +122,8 @@ class WebsocketHandler {
}); });
} }
handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, newTransactions: TransactionExtended[]) { handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
@ -130,6 +132,7 @@ class WebsocketHandler {
const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlocks = mempoolBlocks.getMempoolBlocks();
const mempoolInfo = memPool.getMempoolInfo(); const mempoolInfo = memPool.getMempoolInfo();
const vBytesPerSecond = memPool.getVBytesPerSecond(); const vBytesPerSecond = memPool.getVBytesPerSecond();
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
this.wss.clients.forEach((client: WebSocket) => { this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
@ -204,6 +207,15 @@ class WebsocketHandler {
} }
} }
if (client['track-tx'] && rbfTransactions[client['track-tx']]) {
for (const rbfTransaction in rbfTransactions) {
if (client['track-tx'] === rbfTransaction) {
response['rbfTransaction'] = rbfTransactions[rbfTransaction];
break;
}
}
}
if (Object.keys(response).length) { if (Object.keys(response).length) {
client.send(JSON.stringify(response)); client.send(JSON.stringify(response));
} }
@ -228,7 +240,7 @@ class WebsocketHandler {
} }
} }
matchRate = Math.ceil((matches.length / txIds.length) * 100); matchRate = Math.round((matches.length / (txIds.length - 1)) * 100);
if (matchRate > 0) { if (matchRate > 0) {
const currentMemPool = memPool.getMempool(); const currentMemPool = memPool.getMempool();
for (const txId of matches) { for (const txId of matches) {

View File

@ -35,9 +35,6 @@ export interface Transaction {
} }
export interface TransactionExtended extends Transaction { export interface TransactionExtended extends Transaction {
txid: string;
fee: number;
size: number;
vsize: number; vsize: number;
feePerVsize: number; feePerVsize: number;
firstSeen: number; firstSeen: number;

View File

@ -1,6 +1,14 @@
<div class="container-xl"> <div class="container-xl">
<div class="title-block"> <div class="title-block">
<div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert">
This transaction has been replaced by:
<a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]" [state]="{ data: rbfTransaction }">
<span class="d-inline d-lg-none">{{ rbfTransaction.txid | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ rbfTransaction.txid }}</span>
</a>
</div>
<h1 class="float-left mr-3 mb-md-3">Transaction</h1> <h1 class="float-left mr-3 mb-md-3">Transaction</h1>
<ng-template [ngIf]="tx?.status?.confirmed"> <ng-template [ngIf]="tx?.status?.confirmed">

View File

@ -36,6 +36,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
potentialP2shGains: 0, potentialP2shGains: 0,
}; };
isRbfTransaction: boolean; isRbfTransaction: boolean;
rbfTransaction: undefined | Transaction;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -126,6 +127,9 @@ export class TransactionComponent implements OnInit, OnDestroy {
this.audioService.playSound('magic'); this.audioService.playSound('magic');
this.findBlockAndSetFeeRating(); this.findBlockAndSetFeeRating();
}); });
this.stateService.txReplaced$
.subscribe((rbfTransaction) => this.rbfTransaction = rbfTransaction);
} }
handleLoadElectrsTransactionError(error: any): Observable<any> { handleLoadElectrsTransactionError(error: any): Observable<any> {
@ -198,6 +202,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
this.feeRating = undefined; this.feeRating = undefined;
this.waitingForTransaction = false; this.waitingForTransaction = false;
this.isLoadingTx = true; this.isLoadingTx = true;
this.rbfTransaction = undefined;
this.transactionTime = -1; this.transactionTime = -1;
document.body.scrollTo(0, 0); document.body.scrollTo(0, 0);
this.leaveTransaction(); this.leaveTransaction();

View File

@ -11,6 +11,7 @@ export interface WebsocketResponse {
action?: string; action?: string;
data?: string[]; data?: string[];
tx?: Transaction; tx?: Transaction;
rbfTransaction?: Transaction;
'track-tx'?: string; 'track-tx'?: string;
'track-address'?: string; 'track-address'?: string;
'track-asset'?: string; 'track-asset'?: string;

View File

@ -24,6 +24,7 @@ export class StateService {
mempoolStats$ = new ReplaySubject<MemPoolState>(1); mempoolStats$ = new ReplaySubject<MemPoolState>(1);
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1); mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
txConfirmed$ = new Subject<Block>(); txConfirmed$ = new Subject<Block>();
txReplaced$ = new Subject<Transaction>();
mempoolTransactions$ = new Subject<Transaction>(); mempoolTransactions$ = new Subject<Transaction>();
blockTransactions$ = new Subject<Transaction>(); blockTransactions$ = new Subject<Transaction>();

View File

@ -89,6 +89,10 @@ export class WebsocketService {
this.stateService.conversions$.next(response.conversions); this.stateService.conversions$.next(response.conversions);
} }
if (response.rbfTransaction) {
this.stateService.txReplaced$.next(response.rbfTransaction);
}
if (response['mempool-blocks']) { if (response['mempool-blocks']) {
this.stateService.mempoolBlocks$.next(response['mempool-blocks']); this.stateService.mempoolBlocks$.next(response['mempool-blocks']);
} }

View File

@ -410,3 +410,9 @@ h1, h2, h3 {
.tooltip-inner { .tooltip-inner {
max-width: inherit; max-width: inherit;
} }
.alert-mempool {
color: #ffffff;
background-color: #653b9c;
border-color: #3a1c61;
}