Liquid Multisig
Liquid has a very similar scripting model with respect to Bitcoin. It allows to create complex spending conditions for your wallets.
A relatively simple, yet powerful, example is multisig. In a multisig wallet you need n signatures from a set of m public keys to spend a wallet UTXO.
In this guide we will explain how to setup and operate a Liquid Multisig wallet.
Setup
We want to create a 2of3 between Alice, Bob and Carol.
First each multisig participant creates their signer. Then they get their xpub, and share it with the coordinator, in this case Carol. Carol uses the xpubs to construct the multisig CT descriptor. Finally Carol shares the multisig CT descriptor with Alice and Bob.
sequenceDiagram
participant A as Alice
participant B as Bob
participant C as Carol<br>(coordinator)
Note over A: signer A 🔑
Note over B: signer B 🔑
Note over C: signer C 🔑
A->>C: xpub A
B->>C: xpub B
C->>C: xpub C
Note over C: Create<br>CT descriptor👀
C->>B: CT descriptor👀
C->>A: CT descriptor👀
let is_mainnet = false;
// Derivation for multisig
let bip = lwk_common::Bip::Bip87;
// Alice creates their signer and gets the xpub
let mnemonic_a = Mnemonic::generate(12)?;
let signer_a = SwSigner::new(&mnemonic_a.to_string(), is_mainnet)?;
let xpub_a = signer_a.keyorigin_xpub(bip, is_mainnet)?;
// Bob creates their signer and gets the xpub
let mnemonic_b = Mnemonic::generate(12)?;
let signer_b = SwSigner::new(&mnemonic_b.to_string(), is_mainnet)?;
let xpub_b = signer_b.keyorigin_xpub(bip, is_mainnet)?;
// Carol, who acts as a coordinator, creates their signer and gets the xpub
let mnemonic_c = Mnemonic::generate(12)?;
let signer_c = SwSigner::new(&mnemonic_c.to_string(), is_mainnet)?;
let xpub_c = signer_c.keyorigin_xpub(bip, is_mainnet)?;
// Carol generates a random SLIP77 descriptor blinding key
let mut slip77_rand_key = [0u8; 32];
use rand::{thread_rng, Rng};
thread_rng().fill(&mut slip77_rand_key);
let slip77_rand_key = slip77_rand_key.to_hex();
let desc_blinding_key = format!("slip77({slip77_rand_key})");
// Carol uses the collected xpubs and the descriptor blinding key to create
// the 2of3 descriptor
let threshold = 2;
let desc = format!("ct({desc_blinding_key},elwsh(multi({threshold},{xpub_a}/<0;1>/*,{xpub_b}/<0;1>/*,{xpub_c}/<0;1>/*)))");
// Validate the descriptor string
let wd = WolletDescriptor::from_str(&desc)?;
network = Network.testnet()
# Derivation for multisig
bip = Bip.new_bip87()
# Alice creates their signer and gets the xpub
signer_a = Signer.random(network)
xpub_a = signer_a.keyorigin_xpub(bip);
# Bob creates their signer and gets the xpub
signer_b = Signer.random(network)
xpub_b = signer_b.keyorigin_xpub(bip);
# Carol, who acts as a coordinator, creates their signer and gets the xpub
signer_c = Signer.random(network)
xpub_c = signer_c.keyorigin_xpub(bip);
# Carol generates a random SLIP77 descriptor blinding key
import os
slip77_rand_key = os.urandom(32).hex()
desc_blinding_key = f"slip77({slip77_rand_key})"
# Carol uses the collected xpubs and the descriptor blinding key to create
# the 2of3 descriptor
threshold = 2;
desc = f"ct({desc_blinding_key},elwsh(multi({threshold},{xpub_a}/<0;1>/*,{xpub_b}/<0;1>/*,{xpub_c}/<0;1>/*)))"
# Validate the descriptor string
wd = WolletDescriptor(desc)
const network = lwk.Network.testnet();
// Derivation for multisig
const bip = lwk.Bip.bip87();
// Alice creates their signer and gets the xpub
const mnemonic_a = lwk.Mnemonic.fromRandom(12);
const signer_a = new lwk.Signer(mnemonic_a, network);
const xpub_a = signer_a.keyoriginXpub(bip);
// Bob creates their signer and gets the xpub
const mnemonic_b = lwk.Mnemonic.fromRandom(12);
const signer_b = new lwk.Signer(mnemonic_b, network);
const xpub_b = signer_b.keyoriginXpub(bip);
// Carol, who acts as a coordinator, creates their signer and gets the xpub
const mnemonic_c = lwk.Mnemonic.fromRandom(12);
const signer_c = new lwk.Signer(mnemonic_c, network);
const xpub_c = signer_c.keyoriginXpub(bip);
// Carol generates a random SLIP77 descriptor blinding key
const slip77_rand_key = "<random-64-hex-chars>";
const desc_blinding_key = `slip77(${slip77_rand_key})`;
// Carol uses the collected xpubs and the descriptor blinding key to create
// the 2of3 descriptor
const threshold = 2;
const desc = `ct(${desc_blinding_key},elwsh(multi(${threshold},${xpub_a}/<0;1>/*,${xpub_b}/<0;1>/*,${xpub_c}/<0;1>/*)))`;
// Validate the descriptor string
const wd = new lwk.WolletDescriptor(desc);
In this example Carol creates the SLIP77 key at random, however this is not mandatory and valid alternatives are:
- "elip151", to deterministically derive the descriptor blinding key from the "bitcoin" descriptor;
- derive a SLIP77 deterministic key from a signer, however this descriptor blinding key might be re used in other descriptors.
Receive and monitor
The Liquid Multisig wallet is identified by the CT descriptor created during setup. The descriptor encodes all the information needed to derive scriptpubkeys and blinding keys which are necessary to operate the wallet. In general, it also contains the xpubs key origin, information needed to by signers to sign, consisting in the signer fingerprint and derivation paths.
With the wallet CT descriptor you can:
- Generate wallet (confidential) addresses
- Get the (unblinded) list of the wallet transactions
- Get the wallet balance
// Carol creates the wollet
let network = ElementsNetwork::LiquidTestnet;
let mut wollet_c = Wollet::without_persist(network, wd)?;
// With the wollet, Carol can obtain addresses, transactions and balance
let addr = wollet_c.address(None)?;
let txs = wollet_c.transactions()?;
let balance = wollet_c.balance()?;
// Update the wollet state
let url = "https://blockstream.info/liquidtestnet/api";
let mut client = EsploraClient::new(&url, network)?;
if let Some(update) = client.full_scan(&wollet_c)? {
wollet_c.apply_update(update)?;
}
# Carol creates the wollet
wollet_c = Wollet(network, wd, datadir=None)
# With the wollet, Carol can obtain addresses, transactions and balance
addr = wollet_c.address(None);
txs = wollet_c.transactions();
balance = wollet_c.balance();
# Update the wollet state
url = "https://blockstream.info/liquidtestnet/api"
client = EsploraClient(url, network)
update = client.full_scan(wollet_c)
wollet_c.apply_update(update)
// Carol creates the wollet
const wollet_c = new lwk.Wollet(network, wd);
// With the wollet, Carol can obtain addresses, transactions and balance
const addr = wollet_c.address(null).address().toString();
const txs = wollet_c.transactions();
const balance = wollet_c.balance();
// Update the wollet state
const url = "https://waterfalls.liquidwebwallet.org/liquidtestnet/api";
const client = new lwk.EsploraClient(network, url, true, 4, false);
const update = await client.fullScan(wollet_c);
if (update) {
wollet_c.applyUpdate(update);
}
Note that for generating addresses, getting transactions and balance, you have the same procedure for both singlesig and multisig wallets.
Send
As for addresses, transactions and balance, to create a multisig transaction you only need the CT descriptor. In this example Carol creates the transaction. Since she created the transaction, she's comfortable in skipping validation and she also signs it. However the wallet is a 2of3, so it needs either Alice or Bob to fully sign the transaction. Carol sends the transaction (in PSET format) to Bob. Bob examines the PSET and checks that it does what it's supposed to do (e.g. outgoing addresses, assets, amounts and fees), then it signs the PSET and sends it back to Carol. The PSET is now fully signed, Carol can finalize it and broadcast the transaction.
sequenceDiagram
participant A as Alice
participant B as Bob
participant C as Carol<br>(coordinator)
Note over C: Create PSET<br>(using CT descriptor👀)
Note over C: signer C 🔑 signs PSET
C->>B: PSET
Note over B: Analyze PSET<br>(using CT descriptor👀)
Note over B: signer B 🔑 signs PSET
B->>C: PSET
Note over C: Finalize PSET and<br>extract TX
Note over C: Broadcast TX
// Carol creates a transaction send few sats to a certain address
let address = "<address>";
let sats = 1000;
let lbtc = network.policy_asset();
let mut pset = wollet_c
.tx_builder()
.add_recipient(&address, sats, lbtc)?
.finish()?;
// Carol signs the transaction
let sigs_added = signer_c.sign(&mut pset)?;
assert_eq!(sigs_added, 1);
// Carol sends the PSET to Bob
// Bob wants to analyze the PSET before signing, thus he creates a wollet
let wd = WolletDescriptor::from_str(&desc)?;
let mut wollet_b = Wollet::without_persist(network, wd)?;
if let Some(update) = client.full_scan(&wollet_b)? {
wollet_b.apply_update(update)?;
}
// Then Bob uses the wollet to analyze the PSET
let details = wollet_b.get_details(&pset)?;
// PSET has a reasonable fee
assert!(details.balance.fee < 100);
// PSET has a signature from Carol
let fingerprints_has = details.fingerprints_has();
assert_eq!(fingerprints_has.len(), 1);
assert!(fingerprints_has.contains(&signer_c.fingerprint()));
// PSET needs a signature from either Bob or Carol
let fingerprints_missing = details.fingerprints_missing();
assert_eq!(fingerprints_missing.len(), 2);
assert!(fingerprints_missing.contains(&signer_a.fingerprint()));
assert!(fingerprints_missing.contains(&signer_b.fingerprint()));
// PSET has a single recipient, with data matching what was specified above
assert_eq!(details.balance.recipients.len(), 1);
let recipient = details.balance.recipients[0].clone();
assert_eq!(recipient.address.unwrap(), address);
assert_eq!(recipient.asset.unwrap(), lbtc);
assert_eq!(recipient.value.unwrap(), sats);
// Bob is satisified with the PSET and signs it
let sigs_added = signer_b.sign(&mut pset)?;
assert_eq!(sigs_added, 1);
// Bob sends the PSET back to Carol
// Carol checks that the PSET has enough signatures
let details = wollet_c.get_details(&pset)?;
assert_eq!(details.fingerprints_has().len(), 2);
// Carol finalizes the PSET and broadcast the transaction
let tx = wollet_c.finalize(&mut pset)?;
let txid = client.broadcast(&tx)?;
# Carol creates a transaction send few sats to a certain address
address = "<address>"
sats = 1000
lbtc = network.policy_asset()
b = network.tx_builder()
b.add_recipient(address, sats, lbtc)
pset = b.finish(wollet_c)
pset = signer_c.sign(pset)
# Carol sends the PSET to Bob
# Bob wants to analyze the PSET before signing, thus he creates a wollet
wd = WolletDescriptor(desc)
wollet_b = Wollet(network, wd, datadir=None)
update = client.full_scan(wollet_b)
wollet_b.apply_update(update)
# Then Bob uses the wollet to analyze the PSET
details = wollet_b.pset_details(pset)
# PSET has a reasonable fee
assert details.balance().fee() < 100
# PSET has a signature from Carol
fingerprints_has = details.fingerprints_has()
assert len(fingerprints_has) == 1
assert signer_c.fingerprint() in fingerprints_has
# PSET needs a signature from either Bob or Carol
fingerprints_missing = details.fingerprints_missing()
assert len(fingerprints_missing) == 2
assert signer_a.fingerprint() in fingerprints_missing
assert signer_b.fingerprint() in fingerprints_missing
# PSET has a single recipient, with data matching what was specified above
assert len(details.balance().recipients()) == 1
recipient = details.balance().recipients()[0]
assert str(recipient.address()) == str(address)
assert recipient.asset() == lbtc
assert recipient.value() == sats
# Bob is satisified with the PSET and signs it
pset = signer_b.sign(pset)
# Bob sends the PSET back to Carol
# Carol checks that the PSET has enough signatures
details = wollet_c.pset_details(pset)
fingerprints_has = details.fingerprints_has()
assert len(fingerprints_has) == 2
# Carol finalizes the PSET and broadcast the transaction
tx = pset.finalize()
txid = client.broadcast(tx)
// Carol creates a transaction send few sats to a certain address
const sats = BigInt(1000);
const address = new lwk.Address("<address>");
const asset = new lwk.AssetId("<asset>");
var builder = new lwk.TxBuilder(network)
builder = builder.addRecipient(address, sats, asset)
var pset = builder.finish(wollet_c)
pset = signer_c.sign(pset)
// Carol sends the PSET to Bob
// Bob wants to analyze the PSET before signing, thus he creates a wollet
const wd_b = new lwk.WolletDescriptor(desc);
const wollet_b = new lwk.Wollet(network, wd_b);
const update_b = await client.fullScan(wollet_b);
if (update_b) {
wollet_b.applyUpdate(update_b);
}
// Then Bob uses the wollet to analyze the PSET
const details = wollet_b.psetDetails(pset);
// PSET has a reasonable fee
console.assert(details.balance().fee() < 100);
// PSET has a signature from Carol
console.assert(details.fingerprintsHas().length === 1);
console.assert(details.fingerprintsHas().includes(signer_c.fingerprint()));
// PSET needs a signature from either Bob or Carol
console.assert(details.fingerprintsMissing().length === 2);
console.assert(details.fingerprintsMissing().includes(signer_a.fingerprint()));
console.assert(details.fingerprintsMissing().includes(signer_b.fingerprint()));
// PSET has a single recipient, with data matching what was specified above
console.assert(details.balance().recipients().length === 1);
const recipient = details.balance().recipients()[0];
console.assert(recipient.address().toString() === address.toString());
console.assert(recipient.asset().toString() === asset.toString());
console.assert(recipient.value() === sats);
// Bob is satisified with the PSET and signs it
pset = signer_b.sign(pset)
// Bob sends the PSET back to Carol
// Carol checks that the PSET has enough signatures
const details_b = wollet_b.psetDetails(pset);
console.assert(details_b.fingerprintsHas().length === 2);
// Carol finalizes the PSET and broadcast the transaction
pset = wollet_c.finalize(pset)
const tx = pset.extractTx();
const txid = await client.broadcastTx(tx);
In this example we went through an example where the coordinator is one of the multisig participants and the PSET is signed serially. In general, this is not the case.
The coordinator can be a utility service, as long as it knows the multisig CT descriptor.
Also the PSET can be signed in parallel, and in this case the coordinator must combine the signed PSET using Wollet::combine().