Merge pull request #725 from mempool/simon/liquid-unblinding-refactor
Refactored liquid unblinding code into a new file.
This commit is contained in:
commit
bb407c0b42
138
frontend/src/app/components/transaction/liquid-ublinding.ts
Normal file
138
frontend/src/app/components/transaction/liquid-ublinding.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { Transaction } from '../../interfaces/electrs.interface';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export class LiquidUnblinding {
|
||||||
|
commitments: Map<any, any>;
|
||||||
|
|
||||||
|
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: Transaction) {
|
||||||
|
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) {
|
||||||
|
throw new Error(`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(tx: Transaction) {
|
||||||
|
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(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -17,6 +17,7 @@ import { AudioService } from 'src/app/services/audio.service';
|
|||||||
import { ApiService } from 'src/app/services/api.service';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
import { SeoService } from 'src/app/services/seo.service';
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
import { CpfpInfo } from 'src/app/interfaces/node-api.interface';
|
import { CpfpInfo } from 'src/app/interfaces/node-api.interface';
|
||||||
|
import { LiquidUnblinding } from './liquid-ublinding';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-transaction',
|
selector: 'app-transaction',
|
||||||
@ -40,9 +41,9 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
|||||||
cpfpInfo: CpfpInfo | null;
|
cpfpInfo: CpfpInfo | null;
|
||||||
showCpfpDetails = false;
|
showCpfpDetails = false;
|
||||||
fetchCpfp$ = new Subject<string>();
|
fetchCpfp$ = new Subject<string>();
|
||||||
commitments: Map<any, any>;
|
|
||||||
now = new Date().getTime();
|
now = new Date().getTime();
|
||||||
timeAvg$: Observable<number>;
|
timeAvg$: Observable<number>;
|
||||||
|
liquidUnblinding = new LiquidUnblinding();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -123,10 +124,8 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.subscription = this.route.paramMap
|
this.subscription = this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(async (params: ParamMap) => {
|
switchMap((params: ParamMap) => {
|
||||||
this.txId = params.get('id') || '';
|
this.txId = params.get('id') || '';
|
||||||
|
|
||||||
await this.checkUnblindedTx();
|
|
||||||
this.seoService.setTitle(
|
this.seoService.setTitle(
|
||||||
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
|
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
|
||||||
);
|
);
|
||||||
@ -157,8 +156,7 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe(
|
.subscribe((tx: Transaction) => {
|
||||||
async (tx: Transaction) => {
|
|
||||||
if (!tx) {
|
if (!tx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -199,7 +197,12 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
|||||||
this.fetchCpfp$.next(this.tx.txid);
|
this.fetchCpfp$.next(this.tx.txid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.checkUnblindedTx();
|
if (this.network === 'liquid') {
|
||||||
|
this.liquidUnblinding.checkUnblindedTx(this.tx)
|
||||||
|
.catch((error) => {
|
||||||
|
this.errorUnblinded = error;
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.error = error;
|
this.error = error;
|
||||||
@ -294,145 +297,4 @@ export class TransactionComponent implements OnInit, OnDestroy {
|
|||||||
this.fetchCpfpSubscription.unsubscribe();
|
this.fetchCpfpSubscription.unsubscribe();
|
||||||
this.leaveTransaction();
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user