Skip to main content

Overview

P2PK (Pay-to-Public-Key) locking allows you to lock ecash tokens so that only the holder of a specific private key can redeem them. This provides an additional layer of security for ecash transfers.

Key Management

Sovran includes a built-in keyring for managing P2PK keys:

Key Types

Derived Keys:
app/settings-pages/keyring.tsx
// Generated from your wallet's mnemonic
const keypair = await manager.keyring.generateKeyPair();
Imported Keys:
app/settings-pages/keyring.tsx
// Import from nsec (Nostr) or raw hex
if (input.startsWith('nsec1')) {
  const decoded = nip19.decode(input);
  if (decoded.type === 'nsec') {
    await manager.keyring.addKeyPair(decoded.data as Uint8Array);
  }
}

Keyring Storage

Keys are stored securely and managed through the keyring API:
app/settings-pages/keyring.tsx
const loadKeypairs = useCallback(async () => {
  if (!manager) return;

  try {
    setIsLoading(true);
    const allKeys = await manager.keyring.getAllKeyPairs();
    setKeypairs(allKeys);
  } catch (error) {
    console.error('Failed to load keypairs:', error);
    keysLoadFailedPopup();
  } finally {
    setIsLoading(false);
  }
}, [manager]);

Quick Access

Enable quick access to show your latest P2PK key in the receive menu:
stores/settingsStore.ts
interface SettingsState {
  quickAccessP2PK: boolean;
  regenerateP2PKOnReceive: boolean;
  // ...
}

setQuickAccessP2PK: (enabled: boolean) => set({ quickAccessP2PK: enabled }),
getQuickAccessP2PK: () => get().quickAccessP2PK,

Receive Screen Integration

components/screens/ReceiveScreen.tsx
// Load latest keypair when P2PK quick access is enabled
useEffect(() => {
  const loadLatestKeypair = async () => {
    if (!manager || !quickAccessP2PK) return;
    try {
      const latest = await manager.keyring.getLatestKeyPair();
      setLatestKeypair(latest);
    } catch (error) {
      console.error('Failed to load latest keypair:', error);
    }
  };
  loadLatestKeypair();
}, [manager, quickAccessP2PK]);

// Build tabs array based on settings
const tabs = quickAccessP2PK ? ['Lightning', 'P2PK'] : ['Lightning'];

P2PK Tab

When quick access is enabled, a P2PK tab appears in the receive screen:
components/screens/ReceiveScreen.tsx
const renderP2PKContent = () => (
  <>
    {latestKeypair ? (
      <>
        <PaymentInfo data={latestKeypair.publicKeyHex} copyTarget="p2pk" unit="p2pk" />
        <View style={{ marginHorizontal: 16 }}>
          <Section title="P2PK PUBLIC KEY">
            <ListGroup variant="secondary">
              <PressableFeedback animation={false} onPress={handleCopyP2PKKey}>
                <PressableFeedback.Scale>
                  <ListGroup.Item disabled>
                    <ListGroup.ItemPrefix>
                      <Icon name="solar:key-bold" size={20} color={opacity(foreground, 0.4)} />
                    </ListGroup.ItemPrefix>
                    <ListGroup.ItemContent>
                      <ListGroup.ItemTitle>
                        {truncateMiddle(latestKeypair.publicKeyHex, 10)}
                      </ListGroup.ItemTitle>
                    </ListGroup.ItemContent>
                    <ListGroup.ItemSuffix>
                      <Icon name="lets-icons:copy" size={20} color={opacity(foreground, 0.4)} />
                    </ListGroup.ItemSuffix>
                  </ListGroup.Item>
                </PressableFeedback.Scale>
                <PressableFeedback.Ripple />
              </PressableFeedback>
            </ListGroup>
          </Section>
        </View>
      </>
    ) : (
      <View style={{ marginHorizontal: 16, marginTop: 32 }}>
        <View
          style={{
            backgroundColor: surfaceSecondary,
            borderRadius: 12,
            padding: 24,
            alignItems: 'center',
          }}>
          <Icon name="mdi:key-variant" size={48} color={opacity(foreground, 0.25)} />
          <Text
            size={14}
            style={{
              color: opacity(foreground, 0.4),
              marginTop: 12,
              textAlign: 'center',
            }}>
            No P2PK keys yet. Generate one in SettingsP2PK Keys.
          </Text>
        </View>
      </View>
    )}
  </>
);

Auto-regeneration

Automatically generate a new P2PK key after redeeming locked tokens for improved privacy:
stores/settingsStore.ts
setRegenerateP2PKOnReceive: (enabled: boolean) => set({ regenerateP2PKOnReceive: enabled }),
getRegenerateP2PKOnReceive: () => get().regenerateP2PKOnReceive,
Default: Enabled (recommended for privacy)

Key Display Formats

P2PK Format

Derived keys are displayed as P2PK hex:
app/settings-pages/keyring.tsx
const displayKey = keypair.publicKeyHex;

NPUB Format

Imported keys can be displayed as Nostr npub:
app/settings-pages/keyring.tsx
const npubValue = !isDerived
  ? nip19.npubEncode(keypair.publicKeyHex.replace(/^02/, ''))
  : undefined;

Current Key Display

app/settings-pages/keyring.tsx
const CurrentKeyItem: React.FC<{
  keypair: Keypair;
  onCopy: (publicKey: string) => void;
}> = ({ keypair, onCopy }) => {
  const [selectedTab, setSelectedTab] = useState('P2PK');

  const isDerived = keypair.derivationIndex !== undefined;

  const npubValue = !isDerived
    ? nip19.npubEncode(keypair.publicKeyHex.replace(/^02/, ''))
    : undefined;

  const isNpubTab = selectedTab === 'NPUB' && !isDerived;
  const activeData = isNpubTab ? npubValue! : keypair.publicKeyHex;
  const displayKey = isDerived ? keypair.publicKeyHex : activeData;

  return (
    <View className="p-4">
      {!isDerived && (
        <View className="mb-4">
          <Tabs tabs={['P2PK', 'NPUB']} selectedTab={selectedTab} handleTabPress={handleTabPress} />
        </View>
      )}

      <PressableFeedback
        onPress={handleShowQR}
        className="mb-4 self-center overflow-hidden rounded-xl">
        <PressableFeedback.Highlight />
        <View
          style={{
            alignItems: 'center',
            padding: 12,
            backgroundColor: foreground,
            borderRadius: 12,
          }}>
          <QRCode value={activeData} size={120} color={surface} backgroundColor={foreground} />
        </View>
      </PressableFeedback>

      <HStack align="center" spacing={8} className="mb-3.5 flex-wrap gap-y-2">
        <Badge variant="success" icon="solar:key-bold" size={11}>
          ACTIVE
        </Badge>
        {!isDerived && (
          <Badge variant="primary" icon={isNpubTab ? 'ph:user-bold' : 'solar:key-bold'} size={11}>
            {isNpubTab ? 'NPUB' : 'P2PK'}
          </Badge>
        )}
        {isDerived && (
          <Badge variant="primary" icon="mdi:key-arrow-right" size={11}>
            DERIVED {keypair.derivationIndex}
          </Badge>
        )}
      </HStack>

      <View className="bg-surface rounded-xl px-3.5 py-3">
        <Text size={12} className="text-foreground">
          {displayKey}
        </Text>
      </View>

      <HStack spacing={10} className="mt-3.5">
        <Button variant="secondary" className="flex-1" onPress={handleCopy}>
          <Icon name="lets-icons:copy" size={16} color={muted} />
          <Button.Label style={{ color: muted }}>Copy</Button.Label>
        </Button>
        <Button variant="secondary" className="flex-1" onPress={handleShowQR}>
          <Icon name="stash:qr-code" size={16} color={muted} />
          <Button.Label style={{ color: muted }}>Show QR</Button.Label>
        </Button>
      </HStack>
    </View>
  );
};

Key Import

Supported Formats

Sovran supports importing keys in multiple formats:
app/settings-pages/keyring.tsx
const tryImportKey = async (input: string): Promise<boolean> => {
  if (!manager) return false;

  // Strategy 1: Try as nsec
  if (input.startsWith('nsec1')) {
    try {
      const decoded = nip19.decode(input);
      if (decoded.type === 'nsec') {
        await manager.keyring.addKeyPair(decoded.data as Uint8Array);
        return true;
      }
    } catch {}
  }

  // Strategy 2: Try as raw 64-char hex
  const rawBytes = hexToBytes(input);
  if (rawBytes) {
    try {
      await manager.keyring.addKeyPair(rawBytes);
      return true;
    } catch {}
  }

  return false;
};

const hexToBytes = (hex: string): Uint8Array | null => {
  if (hex.length !== 64 || !/^[0-9a-fA-F]+$/.test(hex)) {
    return null;
  }
  const bytes = new Uint8Array(32);
  for (let i = 0; i < 32; i++) {
    bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
  }
  return bytes;
};

Import Flow

app/settings-pages/keyring.tsx
const handleImportNsec = () => {
  Alert.prompt(
    'Import Private Key',
    'Enter your nsec or hex private key',
    [
      { text: 'Cancel', style: 'cancel' },
      {
        text: 'Import',
        onPress: async (value: string | undefined) => {
          if (!value || !manager) return;

          try {
            const trimmedValue = value.trim();
            const success = await tryImportKey(trimmedValue);

            if (success) {
              keyImportedPopup();
              await loadKeypairs();
            } else {
              invalidKeyFormatPopup();
            }
          } catch (error) {
            console.error('Failed to import key:', error);
            keyImportFailedPopup();
          }
        },
      },
    ],
    'plain-text'
  );
};

Key Generation

app/settings-pages/keyring.tsx
const handleGenerateKey = async () => {
  if (!manager) return;

  try {
    setIsGenerating(true);
    await manager.keyring.generateKeyPair();
    keyGeneratedPopup();
    await loadKeypairs();
  } catch (error) {
    console.error('Failed to generate keypair:', error);
    keyGenerateFailedPopup();
  } finally {
    setIsGenerating(false);
  }
};

Settings

Preferences

app/settings-pages/keyring.tsx
<Section title="Preferences">
  <ListGroup variant="secondary">
    <ListGroup.Item>
      <ListGroup.ItemContent>
        <ListGroup.ItemTitle>Quick Access to Lock</ListGroup.ItemTitle>
        <ListGroup.ItemDescription>
          Show your latest P2PK locking key in the receive ecash menu
        </ListGroup.ItemDescription>
      </ListGroup.ItemContent>
      <ListGroup.ItemSuffix>
        <HeroSwitch
          isSelected={quickAccessP2PK ?? false}
          onSelectedChange={setQuickAccessP2PK}
        />
      </ListGroup.ItemSuffix>
    </ListGroup.Item>
    <Separator className="mx-4" />
    <ListGroup.Item>
      <ListGroup.ItemContent>
        <ListGroup.ItemTitle>Regenerate Key on Receive</ListGroup.ItemTitle>
        <ListGroup.ItemDescription>
          Automatically generate a new P2PK key after redeeming a locked token for improved
          privacy
        </ListGroup.ItemDescription>
      </ListGroup.ItemContent>
      <ListGroup.ItemSuffix>
        <HeroSwitch
          isSelected={regenerateP2PKOnReceive ?? true}
          onSelectedChange={setRegenerateP2PKOnReceive}
        />
      </ListGroup.ItemSuffix>
    </ListGroup.Item>
  </ListGroup>
</Section>

Use Cases

Locked Transfers

Send ecash tokens that can only be redeemed by a specific recipient:
  1. Obtain recipient’s P2PK public key
  2. Create send token with P2PK lock
  3. Only the recipient with matching private key can claim

Personal Recovery

Lock tokens to your own P2PK key for additional security:
  1. Generate or import a P2PK key
  2. Lock tokens to your public key
  3. Keep private key secure as backup recovery method

Nostr Integration

Use Nostr keys (nsec/npub) for P2PK locking:
  1. Import your Nostr nsec
  2. Share your npub as receiving address
  3. Tokens locked to your Nostr identity

Build docs developers (and LLMs) love