Evolution SDK
Smart Contracts

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 PresentCan Spend?
Alice + BobYes (2 of 3)
Alice + CarolYes (2 of 3)
Bob + CarolYes (2 of 3)
Alice onlyNo (1 of 3)
NoneNo

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 treasury

Anyone 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 treasury

The 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

ProblemCauseFix
"Missing required signer"Not enough signers addedAdd .addSigner() for each approving key
"Native script validation failed"Wrong key hashesVerify key hashes match those in the script exactly
Transaction rejectedOnly 1 of 3 signed (need 2)Get a second signer to approve
Wrong treasury addressScript hash doesn't matchEnsure you derive the address from the same script

Next Steps