Blockstream Enterprise Custody SDK
Core Concepts

Multisig Wallet

Create and use confidential k-of-n and n-of-n multisig wallets on Liquid with LWK and AMP, where control is split across several participant keys instead of the standard 2-of-2 setup (the AMP plus a single user).

Throughout, participant keys are the keys held by the wallet's owners (there are n of them), and AMP is the mandatory service co-signer.


How these wallets work

A multisig wallet requires several keys to approve any spend. With the AMP service, every transaction is co-signed by AMP plus a chosen number of participant keys:

To spend, signers supply signatures from any k of the n participant keys, and AMP adds its own signature.

So a "3-of-5" wallet has 5 participant keys, any 3 of which must sign a transaction, and AMP co-signs on top. This gives multi-key control while AMP enforces service policy.

The chosen threshold (k) counts only the participant keys. AMP's signature is always required in addition and is added automatically at the end.

AMP is mandatory — in two ways:

  • The AMP key must be in the descriptor, or the wallet won't be registered. A descriptor without AMP's xpub is rejected at registration.
  • AMP must co-sign every transaction, or it cannot be broadcast. No matter how many participant keys sign, the transaction will not be processed until AMP adds its signature.

How the descriptor is built

These wallets use a confidential multisig descriptor. There are two forms, and they differ in how the threshold treats the AMP signature.

Form 1 — service-assisted (AMP counted separately)

ct(<blinding_key>,elwsh(and_v(v:pk(<amp_key>/<0;1>/*),multi(k,<participant_key_1>/<0;1>/*,…,<participant_key_N>/<0;1>/*))))
ComponentMeaning
ct(…)Confidential transaction wrapper
<blinding_key>Blinding-key derivation
elwsh(…)Elements witness script hash
and_v(v:pk(<amp_key>),…)AMP2 server's keyorigin xpub
multi(k,…)Any k of the N participant keys must co-sign
/<0;1>/*Receive (0) and change (1) derivation
#checksumOptional descriptor checksum (appended as …)#xxxxxxxx)

The threshold k does NOT include AMP. It counts only the participant keys; AMP signs in addition. Example: multi(3, 5 keys) = 3-of-5 participant keys plus AMP.

Form 2 — pure n-of-n (AMP is one of the keys)

ct(<blinding_key>,elwsh(multi(n,<key_1>/<0;1>/*,…,<key_n>/<0;1>/*)))
ComponentMeaning
ct(…)Confidential transaction wrapper
<blinding_key>Blinding-key derivation
elwsh(…)Elements witness script hash
multi(n,…)All n listed keys must co-sign — the AMP key is one of them

The threshold n ALREADY includes AMP. Since the AMP key is one of the n listed keys, its signature is counted in the threshold. Example: multi(5, 5 keys including AMP) = 5-of-5, i.e. AMP plus 4 participant keys, all 5 counted.

The difference in one line:

  • and_v(v:pk(AMP),multi(k,…))k = participant keys only; AMP is extra and mandatory.
  • multi(n,…)n includes AMP (AMP is one of the listed keys); all n required.

The makeWalletDescriptorKofN helper below builds Form 1 (service-assisted).


Creating a wallet

1. Generate the keys (signers)

function createSigner(count: number): lwk.Signer[] {
  const signers: lwk.Signer[] = []
  for (let i = 0; i < count; i++) {
    const mnemonic = new lwk.Mnemonic(bip39.generateMnemonic())
    signers.push(new lwk.Signer(mnemonic, network))
  }
  return signers
}

const signers = createSigner(5) // e.g. 5 keys for a 3-of-5 wallet

Keep each mnemonic safe — it is the only way to recover the corresponding key.

2. Get the AMP service key

const amp = (await ampGetKeyoriginXpub()).details.keyorigin_xpub

3. Build the wallet descriptor

A descriptor is a single line that fully describes the wallet. The helper below assembles it — pass the threshold (k), the signers, and the AMP key:

function makeWalletDescriptorKofN(
  threshold: number,          // k — how many participant keys must sign
  signers: lwk.Signer[],      // the n participant keys
  amp_xpub: String,           // the AMP service key
): string {
  const xpubs = signers.map(s =>
    s.keyoriginXpub(lwk.Bip.bip84()).concat('/<0;1>/*'),
  )
  const blindingKey = generateRandomBytesHex() // random blinding key (privacy)
  return `ct(slip77(${blindingKey}),elwsh(and_v(v:pk(${amp_xpub}/<0;1>/*),` +
         `multi(${threshold},${xpubs.join(',')}))))`
}

const descriptor = makeWalletDescriptorKofN(3, signers, amp) // 3-of-5

The AMP key is required in the descriptor. It is included above via v:pk(${amp_xpub}...). If AMP's xpub is missing, the wallet will not be registered in the next step.

4. Register the wallet with AMP

const resp = await broadcastRequest(AmpWalletRequestSchema.parse({
  action: 'add',
  resource: '/amp/wallets',
  details: { descriptor },
}), {})

if (resp.status !== 'success') {
  throw new Error(`Failed to register wallet: ${resp.message}`)
}

const wid = resp.details.wid // the wallet id — keep it for later calls

If the chosen setup isn't one of the supported combinations, registration is rejected — switch to a row from the table above.

5. Create the wallet object and a receive address

const wd     = new lwk.WolletDescriptor(resp.details.descriptor)
const wallet = new lwk.Wollet(network, wd)
const address = wallet.address(0).address().toString()

Funding and syncing

Send funds to the address, then sync so the wallet sees them:

await faucetLiquidAddress(address) // or send from any Liquid wallet
await AmpSync()

const balance = await ampGetLbtcBalance(wid)

Using the wallet (signing transactions)

Building any transaction — issuing an asset, or sending funds — returns an unsigned PSET (a partially signed transaction). Then:

  1. Add k participant signatures,
  2. Let AMP co-sign,
  3. Broadcast.

Step 2 is not optional. Without AMP's co-signature the transaction is invalid and will not be processed, even if all participant keys have signed. Always route the PSET through AmpSignPset before broadcasting.

async function finalizeAndBroadcast(signerCount: number, psetStr: string) {
  let pset = new lwk.Pset(psetStr)

  // 1) Sign with participant keys (at least k)
  for (let i = 0; i < signerCount; i++) {
    pset = signers[i].sign(pset)
  }

  // 2) AMP co-signs
  const ampSigned = await AmpSignPset(pset)

  // 3) Broadcast
  await AmpBroadcast(ampSigned.details.pset)

  await AmpSync()
  return new lwk.Pset(ampSigned.details.pset)
}

Example — sending an asset from a 3-of-5 wallet

const send = await ampSend(assetId, wid, amountToSend, recipientWid)

// Signing with only 2 participant keys is NOT enough — the broadcast is rejected:
await expect(finalizeAndBroadcast(2, send.details.pset)).rejects.toThrow()

// Signing with 3 (or more, up to 5) participant keys succeeds:
await finalizeAndBroadcast(4, send.details.pset)

The rule in practice: fewer than k participant keys can never spend, and AMP's co-signature is always required on top of the participant keys.

On this page