Skip to main content
A SHHH multisig wallet executes a call only after N of its M owners sign it. This guide shows how to drive that flow — propose → approve → execute — from your own application, using two packages:
PackageWhat it gives you
@chipi-stack/backendsdk.treasury — the typed coordination client (the transport).
@chipi-stack/chipi-react/multisigAction templates + the useProposeAction / useTreasuryProposals hooks.
The multisig endpoints live in your Chipi backend at /v1/treasuries/:treasuryId/.... You drive them with your own API keys — no Chipi dashboard required.

How authorization works

Each proposal carries an OutsideExecution (the Call[] to run) plus, per owner, that owner’s signature over the execution hash. The coordination backend splits auth by step:
StepAuthWhere to call it
propose / signpk_ (public key) + the owner’s signaturebrowser-safe — the signature is the authorization
executesk_ (secret key)a server — it relays through the paymaster and meters usage
listsk_a server — reads are private to your org
Because propose/sign need no secret, an owner can sign in the browser and the key never leaves their device. execute/list use your sk_, so they belong on a server (a Next.js route handler, your backend) — never in client code.

1. Define the actions owners can propose

An ActionTemplate turns typed form values into the contract Call[] and a human title. Use the built-in voteTemplate, or define your own with defineActions:
lib/actions.ts
import {
  defineActions,
  voteTemplate,
  toFelt,
  u256,
  amountToBase,
  type ActionTemplate,
} from "@chipi-stack/chipi-react/multisig";

// A custom action: borrow a USDC amount (6 decimals, u256) from a lending pool.
const borrow: ActionTemplate<{ poolId: string; amount: string }> = {
  key: "borrow",
  label: "Borrow from pool",
  fields: [
    { name: "amount", label: "Amount", type: "amount", decimals: 6 },
  ],
  toCalls: (v) => [
    {
      to: "0x0POOL",
      entrypoint: "borrow",
      // felt-encode the pool id; convert the human amount to base units and
      // split into the [low, high] felts a Cairo u256 expects.
      calldata: [toFelt(v.poolId), ...u256(amountToBase(v.amount, 6))],
    },
  ],
  title: (v) => `Borrow ${v.amount} from pool ${v.poolId}`,
  meta: (v) => ({ poolId: v.poolId }), // optional integrator context
};

export const actions = defineActions([
  voteTemplate({ governanceContract: "0x0DA0" }),
  borrow,
]);
defineActions validates that every key is unique. meta is optional context your approver UI can render (e.g. { poolId }).

2. Implement a signer

A MultisigSigner produces an owner’s inner envelope for a given execution hash — the key never leaves it — and resolves that owner’s index in the on-chain owner set. The sign callback receives { messageHash, ownerIndex } and returns the envelope felts (bigint[]). For a STARK owner (e.g. a programmatic/agent signer), @chipi-stack/backend builds the envelope for you:
lib/signer.ts
import { buildStarkEnvelope } from "@chipi-stack/backend";
import { ec } from "starknet";
import type { MultisigSigner } from "@chipi-stack/chipi-react/multisig";

const privateKey = process.env.OWNER_STARK_PRIVATE_KEY!;

export const signer: MultisigSigner = {
  signerKind: "STARK",
  proposerOwnerIndex: 0, // the index this device proposes/signs as
  sign: ({ messageHash, ownerIndex }) =>
    buildStarkEnvelope({ privateKey, messageHash, ownerIndex }),
  resolveOwnerIndex: async () => 0,
};

// The owner's public key — passed to the transport so the backend can verify
// the signature commits to a current on-chain owner.
export const ownerPubkey = ec.starkCurve.getStarkKey(privateKey);
For a passkey owner (WEBAUTHN_P256), the device’s Face ID / Touch ID produces the assertion in the browser — the private key never leaves the secure enclave. @chipi-stack/chipi-passkey prompts for the assertion and @chipi-stack/backend packs it into the envelope:
lib/passkey-signer.ts
"use client";
import { signShhhMessage, getStoredShhhCredential } from "@chipi-stack/chipi-passkey";
import {
  buildWebAuthnEnvelopeFromAssertion,
  webauthnOwnerPubkeyParam,
} from "@chipi-stack/backend";
import type { MultisigSigner } from "@chipi-stack/chipi-react/multisig";

const hexToBytes = (h: string) => {
  const s = h.replace(/^0x/, "");
  const u = new Uint8Array(s.length / 2);
  for (let i = 0; i < u.length; i++) u[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
  return u;
};

// The passkey registered for this device (see `createShhhPasskey`). Its stored
// P-256 public key is what the owner committed on chain.
const cred = getStoredShhhCredential();
if (!cred) throw new Error("No SHHH passkey on this device — register one first.");
const pubkey = { x: hexToBytes(cred.publicKey.x), y: hexToBytes(cred.publicKey.y) };

export const signer: MultisigSigner = {
  signerKind: "WEBAUTHN_P256",
  proposerOwnerIndex: 0,
  sign: async ({ messageHash, ownerIndex }) => {
    // Prompts Face ID / Touch ID; the OE hash is bound as the WebAuthn challenge.
    const a = await signShhhMessage({ messageHash });
    return buildWebAuthnEnvelopeFromAssertion({
      authenticatorData: a.authenticatorData,
      clientDataJSON: a.clientDataJSON,
      signatureDer: a.signatureDer,
      pubkey,
      messageHash,
      ownerIndex,
    });
  },
  // Map this device's passkey to its index in the on-chain owner set.
  resolveOwnerIndex: async () => 0,
};

// The P-256 point as `"0x<x>,0x<y>"` — passed to the transport so the backend
// can verify the signature commits to a current on-chain owner.
export const ownerPubkey = webauthnOwnerPubkeyParam(pubkey);
For a MetaMask owner (EIP191_SECP256K1), the wallet’s personal_sign produces the signature — MetaMask prefixes and keccak-hashes internally, and the on-chain verifier reproduces the same recipe. The wallet must sign the exact 32 big-endian bytes of the execution hash:
lib/metamask-signer.ts
"use client";
import {
  buildEip191EnvelopeFromSignature,
  eip191OwnerPubkeyParam,
  type Eip191Pubkey,
} from "@chipi-stack/backend";
import type { MultisigSigner } from "@chipi-stack/chipi-react/multisig";

// The wallet's uncompressed secp256k1 pubkey (x, y as 32 BE bytes each) —
// recover it once from a signature at onboarding/claim time, or load it from
// where you stored it.
declare const pubkey: Eip191Pubkey;
declare const address: string; // the connected MetaMask account

export const signer: MultisigSigner = {
  signerKind: "EIP191_SECP256K1",
  proposerOwnerIndex: 0,
  sign: async ({ messageHash, ownerIndex }) => {
    // personal_sign over the 32-byte BE encoding of the SNIP-12 hash.
    const msgHex = "0x" + messageHash.toString(16).padStart(64, "0");
    const signatureHex = (await window.ethereum.request({
      method: "personal_sign",
      params: [msgHex, address],
    })) as string;
    return buildEip191EnvelopeFromSignature({ signatureHex, pubkey, ownerIndex });
  },
  resolveOwnerIndex: async () => 0,
};

// `"0x<x>,0x<y>"` — the FULL coordinates, not the 4-felt `eip191PubkeyFelts` form.
export const ownerPubkey = eip191OwnerPubkeyParam(pubkey);
For a Phantom owner (ED25519), the wallet signs the 64-byte hex-ASCII encoding of the hash (ed25519SignedBytes) — that’s what the user sees in the popup, and what the on-chain verifier reconstructs:
lib/phantom-signer.ts
"use client";
import {
  buildEd25519EnvelopeFromSignature,
  ed25519OwnerPubkeyParam,
  ed25519SignedBytes,
} from "@chipi-stack/backend";
import type { MultisigSigner } from "@chipi-stack/chipi-react/multisig";

await window.solana.connect();
const publicKey: Uint8Array = window.solana.publicKey.toBytes(); // 32 bytes

export const signer: MultisigSigner = {
  signerKind: "ED25519",
  proposerOwnerIndex: 0,
  sign: async ({ messageHash, ownerIndex }) => {
    // Phantom MUST sign ed25519SignedBytes(messageHash) — the hex-ASCII
    // encoding — never the raw 32-byte hash.
    const msg = ed25519SignedBytes(messageHash);
    const { signature } = await window.solana.signMessage(msg);
    // Async: initializes the Garaga WASM hint builder on first call.
    return buildEd25519EnvelopeFromSignature({ signature, publicKey, messageHash, ownerIndex });
  },
  resolveOwnerIndex: async () => 0,
};

// `"0x<low>,0x<high>"` — the LE u128 halves of the 32-byte key.
export const ownerPubkey = ed25519OwnerPubkeyParam(publicKey);
All four launch signer kinds — STARK, WEBAUTHN_P256 (passkey), EIP191_SECP256K1 (MetaMask), and ED25519 (Phantom) — are verified live on propose / sign. Use the *OwnerPubkeyParam helpers for the transport’s ownerPubkey: each kind expects a different format, and the coordinate kinds take the full x/y values, never the 4-felt *PubkeyFelts form.

3. The transport

sdk.treasury.forTreasury(...) returns a transport bound to one treasury and one owner’s public key. It implements exactly the contract the React hooks expect (MultisigTransport), so you can wire it straight in — or call its methods directly from a server.
lib/chipi.ts
import { ChipiServerSDK } from "@chipi-stack/backend";

export const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_SECRET_KEY!, // sk_ — server only
});

export const transport = (treasuryId: string, ownerPubkey: string) =>
  sdk.treasury.forTreasury({ treasuryId, ownerPubkey });

4. Drop-in components

If you don’t want to build UI on the hooks, three embeddable components ship in the same multisig subpath — themeable, mobile-first, no CSS imports or Tailwind required (a namespaced stylesheet injects at runtime):
import {
  ProposeAction,
  Approvals,
  ApprovalInbox,
} from "@chipi-stack/chipi-react/multisig";

// The propose dialog: typed fields from your templates + a live
// "What this does" preview. One signer prompt on submit.
<ProposeAction
  walletAddress={TREASURY}
  actions={POOL_ACTIONS}
  transport={transport}
  signer={signer}
  getRequiredApprovals={readThreshold} // your chain read (e.g. get_threshold)
  onProposed={(p) => console.log("proposed", p.id)}
/>

// The approvals list: Approve / Execute per row, your business context
// injected from the proposal's persisted `meta`.
<Approvals
  walletAddress={TREASURY}
  transport={transport}
  signer={signer}
  renderContext={(p) => <PoolBadge poolId={p.meta?.poolId} />}
  explorerTxUrl={(tx) => `https://starkscan.co/tx/${tx}`}
/>

// The founder surface: cross-treasury "what needs me", one-tap approve.
<ApprovalInbox
  treasuries={[
    { label: "Ops treasury", walletAddress: OPS, transport: opsTransport, signer },
    { label: "Pool fund", walletAddress: POOLS, transport: poolTransport, signer },
  ]}
/>
Theme them to match your app with CSS-var tokens (defaults are Chipi’s neobrutalism):
<Approvals
  // …
  theme={{ accent: "#0f766e", radius: "6px", shadow: "none", font: "Inter, sans-serif" }}
/>
<Approvals> resolves the device’s owner index via your signer’s resolveOwnerIndex; on a device that isn’t an owner it degrades to read-only. Stamp meta in your template (meta: (v) => ({ poolId: v.poolId })) and it round-trips through the backend to every approver’s renderContext.

5. Risk tiers & lifecycle webhooks

Risk tiers (per-action quorum)

Mark a template’s tier once and configure per-tier quorums on the treasury — routine actions (frequent, low-stakes) clear with fewer signatures than sensitive ones:
{ key: "borrow",   label: "Borrow",    risk: "ROUTINE",   /* … */ },
{ key: "set_fees", label: "Set fees",  risk: "SENSITIVE", /* … */ },
When the treasury has the matching quorum configured (dashboard → treasury policy, or PATCH /v1/organizations/:id/treasuries/:treasuryId/policy), the backend overrides requiredApprovals at propose — the client’s value is advisory. Honest scope: this is app-level policy for human treasuries. The wallet’s on-chain threshold remains the hard floor at execute, and agent autonomy is never gated on this layer (contract-enforced caps are the agent tier).

Lifecycle webhooks

Register a webhook on the API key your treasury’s wallet was created under (the standard Chipi /webhooks registration) and subscribe to the governance events — or leave the events list empty to receive everything:
EventWhen
treasury.proposal.createda proposal lands
treasury.proposal.approvedeach accepted signature
treasury.proposal.executablethe quorum is met — react with execute from your server (sk_) for auto-execute
treasury.proposal.executedthe relay succeeded (executedTxHash set)
Payload: { event, data: { treasuryId, proposalId, title, meta, risk, approvals: { have, need }, status, executedTxHash }, ts }meta is your template’s stamped context, so you can route the notification without a read-back. Verify the chipi-signature header the same way as every Chipi webhook (HMAC-SHA256 of the raw body with your whsec-… signing key):
import { createHmac, timingSafeEqual } from "crypto";

function verify(rawBody: string, signature: string, signingKey: string): boolean {
  const expected = createHmac("sha256", signingKey).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
Delivery is at-least-once with a single retry — make your handler idempotent on (event, proposalId).

Next.js quickstart

The browser can run propose/sign (pk_ + owner signature) directly, but list/execute need sk_, so they go through your route handlers. The signer always runs client-side — the key never reaches your server.

Route handlers (server, sk_)

app/api/treasury/[id]/proposals/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sdk } from "@/lib/chipi";

// GET — list proposals (sk_, server-only)
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  // ownerPubkey is irrelevant for reads; any valid value is fine here.
  const t = sdk.treasury.forTreasury({ treasuryId: params.id, ownerPubkey: "0x0" });
  return NextResponse.json(await t.list());
}
app/api/treasury/[id]/transactions/[txId]/execute/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sdk } from "@/lib/chipi";

// POST — relay the assembled threshold calldata through the paymaster (sk_)
export async function POST(req: NextRequest, { params }: { params: { id: string; txId: string } }) {
  const { calldata } = await req.json();
  const t = sdk.treasury.forTreasury({ treasuryId: params.id, ownerPubkey: "0x0" });
  return NextResponse.json(await t.execute(params.txId, calldata));
}

Client component (browser-direct propose/sign, proxied list/execute)

app/treasury/[id]/page.tsx
"use client";
import { ChipiBrowserSDK } from "@chipi-stack/backend";
import {
  useProposeAction,
  useTreasuryProposals,
  type MultisigTransport,
} from "@chipi-stack/chipi-react/multisig";
import { RpcProvider } from "starknet";
import { actions } from "@/lib/actions";
import { signer, ownerPubkey } from "@/lib/signer";

const browser = new ChipiBrowserSDK({ apiPublicKey: process.env.NEXT_PUBLIC_CHIPI_PUBLIC_KEY! });
const rpc = new RpcProvider({ nodeUrl: process.env.NEXT_PUBLIC_STARKNET_RPC! });

export default function TreasuryPage({ params }: { params: { id: string } }) {
  const treasuryId = params.id;
  const walletAddress = "0x0WALLET"; // the treasury's on-chain address

  // propose / sign go straight to Chipi (pk_ + owner sig); list / execute proxy
  // through our own routes so the sk_ stays on the server.
  const direct = browser.treasury.forTreasury({ treasuryId, ownerPubkey });
  const transport: MultisigTransport = {
    propose: direct.propose,
    sign: direct.sign,
    list: () => fetch(`/api/treasury/${treasuryId}/proposals`).then((r) => r.json()),
    execute: (txId, calldata) =>
      fetch(`/api/treasury/${treasuryId}/transactions/${txId}/execute`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ calldata }),
      }).then((r) => r.json()),
  };

  // The wallet's threshold (N) — read from chain so the package stays chain-agnostic.
  const getRequiredApprovals = async () => {
    const [n] = await rpc.callContract({
      contractAddress: walletAddress,
      entrypoint: "threshold",
      calldata: [],
    });
    return Number(BigInt(n));
  };

  const propose = useProposeAction({ walletAddress, transport, signer, getRequiredApprovals });
  const { proposals, approve, execute } = useTreasuryProposals({ walletAddress, transport, signer });

  return (
    <div>
      <button
        onClick={() =>
          propose.mutate({ template: actions[0], values: { contract: "", proposalId: "5", choice: "0x1" } })
        }
      >
        Propose vote
      </button>

      {proposals.data?.items.map((p) => (
        <div key={p.id}>
          <span>{p.title}{p.signatures.length}/{p.requiredApprovals}{p.status}</span>
          <button onClick={() => approve.mutate(p)}>Approve</button>
          {p.status === "APPROVED" && <button onClick={() => execute.mutate(p)}>Execute</button>}
        </div>
      ))}
    </div>
  );
}
That’s the whole loop: propose.mutate({ template, values }) builds the calls, signs the OE as the proposer, and submits it; approve.mutate(proposal) adds another owner’s signature; once signatures.length >= requiredApprovals the proposal is APPROVED and execute.mutate(proposal) relays it on-chain.

Server-only (programmatic) flow

For an agent or backend with no UI, skip the hooks and call the pure helpers with the same transport — every owner is a server-held key:
import { createProposal, buildApproval, buildExecuteCalldata } from "@chipi-stack/chipi-react/multisig";
import { sdk } from "./lib/chipi";
import { signer, ownerPubkey } from "./lib/signer";
import { actions } from "./lib/actions";

const t = sdk.treasury.forTreasury({ treasuryId, ownerPubkey }); // sk_ available → all methods work

const body = await createProposal({
  template: actions[0],
  values: { contract: "", proposalId: "5", choice: "0x1" },
  walletAddress,
  requiredApprovals: 2,
  signer,
});
const proposal = await t.propose(body);

// …collect a second owner's approval (their own signer)…
const approval = await buildApproval({ proposal, walletAddress, signer: secondOwnerSigner });
await t.sign(proposal.id, approval);

// once enough signatures are collected, re-fetch and execute:
const { items } = await t.list();
const ready = items.find((p) => p.id === proposal.id)!;
await t.execute(ready.id, buildExecuteCalldata(ready));

Notes

  • Free executes are metered. sdk.treasury.forTreasury(...).getMultisigAllowance() returns the org’s remaining free executes before you build a proposal.
  • amount for non-payment actions. Templates that aren’t token payments (a vote, a borrow) carry the intent in calls + title; the payment fields are placeholders.
  • The chain is the source of truth. Off-chain signature verification stops a public pk_ from spamming forged proposals, but the on-chain dispatcher is the authoritative gate at execute — a proposal without a valid N-of-M envelope simply reverts.