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; registration is permissionless — no member signatures are required at init because the canonical PDA can only ever store the canonical member set (sha256 binding). Fund safety is enforced by the threshold check on create_transaction / approve_transaction / execute_transaction, not on registration. This mirrors how Bitcoin P2WSH multisig works: the address IS the hash of the script.
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", sha256(sorted_members), &[threshold]], // full 32-byte hash
program_id
)
Implications:
- ✅ Address is knowable BEFORE any on-chain action — pre-fundable
- ✅ No creator role exists at any point — registration is permissionless; only the M-of-N threshold check gates funds
- ✅ Same
(members, threshold)always yields the same address — no front-run - ✅ Front-run protection: an attacker initing with different members lands at a different PDA (no harm); initing with canonical inputs just pays our rent
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", sha256(sorted_members), &[threshold]] // full 32 bytes
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: anyone (typically the relay paymaster, or a wallet bundling init into a first send) calls
initialize_multisig(member_hash, threshold, member_count)with members inremaining_accounts. The program verifiesactual_hash(sorted(remaining_accounts)) == member_hashand stores the canonical config at the PDA. No member signatures involved. - 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, then init (permissionless — no member sigs needed):
await client.initialize(members, threshold, 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); an attacker initing with different members lands at a different PDA, no relationship to the canonical vault |
| Canonical PDA binding | Anchor init seeds = sha256(sorted_members); program verifies actual_hash == member_hash — the canonical address can only ever store the canonical member set |
| No deterministic private keys | Pure PDA derivation — no Keypair::fromSeed |
| Permissionless init is safe | Init has no signer requirement; funds are gated solely by the threshold check on create_transaction / approve_transaction / execute_transaction. Anyone can pay rent to register the canonical config — they can't subvert it |
| 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 |
| Re-init prevention | Anchor's init constraint guarantees the PDA can only be initialized once |
| Account validation | Anchor's seeds + bump constraints + owner check on every account |
| Member-set immutability | members, threshold, bump are written once at init; no instruction can modify them post-init |
| Durable nonce for human-loop signing | provision_nonce creates a system-owned nonce account at createWithSeed(multisigPda, "nonce", SystemProgram). Sends after first use SystemProgram.nonceAdvance at ix[0] + the nonce value as recentBlockhash → wallet's signature survives arbitrary user-approval delays. Address is pure-derivable (paymaster-independent); paymaster rotation transfers authority via standard SystemProgram.nonceAuthorize |
A proposal that reaches threshold can do anything the vault can do — including permanently bricking the vault. The vault works as a fund source only because it is system-owned with empty data, and two SystemProgram instructions silently destroy that property:
| Instruction on the vault | Effect | Recoverable? |
|---|---|---|
SystemProgram::assign (or assignWithSeed) |
Changes the vault's owner away from SystemProgram → SystemProgram::transfer from it stops working |
❌ No program instruction can undo it; native SOL becomes stuck |
SystemProgram::allocate (or allocateWithSeed) |
Gives the vault non-empty data → transfer requires an empty source → breaks |
❌ Same — SOL stranded |
This requires threshold approval, so it is not an external attack vector (a rogue threshold can already drain the vault the normal way). The realistic risk is operator error / blind-signing: a member approves a proposal whose true effect isn't obvious from the raw instruction data.
Mitigations:
- Signing clients (SSP wallet) MUST decode proposal instructions and warn loudly on any
SystemProgram::assign/allocate(and thewithSeedvariants) targeting the vault (account_keys[0]). - Treat any proposal that touches the vault with something other than
transfer/ token instructions as suspicious and surface it to the signer. - The program does not block these patterns — by design, approved members can do anything. The defense lives in the signing UI.
members, threshold, and bump are immutable after init (see the Security table). This is deliberate: the canonical PDA always reflects the canonical member set, so no governance action can retroactively weaken the threshold depositors relied on. The cost is that there is no in-place member rotation — to change the member set or threshold you migrate funds to a new multisig.
Migration playbook:
- Derive the new
(multisig_pda, vault_pda)for the new(members, threshold)off-chain. - Init the new multisig (permissionless — no member sigs).
- Drain the old vault under the old multisig via the normal create → approve×M → execute flow:
- One proposal transferring all native SOL from the old vault to the new vault.
- For SPL: transfer each token balance from the old vault's ATAs to the new vault's ATAs (optionally close the emptied old ATAs to reclaim their rent).
- Repoint downstream consumers of the old vault address — deposit flows, saved addresses, relay/paymaster config, anything that hardcoded the old address.
- Verify the old vault balance is
0and the new vault holds everything.
Caveats:
- The old multisig PDA's rent (~0.003–0.0077 SOL) stays locked — there is no
close_multisiginstruction. - The migration proposal itself must clear threshold. If a member key is lost and the remaining members fall below threshold, you cannot execute the migration and funds are stuck. For high-value vaults, prefer
threshold < Nso a single lost key doesn't brick the multisig.
| Limit | Value | Rationale |
|---|---|---|
MAX_MEMBERS |
30 | Treasury governance + enterprise dual-signing (2 ed25519 keys per SSP signer × 15 signers) |
MAX_TX_ACCOUNT_KEYS |
128 | 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 |
Init is permissionless — no per-member ed25519 ix is required, so init tx
size is just the program ix itself (~50 bytes + member-set encoding via
ALT). Any M-of-N with N ≤ 30 and any threshold ≤ N can init in a
single tx, including 30-of-30.
The SSP consumer wallet pattern bundles init (optional) + create + approve×M
- execute + close into ONE V0 transaction. The bottleneck there is tx-level signers: each member's ed25519 sig (64 bytes) + their pubkey in the static section (32 bytes) = ~96 bytes per signer, which cannot be ALT-compressed. Practical ceiling: ~7 signers per bundled tx (M=7 single-key OR M=3 dual-key in SSP Enterprise's sol_dual mode). For larger M, the send flow splits approvals across separate txs (which is the natural model for multi-party enterprise vaults anyway — each signer on their own device, signing whenever).
anchor build # compile the program
anchor deploy # deploy (devnet/localnet)
anchor test # run the test suiteTests live in tests/:
phase1-basic.ts— address derivationphase4-unit.ts— view function unit testsphase4-integration.ts— permissionless-init flow scenarios (happy paths)phase4-security.ts— permissionless-init security invariants (canonical PDA binding, front-run resistance, arg validation, re-init rejection)phase5-transactions.ts— full proposal lifecycle (create / approve / execute)phase6-extra-coverage.ts— boundary tests + SPL token transferphase7-close-transaction.ts— proposal close + rent refundphase8-provision-nonce.ts— durable nonce account provisioning
Smoke tests against devnet live in scripts/:
devnet-smoke-test.ts— basic 3-of-5 SOL flowdevnet-spl-smoke-test.ts— SPL token transferdevnet-large-smoke-test.ts— 7-of-10 multisig via ALTdevnet-decoupled-init-test.ts— enterprise pattern (paymaster pre-inits with no members online, members operate later)devnet-jupiter-format-test.ts— Jupiter swap fits proposal formatdevnet-durable-nonce-test.ts— durable-nonce flow standalone: provision + bundled send with 90s pause between wallet sign and key sign (no blockhash race)devnet-setup-endpoint-flow-test.ts— the current SSP wallet flow: relay's/v1/sol/setup(init + provision_nonce as paymaster-only tx) → then wallet builds the durable-nonce send → 70s pause → key signs → broadcast. End-to-end.devnet-bundled-singletx-test.ts— (legacy) bundled init+create+approve×2+execute+close in ONE V0 tx without nonce. The program still supports it; the current wallet doesn't use this pattern.devnet-first-send-bundled-test.ts— (legacy) bundled init+provision+send in ONE V0 tx. The program still supports it; the current wallet does setup as a separate paymaster tx instead.
The actual program-level differentiators (no UX layers, no infrastructure, just protocol):
-
Truly self-initiating, permissionlessly — 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, and anyone can register it on-chain — no member signatures required at init. There is no creator role at any point in the multisig's lifecycle. 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 initing with different members lands at a different PDA that has no relationship to the canonical vault; an attacker initing with the canonical inputs just pays our rent for us. The canonical address can only ever store the canonical member set. -
Threshold enforced only at spend, not at registration — this mirrors Bitcoin P2WSH (the address IS the hash of the script; anyone can fund it; only valid script-satisfying signatures can spend it). Init has no signer requirement at all;
create_transaction/approve_transaction/execute_transactionenforce M-of-N. Permissionless registration is what makes M-of-N enterprise vaults trivial — the relay paymaster can register a vault with no member coordination. -
ALT-rejection in proposals —
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, which is built on Squads V4 by Squads Labs, 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 (program ID
CisPSFTQoTnEqn5cUi1pgpfPp2xiTVRkK7eD5jBevxdX, IDL on-chain) - ✅ End-to-end smoke tests passing on devnet (SOL, SPL, 7-of-10, Jupiter format, bundled single-tx, decoupled-init)
- ✅ Anchor test suite passing in isolated phase runs; multi-phase chained runs are flaky due to local validator load (use
bash scripts/run-tests.sh phase4etc. per phase) - ⏳ Mainnet pending (separate keypair; gated on external audit)
MIT