Compare commits
15 Commits
master
...
natsoni/de
Author | SHA1 | Date | |
---|---|---|---|
|
860bc7d14d | ||
|
6c95cd2149 | ||
|
af0c78be81 | ||
|
5b331c144b | ||
|
74fa3c7eb1 | ||
|
e05a9a6dfa | ||
|
80b6fd4a1b | ||
|
2987f86cd3 | ||
|
d852c48370 | ||
|
727f22bc9d | ||
|
e848d711fc | ||
|
74ecd1aaac | ||
|
722eaa3e96 | ||
|
025b0585b4 | ||
|
2de16322ae |
@ -54,6 +54,8 @@ class BitcoinRoutes {
|
|||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||||
|
.post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts)
|
||||||
|
.post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs)
|
||||||
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
|
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
|
||||||
;
|
;
|
||||||
@ -930,6 +932,92 @@ class BitcoinRoutes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async $getPrevouts(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const outpoints = req.body;
|
||||||
|
if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) {
|
||||||
|
handleError(req, res, 400, 'Invalid outpoints format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outpoints.length > 100) {
|
||||||
|
handleError(req, res, 400, 'Too many outpoints requested');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array(outpoints.length).fill(null);
|
||||||
|
const memPool = mempool.getMempool();
|
||||||
|
|
||||||
|
for (let i = 0; i < outpoints.length; i++) {
|
||||||
|
const outpoint = outpoints[i];
|
||||||
|
let prevout: IEsploraApi.Vout | null = null;
|
||||||
|
let unconfirmed: boolean | null = null;
|
||||||
|
|
||||||
|
const mempoolTx = memPool[outpoint.txid];
|
||||||
|
if (mempoolTx) {
|
||||||
|
if (outpoint.vout < mempoolTx.vout.length) {
|
||||||
|
prevout = mempoolTx.vout[outpoint.vout];
|
||||||
|
unconfirmed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false);
|
||||||
|
if (rawPrevout) {
|
||||||
|
prevout = {
|
||||||
|
value: Math.round(rawPrevout.value * 100000000),
|
||||||
|
scriptpubkey: rawPrevout.scriptPubKey.hex,
|
||||||
|
scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '',
|
||||||
|
scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type),
|
||||||
|
scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '',
|
||||||
|
};
|
||||||
|
unconfirmed = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore bitcoin client errors, just leave prevout as null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevout) {
|
||||||
|
result[i] = { prevout, unconfirmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
handleError(req, res, 500, 'Failed to get prevouts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCpfpLocalTxs(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const transactions = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(transactions) || transactions.some(tx =>
|
||||||
|
!tx || typeof tx !== 'object' ||
|
||||||
|
!/^[a-fA-F0-9]{64}$/.test(tx.txid) ||
|
||||||
|
typeof tx.weight !== 'number' ||
|
||||||
|
typeof tx.sigops !== 'number' ||
|
||||||
|
typeof tx.fee !== 'number' ||
|
||||||
|
!Array.isArray(tx.vin) ||
|
||||||
|
!Array.isArray(tx.vout)
|
||||||
|
)) {
|
||||||
|
handleError(req, res, 400, 'Invalid transactions format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions.length > 1) {
|
||||||
|
handleError(req, res, 400, 'More than one transaction is not supported yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpfpInfo = calculateMempoolTxCpfp(transactions[0], mempool.getMempool(), true);
|
||||||
|
res.json([cpfpInfo]);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
handleError(req, res, 500, 'Failed to calculate CPFP info');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new BitcoinRoutes();
|
export default new BitcoinRoutes();
|
||||||
|
@ -167,8 +167,10 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
|
|||||||
/**
|
/**
|
||||||
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
|
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
|
||||||
* that transaction (and all others in the same cluster)
|
* that transaction (and all others in the same cluster)
|
||||||
|
* If the passed transaction is not guaranteed to be in the mempool, set localTx to true: this will
|
||||||
|
* prevent updating the CPFP data of other transactions in the cluster
|
||||||
*/
|
*/
|
||||||
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
|
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }, localTx: boolean = false): CpfpInfo {
|
||||||
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
|
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
|
||||||
tx.cpfpDirty = false;
|
tx.cpfpDirty = false;
|
||||||
return {
|
return {
|
||||||
@ -198,17 +200,26 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool:
|
|||||||
totalFee += tx.fees.base;
|
totalFee += tx.fees.base;
|
||||||
}
|
}
|
||||||
const effectiveFeePerVsize = totalFee / totalVsize;
|
const effectiveFeePerVsize = totalFee / totalVsize;
|
||||||
for (const tx of cluster.values()) {
|
|
||||||
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
|
||||||
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
|
||||||
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
|
||||||
mempool[tx.txid].bestDescendant = null;
|
|
||||||
mempool[tx.txid].cpfpChecked = true;
|
|
||||||
mempool[tx.txid].cpfpDirty = true;
|
|
||||||
mempool[tx.txid].cpfpUpdated = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
tx = mempool[tx.txid];
|
if (localTx) {
|
||||||
|
tx.effectiveFeePerVsize = effectiveFeePerVsize;
|
||||||
|
tx.ancestors = Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base }));
|
||||||
|
tx.descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !cluster.get(tx.txid)?.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
||||||
|
tx.bestDescendant = null;
|
||||||
|
} else {
|
||||||
|
for (const tx of cluster.values()) {
|
||||||
|
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
||||||
|
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
||||||
|
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
||||||
|
mempool[tx.txid].bestDescendant = null;
|
||||||
|
mempool[tx.txid].cpfpChecked = true;
|
||||||
|
mempool[tx.txid].cpfpDirty = true;
|
||||||
|
mempool[tx.txid].cpfpUpdated = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = mempool[tx.txid];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ancestors: tx.ancestors || [],
|
ancestors: tx.ancestors || [],
|
||||||
|
@ -420,6 +420,29 @@ class TransactionUtils {
|
|||||||
|
|
||||||
return { prioritized, deprioritized };
|
return { prioritized, deprioritized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copied from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/bitcoin/bitcoin-api.ts#L324
|
||||||
|
public translateScriptPubKeyType(outputType: string): string {
|
||||||
|
const map = {
|
||||||
|
'pubkey': 'p2pk',
|
||||||
|
'pubkeyhash': 'p2pkh',
|
||||||
|
'scripthash': 'p2sh',
|
||||||
|
'witness_v0_keyhash': 'v0_p2wpkh',
|
||||||
|
'witness_v0_scripthash': 'v0_p2wsh',
|
||||||
|
'witness_v1_taproot': 'v1_p2tr',
|
||||||
|
'nonstandard': 'nonstandard',
|
||||||
|
'multisig': 'multisig',
|
||||||
|
'anchor': 'anchor',
|
||||||
|
'nulldata': 'op_return'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map[outputType]) {
|
||||||
|
return map[outputType];
|
||||||
|
} else {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TransactionUtils();
|
export default new TransactionUtils();
|
||||||
|
@ -0,0 +1,56 @@
|
|||||||
|
<br>
|
||||||
|
<div class="title">
|
||||||
|
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="box cpfp-details">
|
||||||
|
<table class="table table-fixed table-borderless table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
|
||||||
|
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
|
||||||
|
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
|
||||||
|
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
|
||||||
|
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
|
||||||
|
<th class="d-none d-lg-table-cell"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
|
||||||
|
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
|
||||||
|
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
||||||
|
<td>
|
||||||
|
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
||||||
|
<td class="txids">
|
||||||
|
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
|
||||||
|
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
|
||||||
|
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
|
||||||
|
<td class="txids">
|
||||||
|
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
@ -0,0 +1,32 @@
|
|||||||
|
.title {
|
||||||
|
h2 {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpfp-details {
|
||||||
|
.txids {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.txids {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-green {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-red {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CpfpInfo } from '@interfaces/node-api.interface';
|
||||||
|
import { Transaction } from '@interfaces/electrs.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-cpfp-info',
|
||||||
|
templateUrl: './cpfp-info.component.html',
|
||||||
|
styleUrls: ['./cpfp-info.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class CpfpInfoComponent implements OnInit {
|
||||||
|
@Input() cpfpInfo: CpfpInfo;
|
||||||
|
@Input() tx: Transaction;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
roundToOneDecimal(cpfpTx: any): number {
|
||||||
|
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,205 @@
|
|||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
@if (!transaction) {
|
||||||
|
|
||||||
|
<h1 style="margin-top: 19px;" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
|
||||||
|
|
||||||
|
<form [formGroup]="pushTxForm" (submit)="decodeTransaction()" novalidate>
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea formControlName="txRaw" class="form-control" rows="5" i18n-placeholder="transaction.hex" placeholder="Transaction hex"></textarea>
|
||||||
|
</div>
|
||||||
|
<button [disabled]="isLoading" type="submit" class="btn btn-primary mr-2" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</button>
|
||||||
|
<input type="checkbox" [checked]="!offlineMode" id="offline-mode" (change)="onOfflineModeChange($event)">
|
||||||
|
<label class="label" for="offline-mode">
|
||||||
|
<span i18n="transaction.fetch-prevout-data">Fetch prevout data</span>
|
||||||
|
</label>
|
||||||
|
<p *ngIf="error" class="red-color d-inline">Error decoding transaction, reason: {{ error }}</p>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (transaction && !error && !isLoading) {
|
||||||
|
<div class="title-block">
|
||||||
|
<h1 i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
|
||||||
|
|
||||||
|
<span class="tx-link">
|
||||||
|
<span class="txid">
|
||||||
|
<app-truncate [text]="transaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, transaction.txid]" [disabled]="!successBroadcast">
|
||||||
|
<app-clipboard [text]="transaction.txid"></app-clipboard>
|
||||||
|
</app-truncate>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="container-buttons">
|
||||||
|
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
|
||||||
|
<button *ngIf="successBroadcast" type="button" class="btn btn-sm btn-success no-cursor" i18n="transaction.broadcasted|Broadcasted">Broadcasted</button>
|
||||||
|
<button class="btn btn-sm" style="margin-left: 10px; padding: 0;" (click)="resetForm()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="red-color d-inline">{{ errorBroadcast }}</p>
|
||||||
|
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
|
@if (!hasPrevouts) {
|
||||||
|
<div class="alert alert-mempool">
|
||||||
|
@if (offlineMode) {
|
||||||
|
<span><strong>Prevouts are not loaded, some fields like fee rate cannot be displayed.</strong></span>
|
||||||
|
} @else {
|
||||||
|
<span><strong>Error loading prevouts</strong>. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorCpfpInfo) {
|
||||||
|
<div class="alert alert-mempool">
|
||||||
|
<span><strong>Error loading CPFP data</strong>. Reason: {{ errorCpfpInfo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-transaction-details
|
||||||
|
[network]="stateService.network"
|
||||||
|
[tx]="transaction"
|
||||||
|
[isLoadingTx]="false"
|
||||||
|
[isMobile]="isMobile"
|
||||||
|
[isLoadingFirstSeen]="false"
|
||||||
|
[featuresEnabled]="true"
|
||||||
|
[filters]="filters"
|
||||||
|
[hasEffectiveFeeRate]="false"
|
||||||
|
[cpfpInfo]="null"
|
||||||
|
[ETA$]="ETA$"
|
||||||
|
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
|
||||||
|
[cpfpInfo]="cpfpInfo"
|
||||||
|
[hasCpfp]="hasCpfp"
|
||||||
|
(toggleCpfp$)="this.showCpfpDetails = !this.showCpfpDetails"
|
||||||
|
></app-transaction-details>
|
||||||
|
|
||||||
|
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="transaction"></app-cpfp-info>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
|
||||||
|
<div class="title float-left">
|
||||||
|
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-info flow-toggle btn-sm float-right" (click)="toggleGraph()" i18n="hide-diagram">Hide diagram</button>
|
||||||
|
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<div class="graph-container" #graphContainer>
|
||||||
|
<tx-bowtie-graph
|
||||||
|
[tx]="transaction"
|
||||||
|
[cached]="true"
|
||||||
|
[width]="graphWidth"
|
||||||
|
[height]="graphHeight"
|
||||||
|
[lineLimit]="inOutLimit"
|
||||||
|
[maxStrands]="graphExpanded ? maxInOut : 24"
|
||||||
|
[network]="stateService.network"
|
||||||
|
[tooltip]="true"
|
||||||
|
[connectors]="true"
|
||||||
|
[inputIndex]="null" [outputIndex]="null"
|
||||||
|
>
|
||||||
|
</tx-bowtie-graph>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-wrapper" *ngIf="maxInOut > 24">
|
||||||
|
<button class="btn btn-sm btn-primary graph-toggle" (click)="expandGraph();" *ngIf="!graphExpanded; else collapseBtn"><span i18n="show-more">Show more</span></button>
|
||||||
|
<ng-template #collapseBtn>
|
||||||
|
<button class="btn btn-sm btn-primary graph-toggle" (click)="collapseGraph();"><span i18n="show-less">Show less</span></button>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #flowPlaceholder>
|
||||||
|
<div class="box hidden">
|
||||||
|
<div class="graph-container" #graphContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div class="subtitle-block">
|
||||||
|
<div class="title">
|
||||||
|
<h2 i18n="transaction.inputs-and-outputs|Transaction inputs and outputs">Inputs & Outputs</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="title-buttons">
|
||||||
|
<button *ngIf="!flowEnabled" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<app-transactions-list #txList [transactions]="[transaction]" [transactionPage]="true" [txPreview]="true"></app-transactions-list>
|
||||||
|
|
||||||
|
<div class="title text-left">
|
||||||
|
<h2 i18n="transaction.details|Transaction Details">Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.size">Size</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.size | bytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.weight / 4 | vbytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="adjustedVsize">
|
||||||
|
<td><ng-container i18n="transaction.adjusted-vsize|Transaction Adjusted VSize">Adjusted vsize</ng-container>
|
||||||
|
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-is-adjusted-vsize">
|
||||||
|
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td [innerHTML]="'‎' + (adjustedVsize | vbytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.weight">Weight</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.weight | wuBytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="transaction.version">Version</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.version | number)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="transaction.locktime">Locktime</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.locktime | number)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="transaction.sigops >= 0">
|
||||||
|
<td><ng-container i18n="transaction.sigops|Transaction Sigops">Sigops</ng-container>
|
||||||
|
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-are-sigops">
|
||||||
|
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td [innerHTML]="'‎' + (transaction.sigops | number)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="transaction.hex">Transaction hex</td>
|
||||||
|
<td><app-clipboard [text]="pushTxForm.get('txRaw').value" [leftPadding]="false"></app-clipboard></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-light mt-2 mb-2"></div>
|
||||||
|
<h3 i18n="transaction.error.loading-prevouts">
|
||||||
|
Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,328 @@
|
|||||||
|
import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core';
|
||||||
|
import { Transaction, Vout } 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';
|
||||||
|
import { CpfpInfo } from '../../interfaces/node-api.interface';
|
||||||
|
|
||||||
|
@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;
|
||||||
|
isLoadingPrevouts: boolean;
|
||||||
|
isLoadingCpfpInfo: boolean;
|
||||||
|
offlineMode: boolean = false;
|
||||||
|
transaction: Transaction;
|
||||||
|
error: string;
|
||||||
|
errorPrevouts: string;
|
||||||
|
errorCpfpInfo: string;
|
||||||
|
hasPrevouts: boolean;
|
||||||
|
missingPrevouts: string[];
|
||||||
|
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[] = [];
|
||||||
|
hasEffectiveFeeRate: boolean;
|
||||||
|
fetchCpfp: boolean;
|
||||||
|
cpfpInfo: CpfpInfo | null;
|
||||||
|
hasCpfp: boolean = false;
|
||||||
|
showCpfpDetails = false;
|
||||||
|
ETA$: Observable<ETA | null>;
|
||||||
|
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 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<void> {
|
||||||
|
this.resetState();
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network);
|
||||||
|
await this.fetchPrevouts(tx);
|
||||||
|
await this.fetchCpfpInfo(tx);
|
||||||
|
this.processTransaction(tx);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchPrevouts(transaction: Transaction): Promise<void> {
|
||||||
|
if (this.offlineMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout }));
|
||||||
|
|
||||||
|
if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) {
|
||||||
|
this.hasPrevouts = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.missingPrevouts = [];
|
||||||
|
this.isLoadingPrevouts = true;
|
||||||
|
|
||||||
|
const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch));
|
||||||
|
|
||||||
|
if (prevouts?.length !== prevoutsToFetch.length) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.vin = transaction.vin.map((input, index) => {
|
||||||
|
if (prevouts[index]) {
|
||||||
|
input.prevout = prevouts[index].prevout;
|
||||||
|
addInnerScriptsToVin(input);
|
||||||
|
} else {
|
||||||
|
this.missingPrevouts.push(`${input.txid}:${input.vout}`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.missingPrevouts.length) {
|
||||||
|
throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.fee = transaction.vin.some(input => input.is_coinbase)
|
||||||
|
? 0
|
||||||
|
: transaction.vin.reduce((fee, input) => {
|
||||||
|
return fee + (input.prevout?.value || 0);
|
||||||
|
}, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0);
|
||||||
|
transaction.feePerVsize = transaction.fee / (transaction.weight / 4);
|
||||||
|
transaction.sigops = countSigops(transaction);
|
||||||
|
|
||||||
|
this.hasPrevouts = true;
|
||||||
|
this.isLoadingPrevouts = false;
|
||||||
|
this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
this.errorPrevouts = error?.error?.error || error?.message;
|
||||||
|
this.isLoadingPrevouts = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCpfpInfo(transaction: Transaction): Promise<void> {
|
||||||
|
// Fetch potential cpfp data if all prevouts were parsed successfully and at least one of them is unconfirmed
|
||||||
|
if (this.hasPrevouts && this.fetchCpfp) {
|
||||||
|
try {
|
||||||
|
this.isLoadingCpfpInfo = true;
|
||||||
|
const cpfpInfo: CpfpInfo[] = await firstValueFrom(this.apiService.getCpfpLocalTx$([{
|
||||||
|
txid: transaction.txid,
|
||||||
|
weight: transaction.weight,
|
||||||
|
sigops: transaction.sigops,
|
||||||
|
fee: transaction.fee,
|
||||||
|
vin: transaction.vin,
|
||||||
|
vout: transaction.vout
|
||||||
|
}]));
|
||||||
|
|
||||||
|
if (cpfpInfo?.[0]?.ancestors?.length) {
|
||||||
|
const { ancestors, effectiveFeePerVsize } = cpfpInfo[0];
|
||||||
|
transaction.effectiveFeePerVsize = effectiveFeePerVsize;
|
||||||
|
this.cpfpInfo = { ancestors, effectiveFeePerVsize };
|
||||||
|
this.hasCpfp = true;
|
||||||
|
this.hasEffectiveFeeRate = true;
|
||||||
|
}
|
||||||
|
this.isLoadingCpfpInfo = false;
|
||||||
|
} catch (error) {
|
||||||
|
this.errorCpfpInfo = error?.error?.error || error?.message;
|
||||||
|
this.isLoadingCpfpInfo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processTransaction(tx: Transaction): void {
|
||||||
|
this.transaction = tx;
|
||||||
|
|
||||||
|
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) : [];
|
||||||
|
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.mempoolBlocks$.pipe(startWith(null)),
|
||||||
|
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
|
||||||
|
]).pipe(
|
||||||
|
map(([mempoolBlocks, da]) => {
|
||||||
|
return this.etaService.calculateETA(
|
||||||
|
this.stateService.network,
|
||||||
|
this.transaction,
|
||||||
|
mempoolBlocks,
|
||||||
|
null,
|
||||||
|
da,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => {
|
||||||
|
if (this.transaction) {
|
||||||
|
this.stateService.markBlock$.next({
|
||||||
|
txid: this.transaction.txid,
|
||||||
|
txFeePerVSize: this.transaction.effectiveFeePerVsize || this.transaction.feePerVsize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async postTx(): Promise<string> {
|
||||||
|
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;
|
||||||
|
this.transaction.txid = result;
|
||||||
|
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.isLoadingPrevouts = false;
|
||||||
|
this.isLoadingCpfpInfo = false;
|
||||||
|
this.isLoadingBroadcast = false;
|
||||||
|
this.adjustedVsize = null;
|
||||||
|
this.showCpfpDetails = false;
|
||||||
|
this.hasCpfp = false;
|
||||||
|
this.fetchCpfp = false;
|
||||||
|
this.cpfpInfo = null;
|
||||||
|
this.hasEffectiveFeeRate = false;
|
||||||
|
this.filters = [];
|
||||||
|
this.hasPrevouts = false;
|
||||||
|
this.missingPrevouts = [];
|
||||||
|
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({});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -66,64 +66,7 @@
|
|||||||
<ng-template [ngIf]="!isLoadingTx && !error">
|
<ng-template [ngIf]="!isLoadingTx && !error">
|
||||||
|
|
||||||
<!-- CPFP Details -->
|
<!-- CPFP Details -->
|
||||||
<ng-template [ngIf]="showCpfpDetails">
|
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="tx"></app-cpfp-info>
|
||||||
<br>
|
|
||||||
<div class="title">
|
|
||||||
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
|
||||||
</div>
|
|
||||||
<div class="box cpfp-details">
|
|
||||||
<table class="table table-fixed table-borderless table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
|
|
||||||
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
|
|
||||||
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
|
|
||||||
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
|
|
||||||
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
|
|
||||||
<th class="d-none d-lg-table-cell"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
|
|
||||||
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
|
|
||||||
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
|
||||||
<td>
|
|
||||||
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
|
||||||
<td class="txids">
|
|
||||||
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
|
|
||||||
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
|
|
||||||
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
|
|
||||||
<td class="txids">
|
|
||||||
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<!-- Accelerator -->
|
<!-- Accelerator -->
|
||||||
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">
|
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">
|
||||||
|
@ -227,18 +227,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cpfp-details {
|
|
||||||
.txids {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.txids {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-list {
|
.tx-list {
|
||||||
.alert-link {
|
.alert-link {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1054,10 +1054,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.stateService.markBlock$.next({});
|
this.stateService.markBlock$.next({});
|
||||||
}
|
}
|
||||||
|
|
||||||
roundToOneDecimal(cpfpTx: any): number {
|
|
||||||
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupGraph() {
|
setupGraph() {
|
||||||
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
|
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
|
||||||
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
||||||
|
@ -9,6 +9,8 @@ import { TransactionExtrasModule } from '@components/transaction/transaction-ext
|
|||||||
import { GraphsModule } from '@app/graphs/graphs.module';
|
import { GraphsModule } from '@app/graphs/graphs.module';
|
||||||
import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
|
import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
|
||||||
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
|
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
|
||||||
|
import { TransactionRawComponent } from '@components/transaction/transaction-raw.component';
|
||||||
|
import { CpfpInfoComponent } from '@components/transaction/cpfp-info.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -16,6 +18,10 @@ const routes: Routes = [
|
|||||||
redirectTo: '/',
|
redirectTo: '/',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'preview',
|
||||||
|
component: TransactionRawComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: ':id',
|
||||||
component: TransactionComponent,
|
component: TransactionComponent,
|
||||||
@ -49,12 +55,15 @@ export class TransactionRoutingModule { }
|
|||||||
TransactionDetailsComponent,
|
TransactionDetailsComponent,
|
||||||
AccelerateCheckout,
|
AccelerateCheckout,
|
||||||
AccelerateFeeGraphComponent,
|
AccelerateFeeGraphComponent,
|
||||||
|
TransactionRawComponent,
|
||||||
|
CpfpInfoComponent,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
TransactionDetailsComponent,
|
TransactionDetailsComponent,
|
||||||
AccelerateCheckout,
|
AccelerateCheckout,
|
||||||
AccelerateFeeGraphComponent,
|
AccelerateFeeGraphComponent,
|
||||||
|
CpfpInfoComponent,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TransactionModule { }
|
export class TransactionModule { }
|
||||||
|
@ -37,6 +37,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
@Input() addresses: string[] = [];
|
@Input() addresses: string[] = [];
|
||||||
@Input() rowLimit = 12;
|
@Input() rowLimit = 12;
|
||||||
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
|
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
|
||||||
|
@Input() txPreview = false;
|
||||||
|
|
||||||
@Output() loadMore = new EventEmitter();
|
@Output() loadMore = new EventEmitter();
|
||||||
|
|
||||||
@ -81,7 +82,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
this.refreshOutspends$
|
this.refreshOutspends$
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((txIds) => {
|
switchMap((txIds) => {
|
||||||
if (!this.cached) {
|
if (!this.cached && !this.txPreview) {
|
||||||
// break list into batches of 50 (maximum supported by esplora)
|
// break list into batches of 50 (maximum supported by esplora)
|
||||||
const batches = [];
|
const batches = [];
|
||||||
for (let i = 0; i < txIds.length; i += 50) {
|
for (let i = 0; i < txIds.length; i += 50) {
|
||||||
@ -119,7 +120,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
),
|
),
|
||||||
this.refreshChannels$
|
this.refreshChannels$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(() => this.stateService.networkSupportsLightning()),
|
filter(() => this.stateService.networkSupportsLightning() && !this.txPreview),
|
||||||
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
// handle 404
|
// handle 404
|
||||||
@ -187,7 +188,10 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.transactionsLength = this.transactions.length;
|
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;
|
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
|
||||||
this.transactions.forEach((tx) => {
|
this.transactions.forEach((tx) => {
|
||||||
@ -351,7 +355,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadMoreInputs(tx: Transaction): void {
|
loadMoreInputs(tx: Transaction): void {
|
||||||
if (!tx['@vinLoaded']) {
|
if (!tx['@vinLoaded'] && !this.txPreview) {
|
||||||
this.electrsApiService.getTransaction$(tx.txid)
|
this.electrsApiService.getTransaction$(tx.txid)
|
||||||
.subscribe((newTx) => {
|
.subscribe((newTx) => {
|
||||||
tx['@vinLoaded'] = true;
|
tx['@vinLoaded'] = true;
|
||||||
|
@ -14,7 +14,7 @@ class GuardService {
|
|||||||
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
||||||
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
||||||
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -565,6 +565,14 @@ export class ApiService {
|
|||||||
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
|
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable<any> {
|
||||||
|
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCpfpLocalTx$(tx: any[]): Observable<CpfpInfo[]> {
|
||||||
|
return this.httpClient.post<CpfpInfo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache methods
|
// Cache methods
|
||||||
async setBlockAuditLoaded(hash: string) {
|
async setBlockAuditLoaded(hash: string) {
|
||||||
this.blockAuditLoaded[hash] = true;
|
this.blockAuditLoaded[hash] = true;
|
||||||
|
@ -76,6 +76,7 @@
|
|||||||
<p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p>
|
<p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p>
|
||||||
<p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p>
|
<p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p>
|
||||||
<p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p>
|
<p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p>
|
||||||
|
<p><a [routerLink]="['/tx/preview' | relativeUrl]" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</a></p>
|
||||||
<p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p>
|
<p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p>
|
||||||
<p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p>
|
<p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
|
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
|
||||||
<ng-container *ngIf="link">
|
<ng-container *ngIf="link">
|
||||||
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'">
|
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'" [class.disabled]="disabled">
|
||||||
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
|
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -37,6 +37,12 @@
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 567px) {
|
@media (max-width: 567px) {
|
||||||
|
@ -15,6 +15,7 @@ export class TruncateComponent {
|
|||||||
@Input() maxWidth: number = null;
|
@Input() maxWidth: number = null;
|
||||||
@Input() inline: boolean = false;
|
@Input() inline: boolean = false;
|
||||||
@Input() textAlign: 'start' | 'end' = 'start';
|
@Input() textAlign: 'start' | 'end' = 'start';
|
||||||
|
@Input() disabled: boolean = false;
|
||||||
rtl: boolean;
|
rtl: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { TransactionFlags } from '@app/shared/filters.utils';
|
import { TransactionFlags } from '@app/shared/filters.utils';
|
||||||
import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from '@app/shared/script.utils';
|
import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils';
|
||||||
import { Transaction } from '@interfaces/electrs.interface';
|
import { Transaction, Vin } from '@interfaces/electrs.interface';
|
||||||
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
|
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { StateService } from '@app/services/state.service';
|
||||||
|
import { Hash } from './sha256';
|
||||||
|
|
||||||
// Bitcoin Core default policy settings
|
// Bitcoin Core default policy settings
|
||||||
const MAX_STANDARD_TX_WEIGHT = 400_000;
|
const MAX_STANDARD_TX_WEIGHT = 400_000;
|
||||||
@ -588,3 +589,762 @@ export function identifyPrioritizedTransactions(transactions: TransactionStrippe
|
|||||||
|
|
||||||
return { prioritized, deprioritized };
|
return { prioritized, deprioritized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adapted from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L254
|
||||||
|
// Converts hex bitcoin script to ASM
|
||||||
|
function convertScriptSigAsm(hex: string): string {
|
||||||
|
|
||||||
|
const buf = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < buf.length) {
|
||||||
|
const op = buf[i];
|
||||||
|
if (op >= 0x01 && op <= 0x4e) {
|
||||||
|
i++;
|
||||||
|
let push;
|
||||||
|
if (op === 0x4c) {
|
||||||
|
push = buf[i];
|
||||||
|
b.push('OP_PUSHDATA1');
|
||||||
|
i += 1;
|
||||||
|
} else if (op === 0x4d) {
|
||||||
|
push = buf[i] | (buf[i + 1] << 8);
|
||||||
|
b.push('OP_PUSHDATA2');
|
||||||
|
i += 2;
|
||||||
|
} else if (op === 0x4e) {
|
||||||
|
push = buf[i] | (buf[i + 1] << 8) | (buf[i + 2] << 16) | (buf[i + 3] << 24);
|
||||||
|
b.push('OP_PUSHDATA4');
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
push = op;
|
||||||
|
b.push('OP_PUSHBYTES_' + push);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buf.slice(i, i + push);
|
||||||
|
if (data.length !== push) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
b.push(uint8ArrayToHexString(data));
|
||||||
|
i += data.length;
|
||||||
|
} else {
|
||||||
|
if (op === 0x00) {
|
||||||
|
b.push('OP_0');
|
||||||
|
} else if (op === 0x4f) {
|
||||||
|
b.push('OP_PUSHNUM_NEG1');
|
||||||
|
} else if (op === 0xb1) {
|
||||||
|
b.push('OP_CLTV');
|
||||||
|
} else if (op === 0xb2) {
|
||||||
|
b.push('OP_CSV');
|
||||||
|
} else if (op === 0xba) {
|
||||||
|
b.push('OP_CHECKSIGADD');
|
||||||
|
} else {
|
||||||
|
const opcode = opcodes[op];
|
||||||
|
if (opcode) {
|
||||||
|
b.push(opcode);
|
||||||
|
} else {
|
||||||
|
b.push('OP_RETURN_' + op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L327
|
||||||
|
/**
|
||||||
|
* This function must only be called when we know the witness we are parsing
|
||||||
|
* is a taproot witness.
|
||||||
|
* @param witness An array of hex strings that represents the witness stack of
|
||||||
|
* the input.
|
||||||
|
* @returns null if the witness is not a script spend, and the hex string of
|
||||||
|
* the script item if it is a script spend.
|
||||||
|
*/
|
||||||
|
function witnessToP2TRScript(witness: string[]): string | null {
|
||||||
|
if (witness.length < 2) return null;
|
||||||
|
// Note: see BIP341 for parsing details of witness stack
|
||||||
|
|
||||||
|
// If there are at least two witness elements, and the first byte of the
|
||||||
|
// last element is 0x50, this last element is called annex a and
|
||||||
|
// is removed from the witness stack.
|
||||||
|
const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
|
||||||
|
// If there are at least two witness elements left, script path spending is used.
|
||||||
|
// Call the second-to-last stack element s, the script.
|
||||||
|
// (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
|
||||||
|
if (hasAnnex && witness.length < 3) return null;
|
||||||
|
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
|
||||||
|
return witness[positionOfScript];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L227
|
||||||
|
// Fills inner_redeemscript_asm and inner_witnessscript_asm fields of fetched prevouts for decoded transactions
|
||||||
|
export function addInnerScriptsToVin(vin: Vin): void {
|
||||||
|
if (!vin.prevout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||||
|
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||||
|
vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript);
|
||||||
|
if (vin.witness && vin.witness.length > 2) {
|
||||||
|
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||||
|
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
||||||
|
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||||
|
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
|
||||||
|
const witnessScript = witnessToP2TRScript(vin.witness);
|
||||||
|
if (witnessScript !== null) {
|
||||||
|
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78
|
||||||
|
// Reads buffer of raw transaction data
|
||||||
|
function fromBuffer(buffer: Uint8Array, network: string): Transaction {
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
function readInt8(): number {
|
||||||
|
if (offset + 1 > buffer.length) {
|
||||||
|
throw new Error('Buffer out of bounds');
|
||||||
|
}
|
||||||
|
return buffer[offset++];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt16() {
|
||||||
|
if (offset + 2 > buffer.length) {
|
||||||
|
throw new Error('Buffer out of bounds');
|
||||||
|
}
|
||||||
|
const value = buffer[offset] | (buffer[offset + 1] << 8);
|
||||||
|
offset += 2;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt32(unsigned = false): number {
|
||||||
|
if (offset + 4 > buffer.length) {
|
||||||
|
throw new Error('Buffer out of bounds');
|
||||||
|
}
|
||||||
|
const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24);
|
||||||
|
offset += 4;
|
||||||
|
if (unsigned) {
|
||||||
|
return value >>> 0;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInt64(): bigint {
|
||||||
|
if (offset + 8 > buffer.length) {
|
||||||
|
throw new Error('Buffer out of bounds');
|
||||||
|
}
|
||||||
|
const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24));
|
||||||
|
const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24));
|
||||||
|
offset += 8;
|
||||||
|
return (high << 32n) | (low & 0xffffffffn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVarInt(): bigint {
|
||||||
|
const first = readInt8();
|
||||||
|
if (first < 0xfd) {
|
||||||
|
return BigInt(first);
|
||||||
|
} else if (first === 0xfd) {
|
||||||
|
return BigInt(readInt16());
|
||||||
|
} else if (first === 0xfe) {
|
||||||
|
return BigInt(readInt32(true));
|
||||||
|
} else if (first === 0xff) {
|
||||||
|
return readInt64();
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid VarInt prefix");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSlice(n: number | bigint): Uint8Array {
|
||||||
|
const length = Number(n);
|
||||||
|
if (offset + length > buffer.length) {
|
||||||
|
throw new Error('Cannot read slice out of bounds');
|
||||||
|
}
|
||||||
|
const slice = buffer.slice(offset, offset + length);
|
||||||
|
offset += length;
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVarSlice(): Uint8Array {
|
||||||
|
return readSlice(readVarInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVector(): Uint8Array[] {
|
||||||
|
const count = readVarInt();
|
||||||
|
const vector = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
vector.push(readVarSlice());
|
||||||
|
}
|
||||||
|
return vector;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse raw transaction
|
||||||
|
const tx = {
|
||||||
|
status: {
|
||||||
|
confirmed: null,
|
||||||
|
block_height: null,
|
||||||
|
block_hash: null,
|
||||||
|
block_time: null,
|
||||||
|
}
|
||||||
|
} as Transaction;
|
||||||
|
|
||||||
|
tx.version = readInt32();
|
||||||
|
|
||||||
|
const marker = readInt8();
|
||||||
|
const flag = readInt8();
|
||||||
|
|
||||||
|
let hasWitnesses = false;
|
||||||
|
if (
|
||||||
|
marker === 0x00 &&
|
||||||
|
flag === 0x01
|
||||||
|
) {
|
||||||
|
hasWitnesses = true;
|
||||||
|
} else {
|
||||||
|
offset -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinLen = readVarInt();
|
||||||
|
tx.vin = [];
|
||||||
|
for (let i = 0; i < vinLen; ++i) {
|
||||||
|
const txid = uint8ArrayToHexString(readSlice(32).reverse());
|
||||||
|
const vout = readInt32(true);
|
||||||
|
const scriptsig = uint8ArrayToHexString(readVarSlice());
|
||||||
|
const sequence = readInt32(true);
|
||||||
|
const is_coinbase = txid === '0'.repeat(64);
|
||||||
|
const scriptsig_asm = convertScriptSigAsm(scriptsig);
|
||||||
|
tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const voutLen = readVarInt();
|
||||||
|
tx.vout = [];
|
||||||
|
for (let i = 0; i < voutLen; ++i) {
|
||||||
|
const value = Number(readInt64());
|
||||||
|
const scriptpubkeyArray = readVarSlice();
|
||||||
|
const scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray)
|
||||||
|
const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey);
|
||||||
|
const toAddress = scriptPubKeyToAddress(scriptpubkey, network);
|
||||||
|
const scriptpubkey_type = toAddress.type;
|
||||||
|
const scriptpubkey_address = toAddress?.address;
|
||||||
|
tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address });
|
||||||
|
}
|
||||||
|
|
||||||
|
let witnessSize = 0;
|
||||||
|
if (hasWitnesses) {
|
||||||
|
const startOffset = offset;
|
||||||
|
for (let i = 0; i < vinLen; ++i) {
|
||||||
|
tx.vin[i].witness = readVector().map(uint8ArrayToHexString);
|
||||||
|
}
|
||||||
|
witnessSize = offset - startOffset + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.locktime = readInt32(true);
|
||||||
|
|
||||||
|
if (offset !== buffer.length) {
|
||||||
|
throw new Error('Transaction has unexpected data');
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.size = buffer.length;
|
||||||
|
tx.weight = (tx.size - witnessSize) * 3 + tx.size;
|
||||||
|
|
||||||
|
tx.txid = txid(tx);
|
||||||
|
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeRawTransaction(rawtx: string, network: string): Transaction {
|
||||||
|
if (!rawtx.length || rawtx.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(rawtx)) {
|
||||||
|
throw new Error('Invalid hex string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = new Uint8Array(rawtx.length / 2);
|
||||||
|
for (let i = 0; i < rawtx.length; i += 2) {
|
||||||
|
buffer[i / 2] = parseInt(rawtx.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromBuffer(buffer, network);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTransaction(tx: Transaction): Uint8Array {
|
||||||
|
const result: number[] = [];
|
||||||
|
|
||||||
|
// Add version
|
||||||
|
result.push(...intToBytes(tx.version, 4));
|
||||||
|
|
||||||
|
// Add input count and inputs
|
||||||
|
result.push(...varIntToBytes(tx.vin.length));
|
||||||
|
for (const input of tx.vin) {
|
||||||
|
result.push(...hexStringToUint8Array(input.txid).reverse());
|
||||||
|
result.push(...intToBytes(input.vout, 4));
|
||||||
|
const scriptSig = hexStringToUint8Array(input.scriptsig);
|
||||||
|
result.push(...varIntToBytes(scriptSig.length));
|
||||||
|
result.push(...scriptSig);
|
||||||
|
result.push(...intToBytes(input.sequence, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add output count and outputs
|
||||||
|
result.push(...varIntToBytes(tx.vout.length));
|
||||||
|
for (const output of tx.vout) {
|
||||||
|
result.push(...bigIntToBytes(BigInt(output.value), 8));
|
||||||
|
const scriptPubKey = hexStringToUint8Array(output.scriptpubkey);
|
||||||
|
result.push(...varIntToBytes(scriptPubKey.length));
|
||||||
|
result.push(...scriptPubKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add locktime
|
||||||
|
result.push(...intToBytes(tx.locktime, 4));
|
||||||
|
|
||||||
|
return new Uint8Array(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function txid(tx: Transaction): string {
|
||||||
|
const serializedTx = serializeTransaction(tx);
|
||||||
|
const hash1 = new Hash().update(serializedTx).digest();
|
||||||
|
const hash2 = new Hash().update(hash1).digest();
|
||||||
|
return uint8ArrayToHexString(hash2.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L177
|
||||||
|
export function countSigops(transaction: Transaction): number {
|
||||||
|
let sigops = 0;
|
||||||
|
|
||||||
|
for (const input of transaction.vin) {
|
||||||
|
if (input.scriptsig_asm) {
|
||||||
|
sigops += countScriptSigops(input.scriptsig_asm, true);
|
||||||
|
}
|
||||||
|
if (input.prevout) {
|
||||||
|
switch (true) {
|
||||||
|
case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
|
||||||
|
case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
|
||||||
|
sigops += 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
|
||||||
|
case input.prevout.scriptpubkey_type === 'v0_p2wsh':
|
||||||
|
if (input.witness?.length) {
|
||||||
|
sigops += countScriptSigops(convertScriptSigAsm(input.witness[input.witness.length - 1]), false, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case input.prevout.scriptpubkey_type === 'p2sh':
|
||||||
|
if (input.inner_redeemscript_asm) {
|
||||||
|
sigops += countScriptSigops(input.inner_redeemscript_asm);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of transaction.vout) {
|
||||||
|
if (output.scriptpubkey_asm) {
|
||||||
|
sigops += countScriptSigops(output.scriptpubkey_asm, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sigops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address: string, type: string } {
|
||||||
|
// P2PKH
|
||||||
|
if (/^76a914[0-9a-f]{40}88ac$/.test(scriptPubKey)) {
|
||||||
|
return { address: p2pkh(scriptPubKey.substring(6, 6 + 40), network), type: 'p2pkh' };
|
||||||
|
}
|
||||||
|
// P2PK
|
||||||
|
if (/^21[0-9a-f]{66}ac$/.test(scriptPubKey) || /^41[0-9a-f]{130}ac$/.test(scriptPubKey)) {
|
||||||
|
return { address: null, type: 'p2pk' };
|
||||||
|
}
|
||||||
|
// P2SH
|
||||||
|
if (/^a914[0-9a-f]{40}87$/.test(scriptPubKey)) {
|
||||||
|
return { address: p2sh(scriptPubKey.substring(4, 4 + 40), network), type: 'p2sh' };
|
||||||
|
}
|
||||||
|
// P2WPKH
|
||||||
|
if (/^0014[0-9a-f]{40}$/.test(scriptPubKey)) {
|
||||||
|
return { address: p2wpkh(scriptPubKey.substring(4, 4 + 40), network), type: 'v0_p2wpkh' };
|
||||||
|
}
|
||||||
|
// P2WSH
|
||||||
|
if (/^0020[0-9a-f]{64}$/.test(scriptPubKey)) {
|
||||||
|
return { address: p2wsh(scriptPubKey.substring(4, 4 + 64), network), type: 'v0_p2wsh' };
|
||||||
|
}
|
||||||
|
// P2TR
|
||||||
|
if (/^5120[0-9a-f]{64}$/.test(scriptPubKey)) {
|
||||||
|
return { address: p2tr(scriptPubKey.substring(4, 4 + 64), network), type: 'v1_p2tr' };
|
||||||
|
}
|
||||||
|
// multisig
|
||||||
|
if (/^[0-9a-f]+ae$/.test(scriptPubKey)) {
|
||||||
|
return { address: null, type: 'multisig' };
|
||||||
|
}
|
||||||
|
// anchor
|
||||||
|
if (scriptPubKey === '51024e73') {
|
||||||
|
return { address: p2a(network), type: 'anchor' };
|
||||||
|
}
|
||||||
|
// op_return
|
||||||
|
if (/^6a/.test(scriptPubKey)) {
|
||||||
|
return { address: null, type: 'op_return' };
|
||||||
|
}
|
||||||
|
return { address: null, type: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2pkh(pubKeyHash: string, network: string): string {
|
||||||
|
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
||||||
|
const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0x6f : 0x00;
|
||||||
|
const versionedPayload = Uint8Array.from([version, ...pubkeyHashArray]);
|
||||||
|
const hash1 = new Hash().update(versionedPayload).digest();
|
||||||
|
const hash2 = new Hash().update(hash1).digest();
|
||||||
|
const checksum = hash2.slice(0, 4);
|
||||||
|
const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
|
||||||
|
const bitcoinAddress = base58Encode(finalPayload);
|
||||||
|
return bitcoinAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2sh(scriptHash: string, network: string): string {
|
||||||
|
const scriptHashArray = hexStringToUint8Array(scriptHash);
|
||||||
|
const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0xc4 : 0x05;
|
||||||
|
const versionedPayload = Uint8Array.from([version, ...scriptHashArray]);
|
||||||
|
const hash1 = new Hash().update(versionedPayload).digest();
|
||||||
|
const hash2 = new Hash().update(hash1).digest();
|
||||||
|
const checksum = hash2.slice(0, 4);
|
||||||
|
const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
|
||||||
|
const bitcoinAddress = base58Encode(finalPayload);
|
||||||
|
return bitcoinAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2wpkh(pubKeyHash: string, network: string): string {
|
||||||
|
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
||||||
|
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
||||||
|
const version = 0;
|
||||||
|
const words = [version].concat(toWords(pubkeyHashArray));
|
||||||
|
const bech32Address = bech32Encode(hrp, words);
|
||||||
|
return bech32Address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2wsh(scriptHash: string, network: string): string {
|
||||||
|
const scriptHashArray = hexStringToUint8Array(scriptHash);
|
||||||
|
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
||||||
|
const version = 0;
|
||||||
|
const words = [version].concat(toWords(scriptHashArray));
|
||||||
|
const bech32Address = bech32Encode(hrp, words);
|
||||||
|
return bech32Address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2tr(pubKeyHash: string, network: string): string {
|
||||||
|
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
||||||
|
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
||||||
|
const version = 1;
|
||||||
|
const words = [version].concat(toWords(pubkeyHashArray));
|
||||||
|
const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
|
||||||
|
return bech32Address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2a(network: string): string {
|
||||||
|
const pubkeyHashArray = hexStringToUint8Array('4e73');
|
||||||
|
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
||||||
|
const version = 1;
|
||||||
|
const words = [version].concat(toWords(pubkeyHashArray));
|
||||||
|
const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
|
||||||
|
return bech32Address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base58 encoding
|
||||||
|
function base58Encode(data: Uint8Array): string {
|
||||||
|
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
let hexString = Array.from(data)
|
||||||
|
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
let num = BigInt("0x" + hexString);
|
||||||
|
|
||||||
|
let encoded = "";
|
||||||
|
while (num > 0) {
|
||||||
|
const remainder = Number(num % 58n);
|
||||||
|
num = num / 58n;
|
||||||
|
encoded = BASE58_ALPHABET[remainder] + encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let byte of data) {
|
||||||
|
if (byte === 0) {
|
||||||
|
encoded = "1" + encoded;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bech32 encoding
|
||||||
|
// Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts
|
||||||
|
function bech32Encode(prefix: string, words: number[], constant: number = 1) {
|
||||||
|
const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||||
|
|
||||||
|
const checksum = createChecksum(prefix, words, constant);
|
||||||
|
const combined = words.concat(checksum);
|
||||||
|
let result = prefix + '1';
|
||||||
|
for (let i = 0; i < combined.length; ++i) {
|
||||||
|
result += BECH32_ALPHABET.charAt(combined[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polymodStep(pre) {
|
||||||
|
const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||||
|
const b = pre >> 25;
|
||||||
|
return (
|
||||||
|
((pre & 0x1ffffff) << 5) ^
|
||||||
|
((b & 1 ? GENERATORS[0] : 0) ^
|
||||||
|
(b & 2 ? GENERATORS[1] : 0) ^
|
||||||
|
(b & 4 ? GENERATORS[2] : 0) ^
|
||||||
|
(b & 8 ? GENERATORS[3] : 0) ^
|
||||||
|
(b & 16 ? GENERATORS[4] : 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixChk(prefix) {
|
||||||
|
let chk = 1;
|
||||||
|
for (let i = 0; i < prefix.length; ++i) {
|
||||||
|
const c = prefix.charCodeAt(i);
|
||||||
|
chk = polymodStep(chk) ^ (c >> 5);
|
||||||
|
}
|
||||||
|
chk = polymodStep(chk);
|
||||||
|
for (let i = 0; i < prefix.length; ++i) {
|
||||||
|
const c = prefix.charCodeAt(i);
|
||||||
|
chk = polymodStep(chk) ^ (c & 0x1f);
|
||||||
|
}
|
||||||
|
return chk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChecksum(prefix: string, words: number[], constant: number) {
|
||||||
|
const POLYMOD_CONST = constant;
|
||||||
|
let chk = prefixChk(prefix);
|
||||||
|
for (let i = 0; i < words.length; ++i) {
|
||||||
|
const x = words[i];
|
||||||
|
chk = polymodStep(chk) ^ x;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 6; ++i) {
|
||||||
|
chk = polymodStep(chk);
|
||||||
|
}
|
||||||
|
chk ^= POLYMOD_CONST;
|
||||||
|
|
||||||
|
const checksum = [];
|
||||||
|
for (let i = 0; i < 6; ++i) {
|
||||||
|
checksum.push((chk >> (5 * (5 - i))) & 31);
|
||||||
|
}
|
||||||
|
return checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBits(data, fromBits, toBits, pad) {
|
||||||
|
let acc = 0;
|
||||||
|
let bits = 0;
|
||||||
|
const ret = [];
|
||||||
|
const maxV = (1 << toBits) - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; ++i) {
|
||||||
|
const value = data[i];
|
||||||
|
if (value < 0 || value >> fromBits) throw new Error('Invalid value');
|
||||||
|
acc = (acc << fromBits) | value;
|
||||||
|
bits += fromBits;
|
||||||
|
while (bits >= toBits) {
|
||||||
|
bits -= toBits;
|
||||||
|
ret.push((acc >> bits) & maxV);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pad) {
|
||||||
|
if (bits > 0) {
|
||||||
|
ret.push((acc << (toBits - bits)) & maxV);
|
||||||
|
}
|
||||||
|
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxV)) {
|
||||||
|
throw new Error('Invalid data');
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWords(bytes) {
|
||||||
|
return convertBits(bytes, 8, 5, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function uint8ArrayToHexString(uint8Array: Uint8Array): string {
|
||||||
|
return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexStringToUint8Array(hex: string): Uint8Array {
|
||||||
|
const buf = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intToBytes(value: number, byteLength: number): number[] {
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < byteLength; i++) {
|
||||||
|
bytes.push((value >> (8 * i)) & 0xff);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigIntToBytes(value: bigint, byteLength: number): number[] {
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < byteLength; i++) {
|
||||||
|
bytes.push(Number((value >> BigInt(8 * i)) & 0xffn));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function varIntToBytes(value: number | bigint): number[] {
|
||||||
|
const bytes = [];
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value < 0xfd) {
|
||||||
|
bytes.push(value);
|
||||||
|
} else if (value <= 0xffff) {
|
||||||
|
bytes.push(0xfd, value & 0xff, (value >> 8) & 0xff);
|
||||||
|
} else if (value <= 0xffffffff) {
|
||||||
|
bytes.push(0xfe, ...intToBytes(value, 4));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value < 0xfdn) {
|
||||||
|
bytes.push(Number(value));
|
||||||
|
} else if (value <= 0xffffn) {
|
||||||
|
bytes.push(0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn));
|
||||||
|
} else if (value <= 0xffffffffn) {
|
||||||
|
bytes.push(0xfe, ...intToBytes(Number(value), 4));
|
||||||
|
} else {
|
||||||
|
bytes.push(0xff, ...bigIntToBytes(value, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1
|
||||||
|
const opcodes = {
|
||||||
|
0: 'OP_0',
|
||||||
|
76: 'OP_PUSHDATA1',
|
||||||
|
77: 'OP_PUSHDATA2',
|
||||||
|
78: 'OP_PUSHDATA4',
|
||||||
|
79: 'OP_PUSHNUM_NEG1',
|
||||||
|
80: 'OP_RESERVED',
|
||||||
|
81: 'OP_PUSHNUM_1',
|
||||||
|
82: 'OP_PUSHNUM_2',
|
||||||
|
83: 'OP_PUSHNUM_3',
|
||||||
|
84: 'OP_PUSHNUM_4',
|
||||||
|
85: 'OP_PUSHNUM_5',
|
||||||
|
86: 'OP_PUSHNUM_6',
|
||||||
|
87: 'OP_PUSHNUM_7',
|
||||||
|
88: 'OP_PUSHNUM_8',
|
||||||
|
89: 'OP_PUSHNUM_9',
|
||||||
|
90: 'OP_PUSHNUM_10',
|
||||||
|
91: 'OP_PUSHNUM_11',
|
||||||
|
92: 'OP_PUSHNUM_12',
|
||||||
|
93: 'OP_PUSHNUM_13',
|
||||||
|
94: 'OP_PUSHNUM_14',
|
||||||
|
95: 'OP_PUSHNUM_15',
|
||||||
|
96: 'OP_PUSHNUM_16',
|
||||||
|
97: 'OP_NOP',
|
||||||
|
98: 'OP_VER',
|
||||||
|
99: 'OP_IF',
|
||||||
|
100: 'OP_NOTIF',
|
||||||
|
101: 'OP_VERIF',
|
||||||
|
102: 'OP_VERNOTIF',
|
||||||
|
103: 'OP_ELSE',
|
||||||
|
104: 'OP_ENDIF',
|
||||||
|
105: 'OP_VERIFY',
|
||||||
|
106: 'OP_RETURN',
|
||||||
|
107: 'OP_TOALTSTACK',
|
||||||
|
108: 'OP_FROMALTSTACK',
|
||||||
|
109: 'OP_2DROP',
|
||||||
|
110: 'OP_2DUP',
|
||||||
|
111: 'OP_3DUP',
|
||||||
|
112: 'OP_2OVER',
|
||||||
|
113: 'OP_2ROT',
|
||||||
|
114: 'OP_2SWAP',
|
||||||
|
115: 'OP_IFDUP',
|
||||||
|
116: 'OP_DEPTH',
|
||||||
|
117: 'OP_DROP',
|
||||||
|
118: 'OP_DUP',
|
||||||
|
119: 'OP_NIP',
|
||||||
|
120: 'OP_OVER',
|
||||||
|
121: 'OP_PICK',
|
||||||
|
122: 'OP_ROLL',
|
||||||
|
123: 'OP_ROT',
|
||||||
|
124: 'OP_SWAP',
|
||||||
|
125: 'OP_TUCK',
|
||||||
|
126: 'OP_CAT',
|
||||||
|
127: 'OP_SUBSTR',
|
||||||
|
128: 'OP_LEFT',
|
||||||
|
129: 'OP_RIGHT',
|
||||||
|
130: 'OP_SIZE',
|
||||||
|
131: 'OP_INVERT',
|
||||||
|
132: 'OP_AND',
|
||||||
|
133: 'OP_OR',
|
||||||
|
134: 'OP_XOR',
|
||||||
|
135: 'OP_EQUAL',
|
||||||
|
136: 'OP_EQUALVERIFY',
|
||||||
|
137: 'OP_RESERVED1',
|
||||||
|
138: 'OP_RESERVED2',
|
||||||
|
139: 'OP_1ADD',
|
||||||
|
140: 'OP_1SUB',
|
||||||
|
141: 'OP_2MUL',
|
||||||
|
142: 'OP_2DIV',
|
||||||
|
143: 'OP_NEGATE',
|
||||||
|
144: 'OP_ABS',
|
||||||
|
145: 'OP_NOT',
|
||||||
|
146: 'OP_0NOTEQUAL',
|
||||||
|
147: 'OP_ADD',
|
||||||
|
148: 'OP_SUB',
|
||||||
|
149: 'OP_MUL',
|
||||||
|
150: 'OP_DIV',
|
||||||
|
151: 'OP_MOD',
|
||||||
|
152: 'OP_LSHIFT',
|
||||||
|
153: 'OP_RSHIFT',
|
||||||
|
154: 'OP_BOOLAND',
|
||||||
|
155: 'OP_BOOLOR',
|
||||||
|
156: 'OP_NUMEQUAL',
|
||||||
|
157: 'OP_NUMEQUALVERIFY',
|
||||||
|
158: 'OP_NUMNOTEQUAL',
|
||||||
|
159: 'OP_LESSTHAN',
|
||||||
|
160: 'OP_GREATERTHAN',
|
||||||
|
161: 'OP_LESSTHANOREQUAL',
|
||||||
|
162: 'OP_GREATERTHANOREQUAL',
|
||||||
|
163: 'OP_MIN',
|
||||||
|
164: 'OP_MAX',
|
||||||
|
165: 'OP_WITHIN',
|
||||||
|
166: 'OP_RIPEMD160',
|
||||||
|
167: 'OP_SHA1',
|
||||||
|
168: 'OP_SHA256',
|
||||||
|
169: 'OP_HASH160',
|
||||||
|
170: 'OP_HASH256',
|
||||||
|
171: 'OP_CODESEPARATOR',
|
||||||
|
172: 'OP_CHECKSIG',
|
||||||
|
173: 'OP_CHECKSIGVERIFY',
|
||||||
|
174: 'OP_CHECKMULTISIG',
|
||||||
|
175: 'OP_CHECKMULTISIGVERIFY',
|
||||||
|
176: 'OP_NOP1',
|
||||||
|
177: 'OP_CHECKLOCKTIMEVERIFY',
|
||||||
|
178: 'OP_CHECKSEQUENCEVERIFY',
|
||||||
|
179: 'OP_NOP4',
|
||||||
|
180: 'OP_NOP5',
|
||||||
|
181: 'OP_NOP6',
|
||||||
|
182: 'OP_NOP7',
|
||||||
|
183: 'OP_NOP8',
|
||||||
|
184: 'OP_NOP9',
|
||||||
|
185: 'OP_NOP10',
|
||||||
|
186: 'OP_CHECKSIGADD',
|
||||||
|
253: 'OP_PUBKEYHASH',
|
||||||
|
254: 'OP_PUBKEY',
|
||||||
|
255: 'OP_INVALIDOPCODE',
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user