Skip to main content
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.
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/.

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.
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.
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:
The STARK_SNIP12 verifier class is declared on mainnet (2026-10-14). See 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).

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:

Timelock constants

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.
  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 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.
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 hook which wraps this same flow with the WebAuthn ceremony.