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 recovery contact authorizes the rotation onto a fresh device.
  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 V8.4 wallet; 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 — the existing owner signs an outside execution containing propose_add_owner. The on-chain wallet records a pending op tagged with OP_ADD_OWNER and starts a 48-hour timelock.
  2. Wait — the SDK exposes useGuardianRecovery().isReady(validAfter) and secondsRemaining(validAfter) to drive a countdown UI. The pending op state is read from chain — the SDK doesn’t poll on your behalf.
  3. Execute — after 48h, ANY OWNER (or anyone if the wallet is configured as such) calls execute_add_owner with the matching op_id. The wallet re-derives the proposed payload and asserts equality before mutating owner_set.
import { Recover, useGuardianRecovery } from "@chipi-stack/chipi-react";

function AddDevice({ wallet, newOwnerPubkey }) {
  const recovery = useGuardianRecovery();
  // pendingOp comes from your chain-read transport (multicall, indexer, RPC).
  const pendingOp = useChainState(wallet.address);

  return (
    <Recover
      mode="add-device"
      walletAddress={wallet.address}
      newOwner={{ kind: "STARK", pubkeyBytes: [newOwnerPubkey], role: "OWNER", weight: 1, label: "laptop" }}
      pendingOp={pendingOp}
      onPropose={async () => {
        const call = recovery.buildProposeAddOwner({
          walletAddress: wallet.address,
          proposer: 0,
          newOwner: { kind: "STARK", pubkeyBytes: [newOwnerPubkey], role: "OWNER", weight: 1, label: "laptop" },
        });
        const txHash = await submitThroughPaymaster(call);
        return { opId, validAfter };
      }}
      onExecute={async (opId) => {
        const call = recovery.buildExecuteAddOwner({
          walletAddress: wallet.address,
          opId,
          newOwner: { kind: "STARK", pubkeyBytes: [newOwnerPubkey], role: "OWNER", weight: 1, label: "laptop" },
        });
        await submitThroughPaymaster(call);
      }}
    />
  );
}

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 second user, a recovery email’s WebAuthn credential, or a paid “Guardian-as-a-Service” provider) initiates recovery on the user’s behalf. Stages:
  1. Initiate — the guardian (NOT the lost owner) signs an outside execution calling initiate_recovery. The wallet starts a 7-day timelock.
  2. Wait — 7 days. During this window the original owner can cancel_recovery if they regain access — this is the safety valve against a malicious guardian.
  3. Finalize — after 7d, anyone can submit finalize_recovery. The wallet rotates owner_set to the new owner specified in step 1.
<Recover
  mode="guardian-recovery"
  walletAddress={lostWallet.address}
  newOwner={newOwnerFromRecoveryFlow}
  pendingOp={pendingOp}
  onPropose={async () => {
    const call = recovery.buildInitiateRecovery({
      walletAddress: lostWallet.address,
      proposer: guardianOwnerId, // owner_id of the guardian signing
      newOwner: newOwnerFromRecoveryFlow,
    });
    // Guardian signs this OE, paymaster relays.
    const { opId, validAfter } = await submitGuardianSigned(call);
    return { opId, validAfter };
  }}
  onExecute={async (opId) => {
    const call = recovery.buildFinalizeRecovery({
      walletAddress: lostWallet.address,
      newOwner: newOwnerFromRecoveryFlow,
    });
    await submitThroughPaymaster(call);
  }}
/>

Cancel safety valve

Within either timelock window, any current owner can cancel the pending op. The two flows take different on-chain selectors:
  • Add-device uses cancel_pending_op(op_id) (matches any pending OP_ADD_OWNER / OP_REMOVE_OWNER / OP_ROTATE_OWNER / OP_SET_THRESHOLD).
  • Guardian recovery uses cancel_recovery(owner_id) — keyed by which owner is being replaced, not by an op_id, because recovery operates on the owner set directly rather than through the pending-op queue.
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 automatically when you pass an onCancel callback; pick the matching builder for the mode:
// Add-device:
<Recover
  mode="add-device"
  // …other props…
  onCancel={async (opId) => {
    const call = recovery.buildCancelPendingOp({
      walletAddress: wallet.address,
      opId,
    });
    await submitThroughPaymaster(call);
  }}
/>

// Guardian recovery:
<Recover
  mode="guardian-recovery"
  // …other props…
  onCancel={async () => {
    const call = recovery.buildCancelRecovery({
      walletAddress: lostWallet.address,
      ownerId: ownerBeingReplaced, // not an op_id
    });
    await submitThroughPaymaster(call);
  }}
/>

Timelock constants

OpTimelockDefault expiryWhy
OP_ADD_OWNER48h14dTime for the owner to notice an unexpected add-device proposal
OP_REMOVE_OWNER24h14dSame audit window but tighter — removal is reversible by adding back
OP_ROTATE_OWNER24h14d
OP_SET_THRESHOLD48h14dThreshold changes are governance-grade
RECOVERY7dn/aLongest window in the system — the user must have a meaningful chance to cancel
These constants are re-exported on the hook return for UI display: useGuardianRecovery().TIMELOCK_SECONDS and .DEFAULT_OP_EXPIRY_SECONDS.

Who acts as guardian?

Three patterns we’ve seen integrators ship:
  1. Second wallet held by the user — a second device or a paper-backup-rooted wallet. Simplest; no third party.
  2. 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.
  3. 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 at owner-add time; a guardian cannot initiate or sign transactions like an owner, only call initiate_recovery (and cancel_recovery if they want to withdraw).

What does NOT recover

Recovery rotates the wallet’s owner set. It does NOT:
  • 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 is a key rotation, 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.
import { ChipiServerSDK } from "@chipi-stack/backend";

// 1. Decrypt the existing private key with the CURRENT PIN (client-side).
//    The SDK exposes the decryption helper used by all signing flows.
// 2. Re-encrypt the same private key with the NEW key (PIN, passkey-derived,
//    or any other encryption material).
// 3. Push the new ciphertext via the wallets sub-client.
await sdk.wallets.updateWalletEncryption(
  {
    externalUserId,
    newEncryptedPrivateKey,
    // publicKey?: optional, only needed when the SDK can't infer the wallet
  },
  bearerToken,
);
The on-chain account is untouched — same address, same balances, same session keys. Only the encryption wrapper around the private key rotates. This flow CANNOT recover a user who forgot the old PIN (they need step 1’s ciphertext to decrypt); 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.