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

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

> [!WARNING]
>
> **The on-chain N-of-M needs a SHHH V8.5 wallet** (class `0x067828c8…bd96a`; wallets created
> with 14.17.0+). On V8.4 and earlier, one owner signing alone with a single-owner envelope
> executes whatever the threshold says, and every approval this flow collects is such an
> envelope, so a stored approval can execute its proposal by itself. Chipi therefore refuses to
> raise the threshold above 1 on V8.4 wallets: a treasury on a V8.4 wallet has threshold 1,
> and its `requiredApprovals` are enforced only by the coordination backend. Keep its approvals
> as private as the funds, or move the treasury to a V8.5 wallet (V8.x can't upgrade in place).

## 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, // bootstrap owner only; see "Resolving the owner index"
};

// 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, // bootstrap owner only; see "Resolving the owner index"
};

// 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, // bootstrap owner only; see "Resolving the owner index"
};

// `"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, // bootstrap owner only; see "Resolving the owner index"
};

// `"0x<low>,0x<high>"` — the LE u128 halves of the 32-byte key.
export const ownerPubkey = ed25519OwnerPubkeyParam(publicKey);
```

> [!NOTE]
>
> 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. Every V8 `ShhhAccount` class (V8.4 `0x075dfb39...`, V8.5 `0x067828c8...`) 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.

```ts lib/resolve-owner-index.ts theme={null}
import { RpcProvider, shortString } from "starknet";
import { webauthnOwnerId, type WebAuthnPubkey } from "@chipi-stack/backend";

const provider = new RpcProvider({ nodeUrl: process.env.STARKNET_RPC_URL! });
const ROLE_OWNER = BigInt(shortString.encodeShortString("OWNER"));

// OwnerRecord, 9 felts: kind, pubkey_hash, pubkey_len, pubkey_slot, role,
// weight, added_at, label, revoked (src/owner_set/interface.cairo). Confirmed
// against get_owner on a live V8.4 mainnet wallet.
const IDX_PUBKEY_HASH = 1;
const IDX_ROLE = 4;
const IDX_REVOKED = 8;

export async function resolveOwnerIndex(
  walletAddress: string,
  pubkey: WebAuthnPubkey,
): Promise<number> {
  // The same Poseidon commitment the contract stores as pubkey_hash. Use the
  // matching helper for other kinds: starkOwnerId, eip191OwnerId, ed25519OwnerId.
  const commitment = webauthnOwnerId(pubkey);

  const [countFelt] = await provider.callContract({
    contractAddress: walletAddress,
    entrypoint: "owner_count",
    calldata: [],
  });
  const count = Number(BigInt(countFelt));

  for (let ownerId = 0; ownerId < count; ownerId++) {
    const rec = await provider.callContract({
      contractAddress: walletAddress,
      entrypoint: "get_owner",
      calldata: [ownerId.toString()],
    });
    if (rec.length < 9 || BigInt(rec[IDX_PUBKEY_HASH]) !== commitment) continue;
    // A commitment match is not enough: GUARDIAN and RECOVERY_ONLY records
    // carry a pubkey_hash too, and revoked records are kept as tombstones.
    if (BigInt(rec[IDX_ROLE]) !== ROLE_OWNER) throw new Error("This key is a guardian, not an owner");
    if (BigInt(rec[IDX_REVOKED]) !== 0n) throw new Error("This owner was revoked");
    return ownerId;
  }
  throw new Error("This key is not registered on the wallet");
}
```

Resolve once when the signer is created and cache the result; `owner_id` is
stable across removals because revoked records are tombstoned, not shifted.

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

### Starknet browser wallets (`STARK_SNIP12`)

A Starknet wallet the user already has (Bramble, Braavos, Ready, or another Chipi wallet)
can be an owner or a guardian of a SHHH wallet with the **STARK\_SNIP12** kind. These wallets
never sign a raw hash: Bramble signs an `Approval { Wallet, Hash }` typed data with
`wallet_signTypedData`, and the verifier rebuilds it on chain. Its owner record holds Bramble's
STARK key and account address (`starkSnip12PubkeyFelts`).

```ts lib/bramble-envelope.ts theme={null}
"use client";
import { buildStarkSnip12Envelope, starkSnip12ApprovalTypedData } from "@chipi-stack/backend";

declare const bramble: { request(call: { type: string; params?: unknown }): Promise<unknown> };
declare const walletAddress: string; // the SHHH wallet

// Bramble's inner envelope for an execution hash. Call from a click: Bramble
// opens its panel only during a user gesture.
export async function brambleEnvelope(messageHash: bigint, ownerIndex: bigint) {
  const signature = (await bramble.request({
    type: "wallet_signTypedData",
    params: starkSnip12ApprovalTypedData({ walletAddress, messageHash }),
  })) as string[];
  // [r, s] from Bramble or Braavos, ["1", "0", pubkey, r, s] from Ready: pass it as returned.
  return buildStarkSnip12Envelope({ signature, ownerIndex });
}
```

> [!WARNING]
>
> One limit today: the treasury coordination endpoints (`propose` / `sign`, signer invites) don't accept
> `signerKind: "STARK_SNIP12"` yet: use the envelope on chain directly, or
> `sdk.executeWithExternalSigner` for a single owner. A wallet also verifies only the kinds it has
> registered, so `STARK_SNIP12` needs `propose_add_verifier_class` (48h) before the owner can be
> added. See [Trusted wallets](/sdk/guides/trusted-wallets).

## 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 });
```

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

```ts theme={null}
// Three owners for anything sensitive; routine follows the wallet threshold.
await sdk.treasury.setPolicy(treasuryId, { sensitiveQuorum: 3 });

const policy = await sdk.treasury.getPolicy(treasuryId);
// { treasuryId, name, routineQuorum, sensitiveQuorum }
```

An omitted tier is left unchanged and an explicit `null` clears it, so the two are
deliberately different requests.

> [!WARNING]
>
> 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).

> [!NOTE]
>
> 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.

```ts theme={null}
const { items } = await sdk.treasury.list();

for (const t of items) {
  console.log(t.id, t.name, t.wallet.publicKey);
}

const ops = items.find((t) => t.name === "Ops Treasury");
const transport = sdk.treasury.forTreasury({
  treasuryId: ops.id,
  ownerPubkey,
});
```

Options: `page` (1-based), `limit` (server clamps to 1..100, default 25), and
`includeArchived` (default false). The response is
`{ items, total, page, limit, hasMore }`.

> [!NOTE]
>
> 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

> [!NOTE]
>
> **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):

```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. On a V8.5 wallet the on-chain threshold is the hard
floor at execute; on a V8.4 wallet (threshold 1) the backend is the only floor. 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"));
}
```

> [!WARNING]
>
> **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"`:

```bash theme={null}
POST /v1/treasuries/:treasuryId/signers/invite   # sk_
{ "email": "ops-backup@example.com", "signerKind": "STARK", "role": "GUARDIAN" }
```

> [!NOTE]
>
> 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

```
commit the OE          POST .../recovery              pk_
guardian authorises    POST .../recovery/:id/sign     pk_ + guardian signature
   ↓ initiate_recovery relayed, 7-day timelock starts
finalize               permissionless, after validAfter
```

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

> [!WARNING]
>
> `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.
`initiate_recovery` only runs inside an outside execution signed by the guardian (one call,
with `proposer` equal to the guardian's own owner id), but anyone can relay that signed OE
through `execute_from_outside_v2` and pay the 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(owner_id)` to abort, where `owner_id` is the **cancelling** owner's own id
(the wallet checks it is a non-revoked `ROLE_OWNER`). On V8.5 that is the one call an owner
below the threshold can sign alone, and only as the single call in the OE, naming itself. A guardian can't cancel. There is one
pending recovery at a time and it has no expiry: it stays until an owner cancels it or anyone
finalizes it.

> [!WARNING]
>
> 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:

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

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.