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