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 (theCall[] 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
AnActionTemplate 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
AMultisigSigner 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
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
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
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 hardcodesresolveOwnerIndex: 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
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 withrisk: "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.
null clears it, so the two are
deliberately different requests.
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.
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.multisig subpath — themeable, mobile-first, no CSS imports or Tailwind required (a
namespaced stylesheet injects at runtime):
<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: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):
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 withROLE_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.
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 toGUARDIAN. 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.
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.executablewebhook 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”.
0 — the agent can’t move a cent until you raise it:
MultisigSigner at its owner index:
agent-loop.ts
Next.js quickstart
The browser can runpropose/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
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. amountfor non-payment actions. Templates that aren’t token payments (a vote, a borrow) carry the intent incalls+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 atexecute— a proposal without a valid N-of-M envelope simply reverts.
