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>/*))))| Component | Meaning |
|---|---|
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 |
#checksum | Optional 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>/*)))| Component | Meaning |
|---|---|
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 walletKeep 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_xpub3. 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-5The 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 callsIf 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:
- Add k participant signatures,
- Let AMP co-sign,
- 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.
Key management
AMP2 requires RSA and ECDSA P-256 key pairs for issuer encryption and request signing. The page explains key generation, format requirements, validation, and rotation.
Issuer guide overview
The guide provides the implementation track for issuer teams that integrate the private AMP2 SDK. The page covers account activation through ongoing asset monitoring.