Skip to main content

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.

Available without advertisement until external audit closes. Both flows are on-chain since 2026-05-26 and have shipped mainnet smokes — see task #45 (recovery) and scripts/receipts/.

The problem

Pre-SHHH wallets (CHIPI v29) had no recovery primitive — a lost PIN or a broken passkey meant the wallet’s USDC was unreachable. SHHH V8.4 closes this with two timelocked flows that route through the on-chain account itself; no off-chain custodian, no Chipi-side key extraction.

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
  • Signer kinds — what kinds of owners you can add
  • Threshold — recovery composes with N-of-M for stronger governance
  • Migration — get a CHIPI v29 wallet onto SHHH first to use these flows