> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chipipay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Multisig Governance

> Propose, approve, and execute any on-chain action through an N-of-M SHHH multisig — from your own app.

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. |

<Info>
  The multisig endpoints live in your Chipi backend at `/v1/treasuries/:treasuryId/...`. You
  drive them with **your own API keys** — no Chipi dashboard required.
</Info>

## 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`:

```ts lib/actions.ts theme={null}
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:

```ts lib/signer.ts theme={null}
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:

```ts lib/passkey-signer.ts theme={null}
"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:

```ts lib/metamask-signer.ts theme={null}
"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:

```ts lib/phantom-signer.ts theme={null}
"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);
```

<Warning>
  **Helper names below are wrong for 14.8.** There are no `*OwnerPubkeyParam`
  exports — the shipped helpers are `webauthnPubkeyFelts` / `eip191PubkeyFelts` /
  `ed25519PubkeyFelts` / `starkPubkeyFelts` (from `@chipi-stack/backend`), each
  returning `bigint[]`. For the transport's `ownerPubkey`, STARK is a single 0x-hex
  felt; coordinate kinds derive from the corresponding `*PubkeyFelts`. This section
  needs a smoke-tested rewrite (tracked: docs audit 2026-06-09) — don't copy the
  `*OwnerPubkeyParam` calls verbatim.
</Warning>

<Note>
  All four launch signer kinds — **STARK**, **WEBAUTHN\_P256** (passkey),
  **EIP191\_SECP256K1** (MetaMask), and **ED25519** (Phantom) — are verified live on
  `propose` / `sign`.
</Note>

## 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.

```ts lib/chipi.ts theme={null}
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

<Warning>
  **Not shipped in 14.8.** `<ProposeAction>`, `<Approvals>`, and `<ApprovalInbox>` are NOT
  exported by `@chipi-stack/chipi-react/multisig` today — only the hooks
  (`useProposeAction`, `useTreasuryProposals`) and helpers ship. The component examples
  below are roadmap; build your UI on the hooks for now. (Tracked: docs audit
  2026-06-09.)
</Warning>

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):

```tsx theme={null}
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):

```tsx theme={null}
<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:

```ts theme={null}
{ 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):

```ts theme={null}
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)`.

## 6. 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:

```bash theme={null}
POST /v1/organizations/:orgId/treasuries/:treasuryId/agents
{ "ownerIndex": 2, "label": "Vela", "dailyBudgetBase": "50000000" }  # 50 USDC (6 dp)
```

The agent proposes exactly like any owner — a STARK `MultisigSigner` at its owner index:

```ts agent-loop.ts theme={null}
const proposal = await chipi.treasury
  .forTreasury({ treasuryId, ownerPubkey: agentStarkPubkey })
  .propose(await createProposal({
    template: payTemplate,
    values: { to: endur, amount: "5" },
    walletAddress: TREASURY,
    requiredApprovals: humanThreshold, // advisory — the budget decides
    signer: agentStarkSigner,          // owner index 2
  }));
// In budget → proposal.status === "APPROVED"; your webhook handler executes it.
// Over budget → "PROPOSED"; it waits for a human in <ApprovalInbox>.
```

In the inbox, mark agent rows so the founder sees whose action it is:

```tsx theme={null}
<ApprovalInbox
  treasuries={[{
    label: "Ops treasury", walletAddress, transport, signer,
    isAgentProposal: (p) => p.meta?.agent === "vela", // you stamp this in the template's meta
    agentLabel: "Vela",
  }]}
/>
```

<Warning>
  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.
</Warning>

## 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_`)

```ts app/api/treasury/[id]/proposals/route.ts theme={null}
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());
}
```

```ts app/api/treasury/[id]/transactions/[txId]/execute/route.ts theme={null}
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`)

```tsx app/treasury/[id]/page.tsx theme={null}
"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:

```ts theme={null}
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.
