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

# Trusted wallets

> Make Bramble, Braavos, Ready or another Chipi wallet a co-owner or a recovery guardian of a Chipi wallet. Its key never leaves that wallet.

A Chipi passkey wallet can trust a second wallet the user already has. Bramble is the running example here; Braavos, Ready (Argent) and other Chipi wallets work the same way.

* **Co-owner**: signs anything the passkey can, such as approving or revoking an agent session, or replacing a lost passkey.
* **Guardian**: can only start a recovery. The recovery completes after 7 days unless an owner cancels it.

`@chipi-stack/*` 14.17.0+ ships the pieces: the `STARK_SNIP12` signer kind, `sdk.executeWithExternalSigner` / `useExternalSignerExecute`, and the governance builders and readers used below.

<Note>
  The `STARK_SNIP12` verifier class (`0x017884125977631bee198b34cd3e3fcf975a543e453c8b996a818910a964b5e`) is declared on Starknet mainnet (2026-10-14, tx `0x00ba3329…59e6`, block 16377651). Step 2 still checks `isShhhVerifierDeclared` first, so the same code refuses to propose it on a network where it isn't.
</Note>

Both roles assume the wallet's threshold is 1, the default. On a SHHH V8.5 wallet whose threshold you raise above 1, a co-owner of weight 1 can no longer sign alone (only a recovery cancel stays open to it); on V8.4 the threshold isn't enforced for single signers, which is why Chipi keeps V8.4 thresholds at 1. See [Co-signing](/services/gasless/threshold).

## Why a new signer kind

Bramble uses a STARK key, the same curve as the wallet's own `STARK` signer. But a browser wallet never signs a raw hash. Its only signing method, `wallet_signTypedData`, signs a SNIP-12 message hash that always includes the signing wallet's own address. So Bramble can't produce the signature the `STARK` verifier checks.

`STARK_SNIP12` wraps the Chipi wallet's transaction hash (its SNIP-12 outside-execution hash) in a typed data Bramble signs as-is:

```
StarknetDomain { name: "Shhh", version: "1", chainId: "SN_MAIN", revision: "1" }
Approval { Wallet: <the Chipi wallet>, Hash: <the transaction hash> }
```

The verifier rebuilds it on-chain and checks the STARK signature. A signature only works for one Chipi wallet, on one chain, for one transaction. The verifier never calls the Bramble account, so a brand-new Bramble account that is not deployed yet works too.

What each wallet returns from `wallet_signTypedData`. The SDK accepts all three and sends the verifier `[r, s]`:

| Wallet | Signature |
| - | - |
| Bramble (OpenZeppelin account), Braavos (STARK signer) | `[r, s]` |
| Ready / Argent | `["1", "0", publicKey, r, s]` |
| Another Chipi wallet | `[V2_SNIP12, ownerId, "STARK", r, s]` |

Not supported: multisig accounts, Ready accounts with a guardian, and Braavos hardware or passkey signers.

| | Co-owner (`OWNER`) | Guardian (`GUARDIAN`) |
| - | - | - |
| Approve, cap or revoke agent sessions | Yes | No |
| Move funds | Yes | No |
| Replace a lost passkey | Yes: rotates owner 0 (24h) | Yes: starts a recovery (7 days, cancellable) |
| Cancel a recovery | Yes | No |

A co-owner has full control of the wallet. If the user only wants a backup, make Bramble a guardian. You pick the role in step 2.

## 1. Read Bramble's key (browser)

The verifier is bound to Bramble's STARK key and account address. Read them from one signature: connect Bramble from a click, have it sign an `Approval`, then resolve the key.

```ts theme={null}
import {
  resolveStarkSnip12Signer,
  starkSnip12ApprovalTypedData,
  type StarknetCallProvider,
} from "@chipi-stack/backend";

declare const bramble: { request(call: { type: string; params?: unknown }): Promise<unknown> };
declare const provider: StarknetCallProvider; // e.g. new RpcProvider({ nodeUrl })
declare const chipiWalletAddress: string;

// From a click: Bramble opens its panel only during a user gesture.
const [brambleAddress] = (await bramble.request({
  type: "wallet_requestAccounts",
  params: { silent_mode: false },
})) as string[];

// Any fresh value works: this signature only proves which key signs for Bramble.
const messageHash = BigInt(Date.now());
const typedData = starkSnip12ApprovalTypedData({ walletAddress: chipiWalletAddress, messageHash });
const signature = (await bramble.request({
  type: "wallet_signTypedData",
  params: typedData,
})) as string[];

// A new Bramble account is not deployed yet: its deployment data carries the key.
let deploymentData: { address: string; class_hash: string; salt: string; calldata: string[] } | undefined;
try {
  deploymentData = (await bramble.request({ type: "wallet_deploymentData" })) as typeof deploymentData;
} catch {
  // 115: already deployed. The key is read from the account instead.
}

const { starkPubkey, pubkeyFelts } = await resolveStarkSnip12Signer({
  provider,
  signerAddress: brambleAddress,
  walletAddress: chipiWalletAddress,
  messageHash,
  signature,
  deploymentData,
});
```

`resolveStarkSnip12Signer` reads the key from the signature (Ready), the account (`get_public_key` for Bramble's OpenZeppelin account, `get_signers` for Braavos), the deployment data (a new account), or the owner record (a Chipi wallet). It throws unless that key verifies the signature.

## 2. Propose (passkey, one prompt)

A Chipi wallet starts with only the `STARK` verifier. Propose the new kind and the new owner in the same transaction. Both have a 48-hour timelock. `role` is `"OWNER"` for a co-owner or `"GUARDIAN"` for recovery only; `role` and `label` must be the same in step 3, because the timelock commits to them.

```ts theme={null}
import {
  buildProposeAddOwnerCall,
  buildProposeAddVerifierClassCall,
  isShhhVerifierDeclared,
  readVerifierClass,
  type OwnerRole,
  type StarknetCallProvider,
  type StarknetClassProvider,
} from "@chipi-stack/backend";

declare const provider: StarknetCallProvider & StarknetClassProvider;
declare const chipiWalletAddress: string;
declare const pubkeyFelts: bigint[];
declare const role: OwnerRole; // "OWNER" (co-owner) or "GUARDIAN" (recovery only)

if (!(await isShhhVerifierDeclared(provider, "STARK_SNIP12"))) {
  throw new Error("STARK_SNIP12 is not declared on this network yet.");
}

const proposeCalls = [];
if ((await readVerifierClass(provider, chipiWalletAddress, "STARK_SNIP12")) === 0n) {
  proposeCalls.push(
    buildProposeAddVerifierClassCall({ walletAddress: chipiWalletAddress, proposer: 0, kind: "STARK_SNIP12" })
  );
}
proposeCalls.push(
  buildProposeAddOwnerCall({
    walletAddress: chipiWalletAddress,
    proposer: 0,
    newOwner: { kind: "STARK_SNIP12", pubkeyBytes: pubkeyFelts, role, weight: 1, label: "bramble" },
  })
);
```

Send `proposeCalls` with the passkey, like any other call on the wallet (`useCallAnyContract` with `usePasskey: true`, `contractAddress` set to the wallet). Wait for the transaction, then read the op ids from its events: they include the block time, so they can't be computed in advance.

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

declare const receipt: { events: Array<{ from_address?: string; keys: string[]; data: string[] }> };
declare const chipiWalletAddress: string;

const ops = parseOpProposedEvents(receipt.events, chipiWalletAddress);
const addVerifier = ops.find((op) => op.opKind === "ADD_VERIFIER");
const addOwner = ops.find((op) => op.opKind === "ADD_OWNER");
// addOwner.validAfter is the unix time when step 3 opens.
```

## 3. Execute (after 48 hours)

Execute calls are permissionless, so anyone may send them once the timelock is over: the passkey again, or Bramble itself with `wallet_addInvokeTransaction` (Bramble pays its own gas in STRK). Register the verifier **first**: `execute_add_owner` checks the new key through it.

```ts theme={null}
import {
  buildExecuteAddOwnerCall,
  buildExecuteAddVerifierClassCall,
  readShhhOwners,
  type OwnerRole,
  type StarknetCallProvider,
} from "@chipi-stack/backend";

declare const provider: StarknetCallProvider;
declare const chipiWalletAddress: string;
declare const pubkeyFelts: bigint[];
declare const role: OwnerRole; // the same role as in step 2
declare const addVerifier: { opId: bigint } | undefined;
declare const addOwner: { opId: bigint };

const executeCalls = [
  ...(addVerifier
    ? [buildExecuteAddVerifierClassCall({ walletAddress: chipiWalletAddress, opId: addVerifier.opId, kind: "STARK_SNIP12" })]
    : []),
  buildExecuteAddOwnerCall({
    walletAddress: chipiWalletAddress,
    opId: addOwner.opId,
    newOwner: { kind: "STARK_SNIP12", pubkeyBytes: pubkeyFelts, role, weight: 1, label: "bramble" },
  }),
];

// After it lands, Bramble's index in the owner set is what it signs with.
const owners = await readShhhOwners(provider, chipiWalletAddress);
const brambleId = owners.find((o) => o.kind === "STARK_SNIP12" && !o.revoked)?.ownerId;
```

## 4. Sign with Bramble (co-owner)

Only when Bramble was added with `role: "OWNER"`. Bramble signs an `Approval` for the transaction; Chipi's paymaster relays it gaslessly. Here Bramble approves a capped agent session:

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

declare const sdk: ChipiSDK;
declare const bramble: { request(call: { type: string; params?: unknown }): Promise<unknown> };
declare const chipiWalletAddress: string;
declare const brambleId: number;
declare const brambleAddress: string;
declare const starkPubkey: bigint; // from step 1
declare const sessionPublicKey: string;
// bearerToken: the user's JWT, as for any Chipi call.
const USDC = "0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb";

const txHash = await sdk.executeWithExternalSigner({
  params: {
    walletAddress: chipiWalletAddress,
    ownerIndex: brambleId,
    calls: buildSessionSetupCalls(
      chipiWalletAddress,
      {
        sessionPublicKey,
        validUntil: Math.floor(Date.now() / 1000) + 86_400,
        maxCalls: 100,
        allowedEntrypoints: ["transfer", "approve"],
      },
      [{ token: USDC, maxPerCall: 5_000_000n, maxPerWindow: 20_000_000n, windowSeconds: 86_400 }]
    ),
    signTypedData: async (typedData) =>
      (await bramble.request({ type: "wallet_signTypedData", params: typedData })) as string[],
    // Checks the signature off-chain before relaying: a wrong account or key fails here.
    signer: { address: brambleAddress, starkPubkey },
  },
  bearerToken,
});
```

In React, `useExternalSignerExecute()` from `@chipi-stack/nextjs` (or `@chipi-stack/chipi-react`) takes the same input. Wait for the transaction before treating it as done.

Bramble shows the typed data with the wallet address and a hash, not the decoded calls. Show the calls in your own UI before asking for the signature.

## 5. Recover

Both recovery paths below work on-chain today. What Chipi does not support yet is pointing the wallet record at a new passkey: the record keeps the old encrypted key and passkey id, and the SDK has no call to replace both. So after a recovery, the recovered key signs with `executeShhhWithStarkKey`, not with the passkey hooks. Keep that key in memory, or encrypt it yourself, until record updates ship.

The user can still sign in to your app with Bramble ([Sign in with Starknet](/sdk/guides/sign-in-with-starknet)), which gives you the bearer token these calls need.

**With a co-owner.** Nothing is locked: Bramble keeps signing everything. To replace the lost passkey key, Bramble signs `buildProposeRotateOwnerCall({ walletAddress, proposer: brambleId, ownerId: 0, newPubkeyBytes: [newKey] })` through `executeWithExternalSigner`, with a fresh STARK key generated in the browser. After 24 hours anyone executes the rotation (`buildExecuteRotateOwnerCall` with the op id from the propose events). `newKey` then signs as owner 0: `executeShhhWithStarkKey({ privateKey, ownerIndex: 0, ... })`.

**With a guardian (7 days).**

1. Generate a fresh STARK key K in the browser. Bramble signs `buildInitiateRecoveryCall({ walletAddress, proposer: brambleId, newOwner: { kind: "STARK", pubkeyBytes: [K], role: "OWNER", weight: 1, label: "recovered" } })` through `executeWithExternalSigner`. A guardian's signature is accepted for this one call and nothing else.
2. During the 7 days, any owner can cancel with `buildCancelRecoveryCall`, passing the **cancelling owner's** id. Guardians can't cancel. Watch `readPendingRecovery` and alert the user, so a recovery they didn't start gets cancelled.
3. After 7 days anyone sends `buildFinalizeRecoveryCall` with the same new-owner fields, for example Bramble with `wallet_addInvokeTransaction` (it pays its own gas in STRK). Recovery **adds** K as a new owner; it never removes or replaces one.
4. K signs with `executeShhhWithStarkKey({ privateKey: K, ownerIndex: <K's id from readShhhOwners>, ... })`. Remove the lost owner with `buildProposeRemoveOwnerCall` (24 hours) if it may be compromised.

Before starting a recovery, check that it can finalize: the new key's kind is registered on the wallet (`STARK` always is), the key isn't already an owner, and the weight is above 0. Only an owner can clear a recovery that can't finalize, and the user who needs it has none.

## Bramble rules to follow

From Bramble's integration guide (v0.10.5):

* `window.starknet_bramble` can appear after page load. Wait for the `starknet#initialized` event before deciding it is not installed.
* Prompts open only from a click. Don't auto-retry: Bramble allows 5 pending prompts per site.
* Every params object is checked strictly. Never send `undefined` values or extra keys.
* The typed data's `chainId` must match Bramble's active network, or it answers `114` before any prompt. Check `wallet_requestChainId` is `0x534e5f4d41494e` (mainnet) first.
* `113` means the user declined. `163` covers a locked wallet, a site that isn't connected, and other refusals: ask the user to unlock Bramble and retry.

## Related

* [Sign in with Starknet](/sdk/guides/sign-in-with-starknet)
* [Guardian recovery](/services/gasless/recovery)
* [Multi-owner governance](/sdk/guides/multisig-governance)
* [Connect with Chipi](/sdk/connector/overview)

> ✅ Verified against the SDK and contract source on **2026-10-14**.


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