Liquid support for unblinding transactions. (#588)
* Add docker file to generate wallycore wasm js lib. * Add unblinded liquid transactions. * Add background to unblided transactions. * Check liquid network to try to unblind tx. Ww don't want to try to unblind transactions in other networks. Co-authored-by: Felipe Knorr Kuhn <100320+knorrium@users.noreply.github.com> * Delete libwally-core dockerfile. * Delete wallycore.html. * Fix validation unblind tx. Fix lint. Add errorUnblinded. Add vin.prevout unblinded tx. * Add e2e testing to liquids unblinded tx. * Load libwally.js dynamically. * Fix table size. * Add Blockstream License to libwally and wallycore. Co-authored-by: Felipe Knorr Kuhn <100320+knorrium@users.noreply.github.com>
This commit is contained in:
184
frontend/src/app/components/transaction/libwally.js
Normal file
184
frontend/src/app/components/transaction/libwally.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2021 Blockstream Corp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const WALLY_OK = 0,
|
||||
ASSET_COMMITMENT_LEN = 33,
|
||||
ASSET_GENERATOR_LEN = 33,
|
||||
ASSET_TAG_LEN = 32,
|
||||
BLINDING_FACTOR_LEN = 32;
|
||||
|
||||
const WASM_URL = `./resources/wallycore/wallycore.js`;
|
||||
|
||||
let load_promise, Module;
|
||||
export function load() {
|
||||
return (
|
||||
load_promise ||
|
||||
(load_promise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = WASM_URL;
|
||||
script.addEventListener("error", reject);
|
||||
script.addEventListener("load", () =>
|
||||
InitWally().then((module) => {
|
||||
Module = module;
|
||||
resolve();
|
||||
}, reject)
|
||||
);
|
||||
document.body.appendChild(script);
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Simple wrapper to execute both asset_generator_from_bytes and asset_value_commitment,
|
||||
// with hex conversions
|
||||
export function generate_commitments(
|
||||
value,
|
||||
asset_hex,
|
||||
value_blinder_hex,
|
||||
asset_blinder_hex
|
||||
) {
|
||||
const asset = parseHex(asset_hex, ASSET_TAG_LEN),
|
||||
value_blinder = parseHex(value_blinder_hex, BLINDING_FACTOR_LEN),
|
||||
asset_blinder = parseHex(asset_blinder_hex, BLINDING_FACTOR_LEN);
|
||||
|
||||
const asset_commitment = asset_generator_from_bytes(asset, asset_blinder),
|
||||
value_commitment = asset_value_commitment(
|
||||
value,
|
||||
value_blinder,
|
||||
asset_commitment
|
||||
);
|
||||
|
||||
return {
|
||||
asset_commitment: encodeHex(asset_commitment),
|
||||
value_commitment: encodeHex(value_commitment),
|
||||
};
|
||||
}
|
||||
|
||||
export function asset_generator_from_bytes(asset, asset_blinder) {
|
||||
const asset_commitment_ptr = Module._malloc(ASSET_GENERATOR_LEN);
|
||||
checkCode(
|
||||
Module.ccall(
|
||||
"wally_asset_generator_from_bytes",
|
||||
"number",
|
||||
["array", "number", "array", "number", "number", "number"],
|
||||
[
|
||||
asset,
|
||||
asset.length,
|
||||
asset_blinder,
|
||||
asset_blinder.length,
|
||||
asset_commitment_ptr,
|
||||
ASSET_GENERATOR_LEN,
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
const asset_commitment = readBytes(asset_commitment_ptr, ASSET_GENERATOR_LEN);
|
||||
Module._free(asset_commitment_ptr);
|
||||
return asset_commitment;
|
||||
}
|
||||
|
||||
export function asset_value_commitment(value, value_blinder, asset_commitment) {
|
||||
// Emscripten transforms int64 function arguments into two int32 arguments, see:
|
||||
// https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-pass-int64-t-and-uint64-t-values-from-js-into-wasm-functions
|
||||
const [value_lo, value_hi] = split_int52_lo_hi(value);
|
||||
|
||||
const value_commitment_ptr = Module._malloc(ASSET_COMMITMENT_LEN);
|
||||
checkCode(
|
||||
Module.ccall(
|
||||
"wally_asset_value_commitment",
|
||||
"number",
|
||||
[
|
||||
"number",
|
||||
"number",
|
||||
"array",
|
||||
"number",
|
||||
"array",
|
||||
"number",
|
||||
"number",
|
||||
"number",
|
||||
],
|
||||
[
|
||||
value_lo,
|
||||
value_hi,
|
||||
value_blinder,
|
||||
value_blinder.length,
|
||||
asset_commitment,
|
||||
asset_commitment.length,
|
||||
value_commitment_ptr,
|
||||
ASSET_COMMITMENT_LEN,
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
const value_commitment = readBytes(
|
||||
value_commitment_ptr,
|
||||
ASSET_COMMITMENT_LEN
|
||||
);
|
||||
Module._free(value_commitment_ptr);
|
||||
return value_commitment;
|
||||
}
|
||||
|
||||
function checkCode(code) {
|
||||
if (code != WALLY_OK) throw new Error(`libwally failed with code ${code}`);
|
||||
}
|
||||
|
||||
function readBytes(ptr, size) {
|
||||
const bytes = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) bytes[i] = Module.getValue(ptr + i, "i8");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Split a 52-bit JavaScript number into two 32-bits numbers for the low and high bits
|
||||
// https://stackoverflow.com/a/19274574
|
||||
function split_int52_lo_hi(i) {
|
||||
let lo = i | 0;
|
||||
if (lo < 0) lo += 4294967296;
|
||||
|
||||
let hi = i - lo;
|
||||
hi /= 4294967296;
|
||||
|
||||
if (hi < 0 || hi >= 1048576) throw new Error("not an int52: " + i);
|
||||
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
function encodeHex(bytes) {
|
||||
// return Buffer.from(bytes).toString("hex");
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Parse hex string encoded in *reverse*
|
||||
function parseHex(str, expected_size) {
|
||||
if (!/^([0-9a-f]{2})+$/.test(str))
|
||||
throw new Error("Invalid blinders (invalid hex)");
|
||||
if (str.length != expected_size * 2)
|
||||
throw new Error("Invalid blinders (invalid length)");
|
||||
return new Uint8Array(
|
||||
str
|
||||
.match(/.{2}/g)
|
||||
.map((hex_byte) => parseInt(hex_byte, 16))
|
||||
.reverse()
|
||||
);
|
||||
}
|
||||
@@ -9,33 +9,33 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="title">
|
||||
<h1 i18n="shared.transaction">Transaction</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div class="title">
|
||||
<h1 i18n="shared.transaction">Transaction</h1>
|
||||
</div>
|
||||
|
||||
<div class="tx-link">
|
||||
<a [routerLink]="['/tx/' | relativeUrl, txId]">
|
||||
<span class="d-inline d-lg-none">{{ txId | shortenString : 24 }}</span>
|
||||
<span class="d-none d-lg-inline">{{ txId }}</span>
|
||||
</a>
|
||||
<app-clipboard [text]="txId"></app-clipboard>
|
||||
</div>
|
||||
<div class="tx-link">
|
||||
<a [routerLink]="['/tx/' | relativeUrl, txId]">
|
||||
<span class="d-inline d-lg-none">{{ txId | shortenString : 24 }}</span>
|
||||
<span class="d-none d-lg-inline">{{ txId }}</span>
|
||||
</a>
|
||||
<app-clipboard [text]="txId"></app-clipboard>
|
||||
</div>
|
||||
|
||||
<div class="container-buttons">
|
||||
<ng-template [ngIf]="tx?.status?.confirmed">
|
||||
<button *ngIf="latestBlock" type="button" class="btn btn-sm btn-success">
|
||||
<ng-container *ngTemplateOutlet="latestBlock.height - tx.status.block_height + 1 == 1 ? confirmationSingular : confirmationPlural; context: {$implicit: latestBlock.height - tx.status.block_height + 1}"></ng-container>
|
||||
<ng-template #confirmationSingular let-i i18n="shared.confirmation-count.singular|Transaction singular confirmation count">{{ i }} confirmation</ng-template>
|
||||
<ng-template #confirmationPlural let-i i18n="shared.confirmation-count.plural|Transaction plural confirmation count">{{ i }} confirmations</ng-template>
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="tx && !tx?.status.confirmed">
|
||||
<button type="button" class="btn btn-sm btn-danger" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
||||
</ng-template>
|
||||
<div class="container-buttons">
|
||||
<ng-template [ngIf]="tx?.status?.confirmed">
|
||||
<button *ngIf="latestBlock" type="button" class="btn btn-sm btn-success">
|
||||
<ng-container *ngTemplateOutlet="latestBlock.height - tx.status.block_height + 1 == 1 ? confirmationSingular : confirmationPlural; context: {$implicit: latestBlock.height - tx.status.block_height + 1}"></ng-container>
|
||||
<ng-template #confirmationSingular let-i i18n="shared.confirmation-count.singular|Transaction singular confirmation count">{{ i }} confirmation</ng-template>
|
||||
<ng-template #confirmationPlural let-i i18n="shared.confirmation-count.plural|Transaction plural confirmation count">{{ i }} confirmations</ng-template>
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="tx && !tx?.status.confirmed">
|
||||
<button type="button" class="btn btn-sm btn-danger" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<ng-template [ngIf]="!isLoadingTx && !error">
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<app-transactions-list #txList [transactions]="[tx]" [transactionPage]="true"></app-transactions-list>
|
||||
<app-transactions-list #txList [transactions]="[tx]" [errorUnblinded]="errorUnblinded" [transactionPage]="true"></app-transactions-list>
|
||||
|
||||
<h2 class="text-left" i18n="transaction.details">Details</h2>
|
||||
<div class="box">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { switchMap, filter, catchError, retryWhen, delay } from 'rxjs/operators';
|
||||
import {
|
||||
switchMap,
|
||||
filter,
|
||||
catchError,
|
||||
retryWhen,
|
||||
delay,
|
||||
} from 'rxjs/operators';
|
||||
import { Transaction, Block } from '../../interfaces/electrs.interface';
|
||||
import { of, merge, Subscription, Observable, Subject } from 'rxjs';
|
||||
import { StateService } from '../../services/state.service';
|
||||
@@ -14,7 +20,7 @@ import { CpfpInfo } from 'src/app/interfaces/node-api.interface';
|
||||
@Component({
|
||||
selector: 'app-transaction',
|
||||
templateUrl: './transaction.component.html',
|
||||
styleUrls: ['./transaction.component.scss']
|
||||
styleUrls: ['./transaction.component.scss'],
|
||||
})
|
||||
export class TransactionComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
@@ -23,6 +29,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
txInBlockIndex: number;
|
||||
isLoadingTx = true;
|
||||
error: any = undefined;
|
||||
errorUnblinded: any = undefined;
|
||||
waitingForTransaction = false;
|
||||
latestBlock: Block;
|
||||
transactionTime = -1;
|
||||
@@ -32,6 +39,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
cpfpInfo: CpfpInfo | null;
|
||||
showCpfpDetails = false;
|
||||
fetchCpfp$ = new Subject<string>();
|
||||
commitments: Map<any, any>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -40,28 +48,36 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
private websocketService: WebsocketService,
|
||||
private audioService: AudioService,
|
||||
private apiService: ApiService,
|
||||
private seoService: SeoService,
|
||||
) { }
|
||||
private seoService: SeoService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
||||
this.stateService.networkChanged$.subscribe(
|
||||
(network) => (this.network = network)
|
||||
);
|
||||
|
||||
this.fetchCpfpSubscription = this.fetchCpfp$
|
||||
.pipe(
|
||||
switchMap((txId) => this.apiService.getCpfpinfo$(txId)
|
||||
.pipe(
|
||||
retryWhen((errors) => errors.pipe(delay(2000)))
|
||||
)
|
||||
),
|
||||
switchMap((txId) =>
|
||||
this.apiService
|
||||
.getCpfpinfo$(txId)
|
||||
.pipe(retryWhen((errors) => errors.pipe(delay(2000))))
|
||||
)
|
||||
)
|
||||
.subscribe((cpfpInfo) => {
|
||||
if (!this.tx) {
|
||||
return;
|
||||
}
|
||||
const lowerFeeParents = cpfpInfo.ancestors.filter((parent) => (parent.fee / (parent.weight / 4)) < this.tx.feePerVsize);
|
||||
let totalWeight = this.tx.weight + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalFees = this.tx.fee + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
const lowerFeeParents = cpfpInfo.ancestors.filter(
|
||||
(parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize
|
||||
);
|
||||
let totalWeight =
|
||||
this.tx.weight +
|
||||
lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalFees =
|
||||
this.tx.fee +
|
||||
lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
|
||||
if (cpfpInfo.bestDescendant) {
|
||||
totalWeight += cpfpInfo.bestDescendant.weight;
|
||||
@@ -69,98 +85,116 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
|
||||
this.stateService.markBlock$.next({ txFeePerVSize: this.tx.effectiveFeePerVsize });
|
||||
this.stateService.markBlock$.next({
|
||||
txFeePerVSize: this.tx.effectiveFeePerVsize,
|
||||
});
|
||||
this.cpfpInfo = cpfpInfo;
|
||||
});
|
||||
|
||||
this.subscription = this.route.paramMap.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
this.txId = params.get('id') || '';
|
||||
this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`);
|
||||
this.resetTransaction();
|
||||
return merge(
|
||||
of(true),
|
||||
this.stateService.connectionState$.pipe(
|
||||
filter((state) => state === 2 && this.tx && !this.tx.status.confirmed)
|
||||
),
|
||||
);
|
||||
}),
|
||||
switchMap(() => {
|
||||
let transactionObservable$: Observable<Transaction>;
|
||||
if (history.state.data) {
|
||||
transactionObservable$ = of(history.state.data);
|
||||
} else {
|
||||
transactionObservable$ = this.electrsApiService.getTransaction$(this.txId).pipe(
|
||||
catchError(this.handleLoadElectrsTransactionError.bind(this))
|
||||
this.subscription = this.route.paramMap
|
||||
.pipe(
|
||||
switchMap(async (params: ParamMap) => {
|
||||
this.txId = params.get('id') || '';
|
||||
|
||||
await this.checkUnblindedTx();
|
||||
this.seoService.setTitle(
|
||||
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
|
||||
);
|
||||
}
|
||||
return merge(
|
||||
transactionObservable$,
|
||||
this.stateService.mempoolTransactions$
|
||||
);
|
||||
})
|
||||
)
|
||||
.subscribe((tx: Transaction) => {
|
||||
if (!tx) {
|
||||
return;
|
||||
}
|
||||
this.tx = tx;
|
||||
if (tx.fee === undefined) {
|
||||
this.tx.fee = 0;
|
||||
}
|
||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||
this.isLoadingTx = false;
|
||||
this.error = undefined;
|
||||
this.waitingForTransaction = false;
|
||||
this.setMempoolBlocksSubscription();
|
||||
this.resetTransaction();
|
||||
return merge(
|
||||
of(true),
|
||||
this.stateService.connectionState$.pipe(
|
||||
filter(
|
||||
(state) => state === 2 && this.tx && !this.tx.status.confirmed
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
switchMap(() => {
|
||||
let transactionObservable$: Observable<Transaction>;
|
||||
if (history.state.data) {
|
||||
transactionObservable$ = of(history.state.data);
|
||||
} else {
|
||||
transactionObservable$ = this.electrsApiService
|
||||
.getTransaction$(this.txId)
|
||||
.pipe(
|
||||
catchError(this.handleLoadElectrsTransactionError.bind(this))
|
||||
);
|
||||
}
|
||||
return merge(
|
||||
transactionObservable$,
|
||||
this.stateService.mempoolTransactions$
|
||||
);
|
||||
})
|
||||
)
|
||||
.subscribe(
|
||||
async (tx: Transaction) => {
|
||||
if (!tx) {
|
||||
return;
|
||||
}
|
||||
this.tx = tx;
|
||||
if (tx.fee === undefined) {
|
||||
this.tx.fee = 0;
|
||||
}
|
||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||
this.isLoadingTx = false;
|
||||
this.error = undefined;
|
||||
this.waitingForTransaction = false;
|
||||
this.setMempoolBlocksSubscription();
|
||||
|
||||
if (!tx.status.confirmed) {
|
||||
this.websocketService.startTrackTransaction(tx.txid);
|
||||
if (!tx.status.confirmed) {
|
||||
this.websocketService.startTrackTransaction(tx.txid);
|
||||
|
||||
if (tx.firstSeen) {
|
||||
this.transactionTime = tx.firstSeen;
|
||||
} else {
|
||||
this.getTransactionTime();
|
||||
}
|
||||
}
|
||||
if (tx.firstSeen) {
|
||||
this.transactionTime = tx.firstSeen;
|
||||
} else {
|
||||
this.getTransactionTime();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.tx.status.confirmed) {
|
||||
this.stateService.markBlock$.next({ blockHeight: tx.status.block_height });
|
||||
} else {
|
||||
if (tx.cpfpChecked) {
|
||||
this.stateService.markBlock$.next({ txFeePerVSize: tx.effectiveFeePerVsize });
|
||||
this.cpfpInfo = {
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant,
|
||||
};
|
||||
} else {
|
||||
this.fetchCpfp$.next(this.tx.txid);
|
||||
if (this.tx.status.confirmed) {
|
||||
this.stateService.markBlock$.next({
|
||||
blockHeight: tx.status.block_height,
|
||||
});
|
||||
} else {
|
||||
if (tx.cpfpChecked) {
|
||||
this.stateService.markBlock$.next({
|
||||
txFeePerVSize: tx.effectiveFeePerVsize,
|
||||
});
|
||||
this.cpfpInfo = {
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant,
|
||||
};
|
||||
} else {
|
||||
this.fetchCpfp$.next(this.tx.txid);
|
||||
}
|
||||
}
|
||||
await this.checkUnblindedTx();
|
||||
},
|
||||
(error) => {
|
||||
this.error = error;
|
||||
this.isLoadingTx = false;
|
||||
}
|
||||
);
|
||||
|
||||
this.stateService.blocks$.subscribe(([block, txConfirmed]) => {
|
||||
this.latestBlock = block;
|
||||
|
||||
if (txConfirmed && this.tx) {
|
||||
this.tx.status = {
|
||||
confirmed: true,
|
||||
block_height: block.height,
|
||||
block_hash: block.id,
|
||||
block_time: block.timestamp,
|
||||
};
|
||||
this.stateService.markBlock$.next({ blockHeight: block.height });
|
||||
this.audioService.playSound('magic');
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
this.error = error;
|
||||
this.isLoadingTx = false;
|
||||
});
|
||||
|
||||
this.stateService.blocks$
|
||||
.subscribe(([block, txConfirmed]) => {
|
||||
this.latestBlock = block;
|
||||
|
||||
if (txConfirmed && this.tx) {
|
||||
this.tx.status = {
|
||||
confirmed: true,
|
||||
block_height: block.height,
|
||||
block_hash: block.id,
|
||||
block_time: block.timestamp,
|
||||
};
|
||||
this.stateService.markBlock$.next({ blockHeight: block.height });
|
||||
this.audioService.playSound('magic');
|
||||
}
|
||||
});
|
||||
|
||||
this.stateService.txReplaced$
|
||||
.subscribe((rbfTransaction) => this.rbfTransaction = rbfTransaction);
|
||||
this.stateService.txReplaced$.subscribe(
|
||||
(rbfTransaction) => (this.rbfTransaction = rbfTransaction)
|
||||
);
|
||||
}
|
||||
|
||||
handleLoadElectrsTransactionError(error: any): Observable<any> {
|
||||
@@ -174,26 +208,30 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setMempoolBlocksSubscription() {
|
||||
this.stateService.mempoolBlocks$
|
||||
.subscribe((mempoolBlocks) => {
|
||||
if (!this.tx) {
|
||||
return;
|
||||
}
|
||||
this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => {
|
||||
if (!this.tx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const txFeePerVSize = this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4);
|
||||
const txFeePerVSize =
|
||||
this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4);
|
||||
|
||||
for (const block of mempoolBlocks) {
|
||||
for (let i = 0; i < block.feeRange.length - 1; i++) {
|
||||
if (txFeePerVSize <= block.feeRange[i + 1] && txFeePerVSize >= block.feeRange[i]) {
|
||||
this.txInBlockIndex = mempoolBlocks.indexOf(block);
|
||||
}
|
||||
for (const block of mempoolBlocks) {
|
||||
for (let i = 0; i < block.feeRange.length - 1; i++) {
|
||||
if (
|
||||
txFeePerVSize <= block.feeRange[i + 1] &&
|
||||
txFeePerVSize >= block.feeRange[i]
|
||||
) {
|
||||
this.txInBlockIndex = mempoolBlocks.indexOf(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTransactionTime() {
|
||||
this.apiService.getTransactionTimes$([this.tx.txid])
|
||||
this.apiService
|
||||
.getTransactionTimes$([this.tx.txid])
|
||||
.subscribe((transactionTimes) => {
|
||||
this.transactionTime = transactionTimes[0];
|
||||
});
|
||||
@@ -226,4 +264,145 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
||||
this.fetchCpfpSubscription.unsubscribe();
|
||||
this.leaveTransaction();
|
||||
}
|
||||
|
||||
// Parse the blinders data from a string encoded as a comma separated list, in the following format:
|
||||
// <value_in_satoshis>,<asset_tag_hex>,<amount_blinder_hex>,<asset_blinder_hex>
|
||||
// This can be repeated with a comma separator to specify blinders for multiple outputs.
|
||||
|
||||
parseBlinders(str: string) {
|
||||
const parts = str.split(',');
|
||||
const blinders = [];
|
||||
while (parts.length) {
|
||||
blinders.push({
|
||||
value: this.verifyNum(parts.shift()),
|
||||
asset: this.verifyHex32(parts.shift()),
|
||||
value_blinder: this.verifyHex32(parts.shift()),
|
||||
asset_blinder: this.verifyHex32(parts.shift()),
|
||||
});
|
||||
}
|
||||
return blinders;
|
||||
}
|
||||
|
||||
verifyNum(num: string) {
|
||||
if (!+num) {
|
||||
throw new Error('Invalid blinding data (invalid number)');
|
||||
}
|
||||
return +num;
|
||||
}
|
||||
verifyHex32(str: string) {
|
||||
if (!str || !/^[0-9a-f]{64}$/i.test(str)) {
|
||||
throw new Error('Invalid blinding data (invalid hex)');
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
async makeCommitmentMap(blinders: any) {
|
||||
const libwally = await import('./libwally.js');
|
||||
await libwally.load();
|
||||
const commitments = new Map();
|
||||
blinders.forEach(b => {
|
||||
const { asset_commitment, value_commitment } =
|
||||
libwally.generate_commitments(b.value, b.asset, b.value_blinder, b.asset_blinder);
|
||||
|
||||
commitments.set(`${asset_commitment}:${value_commitment}`, {
|
||||
asset: b.asset,
|
||||
value: b.value,
|
||||
});
|
||||
});
|
||||
return commitments;
|
||||
}
|
||||
|
||||
// Look for the given output, returning an { value, asset } object
|
||||
find(vout: any) {
|
||||
return vout.assetcommitment && vout.valuecommitment &&
|
||||
this.commitments.get(`${vout.assetcommitment}:${vout.valuecommitment}`);
|
||||
}
|
||||
|
||||
// Lookup all transaction inputs/outputs and attach the unblinded data
|
||||
tryUnblindTx(tx: any) {
|
||||
if (tx) {
|
||||
if (tx._unblinded) { return tx._unblinded; }
|
||||
let matched = 0;
|
||||
if (tx.vout !== undefined) {
|
||||
tx.vout.forEach(vout => matched += +this.tryUnblindOut(vout));
|
||||
tx.vin.filter(vin => vin.prevout).forEach(vin => matched += +this.tryUnblindOut(vin.prevout));
|
||||
}
|
||||
if (this.commitments !== undefined) {
|
||||
tx._unblinded = { matched, total: this.commitments.size };
|
||||
this.deduceBlinded(tx);
|
||||
if (matched < this.commitments.size) {
|
||||
this.errorUnblinded = `Error: Invalid blinding data.`;
|
||||
}
|
||||
tx._deduced = false; // invalidate cache so deduction is attempted again
|
||||
return tx._unblinded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look the given output and attach the unblinded data
|
||||
tryUnblindOut(vout: any) {
|
||||
const unblinded = this.find(vout);
|
||||
if (unblinded) { Object.assign(vout, unblinded); }
|
||||
return !!unblinded;
|
||||
}
|
||||
|
||||
// Attempt to deduce the blinded input/output based on the available information
|
||||
deduceBlinded(tx: any) {
|
||||
if (tx._deduced) { return; }
|
||||
tx._deduced = true;
|
||||
|
||||
// Find ins/outs with unknown amounts (blinded ant not revealed via the `#blinded` hash fragment)
|
||||
const unknownIns = tx.vin.filter(vin => vin.prevout && vin.prevout.value == null);
|
||||
const unknownOuts = tx.vout.filter(vout => vout.value == null);
|
||||
|
||||
// If the transaction has a single unknown input/output, we can deduce its asset/amount
|
||||
// based on the other known inputs/outputs.
|
||||
if (unknownIns.length + unknownOuts.length === 1) {
|
||||
|
||||
// Keep a per-asset tally of all known input amounts, minus all known output amounts
|
||||
const totals = new Map();
|
||||
tx.vin.filter(vin => vin.prevout && vin.prevout.value != null)
|
||||
.forEach(({ prevout }) =>
|
||||
totals.set(prevout.asset, (totals.get(prevout.asset) || 0) + prevout.value));
|
||||
tx.vout.filter(vout => vout.value != null)
|
||||
.forEach(vout =>
|
||||
totals.set(vout.asset, (totals.get(vout.asset) || 0) - vout.value));
|
||||
|
||||
// There should only be a single asset where the inputs and outputs amounts mismatch,
|
||||
// which is the asset of the blinded input/output
|
||||
const remainder = Array.from(totals.entries()).filter(([ asset, value ]) => value !== 0);
|
||||
if (remainder.length !== 1) { throw new Error('unexpected remainder while deducing blinded tx'); }
|
||||
const [ blindedAsset, blindedValue ] = remainder[0];
|
||||
|
||||
// A positive remainder (when known in > known out) is the asset/amount of the unknown blinded output,
|
||||
// a negative one is the input.
|
||||
if (blindedValue > 0) {
|
||||
if (!unknownOuts.length) { throw new Error('expected unknown output'); }
|
||||
unknownOuts[0].asset = blindedAsset;
|
||||
unknownOuts[0].value = blindedValue;
|
||||
} else {
|
||||
if (!unknownIns.length) { throw new Error('expected unknown input'); }
|
||||
unknownIns[0].prevout.asset = blindedAsset;
|
||||
unknownIns[0].prevout.value = blindedValue * -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkUnblindedTx() {
|
||||
try {
|
||||
if (this.network === 'liquid') {
|
||||
const windowLocationHash = window.location.hash.substring('#blinded='.length);
|
||||
if (windowLocationHash.length > 0) {
|
||||
|
||||
const blinders = this.parseBlinders(windowLocationHash);
|
||||
if (blinders) {
|
||||
this.commitments = await this.makeCommitmentMap(blinders);
|
||||
this.tryUnblindTx(this.tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorUnblinded = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,16 @@
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-bg box" infiniteScroll [alwaysCallback]="true" [infiniteScrollDistance]="2" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="onScroll()">
|
||||
|
||||
<div *ngIf="errorUnblinded" class="error-unblinded">{{ errorUnblinded }}</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
|
||||
<table class="table table-borderless smaller-text table-sm" style="margin: 0;" id="table-tx-vin">
|
||||
<tbody>
|
||||
<ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? tx.vin.slice(0, 10) : tx.vin" [ngForTrackBy]="trackByIndexFn">
|
||||
<tr>
|
||||
<tr [ngClass]="assetsMinimal && assetsMinimal[vin.prevout.asset] && vin.prevout.scriptpubkey_address ? 'assetBox' : ''">
|
||||
<td class="arrow-td">
|
||||
<ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout">
|
||||
<i class="arrow grey"></i>
|
||||
@@ -66,7 +69,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="displayDetails">
|
||||
<td colspan="3">
|
||||
<td colspan="3" class="details-container" >
|
||||
<table class="table table-striped table-borderless details-table mb-3">
|
||||
<tbody>
|
||||
<ng-template [ngIf]="vin.scriptsig">
|
||||
@@ -114,10 +117,10 @@
|
||||
</div>
|
||||
<div class="w-100 d-block d-md-none"></div>
|
||||
<div class="col mobile-bottomcol">
|
||||
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
|
||||
<table class="table table-borderless smaller-text table-sm" style="margin: 0;" id="table-tx-vout">
|
||||
<tbody>
|
||||
<ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] ? tx.vout.slice(0, 10) : tx.vout" [ngForTrackBy]="trackByIndexFn">
|
||||
<tr>
|
||||
<tr [ngClass]="assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address ? 'assetBox' : ''">
|
||||
<td>
|
||||
<a *ngIf="vout.scriptpubkey_address; else scriptpubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
|
||||
<span class="d-block d-lg-none">{{ vout.scriptpubkey_address | shortenString : 16 }}</span>
|
||||
@@ -145,7 +148,7 @@
|
||||
<ng-template [ngIf]="vout.asset && vout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
|
||||
<div *ngIf="assetsMinimal && assetsMinimal[vout.asset]">
|
||||
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vout }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #defaultOutput>
|
||||
<app-amount [satoshis]="vout.value"></app-amount>
|
||||
@@ -162,7 +165,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="displayDetails">
|
||||
<td colspan="3">
|
||||
<td colspan="3" class=" details-container" >
|
||||
<table class="table table-striped table-borderless details-table mb-3">
|
||||
<tbody>
|
||||
<tr *ngIf="vout.scriptpubkey_type">
|
||||
@@ -235,5 +238,4 @@
|
||||
{{ assetsMinimal[item.asset][0] }}
|
||||
<br />
|
||||
<a [routerLink]="['/asset/' | relativeUrl, item.asset]">{{ item.asset | shortenString : 13 }}</a>
|
||||
<br /><br />
|
||||
</ng-template>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
.arrow {
|
||||
display: inline-block!important;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 22px;
|
||||
margin-top: 10px;
|
||||
margin-left: -5px;
|
||||
box-sizing: content-box
|
||||
}
|
||||
|
||||
@@ -66,24 +67,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.details-table {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.details-table td {
|
||||
padding: 0.75rem;
|
||||
&:first-child {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.details-table td:nth-child(2) {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.smaller-text {
|
||||
font-size: 12px;
|
||||
@media (min-width: 576px) {
|
||||
@@ -137,4 +120,36 @@
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.assetBox {
|
||||
background-color: #653b9c90;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
padding: 0px;
|
||||
tr td {
|
||||
padding: 0.75rem;
|
||||
font-size: 12px;
|
||||
&:first-child {
|
||||
color: #ffffff66;
|
||||
white-space: pre-wrap;
|
||||
@media (min-width: 476px) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
&:nth-child(2) {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-unblinded {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #d43131;
|
||||
text-align: right;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
@Input() transactions: Transaction[];
|
||||
@Input() showConfirmations = false;
|
||||
@Input() transactionPage = false;
|
||||
@Input() errorUnblinded = false;
|
||||
|
||||
@Output() loadMore = new EventEmitter();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user