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

# Add a Second Device or Co-Signer

> Let one wallet be controlled by more than one signer — a passkey on the phone plus a passkey on the laptop, or a user plus an agent. Same on-chain account, multiple ways to approve.

A Chipi wallet starts with one owner: whatever passkey or signer the user registered at sign-up. This page covers the two ways to add more signers to that wallet:

1. **Additional devices that each act on their own.** Add the user's laptop passkey alongside their phone passkey. Either device can sign a transfer. This is what most consumer apps want.
2. **Co-signing where multiple signers must approve.** Require two signatures (or three of four, etc.) before any transfer goes through. This is what agent treasuries, shared wallets, and high-value accounts want.

Both routes share the same on-chain account. You're not creating a second wallet — you're widening who controls the existing one.

> [!WARNING]
>
> **Co-signing needs a SHHH V8.5 wallet** (class `0x067828c8…bd96a`, the default for
> wallets created with 14.17.0+). On V8.4 and earlier the contract checks the threshold
> only on threshold envelopes: one owner signing alone with a single-owner envelope still
> executes, so a "2-of-3" V8.4 wallet is really 1-of-3. Chipi's relay refuses to raise the
> threshold above 1 on those wallets (`422 SHHH_THRESHOLD_UNSUPPORTED`), and V8.x wallets
> can't upgrade: a V8.4 wallet that needs N-of-M moves to a new V8.5 wallet. Independent
> owners (threshold 1) work the same on both.

> [!NOTE]
>
> Available without advertisement until external audit closes.

## When to use which

| Your use case | Path |
| - | - |
| User wants their phone + their laptop to both work | Independent owners (each device signs on its own) |
| User wants a backup passkey on a second device "in case" | Independent owners; same as above |
| Agent spends from a treasury you co-control | Co-signing — typically 2-of-2 above a spend threshold, 1-of-2 below |
| Shared org wallet across finance + ops | Co-signing — set the threshold to match your governance |
| High-value personal account where any single device shouldn't be enough | Co-signing — 2-of-3 between phone + laptop + recovery contact |

Concrete examples in production today:

1. **Agent treasuries** — an automated agent spends up to \$X/day on its own, anything bigger requires a co-sign from the user's passkey. 2-of-2 above the threshold, 1-of-2 below.
2. **AI-API service accounts** — a service account shares custody with a human operator. Routine API spend doesn't prompt; settlement transfers do.
3. **Co-signed org treasuries** — Chipi credits balance is N-of-M with key roles distributed across finance + ops + the founder.

## Compared to single-owner

Single-owner SHHH wallets sign via one V2\_SNIP12 envelope:

```
[V2_SNIP12_PREFIX, owner_id, kind_tag, ...payload]
```

A threshold wallet signs via a threshold envelope wrapping several inner frames, each prefixed by its length. A frame is one owner's single-owner envelope without its `V2_SNIP12` tag, which the outer tag replaces:

```
[
  V2_THRESHOLD_PREFIX,
  num_owner_sigs,   // count of frames that follow (at least 2)
  frame_1_len, owner_id_1, kind_tag_1, ...payload_1,
  frame_2_len, owner_id_2, kind_tag_2, ...payload_2,
  ...
]
```

`buildThresholdEnvelope` takes the per-kind builders' envelopes as they are and drops the tags (14.17.0+; earlier versions kept them, and the contract rejected those envelopes with `THRESH: bad owner_id`).

Each inner envelope can be a **different signer kind**. The wallet validates each inner envelope against its registered verifier class, rejects a repeated owner, sums the verified owners' weights, and admits the call only when that sum reaches the threshold. The threshold is not in the envelope: the set of owners, their weights and the threshold live on-chain.

On V8.5 the single-owner envelope is held to the same rule: an owner whose weight is below the threshold gets `SHHH: below threshold`, with one exception, a lone `cancel_recovery` naming that owner (the recovery veto). An owner whose weight alone meets the threshold can still sign alone. One consequence for multisig coordinators: on V8.4 each collected approval is by itself a valid single-owner signature for the same transaction, so treat stored approvals for V8.4 wallets as executable.

## Owner kinds within a threshold

Anything that works as a single-owner signer kind works as a threshold owner. You can mix:

* An EOA on MetaMask (`EIP191_SECP256K1`) co-signs with a passkey (`WEBAUTHN_P256`)
* A Phantom / Solana wallet (`ED25519`) co-signs with a STARK key held server-side
* A JWT\_ES256 service account co-signs with a STARK key held server-side
* A Starknet wallet the user already has, such as Bramble (`STARK_SNIP12`), co-signs with a passkey. Bramble signs with `wallet_signTypedData` and `buildStarkSnip12Envelope` wraps its signature into an inner envelope. See [Trusted wallets](/sdk/guides/trusted-wallets)
* A guardian (`role: "GUARDIAN"`) does NOT count toward the threshold for normal transactions — guardians only initiate recovery, never co-sign

Each kind needs its verifier class registered on the wallet. A wallet starts with its first owner's kind only; register another with `propose_add_verifier_class` (48h) and execute it before `execute_add_owner`. See [New signer kinds](/services/gasless/recovery#new-signer-kinds-need-a-verifier-class).

## Configuring N and M

The wallet is created with N initial owners (M=N at start). Add or remove owners post-creation via the recovery flows in [recovery](/services/gasless/recovery):

* `propose_add_owner` → `execute_add_owner` after 48h
* `propose_remove_owner` → `execute_remove_owner` after 24h
* `propose_set_threshold` → `execute_set_threshold` after 48h (above 1 only on V8.5+)
* `propose_add_verifier_class` → `execute_add_verifier_class` after 48h, for a signer kind the wallet doesn't verify yet

Threshold changes are timelocked because they're governance-grade — a malicious add-owner that immediately set threshold=1 would be a wallet takeover. The 48h window gives the owners a chance to `cancel_pending_op`. On a V8.5 wallet with threshold 2+, every one of these calls, cancels included, needs the threshold too: one owner alone can only cancel a recovery.

## SDK status

| Surface | Status | Notes |
| - | - | - |
| Backend builders (`buildThresholdEnvelope`, `buildProposeSetThresholdCall`, `buildExecuteSetThresholdCall`) | Shipped | See `@chipi-stack/backend` exports |
| Python builders | Shipped | Mirrors TS — see `chipi_sdk.shhh.threshold` |
| React hooks | Shipped | `useThresholdSign` assembles the N-of-M signature (stateless `assemble`, or a collector for owners who sign at different times). `useGuardianRecovery().buildProposeSetThreshold` / `.buildExecuteSetThreshold` cover the governance side |
| Mainnet smoke | Shipped (view-shape + parser) | Real-money smoke deferred until external audit closes |

```ts theme={null}
import {
  buildThresholdEnvelope,
  buildStarkEnvelope,
  buildEip191EnvelopeFromSignature,
  type Eip191Pubkey,
} from "@chipi-stack/backend";

declare const serverStarkKey: string;
declare const oeHash: bigint; // computeOeHash(outsideExecution, walletAddress, chainId)
declare const metaMaskPersonalSig: string; // personal_sign over the 32-byte hash
declare const metaMaskPubkey: Eip191Pubkey; // { x, y }: uncompressed secp256k1 key, 32 BE bytes each

// Two owners co-sign the same OE. Each envelope carries its owner's index.
const innerStark = buildStarkEnvelope({
  privateKey: serverStarkKey,
  messageHash: oeHash,
  ownerIndex: 0n,
});
const innerEip191 = buildEip191EnvelopeFromSignature({
  signatureHex: metaMaskPersonalSig,
  pubkey: metaMaskPubkey,
  ownerIndex: 1n,
});

const envelope = buildThresholdEnvelope({
  innerEnvelopes: [innerStark, innerEip191],
});
// Feed `envelope` into the same execute_from_outside_v2 calldata path
// you'd use for a single-owner OE.
```

### External wallets that don't expose a key (MetaMask, Phantom, Bramble)

MetaMask, Phantom and Bramble never hand you a private key — the user signs in their
wallet and you get a signature back. Each has a builder that wraps that
signature into an inner envelope:

| Wallet | Kind | Builder | Sync? |
| - | - | - | - |
| MetaMask / any EVM EOA | `EIP191_SECP256K1` | `buildEip191EnvelopeFromSignature` | sync |
| Phantom / any Solana wallet | `ED25519` | `buildEd25519EnvelopeFromSignature` | **async** (Garaga WASM init) |
| Bramble, Braavos, Ready, another Chipi wallet | `STARK_SNIP12` | `buildStarkSnip12Envelope` | sync |

Bramble signs typed data, never a raw hash: have it sign
`starkSnip12ApprovalTypedData({ walletAddress, messageHash: oeHash })` with
`wallet_signTypedData`, then pass the result as returned to
`buildStarkSnip12Envelope({ signature, ownerIndex })`.

For Phantom, the wallet must sign the **exact bytes** returned by
`ed25519SignedBytes(messageHash)` — the 64-byte hex-ASCII encoding of the OE
hash — not the raw 32-byte hash. The on-chain verifier reconstructs those same
bytes, so signing anything else fails verification.

```ts theme={null}
import {
  buildEd25519EnvelopeFromSignature,
  ed25519SignedBytes,
} from "@chipi-stack/backend";

// 1. Connect Phantom and read its 32-byte Ed25519 pubkey.
await window.solana.connect();
const publicKey = window.solana.publicKey.toBytes();

// 2. Sign the EXACT bytes the verifier expects (not the raw hash).
const msg = ed25519SignedBytes(oeHash);
const { signature } = await window.solana.signMessage(msg); // 64-byte R‖s

// 3. Wrap into an inner envelope (async — initializes Garaga on first call).
const innerEd25519 = await buildEd25519EnvelopeFromSignature({
  signature,
  publicKey,
  messageHash: oeHash,
});
// `innerEd25519` slots into `buildThresholdEnvelope({ innerEnvelopes: [...] })`
// exactly like the EIP-191 envelope above.
```

## Gas

The paymaster sums the per-kind gas overhead across each inner envelope. A 2-of-2 STARK + EIP-191 OE budgets 2M + 10M = 12M l2\_gas for verification on top of the call's own cost. See the [passkey API reference](/sdk/passkeys/api) for the kinds available client-side; gas tables live with the backend builders.

This composes — you don't budget gas yourself. The paymaster reserves it automatically based on the envelope shape.

## Threshold + recovery together

The headline product story for SHHH (V8.5+) is threshold combined with guardian recovery:

* **Day-to-day operations** require N-of-M signatures
* **Lost-key recovery** still works because a guardian can initiate `initiate_recovery` even if N-1 owners have lost their keys
* **Any one owner can still stop a recovery** with `cancel_recovery` naming itself, even below the threshold

Take a 2-of-3 wallet (three owners of weight 1, threshold 2) with one guardian. If owners are lost, the guardian starts a recovery. Recovery adds **one** owner and leaves the threshold unchanged. After 7 days, remove the lost owners (`propose_remove_owner`, 24h each).

## Related

* [Passkey reference](/sdk/passkeys/api) — what each owner can be on the browser side
* [Recovery](/services/gasless/recovery) — add/remove owners + guardian recovery
* [Migration](/services/gasless/migration) — getting from CHIPI v29 to SHHH first


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