Tutorial: Multi-Sig Treasury
Build a 2-of-3 multi-signature treasury — shared funds that require multiple signers to spend
Tutorial: Multi-Sig Treasury
Build a shared treasury where no single person controls the funds. A 2-of-3 multi-sig requires any 2 out of 3 key holders to approve a withdrawal — perfect for team treasuries, DAOs, and escrow arrangements.
What You'll Build
- A 2-of-3 native script (ScriptNOfK) from 3 key hashes
- A treasury address derived from the script
- A funding transaction to deposit ADA
- A withdrawal transaction requiring 2 of 3 signers
No Plutus required — native scripts handle multi-sig natively.
Prerequisites
- A Blockfrost API key (get one free)
- Familiarity with native scripts
- 3 wallets (or 3 account indices from the same mnemonic for testing)
Step 1: Create the Multi-Sig Script
Define a 2-of-3 policy from three key hashes:
import { NativeScripts, Bytes } from "@evolution-sdk/evolution"
// Three team members' key hashes (28 bytes each)
const alice = Bytes.fromHex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8")
const bob = Bytes.fromHex("b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8")
const carol = Bytes.fromHex("c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8")
// 2-of-3: any two must sign to spend
// Returns a NativeScript — ready to use with attachScript()
const treasuryScript = NativeScripts.makeScriptNOfK(2n, [
NativeScripts.makeScriptPubKey(alice),
NativeScripts.makeScriptPubKey(bob),
NativeScripts.makeScriptPubKey(carol),
])How It Works
| Signers Present | Can Spend? |
|---|---|
| Alice + Bob | Yes (2 of 3) |
| Alice + Carol | Yes (2 of 3) |
| Bob + Carol | Yes (2 of 3) |
| Alice only | No (1 of 3) |
| None | No |
Step 2: Fund the Treasury
Send ADA to the treasury address. Anyone can deposit — no signatures needed:
import { Address, Assets, preprod, Client } from "@evolution-sdk/evolution"
const client = Client.make(preprod)
.withBlockfrost({
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
projectId: process.env.BLOCKFROST_API_KEY!,
})
.withSeed({ mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 })
// The treasury address is derived from the script hash
// In practice, compute this from the native script
declare const treasuryAddress: Address.Address // address derived from the multi-sig script
const tx = await client
.newTx()
.payToAddress({
address: treasuryAddress,
assets: Assets.fromLovelace(100_000_000n), // 100 ADA
})
.build()
const signed = await tx.sign()
const depositTxHash = await signed.submit()
// depositTxHash → funds now locked in the 2-of-3 treasuryAnyone can send funds to the treasury address. The multi-sig restriction only applies to spending — deposits are unrestricted.
Step 3: Spend from the Treasury (2 of 3 Sign)
To withdraw, the transaction needs signatures from at least 2 of the 3 key holders:
import { Address, Assets, KeyHash, NativeScripts, Bytes, preprod, type UTxO, Client } from "@evolution-sdk/evolution"
const client = Client.make(preprod)
.withBlockfrost({
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
projectId: process.env.BLOCKFROST_API_KEY!,
})
.withSeed({ mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 })
declare const treasuryUtxos: UTxO.UTxO[] // from client.getUtxos(treasuryAddress)
declare const treasuryScript: NativeScripts.NativeScript // the 2-of-3 script from Step 1
// Key hashes of the two signers approving this withdrawal
const aliceKeyHash = Bytes.fromHex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8")
const bobKeyHash = Bytes.fromHex("b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8")
const recipient = Address.fromBech32(
"addr_test1vrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qd9k63"
)
const tx = await client
.newTx()
.collectFrom({ inputs: treasuryUtxos })
.attachScript({ script: treasuryScript })
.addSigner({ keyHash: new KeyHash.KeyHash({ hash: aliceKeyHash }) })
.addSigner({ keyHash: new KeyHash.KeyHash({ hash: bobKeyHash }) })
.payToAddress({
address: recipient,
assets: Assets.fromLovelace(50_000_000n), // withdraw 50 ADA
})
.build()
// Both Alice and Bob must sign
const signed = await tx.sign()
const withdrawTxHash = await signed.submit()
// withdrawTxHash → 50 ADA sent to recipient, remainder stays in treasuryThe transaction must be signed by the actual private keys of the listed signers. In a real multi-sig flow, the unsigned transaction CBOR is shared between signers, each adds their signature, then the combined transaction is submitted. See Client Architecture for the frontend/backend signing pattern.
Variations
3-of-3 (Unanimous)
All members must agree:
import { NativeScripts, Bytes } from "@evolution-sdk/evolution"
const alice = Bytes.fromHex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8")
const bob = Bytes.fromHex("b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8")
const carol = Bytes.fromHex("c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8")
// All 3 must sign (ScriptAll)
const unanimousScript = NativeScripts.makeScriptAll([
NativeScripts.makeScriptPubKey(alice),
NativeScripts.makeScriptPubKey(bob),
NativeScripts.makeScriptPubKey(carol),
])Time-Locked Treasury
Add a time constraint — funds can only be withdrawn after a specific date:
import { NativeScripts, Bytes } from "@evolution-sdk/evolution"
const alice = Bytes.fromHex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8")
const bob = Bytes.fromHex("b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8")
// 2-of-2 AND time-locked (can't spend before slot 50000000)
const timedTreasury = NativeScripts.makeScriptAll([
NativeScripts.makeScriptNOfK(2n, [
NativeScripts.makeScriptPubKey(alice),
NativeScripts.makeScriptPubKey(bob),
]).script, // unwrap to get the variant
NativeScripts.makeInvalidBefore(50000000n),
])Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| "Missing required signer" | Not enough signers added | Add .addSigner() for each approving key |
| "Native script validation failed" | Wrong key hashes | Verify key hashes match those in the script exactly |
| Transaction rejected | Only 1 of 3 signed (need 2) | Get a second signer to approve |
| Wrong treasury address | Script hash doesn't match | Ensure you derive the address from the same script |
Next Steps
- Native Scripts — All script types and composition
- Tutorial: Token Vesting — Time-locked release with Plutus
- Client Architecture — Multi-party signing flow (frontend/backend)
- Spending from Script — Required signers and debug labels