npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@runonflux/solana-multisig

v0.9.0

Published

TypeScript SDK for SSP Solana Multisig

Downloads

553

Readme

@runonflux/solana-multisig

TypeScript SDK for the SSP Solana Multisig program — a self-initiating M-of-N multisig on Solana where the multisig address is deterministically derived from (members, threshold) and anyone can pre-fund it before initialization.

Self-initiating means: there is no creator/admin role at any point in the multisig's lifecycle. The multisig PDA is fully determined by (sorted_members, threshold), so the canonical address can only ever be registered with the canonical member set. Init is permissionless — anyone can pay the rent to register the canonical config — but fund safety is enforced by the M-of-N threshold check on every transaction proposal/approval/execution, never on registration. This mirrors how P2WSH multisig works on Bitcoin: the address IS the hash of the script.

Install

yarn add @runonflux/solana-multisig
# or
npm install @runonflux/solana-multisig

Peer-ish requirements: @solana/web3.js, @coral-xyz/anchor (already direct deps; this SDK installs them for you).

Program IDs

| Network | Program ID | |---|---| | Devnet | CisPSFTQoTnEqn5cUi1pgpfPp2xiTVRkK7eD5jBevxdX | | Mainnet | coming soon |

Quick start

import {
  SolanaMultisigClient,
  sortMembers,
} from "@runonflux/solana-multisig";
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import * as anchor from "@coral-xyz/anchor";

const PROGRAM_ID = new PublicKey(
  "CisPSFTQoTnEqn5cUi1pgpfPp2xiTVRkK7eD5jBevxdX",
);

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const payer = Keypair.generate();          // funds the init tx
const wallet = new anchor.Wallet(payer);
const client = new SolanaMultisigClient(connection, PROGRAM_ID, wallet);

// 1. Define members + threshold
const members = [m1.publicKey, m2.publicKey, m3.publicKey];
const threshold = 2;

// 2. Derive the multisig + vault addresses (off-chain, free)
const multisig = client.deriveAddress(members, threshold);
const vault = client.deriveVaultAddress(multisig, 0);
console.log("Vault (deposit address):", vault.toBase58());

// 3. Pre-fund the vault (anyone can — vault is just a system PDA)
//    ... user sends SOL to `vault` ...

// 4. (Optional) ALT for >5 members so each member pubkey costs ~1 byte in the init tx
const alt = await client.createMembersAddressLookupTable(members, payer);

// 5. Submit the init tx — permissionless, no member signatures required
const { multisigAddress, signature } = await client.initialize(
  members,
  threshold,
  payer,
  alt,
);

After initialization, vault is fully self-custodial — only valid member approvals can move funds out via the proposal flow.

Proposal flow

// Member proposes a transaction
const transferIx = SystemProgram.transfer({
  fromPubkey: vault,
  toPubkey: recipient,
  lamports: 1_000_000,
});

const { transactionAddress, transactionIndex } =
  await client.createTransaction(multisig, 0, [transferIx], creatorKeypair);

// Other members approve until threshold is met
await client.approveTransaction(multisig, transactionIndex, m1);
await client.approveTransaction(multisig, transactionIndex, m2);

// Anyone can execute once threshold reached
await client.executeTransaction(
  multisig,
  transactionIndex,
  executorKeypair,
  [
    { pubkey: vault, isSigner: false, isWritable: true },
    { pubkey: recipient, isSigner: false, isWritable: true },
    { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
  ],
);

SPL token transfers

Vault is a system-owned PDA, so it can hold SPL tokens via standard ATAs. The vault is the token authority for those ATAs.

import { createTransferInstruction, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from "@solana/spl-token";

const vaultAta = getAssociatedTokenAddressSync(mint, vault, /* allowOwnerOffCurve */ true);
const recipientAta = getAssociatedTokenAddressSync(mint, recipientOwner);

const transferIx = createTransferInstruction(vaultAta, recipientAta, vault, amount);

// Build a custom V0 message — vault must be account_keys[0]
await client.createTransactionFromMessage(
  multisig,
  /* vaultIndex */ 0,
  {
    numSigners: 1,
    numWritableSigners: 1,
    numWritableNonSigners: 2,
    accountKeys: [vault, vaultAta, recipientAta, TOKEN_PROGRAM_ID],
    instructions: [
      {
        programIdIndex: 3,
        accountIndexes: new Uint8Array([1, 2, 0]), // [source, dest, authority]
        data: new Uint8Array(transferIx.data),
      },
    ],
    addressTableLookups: [],
  },
  creatorKeypair,
);

See examples/full-flow.ts for a complete end-to-end example including init, propose, approve, execute, and SPL token flows.

API

| Method | Description | |---|---| | deriveAddress(members, threshold) | Compute the multisig PDA off-chain. | | deriveVaultAddress(multisig, vaultIndex) | Compute the vault PDA (deposit address) off-chain. | | deriveNonceAccount(multisig) | Compute the durable-nonce account address off-chain. Pure derivation from the multisig PDA — paymaster-independent. | | createMembersAddressLookupTable(members, payer) | Create an ALT for member-list compaction (needed for >5 members under the 1232-byte tx cap). | | initialize(members, threshold, payer, alt?) | Submit the permissionless init tx. Anyone can call. | | provisionNonce({ multisigAddress, payer }) | One-shot create + initialize the durable nonce account for a multisig. Permissionless; payer funds rent (~0.00144 SOL) and becomes the initial authority. | | preFund(address, amount, funder) | Convenience helper to send SOL to a vault. | | createTransaction(multisig, vaultIndex, instructions, creator) | Propose a transaction the multisig should execute. | | createTransactionFromMessage(multisig, vaultIndex, message, creator) | Propose with a pre-built V0 message (for SPL, ALT-using complex flows, etc.). | | approveTransaction(multisig, txIndex, member) | Member approves a pending proposal. | | executeTransaction(multisig, txIndex, executor, remainingAccounts) | Execute once threshold is met. | | getMultisig(address) | Fetch on-chain multisig state. | | getTransaction(address) | Fetch on-chain proposal state. |

Composable instruction builders (low-level)

For bundling multiple program calls into a single Solana transaction (e.g., for atomic 2-of-2 single-tx send flows), use the ix-only builders. They return raw TransactionInstruction objects without sending them, so callers control signing and broadcasting.

| Method | Returns | Use for | |---|---|---| | predictNextTransactionPda(multisig, currentIndex) | { transactionAddress, transactionIndex } | Compute the next proposal PDA before fetching it on-chain. | | buildInitializeMultisigInstruction(opts) | { instruction, multisigAddress, bump } | Bundle the (permissionless) init ix into a multi-ix tx — e.g. silently included on first send. | | buildProvisionNonceInstruction(opts) | { instruction, nonceAccount } | Bundle the (permissionless) durable-nonce provision ix — e.g. bundled with init on the very first send. | | buildCreateTransactionInstruction(opts) | { instruction, transactionAddress, transactionIndex } | Build a proposal ix without auto-sending. | | buildApproveTransactionInstruction(opts) | TransactionInstruction | Build an approval ix; member must be a tx-level signer. | | buildExecuteTransactionInstruction(opts) | TransactionInstruction | Build an execute ix with explicit remainingAccounts. |

Example — 2-of-2 single-tx send (optional permissionless init + create + 2 approvals + execute, signed by both members and broadcast atomically):

// Only include the init ix on first send (when the multisig PDA hasn't
// been registered yet). No member signatures needed for init.
const { instruction: initIx, multisigAddress } =
  await client.buildInitializeMultisigInstruction({
    members: [walletPubkey, keyPubkey],
    threshold: 2,
    payer: walletPubkey,
  });

const { instruction: createIx, transactionAddress, transactionIndex } =
  await client.buildCreateTransactionInstruction({
    multisigAddress,
    currentTransactionIndex: 0n, // fresh init → next index is 1
    vaultIndex: 0,
    message: txMessage,
    creator: walletPubkey,
  });

const approveWalletIx = await client.buildApproveTransactionInstruction({
  multisigAddress, transactionAddress, transactionIndex, member: walletPubkey,
});
const approveKeyIx = await client.buildApproveTransactionInstruction({
  multisigAddress, transactionAddress, transactionIndex, member: keyPubkey,
});
const executeIx = await client.buildExecuteTransactionInstruction({
  multisigAddress, transactionAddress, transactionIndex,
  executor: walletPubkey,
  remainingAccounts: [/* in account_keys order */],
});

// Bundle into one tx; both wallet and key partial-sign before broadcast.
const tx = new Transaction().add(
  initIx, createIx, approveWalletIx, approveKeyIx, executeIx,
);
tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
tx.feePayer = walletPubkey;
tx.partialSign(walletKeypair);  // adds wallet's Ed25519 sig
// ... send to key device for partial-signing ...
tx.partialSign(keyKeypair);      // adds key's Ed25519 sig
const sig = await connection.sendRawTransaction(tx.serialize());

Limits

| | Value | |---|---| | MAX_MEMBERS | 30 | | MAX_TX_ACCOUNT_KEYS | 128 | | MAX_TX_INSTRUCTIONS | 16 | | MAX_INSTRUCTION_ACCOUNTS | 64 | | MAX_INSTRUCTION_DATA_LEN | 1024 bytes |

Init has no signer-count ceiling now that it's permissionless (no ed25519 ix to fit in the tx); the cap on members is just the account space.

Security model

  • No deterministic private keys — the multisig PDA has no associated keypair. Funds can only move via (threshold) member signatures verified on-chain.
  • No creator/admin role — there is no privileged key controlling the multisig. Self-initiating in the literal sense: the configuration IS the address.
  • Canonical PDA binding — multisig PDA seeds include the full 32-byte sha256 of sorted members + threshold; the program rejects any init whose remaining_accounts don't hash to the member_hash argument. The canonical address can only be registered with the canonical member set.
  • Permissionless init is safe — anyone can pay rent to register the canonical config (front-running it just helps us). They can't subvert the member set; an init with non-canonical members lands at a different PDA that has no relationship to the canonical vault.
  • Threshold gate on every movecreate_transaction requires a member signer, approve_transaction requires a member signer with dedup, execute_transaction requires approvals.len() ≥ threshold. Init does not gate funds.
  • No ALT in proposalscreate_transaction rejects non-empty address_table_lookups in proposal messages, preventing ALT-substitution attacks where an executor swaps a different ALT at execute time.
  • Re-initialization preventedinit constraint guarantees the PDA can only be initialized once.

Durable nonces (eliminating the wallet→key blockhash race)

When wallet and key sign at different times — wallet pre-signs, push notification fires, user approves on phone minutes later — Solana's 60-second blockhash validity window would normally expire the wallet's signature. The SDK's durable-nonce flow eliminates this race:

  • Each multisig gets a durable nonce account at a deterministic address Pubkey.createWithSeed(multisigPda, "nonce", SystemProgram). Paymaster-independent — re-derivable from the multisig alone, no DB lookup, no on-chain config.
  • The on-chain provision_nonce ix creates this account at the canonical address using invoke_signed with the multisig PDA as the seed base. Permissionless — anyone can pay rent + become the initial authority.
  • Sends after first use the nonce as recentBlockhash with SystemProgram.nonceAdvance at ix[0]. Wallet's signature stays valid arbitrarily long across the wallet→relay→push→user-approve→key-sign round trip.
  • Paymaster rotation: authority transfers via standard SystemProgram.nonceAuthorize. Address never changes.

Cost: one-time ~0.00144 SOL rent per multisig (refundable via nonceWithdraw if vault ever closes). Tx size adds ~70 bytes for the nonceAdvance ix.

Status

  • ✅ Devnet deployed
  • ✅ End-to-end smoke tests passing on devnet (SOL, SPL, 7-of-10, Jupiter format, bundled single-tx, decoupled-init, durable-nonce flow with 90s pause, bundled first-send with init+provision+send)
  • ✅ Anchor test phases (1, 4-unit, 4-integration, 4-security, 5, 6, 7, 8) passing in isolation

License

MIT