diff --git a/frontend/cypress/integration/liquid/liquid.spec.ts b/frontend/cypress/integration/liquid/liquid.spec.ts
index c1d1c1a02..38abe6bb4 100644
--- a/frontend/cypress/integration/liquid/liquid.spec.ts
+++ b/frontend/cypress/integration/liquid/liquid.spec.ts
@@ -44,38 +44,77 @@ describe('Liquid', () => {
});
describe('assets', () => {
- it('shows the assets screen', () => {
- cy.visit('/liquid');
- cy.get('li:nth-of-type(5) > a').click().then(() => {
- cy.get('table tr').should('have.length', 5);
- });
- });
-
- it('allows searching assets', () => {
- cy.visit('/liquid');
- cy.get('li:nth-of-type(5) > a').click().then(() => {
- cy.get('.container-xl input').click().type('Liquid Bitcoin').then(() => {
- cy.get('table tr').should('have.length', 1);
+ it('shows the assets screen', () => {
+ cy.visit('/liquid');
+ cy.get('li:nth-of-type(5) > a').click().then(() => {
+ cy.get('table tr').should('have.length', 5);
});
});
- });
- it('shows a specific asset ID', () => {
- cy.visit('/liquid');
- cy.get('li:nth-of-type(5) > a').click().then(() => {
- cy.get('.container-xl input').click().type('Liquid CAD').then(() => {
- cy.get('table tr td:nth-of-type(4) a').click();
+ it('allows searching assets', () => {
+ cy.visit('/liquid');
+ cy.get('li:nth-of-type(5) > a').click().then(() => {
+ cy.get('.container-xl input').click().type('Liquid Bitcoin').then(() => {
+ cy.get('table tr').should('have.length', 1);
+ });
});
});
- });
- it('shows a specific asset issuance TX', () => {
- cy.visit('/liquid');
- cy.get('li:nth-of-type(5) > a').click().then(() => {
- cy.get('.container-xl input').click().type('Liquid CAD').then(() => {
- cy.get('table tr td:nth-of-type(5) a').click();
+ it('shows a specific asset ID', () => {
+ cy.visit('/liquid');
+ cy.get('li:nth-of-type(5) > a').click().then(() => {
+ cy.get('.container-xl input').click().type('Liquid CAD').then(() => {
+ cy.get('table tr td:nth-of-type(4) a').click();
+ });
+ });
+ });
+
+ it('shows a specific asset issuance TX', () => {
+ cy.visit('/liquid');
+ cy.get('li:nth-of-type(5) > a').click().then(() => {
+ cy.get('.container-xl input').click().type('Liquid CAD').then(() => {
+ cy.get('table tr td:nth-of-type(5) a').click();
+ });
});
});
- });
});
+
+
+ describe('unblinded TX', () => {
+ it('show unblinded TX', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc,2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3a');
+ cy.get('#table-tx-vin tr').should('have.class', 'assetBox');
+ cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
+ });
+
+ it('show empty unblinded TX', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=');
+ cy.get('#table-tx-vin tr').should('have.class', '');
+ cy.get('#table-tx-vout tr').should('have.class', '');
+ });
+
+ it('show invalid unblinded TX hex', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=123');
+ cy.get('#table-tx-vin tr').should('have.class', '');
+ cy.get('#table-tx-vout tr').should('have.class', '');
+ cy.get('.error-unblinded' ).contains('Error: Invalid blinding data (invalid hex)');
+ });
+
+ it('show first unblinded vout', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc');
+ cy.get('#table-tx-vout tr:first-child()').should('have.class', 'assetBox');
+ });
+
+ it('show second unblinded vout', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3a');
+ cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
+ });
+
+ it('show invalid error unblinded TX', () => {
+ cy.visit('/liquid/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc,2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3c');
+ cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
+ cy.get('.error-unblinded' ).contains('Error: Invalid blinding data.');
+ });
+ });
+
});
diff --git a/frontend/src/app/components/transaction/libwally.js b/frontend/src/app/components/transaction/libwally.js
new file mode 100644
index 000000000..f72cbfc00
--- /dev/null
+++ b/frontend/src/app/components/transaction/libwally.js
@@ -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()
+ );
+}
diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html
index ed35889e8..d2080498b 100644
--- a/frontend/src/app/components/transaction/transaction.component.html
+++ b/frontend/src/app/components/transaction/transaction.component.html
@@ -9,33 +9,33 @@
-
@@ -198,7 +198,7 @@
-
+
Details
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts
index 74d181a50..f3f2191a4 100644
--- a/frontend/src/app/components/transaction/transaction.component.ts
+++ b/frontend/src/app/components/transaction/transaction.component.ts
@@ -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
();
+ commitments: Map;
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;
- 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;
+ 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 {
@@ -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:
+ // ,,,
+ // 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;
+ }
+ }
}
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html
index 03ab90afd..20dee92e5 100644
--- a/frontend/src/app/components/transactions-list/transactions-list.component.html
+++ b/frontend/src/app/components/transactions-list/transactions-list.component.html
@@ -12,13 +12,16 @@
+