A self-initiating Solana multisig program where the multisig address is deterministically derived from members + threshold. Anyone can derive the address before any on-chain action; anyone can pre-fund the vault; only threshold member signatures can initialize.
Devnet Program ID: CisPSFTQoTnEqn5cUi1pgpfPp2xiTVRkK7eD5jBevxdX
Standard Solana multisigs (Squads V4 etc.) require a creator account to call multisig_create_v2 with a random create_key. The address is unknowable until creation, and the creator is a single point of trust.
Here, the multisig PDA is derived from (sorted_members, threshold) directly:
multisig_pda = find_program_address(
[b"multisig", hash(sorted_members)[..8], &[threshold]],
program_id
)
Implications:
- ✅ Address is knowable BEFORE any on-chain action — pre-fundable
- ✅ No single creator — init requires threshold member signatures
- ✅ Same
(members, threshold)always yields the same address — no front-run - ✅ Front-run protection: an attacker with different config derives a different PDA
Two PDA types per multisig:
| PDA | Holds | Owner | Purpose |
|---|---|---|---|
| Multisig | members, threshold, tx counter | Our program | Governance / config |
| Vault | SOL + SPL tokens (via ATAs) | SystemProgram (no data) | Funds storage |
multisig_pda = [b"multisig", hash(members)[..8], &[threshold]]
vault_pda = [b"vault", multisig_pda, &[vault_index]] // vault_index 0..255
Users send funds TO the vault address. SystemProgram::transfer from the vault works because it's system-owned with empty data. Each multisig supports up to 256 vault sub-accounts (most use cases use only vault_index = 0).
- Derive
(multisig_pda, vault_pda)off-chain from members + threshold - Pre-fund the vault address with SOL or SPL (anyone, before init)
- Init: threshold members sign canonical init message off-chain; submit
initialize_multisigtx with one Ed25519 verify ix per signature preceding the program ix. Multisig PDA is created with config. - Propose: a member calls
create_transaction(vault_index, message)storing a V0-style transaction message (header + account_keys + compiled instructions + ALT lookups) on aVaultTransactionPDA. - Approve: each member calls
approve_transaction(index)once. Approvals accumulate. - Execute: when approvals ≥ threshold, anyone calls
execute_transaction(index). The program flushesexecuted = true(re-entrancy guard), then iterates compiled instructions and CPIs each one with the vault PDA as signer viainvoke_signed.
import { SolanaMultisigClient, deriveVaultAddress } from "@runonflux/solana-multisig";
import { Connection, PublicKey, SystemProgram } from "@solana/web3.js";
const client = new SolanaMultisigClient(connection, programId);
// Derive addresses (free, off-chain)
const multisig = client.deriveAddress(members, threshold);
const [vault] = deriveVaultAddress(multisig, 0, programId);
// Pre-fund vault, collect signatures off-chain, then:
await client.initialize(members, threshold, signatures, payer);
// Propose a SOL transfer
const transferIx = SystemProgram.transfer({
fromPubkey: vault,
toPubkey: recipient,
lamports: 0.1 * 1e9,
});
const proposal = await client.createTransaction(multisig, 0, [transferIx], member);
// Threshold approvals + execute
await client.approveTransaction(multisig, proposal.transactionIndex, member1);
await client.approveTransaction(multisig, proposal.transactionIndex, member2);
await client.executeTransaction(multisig, proposal.transactionIndex, executor, [
{ pubkey: vault, isSigner: false, isWritable: true },
{ pubkey: recipient, isSigner: false, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
]);See sdk/examples/full-flow.ts for the complete end-to-end example.
| Property | Mechanism |
|---|---|
| Front-run protection | Multisig PDA is unique per (members, threshold); init requires threshold sigs of canonical message |
| No deterministic private keys | Pure PDA derivation — no Keypair::fromSeed |
| Init signature replay protection | Ed25519 verification requires instruction_index = 0xFFFF (current ix) — prevents binding cryptographic verification to unrelated data |
| Re-entrancy | executed = true is flushed to on-chain data via Account::exit() before the CPI loop |
| Threshold enforcement | Approvals are counted on-chain; execute requires approvals.len() >= threshold |
| Cross-multisig replay | Init signatures bind (members, threshold) into the message |
| Account validation | Anchor's seeds + bump constraints + owner check on every account |
| Limit | Value | Rationale |
|---|---|---|
MAX_MEMBERS |
30 | Treasury governance + enterprise dual-signing (2 ed25519 keys per SSP signer × 15 signers) |
MAX_TX_ACCOUNT_KEYS |
32 | Static account_keys per proposal |
MAX_TX_INSTRUCTIONS |
16 | Per proposal (CU-limited at execute) |
MAX_INSTRUCTION_ACCOUNTS |
64 | Per instruction (1-byte indexes) |
MAX_INSTRUCTION_DATA_LEN |
1024 | Bytes per instruction |
MAX_ADDRESS_TABLE_LOOKUPS |
4 | ALTs per proposal |
MAX_INDEXES_PER_LOOKUP |
28 | Each (writable + readonly) per ALT |
MAX_COMBINED_ACCOUNTS |
256 | Solana's u8 index space cap |
While the program stores up to MAX_MEMBERS = 20 members, single-tx
initialization fits at most ~7 signatures under Solana's 1232-byte tx cap.
Each ed25519 init signature contributes 64 (sig) + 32 (pubkey) + 14 (offset
metadata) = 110 bytes that cannot be ALT-compressed (signatures are payload,
not account references).
| Configuration | Init tx fits? |
|---|---|
Any M-of-N with M ≤ 7 and N ≤ 30 |
✅ |
M ≥ 8 (e.g. 8-of-15, 15-of-15, 20-of-20) |
❌ overflows 1232-byte cap |
This covers the practical majority of multisig configurations — 2-of-3, 3-of-5, 4-of-7, 5-of-9, 7-of-10 all fit. Larger thresholds (8-of-N and above) are uncommon in real-world treasury governance but are blocked by this design today.
If support requests for M ≥ 8 materialize, see
docs/BATCHED_INIT_SCOPE.md for a prepared
scope plan that lifts the ceiling via a multi-tx batched init flow without
compromising the no-creator-key security property. Estimated effort: ~4
days, fully reversible (existing single-tx init path is preserved for
M ≤ 7).
anchor build # compile the program
anchor deploy # deploy (devnet/localnet)
anchor test # run the test suiteTests live in tests/:
phase1-basic.ts— address derivationphase4-integration.ts— init flow scenarios (happy paths)phase4-security.ts— init failure scenarios (attack vectors)phase4-unit.ts— view function unit testsphase5-transactions.ts— full proposal lifecycle (create / approve / execute)
The actual program-level differentiators (no UX layers, no infrastructure, just protocol):
-
Truly self-initiating — multisig PDA =
find_program_address([b"multisig", sha256(sorted_members), &[threshold]]). Anyone can derive the address before any on-chain action, anyone can pre-fund it, but only threshold member signatures can ever initialize. Squads V4 requires acreatorwho callsmultisig_create_v2with a randomcreate_key; the address is unknowable until creation, and the creator is a single point of trust at setup. -
No private key exists, ever — for a given
(members, threshold), the address is purely a function of those inputs hashed into PDA seeds. Same config = same address, deterministically. No creator-supplied randomness, no key generation. -
No front-running at init — different configs produce different PDAs, and
initialize_multisigbinds the PDA to the actual member set at init via the 32-byte hash check (actual_hash == member_hash). An attacker with different members or threshold derives a different address — they can't squat on a victim's deterministic vault. -
On-chain Ed25519 verification of the init message —
initialize_multisigreads the batched Ed25519 native ix at tx index 0 and harvests verified signers. Init can only succeed when threshold members have actually signed the prefix-domain-separated init message (SOLANA_MULTISIG_INIT || sha256(sorted_members) || threshold) off-chain. -
ALT-rejection in proposals (Option D) —
create_transactionrejects non-emptyaddress_table_lookups, preventing executor-side ALT substitution attacks where someone could swap a different ALT at execute time to redirect CPI destinations.
Fee sponsorship / paymaster — any wallet on top of any Solana multisig can layer on a paymaster (Squads-using wallets like Fuse and Backpack ship comparable sponsored-fee experiences without changing the underlying multisig). SSP Wallet runs a paymaster via the open-source ssp-relay so users don't need SOL in their leaf keypair, but that's a UX choice, not a protocol-level difference.
The program-level differences above are what actually distinguish this design.
- ✅ Compiles clean (
cargo check) - ✅ Devnet deployed
- ✅ End-to-end smoke tests passing (SOL, SPL, 7-of-10, Jupiter format)
- ✅ 61/61 unit/integration tests passing
MIT