Skip to content

read only mode for cold wallets#778

Open
dkackman wants to merge 4 commits intoxch-dev:mainfrom
dkackman:readonly-ui
Open

read only mode for cold wallets#778
dkackman wants to merge 4 commits intoxch-dev:mainfrom
dkackman:readonly-ui

Conversation

@dkackman
Copy link
Copy Markdown
Collaborator

@dkackman dkackman commented Apr 5, 2026

Cold Wallet Read-Only UI

Overview

When a wallet is "cold" (no private keys — KeyInfo.has_secrets === false), the UI reflects that no transactions can be made. The design keeps read-only logic centralized: one derived boolean in context, one banner component, one button wrapper used at each write-action site.

Detection

KeyInfo.has_secrets: boolean is returned by the backend for every key. false = cold (no secret key stored), true = hot. This is the single source of truth.

State & Context

isReadOnly is a derived boolean added to WalletContext. No new context, no new store.

// src/contexts/WalletContext.tsx
interface WalletContextType {
  wallet: KeyInfo | null;
  isReadOnly: boolean; // derived: wallet !== null && !wallet.has_secrets
  setWallet: (wallet: KeyInfo | null) => void;
  isSwitching: boolean;
  setIsSwitching: (isSwitching: boolean) => void;
}

// In WalletProvider:
const isReadOnly = wallet !== null && wallet.has_secrets === false;

Global Indicator — ReadOnlyBanner

A thin bar rendered once in WalletTransitionWrapper in Layout.tsx, above {props.children}. Uses semantic theme classes (bg-muted, text-muted-foreground) so it adapts to any active theme. Includes aria-live="polite" so screen readers announce it on wallet switch.

// src/components/ReadOnlyBanner.tsx
export function ReadOnlyBanner() {
  const { isReadOnly } = useWallet();
  if (!isReadOnly) return null;

  return (
    <div
      className='flex items-center gap-2 px-4 py-1.5 text-xs bg-muted border-b text-muted-foreground'
      role='status'
      aria-label='Read-only wallet'
      aria-live='polite'
      aria-atomic='true'
    >
      <EyeIcon className='h-3 w-3 flex-shrink-0' aria-hidden='true' />
      <span>
        <Trans>Read-only wallet — transactions require private keys</Trans>
      </span>
    </div>
  );
}

Write-Action Gating — ReadOnlyButton

A drop-in replacement for Button that reads isReadOnly internally. When true, wraps a disabled button in a <span> + Tooltip (the span is required because native disabled elements suppress pointer events, which prevents the Radix tooltip from firing). When false, renders a normal Button.

// src/components/ReadOnlyButton.tsx
export function ReadOnlyButton({ children, onClick, ...props }: ButtonProps) {
  const { isReadOnly } = useWallet();

  if (isReadOnly) {
    return (
      <Tooltip>
        <TooltipTrigger asChild>
          <span className='inline-flex cursor-not-allowed'>
            <Button disabled {...props} className={cn(props.className, 'pointer-events-none')}>
              {children}
            </Button>
          </span>
        </TooltipTrigger>
        <TooltipContent>
          <Trans>Not available for read-only wallets</Trans>
        </TooltipContent>
      </Tooltip>
    );
  }

  return (
    <Button onClick={onClick} {...props}>
      {children}
    </Button>
  );
}

For DropdownMenuItem entries, use disabled={isReadOnly || ...} directly — Radix already renders these greyed-out with no extra wrapper needed.

For buttons that already have their own disabled condition (Swap, Split, Combine, etc.), prepend isReadOnly || as the first condition rather than using ReadOnlyButton, so the existing disabled logic is preserved independently.

Call Sites

ReadOnlyButton (standalone write-action buttons)

Component Action
TokenCard Send
TokenList Issue Token
NftList Mint NFT
DidList Create Profile
Offers Create Offer
OptionList Mint Option

disabled={isReadOnly \|\| ...} (buttons with existing disabled conditions)

Component Action
Swap Swap
OwnedCoinsCard Split, Combine/Sweep
ClawbackCoinsCard Claw Back, Finalize

disabled={isReadOnly \|\| ...} on DropdownMenuItem

Component Actions
NftCard Transfer, Assign/Edit Profile, Add URL, Burn, Add to Offer
DidList (Profile sub-component) Transfer, Normalize, Burn

Page Reachability

Write-action pages (Send, MintNft, MintOption, MakeOffer, CreateProfile, etc.) are only reachable via their triggering buttons, which are disabled for cold wallets. No route guards or redirects are needed.

Actions Remaining Fully Enabled for Cold Wallets

  • Receive / QR code / Copy address
  • Viewing transactions, addresses, NFTs, offers, options
  • Wallet rename, emoji, resync, delete (local metadata — not on-chain)
  • NFT visibility toggle, hide/show asset (local preference — not on-chain)
  • Unhardened derivation index increase (valid for cold wallets — derives addresses from public key)

Non-Goals

  • No route guards or redirects for write-action pages (wallet will error so this is not a bypass)
  • No changes to the login/wallet-selection screen (already shows Hot/Cold badge on WalletCard)
  • Hardened derivation is already gated by key?.has_secrets && hardened in the existing backend call — no UI change needed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant