doubletake 0.0.0-alpha.11

A library and CLI tool for creating Bitcoin double spend prevention bonds on Liquid
Documentation
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




use std::str::FromStr;

use bitcoin::{Amount, FeeRate};
use bitcoin::secp256k1::SecretKey;
use elements::AssetId;
use hex_conservative::{DisplayHex, FromHex};
use wasm_bindgen::prelude::*;

use crate::{segwit, BitcoinUtxo, BondSpec, ElementsUtxo};



/// Create a segwit bond and address.
///
/// Input:
/// - `pubkey`: public key in hex that will commit to the bond
/// - `bond_value`: value in sats
/// - `bond_asset`: asset id in hex, or "lbtc"
/// - `lock_time_unix`: locktime as a unix timestamp (like block timestamps)
/// - `reclaim_pubkey`: public key in hex to be used for reclaiming the bond
///
/// Output is the same as the [bond_inspect] function.
#[wasm_bindgen]
pub fn create_segwit_bond_spec(
	pubkey: &str,
	bond_value_sat: u64,
	bond_asset: &str,
	lock_time_unix: u64,
	reclaim_pubkey: &str,
) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let pubkey = pubkey.parse().map_err(|e| format!("invalid pubkey: {}", e))?;
	let bond_value = Amount::from_sat(bond_value_sat);
	let bond_asset = parse_asset_id(bond_asset)?;
	let lock_time = lock_time_from_unix(lock_time_unix)?;
	let reclaim_pubkey = reclaim_pubkey.parse().map_err(|e| format!("invalid pubkey: {}", e))?;

	let spec = segwit::BondSpec { pubkey, bond_value, bond_asset, lock_time, reclaim_pubkey };
	Ok(BondSpec::Segwit(spec).to_base64())
}

/// Inspect a base64-encoded bond spec.
///
/// Input:
/// - `spec`: the base64 bond spec
///
/// Output: object with following fields:
/// - `type`: bond type
/// - `pubkey`: public key holding the bond
/// - `bond_value`: the value in satoshi
/// - `bond_asset`: the asset ID
/// - `lock_time`: the locktime of the expiry
/// - `reclaim_pubkey`: the reclaim pubkey
/// - `script_pubkey`: the script pubkey for the bond address
/// - `witness_script`: the witness script used for the address
#[wasm_bindgen]
pub fn inspect_bond(spec: &str) -> Result<JsValue, JsValue> {
	console_error_panic_hook::set_once();
	let spec = BondSpec::from_base64(&spec)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let (ws, spk) = match spec {
		BondSpec::Segwit(ref s) => segwit::create_bond_script(&s),
	};
	let mut json = serde_json::to_value(&spec).unwrap();
	assert!(json.is_object());
	let obj = json.as_object_mut().unwrap();
	obj.insert("script_pubkey".into(), spk.to_bytes().as_hex().to_string().into());
	obj.insert("witness_script".into(), ws.to_bytes().as_hex().to_string().into());
	Ok(serde_wasm_bindgen::to_value(&json).unwrap())
}

/// Create a Liquid/Elements address for the bond, given the spec.
///
/// Input:
/// - `spec`: the base64 encoded bond spec
/// - `network`: "liquid", "liquidtestnet" or "elements"
///
/// Output: a Liquid/Elements address
#[wasm_bindgen]
pub fn bond_address(spec: &str, network: &str) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let spec = BondSpec::from_base64(&spec)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let network = parse_elements_network(network)?;
	let (_, spk) = match spec {
		BondSpec::Segwit(ref s) => segwit::create_bond_script(&s),
	};
	let addr = elements::Address::from_script(&spk, None, network).expect("valid spk");
	Ok(addr.to_string())
}

/// Create a Bitcoin UTXO object to use as function argument.
///
/// Input:
/// - `tx`: the raw tx in hex
/// - `vout`: the output index of the utxo
///
/// Output: a [BitcoinUtxo] object
#[wasm_bindgen]
pub fn create_bitcoin_utxo(
	tx: &str,
	vout: usize,
) -> Result<JsValue, JsValue> {
	console_error_panic_hook::set_once();
	let tx = btc_deserialize_hex::<bitcoin::Transaction>(tx)
		.map_err(|e| format!("invalid tx: {}", e))?;
	let ret = BitcoinUtxo {
		outpoint: bitcoin::OutPoint::new(tx.txid(), vout.try_into().expect("vout overflow")),
		output: tx.output.get(vout).ok_or("vout invalid for tx: too few outputs")?.clone(),
	};
	Ok(serde_wasm_bindgen::to_value(&ret).unwrap())
}

/// Create an Elements/Liquid UTXO object to use as function argument.
///
/// Input:
/// - `tx`: the raw tx in hex
/// - `vout`: the output index of the utxo
///
/// Output: a [ElementsUtxo] object
#[wasm_bindgen]
pub fn create_elements_utxo(
	tx: &str,
	vout: usize,
) -> Result<JsValue, JsValue> {
	console_error_panic_hook::set_once();
	let tx = elem_deserialize_hex::<elements::Transaction>(tx)
		.map_err(|e| format!("invalid tx: {}", e))?;

	let output = tx.output.get(vout).ok_or("vout invalid for tx: too few outputs")?.clone();
	let ret = ElementsUtxo {
		outpoint: elements::OutPoint::new(tx.txid(), vout as u32),
		output: output,
	};
	Ok(serde_wasm_bindgen::to_value(&ret).unwrap())
}

/// Create a transaction to burn a bond.
///
/// Input:
/// - `bond_utxo`: the Elements/Liquid UTXO outpoint, as `<txid>:<vout>`
/// - `bond_tx`: the raw hex bond transaction
/// - `spec_base64`: bond spec encoded as base64
/// - `double_spend_utxo`: the Bitcoin UTXO outpoint that was double spent, as `<txid>:<vout>`
/// - `double_spend_tx`: the Bitcoin tx that was double spent
/// - `tx1_hex`: first double spend Bitcoin tx in hex
/// - `tx2_hex`: second double spend Bitcoin tx in hex
/// - `fee_rate_sat_per_vb`: the fee rate to use in satoshi per virtual byte
/// - `reward_address`: the reward Elements/Liquid address where to send the reward
///
/// Output: an Elements/Liquid transaction in hex
#[wasm_bindgen]
pub fn create_burn_tx(
	bond_utxo: &str,
	bond_tx: &str,
	spec_base64: &str,
	double_spend_utxo: &str,
	double_spend_tx: &str,
	tx1_hex: &str,
	tx2_hex: &str,
	fee_rate_sat_per_vb: u64,
	reward_address: &str,
) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let utxo_outpoint = elements::OutPoint::from_str(bond_utxo)
		.map_err(|e| format!("invalid bond UTXO outpoint: {}", e))?;
	let utxo = ElementsUtxo {
		outpoint: utxo_outpoint,
		output: elem_deserialize_hex::<elements::Transaction>(bond_tx)
			.map_err(|e| format!("invalid bond tx: {}", e))?
			.output.get(utxo_outpoint.vout as usize)
			.ok_or("bond tx and outpoint don't match")?.clone(),
	};
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let double_spend_outpoint = bitcoin::OutPoint::from_str(double_spend_utxo)
		.map_err(|e| format!("invalid bond UTXO: {}", e))?;
	let double_spend_utxo = BitcoinUtxo {
		outpoint: double_spend_outpoint,
		output: btc_deserialize_hex::<bitcoin::Transaction>(double_spend_tx)
			.map_err(|e| format!("invalid double spend tx: {}", e))?
			.output.get(double_spend_outpoint.vout as usize)
			.ok_or("double spend tx and outpoint don't match")?.clone(),
	};
	let tx1 = elem_deserialize_hex(tx1_hex)
		.map_err(|e| format!("bad tx1_hex: {}", e))?;
	let tx2 = elem_deserialize_hex(tx2_hex)
		.map_err(|e| format!("bad tx2_hex: {}", e))?;
	let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_per_vb)
		.ok_or_else(|| "invalid feerate")?;
	let reward_address = elements::Address::from_str(reward_address)
		.map_err(|e| format!("invalid reward address: {}", e))?;

	let tx = crate::create_burn_tx(
		&utxo, &spec, &double_spend_utxo, &tx1, &tx2, fee_rate, &reward_address,
	)?;
	Ok(elements::encode::serialize_hex(&tx))
}

/// Create an unsigned transaction to reclaim a bond after it has expired.
///
/// Input:
/// - `bond_utxo`: the Elements/Liquid UTXO outpoint, as `<txid>:<vout>`
/// - `bond_tx`: the raw hex bond transaction
/// - `spec_base64`: bond spec encoded as base64
/// - `fee_rate_sat_per_vb`: the fee rate to use in satoshi per virtual byte
/// - `claim_address`: the claim Elements/Liquid address where to send the funds
///
/// Output: an Elements/Liquid transaction in hex
#[wasm_bindgen]
pub fn create_unsigned_reclaim_tx(
	bond_utxo: &str,
	bond_tx: &str,
	spec_base64: &str,
	fee_rate_sat_per_vb: u64,
	claim_address: &str,
) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let utxo_outpoint = elements::OutPoint::from_str(bond_utxo)
		.map_err(|e| format!("invalid bond UTXO outpoint: {}", e))?;
	let utxo = ElementsUtxo {
		outpoint: utxo_outpoint,
		output: elem_deserialize_hex::<elements::Transaction>(bond_tx)
			.map_err(|e| format!("invalid bond tx: {}", e))?
			.output.get(utxo_outpoint.vout as usize)
			.ok_or("bond tx and outpoint don't match")?.clone(),
	};
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_per_vb).ok_or_else(|| "invalid feerate")?;
	let claim_address = elements::Address::from_str(claim_address)
		.map_err(|e| format!("invalid reward address: {}", e))?;

	let tx = crate::create_unsigned_reclaim_tx(&utxo, &spec, fee_rate, &claim_address);
	Ok(elements::encode::serialize_hex(&tx))
}

/// Finalize a reclaim transaction with an ECDSA signature.
///
/// Input:
/// - `spec_base64`: bond spec encoded as base64
/// - `reclaim_tx`: the hex unsigned reclaim tx
/// - `signature`: the ECDSA signature of the tx by the reclaim private key
///
/// Output: an Elements/Liquid transaction in hex
#[wasm_bindgen]
pub fn finalize_ecdsa_reclaim_tx(
	spec_base64: &str,
	reclaim_tx: &str,
	signature: &str,
) -> Result<String, JsValue> {
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let reclaim_tx = elem_deserialize_hex::<elements::Transaction>(reclaim_tx)
		.map_err(|e| format!("invalid reclaim tx: {}", e))?;
	let signature = {
		let bytes = Vec::<u8>::from_hex(signature).map_err(|_| "invalid signature hex")?;
		crate::util::parse_ecdsa_signature_all(&bytes)?
	};

	let ret = crate::finalize_ecdsa_reclaim_tx(&spec, reclaim_tx, signature)?;
	Ok(elements::encode::serialize_hex(&ret))
}

/// Create a PSET to reclaim a bond after it has expired.
///
/// Input:
/// - `bond_utxo`: the Elements/Liquid UTXO outpoint, as `<txid>:<vout>`
/// - `bond_tx`: the raw hex bond transaction
/// - `spec_base64`: bond spec encoded as base64
/// - `fee_rate_sat_per_vb`: the fee rate to use in satoshi per virtual byte
/// - `claim_address`: the claim Elements/Liquid address where to send the funds
///
/// Output: a PartiallySignedElementsTransaction in base64
#[wasm_bindgen]
pub fn create_reclaim_pset(
	bond_utxo: &str,
	bond_tx: &str,
	spec_base64: &str,
	fee_rate_sat_per_vb: u64,
	claim_address: &str,
) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let utxo_outpoint = elements::OutPoint::from_str(bond_utxo)
		.map_err(|e| format!("invalid bond UTXO outpoint: {}", e))?;
	let utxo = ElementsUtxo {
		outpoint: utxo_outpoint,
		output: elem_deserialize_hex::<elements::Transaction>(bond_tx)
			.map_err(|e| format!("invalid bond tx: {}", e))?
			.output.get(utxo_outpoint.vout as usize)
			.ok_or("bond tx and outpoint don't match")?.clone(),
	};
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_per_vb).ok_or_else(|| "invalid feerate")?;
	let claim_address = elements::Address::from_str(claim_address)
		.map_err(|e| format!("invalid reward address: {}", e))?;

	let pset = crate::create_reclaim_pset(&utxo, &spec, fee_rate, &claim_address);
	let bytes = elements::encode::serialize(&pset);
	Ok(base64::encode_config(&bytes, base64::STANDARD))
}

/// Finalize a reclaim PSET.
///
/// Input:
/// - `spec_base64`: bond spec encoded as base64
/// - `reclaim_pset`: the base64 reclaim PSET
///
/// Output: an Elements/Liquid transaction in hex
#[wasm_bindgen]
pub fn finalize_reclaim_pset(
	spec_base64: &str,
	reclaim_pset: &str,
) -> Result<String, JsValue> {
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let pset = {
		let bytes = base64::decode_config(reclaim_pset, base64::STANDARD)
			.map_err(|e| format!("invalid base64 for pset: {}", e))?;
		elements::encode::deserialize::<elements::pset::PartiallySignedTransaction>(&bytes)
			.map_err(|e| format!("invalid pset: {}", e))?
	};

	let ret = crate::finalize_reclaim_pset(&spec, &pset)?;
	Ok(elements::encode::serialize_hex(&ret))
}

/// Create a signed transaction to reclaim a bond after it has expired.
///
/// Input:
/// - `bond_utxo`: the Elements/Liquid UTXO outpoint, as `<txid>:<vout>`
/// - `bond_tx`: the raw hex bond transaction
/// - `spec_base64`: bond spec encoded as base64
/// - `fee_rate_sat_per_vb`: the fee rate to use in satoshi per virtual byte
/// - `claim_address`: the claim Elements/Liquid address where to send the funds
/// - `reclaim_sk`: secret key of the reclaim pubkey in either WIF or hex
///
/// Output: an Elements/Liquid transaction in hex
#[wasm_bindgen]
pub fn create_signed_ecdsa_reclaim_tx(
	bond_utxo: &str,
	bond_tx: &str,
	spec_base64: &str,
	fee_rate_sat_per_vb: u64,
	claim_address: &str,
	reclaim_sk: &str,
) -> Result<String, JsValue> {
	console_error_panic_hook::set_once();
	let utxo_outpoint = elements::OutPoint::from_str(bond_utxo)
		.map_err(|e| format!("invalid bond UTXO outpoint: {}", e))?;
	let utxo = ElementsUtxo {
		outpoint: utxo_outpoint,
		output: elem_deserialize_hex::<elements::Transaction>(bond_tx)
			.map_err(|e| format!("invalid bond tx: {}", e))?
			.output.get(utxo_outpoint.vout as usize)
			.ok_or("bond tx and outpoint don't match")?.clone(),
	};
	let spec = BondSpec::from_base64(spec_base64)
		.map_err(|e| format!("invalid spec: {}", e))?;
	let fee_rate = FeeRate::from_sat_per_vb(fee_rate_sat_per_vb).ok_or_else(|| "invalid feerate")?;
	let reclaim_sk = parse_secret_key(reclaim_sk)?;
	let claim_address = elements::Address::from_str(claim_address)
		.map_err(|e| format!("invalid reward address: {}", e))?;

	let tx = crate::create_signed_ecdsa_reclaim_tx(
		&utxo, &spec, fee_rate, &claim_address, &reclaim_sk,
	)?;
	Ok(elements::encode::serialize_hex(&tx))
}

/// Deserialize an bitcoin object from hex.
fn btc_deserialize_hex<T: bitcoin::consensus::Decodable>(hex: &str) -> Result<T, String> {
	let mut iter = hex_conservative::HexToBytesIter::new(hex)
		.map_err(|e| format!("invalid hex string: {}", e))?;
	Ok(T::consensus_decode(&mut iter).map_err(|e| format!("decoding failed: {}", e))?)
}

/// Deserialize an elements object from hex.
fn elem_deserialize_hex<T: elements::encode::Decodable>(hex: &str) -> Result<T, String> {
	let mut iter = hex_conservative::HexToBytesIter::new(hex)
		.map_err(|e| format!("invalid hex string: {}", e))?;
	Ok(T::consensus_decode(&mut iter).map_err(|e| format!("decoding failed: {}", e))?)
}

/// Parse a secret key from a string.
/// Supports both WIF format and hexadecimal.
fn parse_secret_key(s: &str) -> Result<SecretKey, String> {
	if let Ok(k) = bitcoin::PrivateKey::from_str(&s) {
		Ok(k.inner)
	} else {
		Ok(SecretKey::from_str(&s).map_err(|_| "invalid secret key")?)
	}
}

/// Parse an Elements network address params identifier from string.
///
/// Values supported:
/// - "liquid"
/// - "liquidtestnet"
/// - "elements"
fn parse_elements_network(s: &str) -> Result<&'static elements::AddressParams, String> {
	match s {
		"liquid" => Ok(&elements::AddressParams::LIQUID),
		"liquidtestnet" => Ok(&elements::AddressParams::LIQUID_TESTNET),
		"elements" => Ok(&elements::AddressParams::ELEMENTS),
		_ => Err("invalid network")?,
	}
}

/// Parse an Elements asset ID from hex.
///
/// Special case: "lbtc".
fn parse_asset_id(s: &str) -> Result<AssetId, String> {
	match s {
		"lbtc" => Ok(AssetId::LIQUID_BTC),
		_ => Ok(AssetId::from_str(s).map_err(|_| "invalid asset id")?),
	}
}

/// Convert a UNIX timestamp in seconds to a valid [LockTime] value.
fn lock_time_from_unix(secs: u64) -> Result<elements::LockTime, String> {
	let secs_u32 = secs.try_into().map_err(|_| "timelock overflow")?;
	Ok(elements::LockTime::from_time(secs_u32).map_err(|e| format!("invalid timelock: {}", e))?)
}