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:
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: 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
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
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
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
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
The *OwnerPubkeyParam helpers used above ship from @chipi-stack/backend as of 14.9.0: webauthnOwnerPubkeyParam, eip191OwnerPubkeyParam and ed25519OwnerPubkeyParam. Each returns the kind-specific ownerPubkey string the coordination endpoints expect, so the code above is correct to copy.On 14.8.0 and earlier they did not exist, and you had to derive the value from webauthnPubkeyFelts / eip191PubkeyFelts / ed25519PubkeyFelts / starkPubkeyFelts by hand. Those still ship and still return bigint[], but they are no longer the shortest path.Watch the shape: the coordinate kinds take the FULL "0x<x>,0x<y>" values, not the 4-felt *PubkeyFelts form, which the backend rejects. STARK is a single 0x-hex felt.

Resolving the owner index

Every example above hardcodes resolveOwnerIndex: async () => 0, which is only right for the bootstrap owner. Any other device has to look its index up on chain, and a wrong index fails as “signature invalid” on a device that is an owner. The deployed ShhhAccount class (V8.4, 0x075dfb39...) exposes owner_count and get_owner(owner_id) for this; there is no lookup-by-hash entrypoint, so walk the records once and cache the result.
lib/resolve-owner-index.ts
Resolve once when the signer is created and cache the result; owner_id is stable across removals because revoked records are tombstoned, not shifted.
All four launch signer kinds — STARK, WEBAUTHN_P256 (passkey), EIP191_SECP256K1 (MetaMask), and ED25519 (Phantom) — are verified live on propose / sign.

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

Risk-tier quorums

A proposal filed with risk: "ROUTINE" | "SENSITIVE" has its requiredApprovals overridden server-side from the treasury’s matching quorum. That is how you demand more signatures to borrow than to repay, without changing the wallet’s on-chain threshold.
An omitted tier is left unchanged and an explicit null clears it, so the two are deliberately different requests.
A quorum can only raise requiredApprovals, never lower it below the wallet’s on-chain threshold.Set one below and it is silently raised at propose time, not rejected and not reverted: a routineQuorum of 1 on a 2-of-3 wallet is clamped to 2 and executes normally. The risk is a policy that reads 1 everywhere while the system uses 2.Since 14.12.2 setPolicy rejects that outright so the stored value means what it says. To genuinely lower the requirement, lower the on-chain threshold first with propose_set_threshold (48h timelock).
Server-only, since both need a sk_. Before 14.12.0 these were settable only from the dashboard.

Finding your treasuryId

sdk.treasury.list() returns every treasury the secret key’s organization owns. The sk_ resolves the org server-side, so there is no organization id to pass and nothing to look up in the dashboard first.
Options: page (1-based), limit (server clamps to 1..100, default 25), and includeArchived (default false). The response is { items, total, page, limit, hasMore }.
Server-only, since it needs a sk_. Use it to recover a treasuryId that fell out of your config, or resolve the treasury by name at boot instead of pinning the id in an environment variable.

4. Drop-in components

Shipped since 14.9.0. <ProposeAction>, <Approvals> and <ApprovalInbox> are exported from @chipi-stack/chipi-react/multisig, so the examples below are usable as written. You do not have to build this UI yourself.On 14.8.0 only the hooks (useProposeAction, useTreasuryProposals) and helpers shipped, and this page previously said the components were roadmap. If you built your own approval UI against 14.8.0 on that basis, these are the drop-in replacements.
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):
Theme them to match your app with CSS-var tokens (defaults are Chipi’s neobrutalism):
<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:
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: 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):
Delivery is at-most-once, not at-least-once. One attempt plus a single retry two seconds later, then the event is dropped and only logged. There is no durable queue and no replay.Make your handler idempotent on (event, proposalId), and do not let the webhook be the only thing that advances your state. Treat it as a latency optimisation and keep an indexer or a reconciliation sweep as the source of truth. ts is inside the signed payload, so you can enforce a replay window.

6. Recovering a treasury

If enough owner devices are lost that the threshold can no longer be met, a treasury is unusable and so is everything it controls. Guardians are the way back. A guardian is an owner record with ROLE_GUARDIAN. On chain it can call initiate_recovery and nothing else: it cannot sign a payment or move funds. Enrol one exactly like any other signer, with role: "GUARDIAN":
Do this at setup, not during an incident. At least two live guardians are required before a recovery can be started through Chipi’s relay, and a guardian only counts once its on-chain add has landed and you have called mark-added.

The flow

create commits an initiate_recovery OutsideExecution whose signature span is empty (a single trailing 0x0). That committed calldata is the source of truth and is never rebuilt from anything a guardian sends. Committing it authorises nothing. A guardian then signs only the OE hash. Chipi splices their signature into the trailing slot and relays. Chipi signs nothing and holds no key. A guardian cannot substitute a different new owner, because a signature over any other OE reverts on chain.
oeHash is supplied by whoever created the request and is never recomputed server-side. Recompute it from oeCalldata before signing rather than trusting the value we return. A mismatch fails safe, since the chain verifies against the OE it derives from the calldata, but you should not be trusting our echo in the first place. That is why the read endpoint returns the full calldata.

What authorises it

The API key is app identity only. The guardian’s signature is the authorization, verified against the on-chain owner set with the role gate set to GUARDIAN. Holding pk_, sk_ and a Chipi dashboard session is not enough to start a recovery. Chipi is also removable from the path: finalize_recovery is permissionless and initiate_recovery can be self-relayed by anyone willing to pay gas, so the paymaster is convenience rather than a chokepoint.

The 7-day window, and when it does not protect you

Recovery is additive: finalize_recovery adds the new owner and removes none, so the threshold is unchanged. During the 7 days, any current ROLE_OWNER can call cancel_recovery to abort.
That veto needs a surviving owner. A 1-of-1 treasury has none, so recovery there is unvetoable: the threshold stays 1, and the recovered owner is immediately fully authorised. It is the case that most needs recovery and the one where the control is absent.The mitigation is structural, not procedural: get to two owners before a treasury holds anything meaningful, and enrol guardians at setup. Budget 48h for the ADD_OWNER timelock.
Recovery is deliberately still permitted on an archived treasury, unlike every other mutating operation, because there is no unarchive endpoint and refusing would make “archive, then lose a device” permanently unrecoverable.

7. AI agents as constrained proposers

An AI agent is just one more owner of the multisig — a constrained one. Add its STARK key as an owner, register it with a daily budget, and the coordination layer treats that budget as a dynamic approval rule:
  • An in-budget USDC payment the agent proposes auto-approves (quorum 1) → the treasury.proposal.executable webhook fires → your server executes it. The agent acts alone, within its allowance.
  • Over budget, a non-USDC payment, or any contract action escalates to the human threshold and lands in <ApprovalInbox> as “Vela wants: Move $500 to Endur”.
Register the agent (dashboard → treasury → agents, or the API). The default budget is 0 — the agent can’t move a cent until you raise it:
The agent proposes exactly like any owner — a STARK MultisigSigner at its owner index:
agent-loop.ts
In the inbox, mark agent rows so the founder sees whose action it is:
Daily budgets are app-level policy: they make a misbehaving agent loud (escalation), not impossible. A valid agent signature on an in-budget payment executes without a human. For hard, on-chain-enforced spending caps — the real safety boundary for autonomous agents — use session-key policies (coming in a future release). Size the budget to what you’d let the agent move unattended.

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
app/api/treasury/[id]/transactions/[txId]/execute/route.ts

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

app/treasury/[id]/page.tsx
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:

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.