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:
| Package | What it gives you |
|---|
@chipi-stack/backend | sdk.treasury — the typed coordination client (the transport). |
@chipi-stack/chipi-react/multisig | Action 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:
| Step | Auth | Where to call it |
|---|
propose / sign | pk_ (public key) + the owner’s signature | browser-safe — the signature is the authorization |
execute | sk_ (secret key) | a server — it relays through the paymaster and meters usage |
list | sk_ | 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:
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:
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:
"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:
"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:
"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.
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:
| Event | When |
|---|
treasury.proposal.created | a proposal lands |
treasury.proposal.approved | each accepted signature |
treasury.proposal.executable | the quorum is met — react with execute from your server (sk_) for auto-execute |
treasury.proposal.executed | the 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.