-
-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathLiquidWallet.js
1657 lines (1454 loc) · 63.4 KB
/
LiquidWallet.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { ElectrumWS } from "ws-electrumx-client";
import Liquid, { address } from "liquidjs-lib";
import ZkpLib from "@vulpemventures/secp256k1-zkp";
import QrCode from "qrcode";
import AssetProvider from "./AssetProvider.js";
import VByteEstimator from "./VByteEstimator.js";
import Constants from "./Constants.js";
import SideSwap from "./SideSwap.js";
import Esplora from "./Esplora.js";
import { SLIP77Factory } from "slip77";
import BrowserStore from "./storage/BrowserStore.js";
import Errors from "./Errors.js";
/**
* The full wallet Api.
* Doesn't need an ui to work.
*
* NB: Every monetary value inputted and outputted in this class must be considered as an
* integer according to the asset precision.
* Only the v() method deals with float and strings and can be used to convert to and from the
* more intuitive floating point representation.
*/
export default class LiquidWallet {
constructor(electrumWs, esploraHttps, sideswapWs) {
this.electrumWs = electrumWs;
this.esploraHttps = esploraHttps;
this.sideswapWs = sideswapWs;
// initialize refresh callback list
if (!this.refreshCallbacks) this.refreshCallbacks = [];
}
async start() {
await this._reloadAccount();
await this.refresh();
this.started = true;
}
/**
* Check if an address is syntactically valid
* @param {string} address
*/
verifyAddress(address) {
try {
const buf = Liquid.address.toOutputScript(address, this.network);
return buf.length > 0;
} catch (e) {
console.log(e);
return false;
}
}
async elcAction(action, params) {
return this.elc.request(action, ...params);
}
async elcActions(batch) {
return this.elc.batchRequest(batch);
}
getNetworkName() {
return this.networkName;
}
getBaseAsset() {
return this.baseAsset;
}
getBaseAssetInfo() {
return this.baseAssetInfo;
}
// Called at startup and when the account changes
async _reloadAccount() {
await this.check();
console.log("Reloading account");
// detect network for new account
this.networkName = (await window.liquid.getAddress()).address.startsWith("tlq")
? "testnet"
: "liquid";
this.cache = await BrowserStore.best("cache:" + this.networkName + (await this.getAddress()).address);
this.store = await BrowserStore.best(
"store:" + this.networkName + (await this.getAddress()).address,
0,
);
// load electrum and esplora endpoints
// if they are provided in the constructor: use them
// if they are functions: evaluate them with the network name
// if they are unset: use the defaults
let electrumWs = this.electrumWs;
let esploraHttps = this.esploraHttps;
let sideswapWs = this.sideswapWs;
if (!electrumWs) {
if (this.networkName === "testnet") {
electrumWs = "wss://blockstream.info/liquidtestnet/electrum-websocket/api";
} else {
electrumWs = "wss://blockstream.info/liquid/electrum-websocket/api";
}
} else if (typeof electrumWs === "function") {
electrumWs = electrumWs(this.networkName);
}
if (!esploraHttps) {
if (this.networkName === "testnet") {
esploraHttps = "https://blockstream.info/liquidtestnet/api/";
} else {
esploraHttps = "https://blockstream.info/liquid/api/";
}
} else if (typeof esploraHttps === "function") {
esploraHttps = esploraHttps(this.networkName);
}
if (!sideswapWs) {
if (this.networkName === "testnet") {
sideswapWs = "wss://api-testnet.sideswap.io/json-rpc-ws";
} else {
sideswapWs = "wss://api.sideswap.io/json-rpc-ws";
}
} else if (typeof sideswapWs === "function") {
sideswapWs = sideswapWs(this.networkName);
}
// get network object
this.network = Liquid.networks[this.networkName];
if (!this.network) throw new Error("Invalid network");
// initialize electrum client
this.elc = new ElectrumWS(electrumWs);
this.elc.ws.addEventListener("message", (msg) => {
try {
if (msg.type && msg.type == "message" && msg.data && msg.data.includes('"error":')) {
msg = JSON.parse(msg.data);
msg = msg.error;
let realErrorStart = msg.indexOf("RPC error: {");
if (realErrorStart < 0) throw new Error(msg);
msg = msg.substring(realErrorStart + 11);
msg = JSON.parse(msg);
if (msg) {
if (msg.message && Errors[msg.message]) {
msg = Errors[msg.message];
} else if (msg.code && Errors[msg.code]) {
msg = Errors[msg.code];
} else {
msg = msg.message || JSON.stringify(msg);
}
console.error(msg);
}
}
} catch (e) {
console.log(e);
}
});
// get base asset
this.baseAsset = this.network.assetHash;
// load exchange
this.sideSwap = new SideSwap(this.cache, this.store, sideswapWs);
this.esplora = new Esplora(this.cache, this.store, esploraHttps);
// initialize asset registry
this.assetProvider = new AssetProvider(
this.cache,
this.store,
this.sideSwap,
this.esplora,
this.baseAsset,
8,
"L-BTC",
"Bitcoin (Liquid)",
);
// get base asset info
this.baseAssetInfo = await this.assetProvider.getAssetInfo(this.baseAsset);
// subscribe to events
const scriptHash = this.getElectrumScriptHash((await this.getAddress()).outputScript);
this.scripthashEventSubscription = scriptHash;
// listen for updates
this.elc.subscribe(
"blockchain.scripthash",
(incomingScriptHash, state) => {
if (scriptHash == incomingScriptHash) {
if (!this.lastState) this.lastState = state;
let newState = this.lastState != state;
if (newState) {
this.lastState = state;
this._executeRefreshCallbacks();
}
} else {
console.log("Received event for another script hash", incomingScriptHash);
}
},
scriptHash,
);
// print some info
console.log(`!!! LiquidWallet initialized
network: ${this.networkName}
electrumWs: ${electrumWs}
esploraHttps: ${esploraHttps}
baseAsset: ${this.baseAsset}
baseAssetInfo: ${JSON.stringify(this.baseAssetInfo)}
`);
}
// Called everytime there is a change that requires a wallet refresh
_executeRefreshCallbacks() {
this.refreshCallbacks.forEach((clb) => {
clb();
});
}
// Called when the wallet is destroyed
async destroy() {
if (window.liquid && window.liquid.isEnabled && (await window.liquid.isEnabled())) {
if (window.liquid.on) {
window.liquid.off("accountChanged", this._reloadAccount);
}
if (this.scripthashEventSubscription) {
const method = "blockchain.scripthash.subscribe";
this.elc.unsubscribe(method, this.scripthashEventSubscription);
this.scripthashEventSubscription = undefined;
}
}
}
// Called everytime a method is called
// Performs the initialization if needed
async check() {
if (typeof window.liquid === "undefined") {
throw new Error("Liquid is not available.", { cause: "liquid_not_available" });
}
if (typeof window.liquid.isEnabled === "undefined") {
throw new Error("Liquid is not supported.", { cause: "liquid_not_available" });
}
const enabled = await window.liquid.isEnabled();
if (!enabled) {
try {
await window.liquid.enable();
if (!(await window.liquid.isEnabled())) {
throw new Error("Liquid is not enabled.");
} else {
if (!window.liquid.on) {
throw new Error("Callbacks not supported!");
} else {
window.liquid.on("accountChanged", this._reloadAccount);
}
}
} catch (err) {
throw new Error("Liquid is not enabled. " + err);
}
}
if (!this.zkpLib) {
this.zkpLib = await ZkpLib();
this.zkpLibValidator = new Liquid.ZKPValidator(this.zkpLib);
this.slip77 = new SLIP77Factory(this.zkpLib.ecc);
}
}
async getAddress() {
await this.check();
const out = await window.liquid.getAddress();
const network = this.network;
const outputScript = Liquid.address.toOutputScript(out.address, network);
const outScript = outputScript;
const pubKey = Buffer.from(out.publicKey, "hex");
return {
outputScript: outScript,
address: out.address,
blindingPrivateKey: Buffer.from(out.blindingPrivateKey, "hex"),
publicKey: pubKey,
};
}
// amount is int
async receive(amount, asset = null, qrOptions = {}) {
await this.check();
let address = await this.getAddress();
if (!asset) {
asset = this.baseAsset;
}
amount = await this.v(amount, asset).float(asset);
address = address.address;
if (amount < 0 || isNaN(amount) || amount === Infinity) amount = 0;
let payLink = address;
let hasParams = false;
if (amount) {
if (payLink.includes("?")) {
payLink += "&";
} else {
payLink += "?";
}
payLink += "amount=" + amount;
hasParams = true;
}
if (asset !== this.baseAsset || hasParams) {
if (payLink.includes("?")) {
payLink += "&";
} else {
payLink += "?";
}
payLink += "assetid=" + asset;
hasParams = true;
}
if (hasParams) {
const prefix = Constants.PAYURL_PREFIX[this.networkName];
payLink = prefix + ":" + payLink;
}
if (!qrOptions.errorCorrectionLevel) qrOptions.errorCorrectionLevel = "M";
if (!qrOptions.margin) qrOptions.margin = 1;
if (!qrOptions.width) qrOptions.width = 1024;
if (!qrOptions.color) qrOptions.color = {};
if (!qrOptions.color.dark) qrOptions.color.dark = "#000000";
if (!qrOptions.color.light) qrOptions.color.light = "#ffffff";
const qrCode = await QrCode.toDataURL(payLink, qrOptions);
return {
addr: payLink,
qr: qrCode,
};
}
async addRefreshCallback(clb) {
if (!this.refreshCallbacks.includes(clb)) {
this.refreshCallbacks.push(clb);
}
}
async removeRefreshCallback(clb) {
if (this.refreshCallbacks.includes(clb)) {
this.refreshCallbacks.splice(this.refreshCallbacks.indexOf(clb), 1);
}
}
async refresh() {
await this.check();
this._executeRefreshCallbacks();
}
// async getBalance(){
// await this.check();
// const addr = await this.getAddress();
// const scripthash = addr.outputScript;
// const balance = await this.elc.request('blockchain.scripthash.get_balance', scripthash);
// return balance;
// }
getElectrumScriptHash(script) {
return Liquid.crypto.sha256(script).reverse().toString("hex");
}
/**
* Get the height in blocks of the blockchain
* @param {string} tx_hash
* @returns
*/
async getTransactionHeight(tx_hash) {
await this.check();
const height = await this.store.get("tx:" + tx_hash + ":height");
return height ? height : -1;
}
/**
* Get the block header at the given height
* @param {number} height
*/
async getBlockHeaderData(height) {
height = Number(height);
if (height < 0) return undefined;
const hex = await this.cache.get(
"bhx:" + height,
false,
async () => {
const hex = await this.elcAction("blockchain.block.header", [height]);
if (!hex) return [undefined, 0];
return [hex, 0];
},
true,
);
if (!hex) {
console.log("Block not found!", height);
return undefined;
} else {
const buffer = Buffer.from(hex, "hex");
let offset = 0;
const version = buffer.readUInt32LE(offset);
offset += 4;
const prev_blockhash = buffer.subarray(offset, offset + 32).toString("hex");
offset += 32;
const merkle_root = buffer.subarray(offset, offset + 32).toString("hex");
offset += 32;
const time = buffer.readUInt32LE(offset);
offset += 4;
const bits = buffer.readUInt32LE(offset);
offset += 4;
const nonce = buffer.readUInt32LE(offset);
const blockData = {
version,
prev_blockhash,
merkle_root,
time,
bits,
nonce,
timestamp: time * 1000,
};
return blockData;
}
}
/**
* Estimate the fee rate for a transaction
* @param {number} priority how fast you want the transaction to be confirmed, from 0 to 1 (1 means next block)
* @returns
*/
async estimateFeeRate(priority = 1) {
await this.check();
const fee = await this.esplora.getFee(priority);
return fee;
}
/**
* This is the most complex method of the wallet.
* It prepares a confidential transaction to be broadcasted.
* Remember: inputs are always integers
* @param {number} amount input amount (int)
* @param {string} asset asset hash
* @param {string} toAddress destination address
* @param {number} estimatedFeeVByte estimated fee rate in vbyte if unset, will get the best fee for 1 block confirmation (if possible)
* @param {number} averageSizeVByte average transaction size in vbyte is used to apr the initial coins collection for fees.
* @param {boolean} simulation if true, skip some checks and compute intensive operations the resulting transaction is not broadcastable
*/
async prepareTransaction(
amount,
asset,
toAddress,
estimatedFeeVByte = null,
averageSizeVByte = 2000,
simulation = false,
) {
await this.check();
if (!this.verifyAddress(toAddress)) throw new Error("Invalid address", { cause: "invalid_address" });
// if estimateFee is not set, we get the best fee for speed
if (!estimatedFeeVByte) {
estimatedFeeVByte = await this.estimateFeeRate(1);
estimatedFeeVByte = estimatedFeeVByte.feeRate;
}
if (isNaN(estimatedFeeVByte)) throw new Error("Invalid fee rate", { cause: "invalid_fee_rate" });
// If asset unset, we assume its the base asset (L-BTC)
if (!asset) asset = this.baseAsset;
// Fee asset is Always L-BTC
const feeAsset = this.baseAsset;
console.log(
"Initialize preparations for transaction of amount",
amount,
"asset",
asset,
"to",
toAddress,
"estimatedFeeVByte",
estimatedFeeVByte,
"of asset",
feeAsset,
"averageSizeVByte",
averageSizeVByte,
);
// Our address
const address = await this.getAddress();
// Check if the address supports confidential transactions
const isConfidential = Liquid.address.isConfidential(toAddress);
// Now lets grab all our properly resolved UTXOs
const utxos = await this.getUTXOs();
// We wrap the code to build the psed data into a function
// since it has to be called twice (once to estimate the fee, once to build the actual transaction)
const build = async (fee, size, withFeeOutput = false) => {
const inputs = [];
const outputs = [];
const feeXsize = Math.floor(fee * size * 1.1); // fee for the entire size of the transaction
// how much we expect to collect in amount to send and in fees (nb if feeAsset==asset, we sum the fee to the amount and use a single input)
const expectedCollectedAmount = feeAsset === asset ? amount + feeXsize : amount;
const expectedFee = feeAsset === asset ? 0 : feeXsize;
console.log(
"Build a pset with fee",
fee,
"size",
size,
"expectedCollectedAmount",
expectedCollectedAmount,
"expectedFee",
expectedFee,
);
let collectedAmount = 0;
let collectedFee = 0;
/////////// INPUTS
////// VALUE
// Collect inputs
for (const utxo of utxos) {
if (utxo.ldata.assetHash !== asset) continue; // not an input for this asset!
// first guard: something broken, better to throw an error and stop it here
if (
!utxo.ldata.value ||
utxo.ldata.value <= 0 ||
isNaN(utxo.ldata.value) ||
Math.floor(utxo.ldata.value) != utxo.ldata.value
)
throw new Error("Invalid UTXO", { cause: "invalid_utxo" });
collectedAmount += utxo.ldata.value;
inputs.push(utxo); // collect this input
if (collectedAmount >= expectedCollectedAmount) break; // we have enough input
}
// not enough funds
if (collectedAmount < expectedCollectedAmount) {
throw new Error(
"Insufficient funds " +
collectedAmount +
" < " +
expectedCollectedAmount +
"(" +
amount +
"+" +
feeXsize +
")",
{ cause: "insufficient_funds" },
);
}
// Calculate change
const changeAmount = collectedAmount - expectedCollectedAmount;
if (changeAmount < 0 || Math.floor(changeAmount) != changeAmount) {
// guard
throw new Error("Invalid change amount " + changeAmount, { cause: "invalid_change_amount" });
}
// Set change outputs
if (changeAmount > 0) {
const changeOutput = {
asset,
amount: changeAmount,
script: Liquid.address.toOutputScript(address.address),
blinderIndex: 0, // we have a single blinder
blindingPublicKey: Liquid.address.fromConfidential(address.address).blindingKey,
};
outputs.push(changeOutput);
}
///// FEES
// Collect fees inputs
if (expectedFee > 0) {
for (const utxo of utxos) {
if (utxo.ldata.assetHash !== feeAsset) continue; // not the fee asset
if (utxo.ldata.value <= 0) throw new Error("Invalid UTXO", { cause: "invalid_utxo" });
if (
!utxo.ldata.value ||
utxo.ldata.value <= 0 ||
isNaN(utxo.ldata.value) ||
Math.floor(utxo.ldata.value) != utxo.ldata.value
)
// guard
throw new Error("Invalid UTXO", { cause: "invalid_utxo" });
collectedFee += utxo.ldata.value;
inputs.push(utxo); // we collect this fee
if (collectedFee >= expectedFee) break; // enough fee
}
if (collectedFee < expectedFee) {
throw new Error("Insufficient funds for fees " + collectedFee + " < " + expectedFee, {
cause: "insufficient_funds",
});
}
// Calculate change
const changeFee = collectedFee - expectedFee;
if (changeFee < 0 || Math.floor(changeFee) != changeFee) {
// guard
throw new Error("Invalid fee change " + changeFee, { cause: "invalid_change" });
}
// Set changes
if (changeFee > 0) {
const changeFeeOutput = {
asset: feeAsset,
amount: changeFee,
script: Liquid.address.toOutputScript(address.address),
blinderIndex: 0, // only us as blinder
blindingPublicKey: Liquid.address.fromConfidential(address.address).blindingKey,
};
outputs.push(changeFeeOutput);
}
}
/////// OUTPUTS
// Set primary output
outputs.push({
asset,
amount,
script: Liquid.address.toOutputScript(toAddress), // this is the destination address
blinderIndex: isConfidential ? 0 : undefined, // only one blinder if any
blindingPublicKey: isConfidential
? Liquid.address.fromConfidential(toAddress).blindingKey // blinded to the destination
: undefined,
});
// Set fee output
if (withFeeOutput) {
outputs.push({
asset: feeAsset,
amount: feeXsize,
});
}
return [inputs, outputs, feeXsize];
};
// take an input and prepares it for the pset
const processInput = (utxo) => {
return {
txid: utxo.tx_hash,
txIndex: utxo.tx_pos,
explicitAsset: Liquid.AssetHash.fromBytes(utxo.ldata.asset).bytes,
explicitAssetProof: utxo.rangeProof,
explicitValue: utxo.ldata.value,
explicitValueProof: utxo.surjectionProof,
witnessUtxo: {
script: utxo.script,
value: utxo.value,
asset: utxo.asset,
nonce: utxo.nonce,
rangeProof: utxo.rangeProof,
surjectionProof: utxo.surjectionProof,
},
sighashType: Liquid.Transaction.SIGHASH_DEFAULT,
};
};
// create a pset from inputs and outputs
const newPset = (inputs, outputs) => {
let pset = Liquid.Creator.newPset();
let psetUpdater = new Liquid.Updater(pset);
psetUpdater.addInputs(inputs.map(processInput));
psetUpdater.addOutputs(outputs);
return [pset, psetUpdater];
};
let inputs;
let outputs;
let pset;
let psetUpdater;
let totalFee;
// first pset to calculate the fee
[inputs, outputs, totalFee] = await build(estimatedFeeVByte, averageSizeVByte, false);
[pset, psetUpdater] = newPset(inputs, outputs);
// estimate the fee
const estimatedSize = VByteEstimator.estimateVirtualSize(pset, false);
// real pset
[inputs, outputs, totalFee] = await build(estimatedFeeVByte, estimatedSize, true);
[pset, psetUpdater] = newPset(inputs, outputs);
// print some useful info
console.log(`Preparing transaction
estimated fee VByte: ${estimatedFeeVByte}
estimated size: ${estimatedSize}
estimated total fee: ${totalFee}
inputs:${JSON.stringify(inputs)}
outputs: ${JSON.stringify(outputs)}
fee: ${totalFee}
`);
// Now the fun part, we verify if the transaction is constructed properly
// and if it makes sense.
// Better safe than sorry
// GUARD
const asserts = [];
if (!simulation) {
// decode transaction
let totalIn = 0;
let totalOut = 0;
let fees = 0;
for (const input of pset.inputs) {
totalIn += input.explicitValue;
}
for (const output of pset.outputs) {
const isFee = output.script.length === 0;
console.log(output);
if (isFee) {
fees += output.value;
} else {
totalOut += output.value;
}
}
if (totalOut + fees !== totalIn) {
// We don't have the same amount of input and output
throw new Error("Invalid transaction " + (totalOut + fees) + " " + totalIn, {
cause: "invalid_transaction",
});
} else {
console.log("Total in", totalIn);
console.log("Total out", totalOut);
asserts.push("Total in = total out");
}
if (fees > totalOut) {
// we have more fees than outputs (likely an error...)
throw new Error("Fees too high compared to output " + fees, { cause: "fees_too_high" });
} else {
asserts.push("Fees are lower than outputs");
}
if (fees > Constants.FEE_GUARD) {
// Fees are higher than the hardcoded value. This catches user mistakes
throw new Error("Fees too high compared to guard " + fees, { cause: "fees_too_high" });
} else {
asserts.push("Fees are lower than guard value");
}
console.log("Verification OK: fees", fees, "totalOut+fee", totalOut + fees, "totalIn", totalIn);
}
// Prepare zkp
if (!simulation) {
const ownedInputs = inputs.map((input, i) => {
return {
index: i,
value: input.ldata.value,
valueBlindingFactor: input.ldata.valueBlindingFactor,
asset: input.ldata.asset,
assetBlindingFactor: input.ldata.assetBlindingFactor,
};
});
const outputIndexes = [];
for (const [index, output] of pset.outputs.entries()) {
if (output.blindingPubkey && output.blinderIndex) {
outputIndexes.push(index);
}
}
const inputIndexes = ownedInputs.map((input) => input.index);
let isLast = true;
for (const out of pset.outputs) {
if (out.isFullyBlinded()) continue;
if (out.needsBlinding() && out.blinderIndex) {
if (!inputIndexes.includes(out.blinderIndex)) {
isLast = false;
break;
}
}
}
const zkpLib = this.zkpLib;
const zkpLibValidator = this.zkpLibValidator;
const zkpGenerator = new Liquid.ZKPGenerator(
zkpLib,
Liquid.ZKPGenerator.WithOwnedInputs(ownedInputs),
);
const outputBlindingArgs = zkpGenerator.blindOutputs(
pset,
Liquid.Pset.ECCKeysGenerator(zkpLib.ecc),
outputIndexes,
);
const blinder = new Liquid.Blinder(pset, ownedInputs, zkpLibValidator, zkpGenerator);
if (isLast) {
blinder.blindLast({ outputBlindingArgs });
} else {
blinder.blindNonLast({ outputBlindingArgs });
}
pset = blinder.pset;
psetUpdater = new Liquid.Updater(pset);
const walletScript = address.outputScript;
const xOnlyPubKey = address.publicKey.subarray(1);
for (const [index, input] of pset.inputs.entries()) {
if (!input.witnessUtxo) continue;
const script = input.witnessUtxo.script;
if (script.equals(walletScript)) {
psetUpdater.addInTapInternalKey(index, xOnlyPubKey);
}
}
}
const outtx = {
dest: toAddress,
fee: totalFee,
asserts: asserts,
amount: amount,
_compiledTx: undefined,
_verified: false,
_txData: undefined,
compile: async () => {
if (simulation) throw new Error("Can't compile in simulation mode");
if (outtx._compiledTx) return outtx._compiledTx;
let signedPset = await window.liquid.signPset(psetUpdater.pset.toBase64());
if (!signedPset || !signedPset.signed) {
throw new Error("Failed to sign transaction", { cause: "failed_to_sign" });
}
signedPset = signedPset.signed;
const signed = Liquid.Pset.fromBase64(signedPset);
const finalizer = new Liquid.Finalizer(signed);
finalizer.finalize();
const hex = Liquid.Extractor.extract(finalizer.pset).toHex();
outtx._compiledTx = hex;
return hex;
},
verify: async () => {
if (simulation) throw new Error("Can't verify in simulation mode");
if (outtx._verified) return true;
// now the even funnier part, we verify the transaction as if we were the receiver
// and we check if everything is in order
// get and unblind
if (!outtx._txData) {
const hex = await outtx.compile();
const txBuffer = Buffer.from(hex, "hex");
outtx._txData = await this.getTransaction(undefined, txBuffer);
}
const txData = outtx._txData;
if (!txData.info.valid)
throw new Error("Invalid transaction", { cause: "invalid_transaction" });
if (txData.info.isIncoming || !txData.info.isOutgoing)
throw new Error(
"Transaction direction is wrong " +
txData.info.isIncoming +
"!=" +
txData.info.isOutgoing,
{
cause: "wrong_direction",
},
);
if (txData.info.outAmount !== amount)
throw new Error("Transaction amount is wrong " + txData.info.outAmount + "!=" + amount, {
cause: "amount_missmatch",
});
if (txData.info.outAsset !== asset)
throw new Error("Transaction asset is wrong " + txData.info.outAsset + "!=" + asset, {
cause: "asset_missmatch",
});
if (txData.info.feeAmount !== totalFee)
throw new Error("Transaction fee is wrong " + txData.info.feeAmount + "!=" + totalFee, {
cause: "fee_missmatch",
});
if (txData.info.feeAsset !== feeAsset)
throw new Error(
"Transaction fee asset is wrong " + txData.info.feeAsset + "!=" + feeAsset,
{ cause: "fee_asset_missmatch" },
);
outtx._verified = true;
return outtx;
},
broadcast: async () => {
if (simulation) throw new Error("Can't broadcast in simulation mode");
const hex = await outtx.compile();
await outtx.verify();
const txid = await this.elcAction("blockchain.transaction.broadcast", [hex]);
return txid;
},
};
return outtx;
}
/**
* Plain and simple, input a tx hash and get the txData buffer (cached internally)
* @param {string} tx_hash
* @returns
*/
async getTransactionBuffer(tx_hash) {
let txBuffer = await this.cache.get("txbf:" + tx_hash);
if (!txBuffer) {
const txHex = await this.elcAction("blockchain.transaction.get", [tx_hash, false]);
txBuffer = Buffer.from(txHex, "hex");
}
await this.cache.set("txbf:" + tx_hash, txBuffer); // we always cache the buffer for ever
return txBuffer;
}
/**
* infers some metadata from the transaction
* @param {*} txData
*/
async getTransactionInfo(txData) {
const addr = await this.getAddress();
const tx_hash = txData.tx_hash;
// Due to the confidential nature of liquid tx, we have a bazillion of checks to do
// and pieces to connect together to get all the information we need.
// for this reason we always cache.
// Check if we already have the metadata
let info = tx_hash ? await this.cache.get(`tx:${tx_hash}:info`) : undefined;
if (!info) {
// no metadata, we need to compute it
info = {};
try {
// First we check if we own at least one input
let ownedInput = false;
for (const inp of txData.ins) {
if (!inp.ldata) continue; // no ldata means not unblindable, we can't possibly own this input...
if (inp.owner.equals(addr.outputScript)) {
// It seems we can read this input, so we check if we actually own it
ownedInput = true;
break;
}
}
if (ownedInput) {
// if there is at least one owned input, likely the transaction is coming from us
info.isOutgoing = true;
info.isIncoming = false;
} else {
// if not, we are likely receiving it
info.isOutgoing = false;
info.isIncoming = true;
}
info.feeAsset = this.baseAsset; // fee asset is always base asset
info.feeAmount = 0;
if (info.isOutgoing) {
// unfortunately we can't simply read the blinded outputs, but
// we can infer them by summing our inputs and subtracting the output change if preset
// and the fee
const outXasset = {};
// const outDestXasset = {};
const changeXasset = {};
// lets start by collecting the change for each output asset
// and the fee
for (const out of txData.outs) {
if (out.fee) {
// this is a special fee output, we handle it specially
if (!changeXasset[out.fee.assetHash]) changeXasset[out.fee.assetHash] = 0;
changeXasset[out.fee.assetHash] += out.fee.value;
info.feeAmount += out.fee.value;
continue;
}
if (!out.ldata) continue; // ldata is not available, this can't possibly be a change out, so skip
if (out.owner.equals(addr.outputScript)) {
// seems we can read it, so we check if the output is coming back to us
const hash = out.ldata.assetHash;
if (!changeXasset[hash]) changeXasset[hash] = 0;
changeXasset[hash] += out.ldata.value;
// outDestXasset[hash] = out.owner;
}
}
// Now lets collect the input amount