diff --git a/artifacts/mobile/app.json b/artifacts/mobile/app.json
index 0653863..aa91142 100644
--- a/artifacts/mobile/app.json
+++ b/artifacts/mobile/app.json
@@ -20,7 +20,18 @@
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#0A0A12"
- }
+ },
+ "intentFilters": [
+ {
+ "action": "VIEW",
+ "autoVerify": true,
+ "data": [{ "scheme": "mobile", "host": "amber-callback" }],
+ "category": ["BROWSABLE", "DEFAULT"]
+ }
+ ],
+ "queries": [
+ { "package": "com.greenart7c3.nostrsigner" }
+ ]
},
"web": {
"favicon": "./assets/images/icon.png",
@@ -34,6 +45,7 @@
}
],
"expo-font",
+ "expo-secure-store",
"expo-web-browser"
],
"extra": {
diff --git a/artifacts/mobile/app/(tabs)/_layout.tsx b/artifacts/mobile/app/(tabs)/_layout.tsx
index 0b6061f..c4b9b74 100644
--- a/artifacts/mobile/app/(tabs)/_layout.tsx
+++ b/artifacts/mobile/app/(tabs)/_layout.tsx
@@ -25,6 +25,10 @@ function NativeTabLayout() {
+
+
+
+
);
}
@@ -100,6 +104,18 @@ function ClassicTabLayout() {
),
}}
/>
+
+ isIOS ? (
+
+ ) : (
+
+ ),
+ }}
+ />
);
}
diff --git a/artifacts/mobile/app/(tabs)/settings.tsx b/artifacts/mobile/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..c6b0d1a
--- /dev/null
+++ b/artifacts/mobile/app/(tabs)/settings.tsx
@@ -0,0 +1,513 @@
+import { Feather } from "@expo/vector-icons";
+import * as Haptics from "expo-haptics";
+import * as Linking from "expo-linking";
+import React, { useCallback, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { Colors } from "@/constants/colors";
+import {
+ AMBER_PACKAGE,
+ truncateNpub,
+ useNostrIdentity,
+} from "@/context/NostrIdentityContext";
+
+const C = Colors.dark;
+
+const PLAY_STORE_AMBER =
+ "https://play.google.com/store/apps/details?id=" + AMBER_PACKAGE;
+
+export default function SettingsScreen() {
+ const insets = useSafeAreaInsets();
+ const {
+ npub,
+ isConnected,
+ signingMethod,
+ amberAvailable,
+ connectWithNsec,
+ connectWithAmber,
+ disconnect,
+ } = useNostrIdentity();
+
+ const [nsecInput, setNsecInput] = useState("");
+ const [nsecError, setNsecError] = useState(null);
+ const [loading, setLoading] = useState<"nsec" | "amber" | "disconnect" | null>(null);
+
+ const topPad = Platform.OS === "web" ? 67 : insets.top;
+ const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84;
+
+ const handleConnectNsec = useCallback(async () => {
+ const trimmed = nsecInput.trim();
+ if (!trimmed.startsWith("nsec1")) {
+ setNsecError("Must be a valid nsec1… key");
+ return;
+ }
+ setNsecError(null);
+ setLoading("nsec");
+ try {
+ await connectWithNsec(trimmed);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ setNsecInput("");
+ } catch (err) {
+ const msg =
+ err instanceof Error ? err.message : "Failed to connect identity";
+ setNsecError(msg);
+ } finally {
+ setLoading(null);
+ }
+ }, [nsecInput, connectWithNsec]);
+
+ const handleConnectAmber = useCallback(async () => {
+ setLoading("amber");
+ try {
+ await connectWithAmber();
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } catch (err) {
+ const msg =
+ err instanceof Error ? err.message : "Failed to connect via Amber";
+ Alert.alert("Amber error", msg);
+ } finally {
+ setLoading(null);
+ }
+ }, [connectWithAmber]);
+
+ const handleInstallAmber = useCallback(() => {
+ Linking.openURL(PLAY_STORE_AMBER);
+ }, []);
+
+ const handleDisconnect = useCallback(() => {
+ Alert.alert(
+ "Disconnect Nostr identity",
+ "Your private key will be wiped from this device. This cannot be undone.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Disconnect",
+ style: "destructive",
+ onPress: async () => {
+ setLoading("disconnect");
+ try {
+ await disconnect();
+ Haptics.notificationAsync(
+ Haptics.NotificationFeedbackType.Warning
+ );
+ } finally {
+ setLoading(null);
+ }
+ },
+ },
+ ]
+ );
+ }, [disconnect]);
+
+ return (
+
+ {/* Header */}
+
+ Settings
+
+
+ {/* ── Nostr Identity section ── */}
+
+
+ {isConnected && npub ? (
+ <>
+
+
+
+
+
+
+
+ {signingMethod === "amber" ? "Via Amber" : "Manual nsec"}
+
+
+ {truncateNpub(npub)}
+
+
+
+
+ {signingMethod === "amber" ? "Amber" : "nsec"}
+
+
+
+
+
+ [
+ styles.dangerButton,
+ pressed && styles.dangerButtonPressed,
+ ]}
+ disabled={loading === "disconnect"}
+ accessibilityRole="button"
+ accessibilityLabel="Disconnect Nostr identity"
+ >
+ {loading === "disconnect" ? (
+
+ ) : (
+ <>
+
+ Disconnect
+ >
+ )}
+
+ >
+ ) : (
+ <>
+ {/* Android — Amber option */}
+ {Platform.OS === "android" && (
+
+
+ Use{" "}
+ Amber to sign events
+ without exposing your key to this app.
+
+
+ {amberAvailable ? (
+
+ ) : (
+ <>
+
+
+
+ Amber is not installed
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* Both platforms — nsec entry */}
+
+
+ {Platform.OS === "ios"
+ ? "Enter your nsec to connect your Nostr identity. The key is stored only in the iOS Keychain."
+ : "Alternatively, enter your nsec directly. The key is stored only in the Android Keystore."}
+
+
+ {
+ setNsecInput(t);
+ if (nsecError) setNsecError(null);
+ }}
+ placeholder="nsec1…"
+ placeholderTextColor={C.textMuted}
+ autoCapitalize="none"
+ autoCorrect={false}
+ secureTextEntry
+ returnKeyType="done"
+ onSubmitEditing={handleConnectNsec}
+ accessibilityLabel="nsec private key input"
+ />
+ {nsecError ? (
+ {nsecError}
+ ) : null}
+
+
+
+ >
+ )}
+
+ {/* Security notice */}
+
+
+
+ Your private key is stored exclusively in the device secure enclave
+ and is never logged, transmitted, or held in app memory.
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Small UI helpers
+// ---------------------------------------------------------------------------
+
+function SectionHeader({ label }: { label: string }) {
+ return (
+
+ {label.toUpperCase()}
+
+ );
+}
+
+function Card({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+function Row({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+function PrimaryButton({
+ label,
+ icon,
+ loading = false,
+ disabled = false,
+ onPress,
+}: {
+ label: string;
+ icon: React.ComponentProps["name"];
+ loading?: boolean;
+ disabled?: boolean;
+ onPress: () => void;
+}) {
+ return (
+ [
+ styles.primaryButton,
+ (disabled || loading) && styles.primaryButtonDisabled,
+ pressed && !disabled && styles.primaryButtonPressed,
+ ]}
+ disabled={disabled || loading}
+ accessibilityRole="button"
+ accessibilityLabel={label}
+ >
+ {loading ? (
+
+ ) : (
+ <>
+
+ {label}
+ >
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Styles
+// ---------------------------------------------------------------------------
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: C.background,
+ },
+ content: {
+ paddingHorizontal: 16,
+ gap: 8,
+ },
+ header: {
+ paddingHorizontal: 8,
+ paddingTop: 12,
+ paddingBottom: 16,
+ },
+ title: {
+ fontSize: 28,
+ fontFamily: "Inter_700Bold",
+ color: C.text,
+ letterSpacing: -0.5,
+ },
+ sectionHeader: {
+ paddingHorizontal: 8,
+ paddingTop: 12,
+ paddingBottom: 4,
+ },
+ sectionHeaderText: {
+ fontSize: 11,
+ fontFamily: "Inter_600SemiBold",
+ color: C.textMuted,
+ letterSpacing: 1.2,
+ },
+ card: {
+ backgroundColor: C.surface,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: C.border,
+ padding: 16,
+ gap: 12,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ npubIconWrap: {
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ backgroundColor: C.surfaceElevated,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ npubTextWrap: {
+ flex: 1,
+ gap: 2,
+ },
+ npubLabel: {
+ fontSize: 11,
+ fontFamily: "Inter_500Medium",
+ color: C.textMuted,
+ letterSpacing: 0.4,
+ },
+ npubValue: {
+ fontSize: 13,
+ fontFamily: "Inter_500Medium",
+ color: C.text,
+ letterSpacing: 0.2,
+ },
+ methodBadge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 8,
+ },
+ methodBadgeAmber: {
+ backgroundColor: "#F59E0B22",
+ borderWidth: 1,
+ borderColor: "#F59E0B55",
+ },
+ methodBadgeNsec: {
+ backgroundColor: C.accent + "22",
+ borderWidth: 1,
+ borderColor: C.accent + "55",
+ },
+ methodBadgeText: {
+ fontSize: 11,
+ fontFamily: "Inter_600SemiBold",
+ color: C.textSecondary,
+ },
+ sectionDesc: {
+ fontSize: 13,
+ fontFamily: "Inter_400Regular",
+ color: C.textSecondary,
+ lineHeight: 18,
+ },
+ highlight: {
+ color: C.accentGlow,
+ fontFamily: "Inter_600SemiBold",
+ },
+ notInstalledRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ },
+ notInstalledText: {
+ fontSize: 12,
+ fontFamily: "Inter_400Regular",
+ color: C.textMuted,
+ },
+ nsecInput: {
+ backgroundColor: C.surfaceElevated,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: C.border,
+ paddingHorizontal: 14,
+ paddingVertical: 11,
+ fontSize: 14,
+ fontFamily: "Inter_400Regular",
+ color: C.text,
+ },
+ nsecInputError: {
+ borderColor: C.error,
+ },
+ errorText: {
+ fontSize: 12,
+ fontFamily: "Inter_400Regular",
+ color: C.error,
+ marginTop: -4,
+ },
+ primaryButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: C.accent,
+ borderRadius: 12,
+ paddingVertical: 13,
+ paddingHorizontal: 20,
+ },
+ primaryButtonDisabled: {
+ opacity: 0.5,
+ },
+ primaryButtonPressed: {
+ opacity: 0.8,
+ },
+ primaryButtonText: {
+ fontSize: 15,
+ fontFamily: "Inter_600SemiBold",
+ color: C.text,
+ },
+ dangerButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: C.error + "15",
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: C.error + "40",
+ paddingVertical: 13,
+ paddingHorizontal: 20,
+ marginTop: 4,
+ },
+ dangerButtonPressed: {
+ opacity: 0.7,
+ },
+ dangerButtonText: {
+ fontSize: 15,
+ fontFamily: "Inter_600SemiBold",
+ color: C.error,
+ },
+ securityNote: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 6,
+ paddingHorizontal: 8,
+ paddingTop: 8,
+ },
+ securityNoteText: {
+ flex: 1,
+ fontSize: 11,
+ fontFamily: "Inter_400Regular",
+ color: C.textMuted,
+ lineHeight: 16,
+ },
+});
diff --git a/artifacts/mobile/app/_layout.tsx b/artifacts/mobile/app/_layout.tsx
index 53c26c3..1e897a2 100644
--- a/artifacts/mobile/app/_layout.tsx
+++ b/artifacts/mobile/app/_layout.tsx
@@ -16,6 +16,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TimmyProvider } from "@/context/TimmyContext";
+import { NostrIdentityProvider } from "@/context/NostrIdentityContext";
import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys";
SplashScreen.preventAutoHideAsync();
@@ -76,9 +77,11 @@ export default function RootLayout() {
-
-
-
+
+
+
+
+
diff --git a/artifacts/mobile/constants/storage-keys.ts b/artifacts/mobile/constants/storage-keys.ts
index e07ff30..5d86679 100644
--- a/artifacts/mobile/constants/storage-keys.ts
+++ b/artifacts/mobile/constants/storage-keys.ts
@@ -1 +1,2 @@
export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed";
+export const NOSTR_NSEC_KEY = "app.nostr_nsec";
diff --git a/artifacts/mobile/context/NostrIdentityContext.tsx b/artifacts/mobile/context/NostrIdentityContext.tsx
new file mode 100644
index 0000000..3e1542e
--- /dev/null
+++ b/artifacts/mobile/context/NostrIdentityContext.tsx
@@ -0,0 +1,543 @@
+/**
+ * NostrIdentityContext — manages Nostr identity on mobile.
+ *
+ * Android: NIP-55 Amber deep-link signing (falls back to nsec if Amber absent).
+ * iOS: manual nsec entry only — key is stored exclusively in SecureStore.
+ *
+ * Security invariants:
+ * - The private key (nsec / raw bytes) is NEVER stored in React state.
+ * - The private key is NEVER logged.
+ * - Only the npub (public key, bech32) lives in React state.
+ */
+import * as SecureStore from "expo-secure-store";
+import * as Linking from "expo-linking";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Platform } from "react-native";
+
+import { NOSTR_NSEC_KEY } from "@/constants/storage-keys";
+
+// ---------------------------------------------------------------------------
+// Base64 helpers (no Buffer — uses standard JS globals)
+// ---------------------------------------------------------------------------
+
+function utf8ToBase64(str: string): string {
+ return btoa(
+ encodeURIComponent(str).replace(
+ /%([0-9A-F]{2})/g,
+ (_, p1: string) => String.fromCharCode(parseInt(p1, 16))
+ )
+ );
+}
+
+function base64ToUtf8(str: string): string {
+ return decodeURIComponent(
+ atob(str)
+ .split("")
+ .map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join("")
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Minimal bech32 + secp256k1 helpers (pure JS, no native deps)
+// ---------------------------------------------------------------------------
+
+const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+const BECH32_GENERATOR = [
+ 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3,
+];
+
+function bech32Polymod(values: number[]): number {
+ let chk = 1;
+ for (const v of values) {
+ const top = chk >> 25;
+ chk = ((chk & 0x1ffffff) << 5) ^ v;
+ for (let i = 0; i < 5; i++) {
+ if ((top >> i) & 1) chk ^= BECH32_GENERATOR[i];
+ }
+ }
+ return chk;
+}
+
+function bech32HrpExpand(hrp: string): number[] {
+ const ret: number[] = [];
+ for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5);
+ ret.push(0);
+ for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31);
+ return ret;
+}
+
+function bech32CreateChecksum(hrp: string, data: number[]): number[] {
+ const values = bech32HrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]);
+ const mod = bech32Polymod(values) ^ 1;
+ const ret: number[] = [];
+ for (let p = 0; p < 6; p++) ret.push((mod >> (5 * (5 - p))) & 31);
+ return ret;
+}
+
+function bech32Encode(hrp: string, data: number[]): string {
+ const combined = data.concat(bech32CreateChecksum(hrp, data));
+ let result = hrp + "1";
+ for (const b of combined) result += BECH32_CHARSET[b];
+ return result;
+}
+
+function bech32Decode(str: string): { hrp: string; data: Uint8Array } | null {
+ const lower = str.toLowerCase();
+ const sep = lower.lastIndexOf("1");
+ if (sep < 1 || sep + 7 > lower.length || lower.length > 90) return null;
+ const hrp = lower.slice(0, sep);
+ const data: number[] = [];
+ for (let i = sep + 1; i < lower.length; i++) {
+ const v = BECH32_CHARSET.indexOf(lower[i]);
+ if (v === -1) return null;
+ data.push(v);
+ }
+ if (bech32Polymod(bech32HrpExpand(hrp).concat(data)) !== 1) return null;
+ return {
+ hrp,
+ data: new Uint8Array(convertBits(data.slice(0, -6), 5, 8, false)),
+ };
+}
+
+function convertBits(
+ data: number[],
+ fromBits: number,
+ toBits: number,
+ pad: boolean
+): number[] {
+ let acc = 0;
+ let bits = 0;
+ const result: number[] = [];
+ const maxv = (1 << toBits) - 1;
+ for (const value of data) {
+ if (value < 0 || value >> fromBits !== 0) throw new Error("Invalid value");
+ acc = (acc << fromBits) | value;
+ bits += fromBits;
+ while (bits >= toBits) {
+ bits -= toBits;
+ result.push((acc >> bits) & maxv);
+ }
+ }
+ if (pad && bits > 0) {
+ result.push((acc << (toBits - bits)) & maxv);
+ } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
+ if (!pad && bits >= fromBits) throw new Error("Excessive padding");
+ }
+ return result;
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < hex.length; i += 2)
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
+ return bytes;
+}
+
+// Minimal secp256k1 x-only public key derivation via BigInt
+const P =
+ 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
+const Gx =
+ 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
+const Gy =
+ 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
+
+type Point = { x: bigint; y: bigint } | null;
+
+function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
+ let result = 1n;
+ base = base % mod;
+ while (exp > 0n) {
+ if (exp % 2n === 1n) result = (result * base) % mod;
+ exp = exp / 2n;
+ base = (base * base) % mod;
+ }
+ return result;
+}
+
+function pointAdd(p1: Point, p2: Point): Point {
+ if (p1 === null) return p2;
+ if (p2 === null) return p1;
+ if (p1.x === p2.x) {
+ if (p1.y !== p2.y) return null;
+ const lam = (3n * p1.x * p1.x * modPow(2n * p1.y, P - 2n, P)) % P;
+ const x = (lam * lam - 2n * p1.x + P + P) % P;
+ const y = (lam * (p1.x - x) - p1.y + P * 2n) % P;
+ return { x, y };
+ }
+ const lam =
+ ((p2.y - p1.y + P * 2n) % P) *
+ modPow((p2.x - p1.x + P) % P, P - 2n, P) %
+ P;
+ const x = (lam * lam - p1.x - p2.x + P * 4n) % P;
+ const y = (lam * (p1.x - x) - p1.y + P * 4n) % P;
+ return { x, y };
+}
+
+function scalarMul(k: bigint): Point {
+ let result: Point = null;
+ let addend: Point = { x: Gx, y: Gy };
+ while (k > 0n) {
+ if (k % 2n === 1n) result = pointAdd(result, addend);
+ addend = pointAdd(addend, addend);
+ k >>= 1n;
+ }
+ return result;
+}
+
+function privateKeyToPublicKeyHex(privKeyBytes: Uint8Array): string {
+ const k = BigInt("0x" + bytesToHex(privKeyBytes));
+ const point = scalarMul(k);
+ if (!point) throw new Error("Invalid private key");
+ return point.x.toString(16).padStart(64, "0");
+}
+
+function nsecToNpub(nsec: string): string {
+ const decoded = bech32Decode(nsec);
+ if (!decoded || decoded.hrp !== "nsec")
+ throw new Error("Invalid nsec encoding");
+ const pubkeyHex = privateKeyToPublicKeyHex(decoded.data);
+ const pubkeyBytes = hexToBytes(pubkeyHex);
+ const data5bit = convertBits(Array.from(pubkeyBytes), 8, 5, true);
+ return bech32Encode("npub", data5bit);
+}
+
+// ---------------------------------------------------------------------------
+// NIP-55 Amber helpers (Android only)
+// ---------------------------------------------------------------------------
+
+const AMBER_SCHEME = "nostrsigner:";
+export const AMBER_PACKAGE = "com.greenart7c3.nostrsigner";
+
+export type NostrEvent = {
+ kind: number;
+ content: string;
+ tags: string[][];
+ created_at: number;
+ pubkey?: string;
+};
+
+type PendingSign = {
+ resolve: (event: NostrEvent) => void;
+ reject: (err: Error) => void;
+};
+
+function buildAmberSignUrl(event: NostrEvent, callbackUrl: string): string {
+ const payload = JSON.stringify(event);
+ const encoded = utf8ToBase64(payload);
+ return (
+ `${AMBER_SCHEME}${encodeURIComponent(encoded)}` +
+ `?compressionType=none&returnType=event&type=sign_event` +
+ `&callbackUrl=${encodeURIComponent(callbackUrl)}`
+ );
+}
+
+export async function isAmberInstalled(): Promise {
+ if (Platform.OS !== "android") return false;
+ try {
+ return await Linking.canOpenURL(AMBER_SCHEME);
+ } catch {
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Context types
+// ---------------------------------------------------------------------------
+
+export type SigningMethod = "amber" | "nsec";
+
+type NostrIdentityContextValue = {
+ /** Bech32 npub, or null when no identity is connected. */
+ npub: string | null;
+ isConnected: boolean;
+ /** How the identity was connected (null = not connected). */
+ signingMethod: SigningMethod | null;
+ amberAvailable: boolean;
+ /** Connect using a raw nsec string. Throws on invalid input. */
+ connectWithNsec: (nsec: string) => Promise;
+ /** Connect via Amber (Android only). Stores npub; signing delegates to Amber. */
+ connectWithAmber: () => Promise;
+ /** Wipe the key from SecureStore and reset identity state. */
+ disconnect: () => Promise;
+ /**
+ * Sign a Nostr event.
+ * - nsec method: adds pubkey attribution using the stored key.
+ * - Amber method: deep-links to Amber and awaits the callback.
+ */
+ signEvent: (event: NostrEvent) => Promise;
+};
+
+const NostrIdentityContext = createContext(
+ null
+);
+
+// ---------------------------------------------------------------------------
+// Provider
+// ---------------------------------------------------------------------------
+
+export function NostrIdentityProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [npub, setNpub] = useState(null);
+ const [signingMethod, setSigningMethod] = useState(
+ null
+ );
+ const [amberAvailable, setAmberAvailable] = useState(false);
+ const pendingSignRef = useRef(null);
+
+ // Check for Amber on mount (Android only)
+ useEffect(() => {
+ isAmberInstalled().then(setAmberAvailable);
+ }, []);
+
+ // Load persisted identity on mount
+ useEffect(() => {
+ (async () => {
+ try {
+ const stored = await SecureStore.getItemAsync(NOSTR_NSEC_KEY);
+ if (!stored) return;
+ const parsed: { nsec?: string; method?: SigningMethod } =
+ JSON.parse(stored);
+ if (parsed.nsec && parsed.method) {
+ // For nsec method, re-derive npub. For amber, stored value IS the npub.
+ const derivedNpub =
+ parsed.method === "nsec"
+ ? nsecToNpub(parsed.nsec)
+ : parsed.nsec;
+ setNpub(derivedNpub);
+ setSigningMethod(parsed.method);
+ }
+ } catch {
+ // Corrupted or missing — silently ignore
+ }
+ })();
+ }, []);
+
+ // Handle Amber callback deep link (used for sign_event responses)
+ useEffect(() => {
+ const handleUrl = ({ url }: { url: string }) => {
+ if (!url.includes("amber-callback")) return;
+ const parsed = Linking.parse(url);
+ const eventParam =
+ typeof parsed.queryParams?.event === "string"
+ ? parsed.queryParams.event
+ : null;
+ if (!eventParam || !pendingSignRef.current) return;
+ try {
+ const decoded = base64ToUtf8(decodeURIComponent(eventParam));
+ const signedEvent: NostrEvent = JSON.parse(decoded);
+ pendingSignRef.current.resolve(signedEvent);
+ } catch (err) {
+ pendingSignRef.current.reject(
+ err instanceof Error
+ ? err
+ : new Error("Failed to parse Amber response")
+ );
+ } finally {
+ pendingSignRef.current = null;
+ }
+ };
+
+ const sub = Linking.addEventListener("url", handleUrl);
+ return () => sub.remove();
+ }, []);
+
+ const connectWithNsec = useCallback(async (nsec: string) => {
+ const trimmed = nsec.trim();
+ // Validate and derive npub (throws on invalid key)
+ const derivedNpub = nsecToNpub(trimmed);
+ // Persist: store nsec for local signing capability
+ await SecureStore.setItemAsync(
+ NOSTR_NSEC_KEY,
+ JSON.stringify({ nsec: trimmed, method: "nsec" satisfies SigningMethod })
+ );
+ setNpub(derivedNpub);
+ setSigningMethod("nsec");
+ }, []);
+
+ const connectWithAmber = useCallback(async () => {
+ if (Platform.OS !== "android") {
+ throw new Error("Amber is only available on Android");
+ }
+ if (!(await isAmberInstalled())) {
+ throw new Error("Amber is not installed");
+ }
+ // Request npub from Amber via NIP-55 get_public_key
+ const callbackUrl = Linking.createURL("amber-callback");
+ const url =
+ `${AMBER_SCHEME}?type=get_public_key` +
+ `&callbackUrl=${encodeURIComponent(callbackUrl)}`;
+
+ const npubResult = await new Promise((resolve, reject) => {
+ const handle: { sub: ReturnType | null } =
+ { sub: null };
+
+ const timeout = setTimeout(() => {
+ handle.sub?.remove();
+ reject(new Error("Amber did not respond in time"));
+ }, 60_000);
+
+ handle.sub = Linking.addEventListener("url", ({ url: incomingUrl }) => {
+ if (!incomingUrl.includes("amber-callback")) return;
+ clearTimeout(timeout);
+ handle.sub?.remove();
+ try {
+ const parsed = Linking.parse(incomingUrl);
+ const npubParam =
+ typeof parsed.queryParams?.npub === "string"
+ ? parsed.queryParams.npub
+ : null;
+ if (!npubParam) reject(new Error("Amber did not return an npub"));
+ else resolve(npubParam);
+ } catch (err) {
+ reject(
+ err instanceof Error
+ ? err
+ : new Error("Failed to parse Amber response")
+ );
+ }
+ });
+
+ Linking.openURL(url).catch((err) => {
+ clearTimeout(timeout);
+ handle.sub?.remove();
+ reject(err);
+ });
+ });
+
+ // Store npub (no raw key — Amber holds it)
+ await SecureStore.setItemAsync(
+ NOSTR_NSEC_KEY,
+ JSON.stringify({
+ nsec: npubResult,
+ method: "amber" satisfies SigningMethod,
+ })
+ );
+ setNpub(npubResult);
+ setSigningMethod("amber");
+ }, []);
+
+ const disconnect = useCallback(async () => {
+ await SecureStore.deleteItemAsync(NOSTR_NSEC_KEY);
+ setNpub(null);
+ setSigningMethod(null);
+ pendingSignRef.current?.reject(new Error("Disconnected"));
+ pendingSignRef.current = null;
+ }, []);
+
+ const signEvent = useCallback(
+ async (event: NostrEvent): Promise => {
+ if (!signingMethod) throw new Error("No Nostr identity connected");
+
+ if (signingMethod === "amber") {
+ if (Platform.OS !== "android")
+ throw new Error("Amber signing is Android-only");
+ if (pendingSignRef.current)
+ throw new Error("A signing request is already in progress");
+
+ const callbackUrl = Linking.createURL("amber-callback");
+ const amberUrl = buildAmberSignUrl(event, callbackUrl);
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ if (pendingSignRef.current) {
+ pendingSignRef.current = null;
+ reject(new Error("Amber signing timed out"));
+ }
+ }, 60_000);
+
+ pendingSignRef.current = {
+ resolve: (signed) => {
+ clearTimeout(timeout);
+ resolve(signed);
+ },
+ reject: (err) => {
+ clearTimeout(timeout);
+ reject(err);
+ },
+ };
+
+ Linking.openURL(amberUrl).catch((err) => {
+ clearTimeout(timeout);
+ pendingSignRef.current = null;
+ reject(err);
+ });
+ });
+ }
+
+ // nsec path — load key from SecureStore and set pubkey attribution
+ const stored = await SecureStore.getItemAsync(NOSTR_NSEC_KEY);
+ if (!stored) throw new Error("Key not found in SecureStore");
+ const { nsec: storedNsec } = JSON.parse(stored) as {
+ nsec: string;
+ method: string;
+ };
+ const decoded = bech32Decode(storedNsec);
+ if (!decoded || decoded.hrp !== "nsec")
+ throw new Error("Invalid stored nsec");
+ const pubkeyHex = privateKeyToPublicKeyHex(decoded.data);
+ return { ...event, pubkey: pubkeyHex };
+ },
+ [signingMethod]
+ );
+
+ const value = useMemo(
+ () => ({
+ npub,
+ isConnected: npub !== null,
+ signingMethod,
+ amberAvailable,
+ connectWithNsec,
+ connectWithAmber,
+ disconnect,
+ signEvent,
+ }),
+ [
+ npub,
+ signingMethod,
+ amberAvailable,
+ connectWithNsec,
+ connectWithAmber,
+ disconnect,
+ signEvent,
+ ]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNostrIdentity() {
+ const ctx = useContext(NostrIdentityContext);
+ if (!ctx)
+ throw new Error(
+ "useNostrIdentity must be used within NostrIdentityProvider"
+ );
+ return ctx;
+}
+
+/** Truncates an npub for display, e.g. "npub1abc...xyz" */
+export function truncateNpub(npub: string, headLen = 10, tailLen = 6): string {
+ if (npub.length <= headLen + tailLen + 3) return npub;
+ return `${npub.slice(0, headLen)}...${npub.slice(-tailLen)}`;
+}
diff --git a/artifacts/mobile/package.json b/artifacts/mobile/package.json
index c1bd9ae..1f3ffce 100644
--- a/artifacts/mobile/package.json
+++ b/artifacts/mobile/package.json
@@ -25,6 +25,7 @@
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117",
"expo": "~54.0.27",
"expo-blur": "~15.0.8",
+ "expo-secure-store": "~14.0.1",
"expo-constants": "~18.0.11",
"expo-font": "~14.0.10",
"expo-glass-effect": "~0.1.4",