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

# Recover Access

> Help users who lost their device, switched browsers, or forgot their PIN. Two on-chain flows close the lost-key dead end plus a side door for rotating a forgotten PIN.

A user who can't sign anymore — broken phone, new laptop, forgotten PIN — needs a path back into their wallet. This page covers three of them:

1. **Add a new device to a wallet they can still sign with** (48-hour timelock). The canonical "switching from old phone to new" case.
2. **Recover a wallet they can no longer sign with at all** (7-day timelock). A pre-registered guardian adds a fresh device as a new owner.
3. **Change the PIN that encrypts their wallet's signing key** (instant). For users who remember the old PIN and just want to pick a new one — or upgrade to a passkey.

The first two flows require a SHHH wallet (V8.4 or V8.5); both run on-chain through the wallet itself with no off-chain custodian. The third works on any wallet type.

> [!NOTE]
>
> SHHH-side flows are available without advertisement until external audit closes. Both are on-chain since 2026-05-26 with shipped mainnet smokes — see [scripts/receipts/](https://github.com/chipi-pay/sdks/tree/main/scripts/receipts).

## Flow 1 — Add device (user still has wallet access)

Use when the user wants a second device controlling the same wallet (the canonical "switching from laptop to phone" case) or wants to add a guardian after onboarding.

**Stages:**

1. **Propose** — an existing owner signs an outside execution containing `propose_add_owner`. The wallet records a pending op tagged `ADD_OWNER` and starts a 48-hour timelock. The `op_id` mixes in the block timestamp and a counter, so read it from the `OpProposed` event in the receipt (`parseOpProposedEvents`).
2. **Wait** — `useGuardianRecovery().isReady(validAfter)` and `secondsRemaining(validAfter)` drive a countdown UI. They don't read the chain: keep the `opId` and `validAfter` from step 1.
3. **Execute** — after 48h, anyone can submit `execute_add_owner` with the matching `op_id`: the execute calls are permissionless once the timelock has passed. The wallet re-derives the proposed payload and asserts equality before it changes the owner set.

If the new owner's kind is not one the wallet already verifies, register its verifier class first: see [New signer kinds](#new-signer-kinds-need-a-verifier-class).

```tsx theme={null}
import type { Call } from "starknet";
import { Recover, useGuardianRecovery } from "@chipi-stack/chipi-react";
import {
  parseOpProposedEvents,
  waitForTransaction,
  type NewOwnerInput,
} from "@chipi-stack/backend";

// Your outside-execution submit path: an owner signs, the paymaster relays.
// Returns the transaction hash.
declare function submitThroughPaymaster(calls: Call[]): Promise<string>;

function AddDevice(props: {
  walletAddress: string;
  // e.g. { kind: "STARK", pubkeyBytes: [newOwnerPubkey], role: "OWNER", weight: 1, label: "laptop" }
  newOwner: NewOwnerInput;
  // The op you stored after onPropose, or null.
  pendingOp: { opId: bigint; validAfter: number } | null;
}) {
  const { walletAddress, newOwner } = props;
  const recovery = useGuardianRecovery();

  return (
    <Recover
      mode="add-device"
      walletAddress={walletAddress}
      newOwner={newOwner}
      pendingOp={props.pendingOp}
      onPropose={async () => {
        const call = recovery.buildProposeAddOwner({
          walletAddress,
          proposer: 0, // owner_id of the owner who signs
          newOwner,
        });
        const tx = await waitForTransaction(await submitThroughPaymaster([call]));
        if (!tx.success) throw new Error(tx.revertReason ?? "propose_add_owner reverted");
        const [op] = parseOpProposedEvents(tx.events, walletAddress);
        if (!op) throw new Error("No OpProposed event in the receipt");
        return { opId: op.opId, validAfter: op.validAfter };
      }}
      onExecute={async (opId) => {
        const call = recovery.buildExecuteAddOwner({ walletAddress, opId, newOwner });
        const tx = await waitForTransaction(await submitThroughPaymaster([call]));
        if (!tx.success) throw new Error(tx.revertReason ?? "execute_add_owner reverted");
      }}
    />
  );
}
```

`newOwner` must be the same in the propose and the execute: the wallet compares the payload hash.

## Flow 2 — Guardian recovery (user lost their key)

Use when the user can no longer sign with any existing owner. A pre-registered guardian (a wallet the user already has, a second device, or a "Guardian-as-a-Service" provider) starts the recovery on the user's behalf.

**Stages:**

1. **Initiate** — the **guardian** (not the lost owner) signs an outside execution whose only call is `initiate_recovery`, with `proposer` set to the guardian's own owner id. Anyone can relay that signed OE. The wallet stores the new owner's commitment and starts a 7-day timelock. Only one recovery can be pending at a time.
2. **Wait** — 7 days. During this window any current owner that still has its key (role `OWNER`, not revoked) can `cancel_recovery`. This is the safety valve against a malicious guardian. If no owner key survives, nobody can cancel: the 7 days are only a delay.
3. **Finalize** — after 7 days, anyone can submit `finalize_recovery` with the same new-owner arguments. The wallet **adds** that owner. It removes nobody and leaves the threshold as it is.

A pending recovery has no expiry and no `op_id`. Read it with `readPendingRecovery`.

```tsx theme={null}
import type { Call } from "starknet";
import { RpcProvider } from "starknet";
import { Recover, useGuardianRecovery } from "@chipi-stack/chipi-react";
import {
  readPendingRecovery,
  waitForTransaction,
  type NewOwnerInput,
} from "@chipi-stack/backend";

// The guardian signs an OE with exactly this one call; anyone relays it.
declare function submitGuardianSigned(calls: Call[]): Promise<string>;
// Any submit path: finalize_recovery needs no signature.
declare function submitThroughPaymaster(calls: Call[]): Promise<string>;

const provider = new RpcProvider({ nodeUrl: "https://api.cartridge.gg/x/starknet/mainnet" });

function RecoverWallet(props: {
  walletAddress: string;
  guardianOwnerId: number;
  newOwner: NewOwnerInput;
  pendingOp: { opId: bigint; validAfter: number } | null;
}) {
  const { walletAddress, guardianOwnerId, newOwner } = props;
  const recovery = useGuardianRecovery();

  return (
    <Recover
      mode="guardian-recovery"
      walletAddress={walletAddress}
      newOwner={newOwner}
      pendingOp={props.pendingOp}
      onPropose={async () => {
        const call = recovery.buildInitiateRecovery({
          walletAddress,
          proposer: guardianOwnerId, // must be the signing guardian's own owner_id
          newOwner,
        });
        const tx = await waitForTransaction(await submitGuardianSigned([call]));
        if (!tx.success) throw new Error(tx.revertReason ?? "initiate_recovery reverted");
        const pending = await readPendingRecovery(provider, walletAddress);
        // Recovery has no op_id; the component only needs a stable key for it.
        return { opId: pending.newOwnerHash, validAfter: pending.validAfter };
      }}
      onExecute={async () => {
        const call = recovery.buildFinalizeRecovery({ walletAddress, newOwner });
        const tx = await waitForTransaction(await submitThroughPaymaster([call]));
        if (!tx.success) throw new Error(tx.revertReason ?? "finalize_recovery reverted");
      }}
    />
  );
}
```

After finalize, the lost owners are still in the owner set. Remove them with `propose_remove_owner` (24h) from the recovered device.

The new owner must be of a kind the wallet already verifies. `finalize_recovery` checks the new key through that kind's verifier class, and a user who lost every key can no longer add a verifier class. Recover onto the wallet's own kind, or onto a kind registered while an owner could still sign.

### Bramble as the guardian

A wallet the user already has can be the guardian: Bramble, Braavos, Ready or another Chipi wallet, registered with the `STARK_SNIP12` kind. Its key never leaves that wallet. It signs the recovery from a click, and Chipi's paymaster relays it:

```ts theme={null}
import { useExternalSignerExecute } from "@chipi-stack/chipi-react";
import { buildInitiateRecoveryCall, type NewOwnerInput } from "@chipi-stack/backend";

declare const bramble: { request(call: { type: string; params?: unknown }): Promise<unknown> };

export function useStartRecoveryWithBramble() {
  const { executeAsync } = useExternalSignerExecute();

  // Call from a click: Bramble opens its panel only during a user gesture.
  return (args: {
    walletAddress: string;
    guardianOwnerId: number; // Bramble's index in the owner set (readShhhOwners)
    newOwner: NewOwnerInput;
    bearerToken: string;
  }) =>
    executeAsync({
      params: {
        walletAddress: args.walletAddress,
        ownerIndex: args.guardianOwnerId,
        // Exactly one call, with proposer = the guardian's own owner id.
        calls: [
          buildInitiateRecoveryCall({
            walletAddress: args.walletAddress,
            proposer: args.guardianOwnerId,
            newOwner: args.newOwner,
          }),
        ],
        signTypedData: async (typedData) =>
          (await bramble.request({ type: "wallet_signTypedData", params: typedData })) as string[],
      },
      bearerToken: args.bearerToken,
    }); // resolves to the tx hash: wait for it before showing the countdown
}
```

> [!NOTE]
>
> The `STARK_SNIP12` verifier class is declared on mainnet (2026-10-14). See [Trusted wallets](/sdk/guides/trusted-wallets) for the full Bramble setup.

## New signer kinds need a verifier class

A SHHH wallet verifies only the kinds in its own verifier-class map. Its constructor registers one: the kind of its first owner (`STARK` for a Chipi passkey wallet). To add an owner or guardian of another kind (`STARK_SNIP12` for Bramble, `WEBAUTHN_P256` for a device passkey, `EIP191_SECP256K1` for MetaMask, and so on), the wallet needs that kind's verifier class first:

1. Check with `readVerifierClass(provider, walletAddress, kind)`. `0n` means the kind is not registered. For a kind new on the network, also check `isShhhVerifierDeclared`.
2. An owner signs one OE with `buildProposeAddVerifierClassCall` and `buildProposeAddOwnerCall` (both 48h).
3. After 48h, submit `buildExecuteAddVerifierClassCall` **before** `buildExecuteAddOwnerCall`. `execute_add_owner` checks the new key through the verifier and reverts with `'SHHH: verifier missing'` otherwise.

Both proposals emit an `OpProposed` event; `parseOpProposedEvents(tx.events, walletAddress)` returns both, with their `opKind` (`ADD_VERIFIER`, `ADD_OWNER`).

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

declare const provider: StarknetCallProvider;
declare const walletAddress: string;
declare const newOwner: NewOwnerInput; // kind: "WEBAUTHN_P256"

const calls = [buildProposeAddOwnerCall({ walletAddress, proposer: 0, newOwner })];
if ((await readVerifierClass(provider, walletAddress, "WEBAUTHN_P256")) === 0n) {
  calls.unshift(
    buildProposeAddVerifierClassCall({ walletAddress, proposer: 0, kind: "WEBAUTHN_P256" })
  );
}
// An owner signs one OE with `calls`.
```

## Cancel safety valve

Within either timelock window, an owner can cancel. The two flows use different entrypoints:

* **Add-device** uses `cancel_pending_op(op_id)`. It cancels any pending governance op (`ADD_OWNER`, `REMOVE_OWNER`, `ROTATE_OWNER`, `SET_THRESHOLD`, `ADD_VERIFIER`, `REMOVE_VERIFIER`), signed by an owner.
* **Guardian recovery** uses `cancel_recovery(owner_id)`. `owner_id` is the id of the **owner who cancels**, not of an owner being replaced: the wallet checks that it is a non-revoked owner with role `OWNER`. Pass the signing owner's own id. There is only one pending recovery, so no op id is needed.

A guardian can't cancel a recovery, its own included. Only an owner can.

This is the protection against a guardian going rogue (during recovery) or a stolen device proposing an attacker as a new owner. `<Recover />` shows a Cancel button when you pass an `onCancel` callback; pick the matching builder for the mode:

```tsx theme={null}
import type { Call } from "starknet";
import { useGuardianRecovery } from "@chipi-stack/chipi-react";
import { waitForTransaction } from "@chipi-stack/backend";

declare function submitThroughPaymaster(calls: Call[]): Promise<string>;

function useCancelHandlers(walletAddress: string, signingOwnerId: number) {
  const recovery = useGuardianRecovery();
  return {
    // <Recover mode="add-device" onCancel={cancelAddDevice} />
    cancelAddDevice: async (opId: bigint | string) => {
      const call = recovery.buildCancelPendingOp({ walletAddress, opId });
      await waitForTransaction(await submitThroughPaymaster([call]));
    },
    // <Recover mode="guardian-recovery" onCancel={cancelRecovery} />
    cancelRecovery: async () => {
      const call = recovery.buildCancelRecovery({
        walletAddress,
        ownerId: signingOwnerId, // the owner signing this cancel (role OWNER), not a lost owner
      });
      await waitForTransaction(await submitThroughPaymaster([call]));
    },
  };
}
```

## Timelock constants

| Op | Timelock | Expiry | Why |
| - | - | - | - |
| `ADD_OWNER` | 48h | 14d after the timelock ends | Time for the owner to notice an unexpected add-device proposal. Guardians are added this way too. |
| `REMOVE_OWNER` | 24h | 14d after the timelock ends | Tighter: removal is reversible by adding back |
| `ROTATE_OWNER` | 24h | 14d after the timelock ends | |
| `SET_THRESHOLD` | 48h | 14d after the timelock ends | Threshold changes are governance-grade |
| `ADD_VERIFIER` | 48h | 14d after the timelock ends | A new signer kind is a new way to sign |
| `REMOVE_VERIFIER` | 24h | 14d after the timelock ends | The wallet's own first kind can't be removed |
| Recovery | 7d | None | Longest window in the system: the user must have a meaningful chance to cancel |

The expiry is measured from `valid_after`: `expires_at = proposed_at + timelock + 14d`. A 48h op can be executed from day 2 to day 16 after the propose. `parseOpProposedEvents` returns the exact `validAfter` and `expiresAt`.

These constants are re-exported on the hook return for UI display: `useGuardianRecovery().TIMELOCK_SECONDS` and `.DEFAULT_OP_EXPIRY_SECONDS`. `TIMELOCK_SECONDS` also lists `ADD_GUARDIAN` and `REMOVE_GUARDIAN` (24h); the wallet has no separate guardian ops, so a guardian is added with `propose_add_owner` (48h) and removed with `propose_remove_owner` (24h).

## Who acts as guardian?

Patterns integrators ship:

1. **A wallet the user already has** — Bramble, Braavos, Ready or another Chipi wallet, as a `STARK_SNIP12` guardian. See [Trusted wallets](/sdk/guides/trusted-wallets).
2. **Second wallet held by the user** — a second device or a paper-backup-rooted wallet. Simplest; no third party.
3. **Email-rooted WebAuthn credential** — register a passkey against the user's email-bound device at onboarding; that credential is the guardian. The user doesn't need a second wallet day-one.
4. **Guardian-as-a-Service** — a paid third party (could be Chipi, could be a partner) holds a guardian key, with a published policy on when they sign recoveries (e.g., on a 48h email + 2FA confirmation). This is on our roadmap; the productization design is internal-only for now.

`role: "GUARDIAN"` is set when the guardian is added. A guardian can sign only an OE whose single call is `initiate_recovery` with `proposer` equal to its own owner id. It can't sign transactions, move funds, or cancel a recovery.

## What does NOT recover

Recovery adds one owner to the wallet's **owner set**. It does NOT:

* Remove the lost owners (follow up with `propose_remove_owner`, 24h)
* Change the threshold
* Reset session keys (those remain valid until their own `valid_until`)
* Re-create CHIPI v29 STARK key encryption (those wallets don't have on-chain recovery at all — see [migration](/services/gasless/migration) to move to SHHH first)
* Touch USDC balances or any other on-chain state — recovery adds a key, it is not a state rollback

## Change a PIN (any wallet type)

The two on-chain flows above handle "user can't sign at all." A simpler flow handles "user remembers their PIN but wants to change it" — for example, they want to upgrade to a passkey, or they suspect their PIN was shoulder-surfed.

This is **client-side re-encryption** of the same private key, then one backend call to store the new ciphertext. The wallet's address never changes.

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

declare const sdk: ChipiServerSDK;
declare const externalUserId: string;
declare const bearerToken: string;
// The same private key, re-encrypted with the new PIN or passkey-derived key.
// You produce this ciphertext: the SDK's encryption helpers are not exported
// from the package entry points.
declare const newEncryptedPrivateKey: string;

await sdk.wallets.updateWalletEncryption(
  {
    externalUserId,
    newEncryptedPrivateKey,
    // publicKey?: only needed when the user has more than one wallet
  },
  bearerToken
);
```

The on-chain account is untouched: same address, same balances, same session keys. Only the encryption around the private key changes. This flow can't help a user who forgot the old PIN (the key has to be decrypted first); for that case, use the guardian recovery flow above.

For migrating a PIN-only wallet to a passkey, see the [`useMigrateWalletToPasskey`](/sdk/react/hooks/use-migrate-wallet-to-passkey) hook which wraps this same flow with the WebAuthn ceremony.

## Related

* [Add a second device](/services/gasless/threshold) — proactively register a backup device before the user needs to recover
* [Upgrade existing wallets](/services/gasless/migration) — get a CHIPI v29 wallet onto SHHH first to use the on-chain recovery flows
* [When passkeys fail](/sdk/passkeys/when-passkeys-fail) — the matching browser-side playbook for passkey failure modes


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