advanced 60 min

Multi-Signature Transactions

Set up multi-sig accounts with weighted signers and quorums. Sign transactions collaboratively and combine signatures for submission.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Explain how SignerListSet, weighted signer entries, and a quorum define an M-of-N account.
  • Configure a 2-of-3 multi-signature account on testnet with SignerListSet.
  • Autofill a transaction once for N signers and have each signer sign it independently.
  • Combine multiple signatures with multisign() and submit them as a single multi-signed transaction.
  • Recognise and debug a failed multi-sign whose collected weight falls below the quorum.
Complete this module by peer review. Jump to assessment

Overview

So far every transaction you've sent has had exactly one signer: the account's own key, used by one person, deciding alone. That's fine for a personal wallet. It's a terrible idea for a company treasury, a shared escrow, or a DAO's funds — anywhere a single leaked key, or a single bad actor, shouldn't be able to move the money.

Multi-signing is the XRP Ledger's answer. Think of it as a safe with several keyholders where the rule is written on the door: "any two of these three people, turning their keys together, may open it." No single keyholder can open it alone, and losing one key doesn't lock everyone out. On the ledger that rule lives in an object called a SignerList, and the people are signers, each carrying a weight. A transaction is valid only when the combined weight of the signers who actually signed it meets or exceeds a threshold called the quorum.

In this module you'll do the whole loop by hand against testnet: stand up an account, attach a 2-of-3 SignerList to it, then have two independent signers each sign the same payment, combine their signatures, and submit one multi-signed transaction. By the end you'll understand exactly what "M-of-N control" means on-chain — and you'll have code that proves it.

Setup

Same toolchain as the payments module — one dependency:

npm init -y
npm i xrpl
npm i -D typescript tsx @types/node

Create multisig.ts and open a testnet connection. We'll fund four wallets up front: one master account (the shared treasury whose funds we'll govern) and three signer accounts (the keyholders).

import * as xrpl from 'xrpl'

const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')

async function main() {
  await client.connect()
  console.log('Connected to the XRPL testnet')

  const { wallet: master } = await client.fundWallet()
  const { wallet: signerA } = await client.fundWallet()
  const { wallet: signerB } = await client.fundWallet()
  const { wallet: signerC } = await client.fundWallet()

  // ... everything below goes here ...

  await client.disconnect()
}

main()

A subtle but important point: the signer accounts are just addresses on the SignerList. Their keys sign off-ledger — the signers never need to fund or pre-register anything special beyond existing as accounts. Here we fund them so they're real testnet accounts you can inspect, but the master account is the one whose money the SignerList actually protects.

Attach a SignerList

The SignerListSet transaction is what turns an ordinary account into a multi-signed one. It's sent by the master account and describes who may sign on its behalf and how much each signature counts.

const signerListTx: xrpl.SignerListSet = {
  TransactionType: 'SignerListSet',
  Account: master.classicAddress,
  SignerQuorum: 2,
  SignerEntries: [
    { SignerEntry: { Account: signerA.classicAddress, SignerWeight: 1 } },
    { SignerEntry: { Account: signerB.classicAddress, SignerWeight: 1 } },
    { SignerEntry: { Account: signerC.classicAddress, SignerWeight: 1 } },
  ],
}

const setup = await client.submitAndWait(signerListTx, {
  autofill: true,
  wallet: master,
})

if (setup.result.meta?.TransactionResult !== 'tesSUCCESS') {
  throw new Error(`SignerListSet failed: ${setup.result.meta?.TransactionResult}`)
}
console.log('SignerList installed:', setup.result.hash)

Read that structure carefully — it's the part everyone gets wrong:

  • SignerEntries is an array, and each element is wrapped: { SignerEntry: { Account, SignerWeight } }. The double nesting is required by the ledger; a flat { Account, SignerWeight } will be rejected.
  • SignerWeight is how much that signer's signature is worth. Here all three carry weight 1.
  • SignerQuorum is the threshold. With three signers each weighing 1 and a quorum of 2, any two of them together (weight 1 + 1 = 2) clear the bar — a classic 2-of-3.

A SignerList can hold up to 32 entries. Weights don't have to be equal: give a CFO weight 2 and two analysts weight 1 each with a quorum of 2, and you've encoded "the CFO alone, or both analysts together." That flexibility is the whole point — you're describing a policy, not just counting heads.

This SignerListSet is itself signed the normal, single-signer way, by the master's own key. The master key still works until you choose to disable it. Multi-signing adds a way to authorize transactions; it doesn't remove the master key unless you explicitly do so later.

Build the transaction to be multi-signed

Now the shared account wants to spend. We'll send a Payment from the master account to some destination — say, one of the signers. The crucial detail: the Account on this transaction is the master, not any signer. The signers are merely authorizing the master's transaction.

const destination = signerC

const payment: xrpl.Payment = {
  TransactionType: 'Payment',
  Account: master.classicAddress,
  Destination: destination.classicAddress,
  Amount: xrpl.xrpToDrops('20'),
}

Before anyone signs, this transaction must be autofilled exactly once — and every signer must then sign that identical object. Autofill stamps in the Sequence, LastLedgerSequence, and a Fee. If two signers autofilled separately they'd get different sequence numbers or fees, their signatures would describe different transactions, and the blobs would refuse to combine. Autofill once; share the result.

For multi-signing you pass a second argument telling autofill how many signers will sign, so it can size the fee:

const prepared = await client.autofill(payment, 2)

That 2 matters because multi-signed transactions cost more. The fee is roughly base fee + (base fee × number of signers) — each extra signature adds work for validators, so each one adds cost. Autofill does this arithmetic for you; just tell it the truth about how many signatures are coming.

Sign independently, then combine

Each signer now signs the same prepared transaction. The second argument true tells wallet.sign() to produce a multi-signer signature rather than a normal single-signer one — this is the difference between "I am the account" and "I am one authorized signer of the account."

const signedA = signerA.sign(prepared, true)
const signedB = signerB.sign(prepared, true)

Each call returns an object with a tx_blob (the signed bytes) and a hash. In the real world these two signers might be different people on different machines, signing hours apart and emailing their blobs to a coordinator — that's the entire value of multi-sig: nobody has to share a key or be online at the same time.

The coordinator combines the blobs with multisign(), which merges the individual signatures into one transaction carrying a Signers array:

const combined = xrpl.multisign([signedA.tx_blob, signedB.tx_blob])

Finally, submit the combined blob. Because it's already signed, you submit it without a wallet option — there's nothing left to sign:

const result = await client.submitAndWait(combined)

const code = result.result.meta?.TransactionResult
if (code === 'tesSUCCESS') {
  console.log('Multi-signed payment validated:', result.result.hash)
} else {
  throw new Error(`Multi-sign failed: ${code}`)
}

If you see tesSUCCESS, two independent keys just moved money from an account that neither of them controls alone. Open the Testnet Explorer, paste the master address, and you'll see the payment with a Signers array listing both signers — the on-chain receipt of a collective decision.

When the quorum isn't met

The most instructive failure is signing with too little weight. Combine only one weight-1 signature against a quorum of 2:

const tooFew = xrpl.multisign([signedA.tx_blob])
const r = await client.submitAndWait(tooFew)
console.log(r.result.meta?.TransactionResult) // not tesSUCCESS

The ledger rejects it: the collected weight (1) is below the quorum (2), so the transaction was never authorized by the policy. You'll see an error from the tef/tem family such as tefBAD_QUORUM rather than tesSUCCESS. That's the safety property working exactly as designed — and it's the single most valuable thing to observe, not just read about. Make your script deliberately under-sign once so you can watch the rejection, then add the second signer and watch it succeed.

It's worth pausing on why this matters. A normal single-signed transaction has exactly one point of failure: whoever holds the master key. Compromise that one secret and the funds are gone. A 2-of-3 SignerList turns that single point of failure into a threshold one: an attacker now needs to compromise two independent keys, held by two different people, possibly on two different continents — while any one of the three keys can be lost without freezing the account. You're trading a little operational ceremony (collecting signatures) for a large reduction in catastrophic risk. That trade is why treasuries, custodians, and on-chain governance almost always run on multi-sig.

A few more facts worth holding onto: signers must be distinct accounts (one person can't satisfy a quorum by signing twice), the order you list blobs in multisign() doesn't matter, and a transaction signed multi-style against an account with no SignerList — or signed by an address that isn't on the list — won't be authorized. The ledger only ever applies what the policy on the account permits.

Check

You're done when one script, in a single run, funds a master account and three signers, installs a 2-of-3 SignerList on the master with SignerListSet, autofills a Payment once for two signers, has two signers sign it independently, combines their signatures with multisign(), and submits the result for a tesSUCCESS. Bonus credit for deliberately under-signing first and capturing the quorum-failure code. Grab the validated transaction hash, confirm the Signers array on the Testnet Explorer, and submit a repo or gist containing your multisig.ts and the tx hash for a peer to verify.

Assignments

0 of 2 complete