diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example
new file mode 100644
index 0000000..9b28b81
--- /dev/null
+++ b/examples/nextjs/.env.example
@@ -0,0 +1,8 @@
+# UTXOS Configuration
+# Get your Project ID from https://utxos.dev/dashboard
+
+# Network: 0 = preprod/testnet, 1 = mainnet
+NEXT_PUBLIC_NETWORK_ID=0
+
+# Your UTXOS Project ID
+NEXT_PUBLIC_UTXOS_PROJECT_ID=your-project-id-here
diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore
new file mode 100644
index 0000000..329fc6f
--- /dev/null
+++ b/examples/nextjs/.gitignore
@@ -0,0 +1,30 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx
new file mode 100644
index 0000000..73e7ea8
--- /dev/null
+++ b/examples/nextjs/app/layout.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "UTXOS Wallet | Web3 Wallet as a Service",
+ description: "Connect your wallet with social login - Google, Discord, Twitter, Apple, Email",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx
new file mode 100644
index 0000000..56d935f
--- /dev/null
+++ b/examples/nextjs/app/page.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { useState } from "react";
+import type { EnableWeb3WalletOptions, Web3AuthProvider } from "@utxos/sdk";
+
+type UserData = {
+ avatarUrl: string | null;
+ email: string | null;
+ id: string;
+ username: string | null;
+};
+
+type WalletState = {
+ user: UserData | undefined;
+ cardanoAddress: string;
+ bitcoinAddress: string;
+ sparkAddress: string;
+};
+
+const PROVIDERS: { id: Web3AuthProvider; label: string; icon: string }[] = [
+ { id: "google", label: "Google", icon: "G" },
+ { id: "discord", label: "Discord", icon: "D" },
+ { id: "twitter", label: "Twitter", icon: "X" },
+ { id: "apple", label: "Apple", icon: "" },
+ { id: "email", label: "Email", icon: "@" },
+];
+
+export default function Home() {
+ const [wallet, setWallet] = useState(null);
+ const [loading, setLoading] = useState(null);
+ const [error, setError] = useState(null);
+
+ const handleConnect = async (provider?: Web3AuthProvider) => {
+ setLoading(provider || "any");
+ setError(null);
+
+ try {
+ const { Web3Wallet } = await import("@utxos/sdk");
+
+ const options: EnableWeb3WalletOptions = {
+ networkId: parseInt(process.env.NEXT_PUBLIC_NETWORK_ID || "0") as 0 | 1,
+ projectId: process.env.NEXT_PUBLIC_UTXOS_PROJECT_ID || "",
+ directTo: provider,
+ };
+
+ const web3Wallet = await Web3Wallet.enable(options);
+
+ const user = web3Wallet.getUser();
+
+ const cardanoAddress =
+ (await web3Wallet.cardano.getChangeAddress()) || "";
+ const bitcoinAddresses = await web3Wallet.bitcoin.getAddresses();
+ const bitcoinAddress = bitcoinAddresses[0]?.address || "";
+ const sparkAddressInfo = web3Wallet.spark.getAddress();
+ const sparkAddress = sparkAddressInfo.address || "";
+
+ setWallet({
+ user,
+ cardanoAddress,
+ bitcoinAddress,
+ sparkAddress,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Connection failed");
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ const handleDisconnect = async () => {
+ setWallet(null);
+ };
+
+ return (
+
+
+
+
UTXOS Wallet
+
Web3 Wallet as a Service
+
+
+ {error &&
{error}
}
+
+ {!wallet ? (
+
+
+
+
+ or continue with
+
+
+
+ {PROVIDERS.map((p) => (
+
+ ))}
+
+
+ ) : (
+
+ {wallet.user && (
+
+ {wallet.user.avatarUrl && (
+

+ )}
+
+
+ {wallet.user.username || wallet.user.email || "User"}
+
+
+ ID: {wallet.user.id.slice(0, 8)}...
+
+
+
+ )}
+
+
+
+
+
+ )}
+
+
+ Network:{" "}
+ {process.env.NEXT_PUBLIC_NETWORK_ID === "1" ? "Mainnet" : "Preprod"}
+
+
+
+ );
+}
+
+function AddressCard({
+ chain,
+ address,
+ color,
+}: {
+ chain: string;
+ address: string;
+ color: string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ if (address) {
+ navigator.clipboard.writeText(address);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ return (
+
+
+
+ {chain}
+
+
+
+
+ {address
+ ? `${address.slice(0, 20)}...${address.slice(-8)}`
+ : "Not available"}
+
+
+ );
+}
+
+const styles: Record = {
+ main: {
+ minHeight: "100vh",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ background:
+ "linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%)",
+ padding: "1rem",
+ },
+ container: {
+ width: "100%",
+ maxWidth: "420px",
+ background: "rgba(255,255,255,0.03)",
+ borderRadius: "24px",
+ padding: "2rem",
+ border: "1px solid rgba(255,255,255,0.08)",
+ },
+ header: {
+ textAlign: "center",
+ marginBottom: "2rem",
+ },
+ title: {
+ fontSize: "1.75rem",
+ fontWeight: 700,
+ color: "#fff",
+ margin: 0,
+ },
+ subtitle: {
+ fontSize: "0.875rem",
+ color: "#6b7280",
+ margin: "0.5rem 0 0",
+ },
+ error: {
+ background: "rgba(239,68,68,0.1)",
+ border: "1px solid rgba(239,68,68,0.3)",
+ color: "#fca5a5",
+ padding: "0.75rem 1rem",
+ borderRadius: "12px",
+ marginBottom: "1rem",
+ fontSize: "0.875rem",
+ },
+ loginSection: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "1rem",
+ },
+ primaryButton: {
+ width: "100%",
+ padding: "0.875rem",
+ fontSize: "1rem",
+ fontWeight: 600,
+ color: "#fff",
+ background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
+ border: "none",
+ borderRadius: "12px",
+ cursor: "pointer",
+ },
+ divider: {
+ display: "flex",
+ alignItems: "center",
+ gap: "1rem",
+ margin: "0.5rem 0",
+ },
+ dividerText: {
+ flex: 1,
+ textAlign: "center",
+ fontSize: "0.75rem",
+ color: "#6b7280",
+ position: "relative",
+ },
+ socialGrid: {
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ gap: "0.75rem",
+ },
+ socialButton: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "0.5rem",
+ padding: "0.75rem",
+ fontSize: "0.875rem",
+ fontWeight: 500,
+ color: "#e5e7eb",
+ background: "rgba(255,255,255,0.05)",
+ border: "1px solid rgba(255,255,255,0.1)",
+ borderRadius: "10px",
+ cursor: "pointer",
+ },
+ socialIcon: {
+ fontSize: "1rem",
+ fontWeight: 700,
+ },
+ spinner: {
+ width: "12px",
+ height: "12px",
+ border: "2px solid rgba(255,255,255,0.3)",
+ borderTopColor: "#fff",
+ borderRadius: "50%",
+ animation: "spin 0.6s linear infinite",
+ },
+ walletSection: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "1.25rem",
+ },
+ userCard: {
+ display: "flex",
+ alignItems: "center",
+ gap: "1rem",
+ padding: "1rem",
+ background: "rgba(255,255,255,0.05)",
+ borderRadius: "12px",
+ },
+ avatar: {
+ width: "48px",
+ height: "48px",
+ borderRadius: "50%",
+ objectFit: "cover",
+ },
+ userName: {
+ fontSize: "1rem",
+ fontWeight: 600,
+ color: "#fff",
+ },
+ userId: {
+ fontSize: "0.75rem",
+ color: "#6b7280",
+ marginTop: "0.25rem",
+ },
+ addressList: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.75rem",
+ },
+ addressCard: {
+ padding: "1rem",
+ background: "rgba(0,0,0,0.2)",
+ borderRadius: "12px",
+ border: "1px solid rgba(255,255,255,0.05)",
+ },
+ addressHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "0.5rem",
+ },
+ chainBadge: {
+ padding: "0.25rem 0.75rem",
+ borderRadius: "6px",
+ fontSize: "0.75rem",
+ fontWeight: 600,
+ color: "#fff",
+ },
+ copyButton: {
+ padding: "0.25rem 0.5rem",
+ fontSize: "0.75rem",
+ color: "#9ca3af",
+ background: "transparent",
+ border: "1px solid rgba(255,255,255,0.1)",
+ borderRadius: "6px",
+ cursor: "pointer",
+ },
+ address: {
+ fontSize: "0.8rem",
+ fontFamily: "monospace",
+ color: "#9ca3af",
+ wordBreak: "break-all",
+ },
+ disconnectButton: {
+ width: "100%",
+ padding: "0.75rem",
+ fontSize: "0.875rem",
+ fontWeight: 500,
+ color: "#f87171",
+ background: "transparent",
+ border: "1px solid rgba(248,113,113,0.3)",
+ borderRadius: "10px",
+ cursor: "pointer",
+ },
+ footer: {
+ textAlign: "center",
+ fontSize: "0.75rem",
+ color: "#4b5563",
+ marginTop: "1.5rem",
+ marginBottom: 0,
+ },
+};
diff --git a/examples/nextjs/next.config.js b/examples/nextjs/next.config.js
new file mode 100644
index 0000000..f56d8e4
--- /dev/null
+++ b/examples/nextjs/next.config.js
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ transpilePackages: ["@utxos/sdk"],
+};
+
+module.exports = nextConfig;
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
new file mode 100644
index 0000000..48e6248
--- /dev/null
+++ b/examples/nextjs/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@utxos/nextjs-example",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@utxos/sdk": "file:../..",
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json
new file mode 100644
index 0000000..e7ff90f
--- /dev/null
+++ b/examples/nextjs/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/react-native/.env.example b/examples/react-native/.env.example
new file mode 100644
index 0000000..1808e50
--- /dev/null
+++ b/examples/react-native/.env.example
@@ -0,0 +1,7 @@
+# UTXOS Configuration
+# Copy this file to .env and fill in your values
+# Get your project ID from https://utxos.dev/dashboard
+
+EXPO_PUBLIC_UTXOS_PROJECT_ID=your-project-id-here
+EXPO_PUBLIC_UTXOS_NETWORK_ID=0
+# Network: 0 = preprod (testnet), 1 = mainnet
diff --git a/examples/react-native/.gitignore b/examples/react-native/.gitignore
new file mode 100644
index 0000000..da9569c
--- /dev/null
+++ b/examples/react-native/.gitignore
@@ -0,0 +1,42 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+# generated native folders
+/ios
+/android
diff --git a/examples/react-native/App.tsx b/examples/react-native/App.tsx
new file mode 100644
index 0000000..5dd9777
--- /dev/null
+++ b/examples/react-native/App.tsx
@@ -0,0 +1,443 @@
+import { StatusBar } from "expo-status-bar";
+import { useState } from "react";
+import {
+ StyleSheet,
+ Text,
+ View,
+ TouchableOpacity,
+ ActivityIndicator,
+ SafeAreaView,
+ Image,
+ Platform,
+} from "react-native";
+import * as Clipboard from "expo-clipboard";
+import { Web3Wallet } from "@utxos/sdk";
+import type { EnableWeb3WalletOptions, Web3AuthProvider } from "@utxos/sdk";
+
+type UserData = {
+ avatarUrl: string | null;
+ email: string | null;
+ id: string;
+ username: string | null;
+};
+
+type WalletState = {
+ user: UserData | undefined;
+ cardanoAddress: string;
+ bitcoinAddress: string;
+ sparkAddress: string;
+};
+
+const PROVIDERS: { id: Web3AuthProvider; label: string; icon: string }[] = [
+ { id: "google", label: "Google", icon: "G" },
+ { id: "discord", label: "Discord", icon: "D" },
+ { id: "twitter", label: "Twitter", icon: "X" },
+ { id: "apple", label: "Apple", icon: "" },
+ { id: "email", label: "Email", icon: "@" },
+];
+
+const UTXOS_CONFIG = {
+ networkId: Number(process.env.EXPO_PUBLIC_UTXOS_NETWORK_ID) || 0,
+ projectId: process.env.EXPO_PUBLIC_UTXOS_PROJECT_ID || "",
+};
+
+export default function App() {
+ const [wallet, setWallet] = useState(null);
+ const [loading, setLoading] = useState(null);
+ const [error, setError] = useState(null);
+
+ const handleConnect = async (provider?: Web3AuthProvider) => {
+ setLoading(provider || "any");
+ setError(null);
+
+ try {
+ const options: EnableWeb3WalletOptions = {
+ networkId: UTXOS_CONFIG.networkId as 0 | 1,
+ projectId: UTXOS_CONFIG.projectId,
+ directTo: provider,
+
+ appUrl: 'http://localhost:3000',
+ };
+
+ const web3Wallet = await Web3Wallet.enable(options);
+
+ const user = web3Wallet.getUser();
+
+ const cardanoAddress =
+ (await web3Wallet.cardano.getChangeAddress()) || "";
+ const bitcoinAddresses = await web3Wallet.bitcoin.getAddresses();
+ const bitcoinAddress = bitcoinAddresses[0]?.address || "";
+ const sparkAddressInfo = web3Wallet.spark.getAddress();
+ const sparkAddress = sparkAddressInfo.address || "";
+
+ setWallet({
+ user,
+ cardanoAddress,
+ bitcoinAddress,
+ sparkAddress,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Connection failed");
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ const handleDisconnect = () => {
+ setWallet(null);
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+ UTXOS Wallet
+ Web3 Wallet as a Service
+
+
+ {/* Error Message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {!wallet ? (
+
+ {/* Primary Connect Button */}
+ handleConnect()}
+ disabled={loading !== null}
+ >
+ {loading === "any" ? (
+
+ ) : (
+ Connect Wallet
+ )}
+
+
+ {/* Divider */}
+
+
+ or continue with
+
+
+
+ {/* Social Grid */}
+
+ {PROVIDERS.map((p) => (
+ handleConnect(p.id)}
+ disabled={loading !== null}
+ >
+ {p.icon}
+ {p.label}
+ {loading === p.id && (
+
+ )}
+
+ ))}
+
+
+ ) : (
+
+ {/* User Card */}
+ {wallet.user && (
+
+ {wallet.user.avatarUrl && (
+
+ )}
+
+
+ {wallet.user.username || wallet.user.email || "User"}
+
+
+ ID: {wallet.user.id.slice(0, 8)}...
+
+
+
+ )}
+
+ {/* Address Cards */}
+
+
+
+
+
+
+ {/* Disconnect Button */}
+
+ Disconnect
+
+
+ )}
+
+ {/* Footer */}
+
+ Network: {UTXOS_CONFIG.networkId === 1 ? "Mainnet" : "Preprod"}
+
+
+
+ );
+}
+
+function AddressCard({
+ chain,
+ address,
+ color,
+}: {
+ chain: string;
+ address: string;
+ color: string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ if (address) {
+ await Clipboard.setStringAsync(address);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ const truncateAddress = (addr: string) => {
+ if (!addr) return "Not available";
+ return `${addr.slice(0, 20)}...${addr.slice(-8)}`;
+ };
+
+ return (
+
+
+
+ {chain}
+
+
+
+ {copied ? "Copied!" : "Copy"}
+
+
+
+ {truncateAddress(address)}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#0f0f1a",
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: 24,
+ paddingTop: 40,
+ },
+ header: {
+ alignItems: "center",
+ marginBottom: 32,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: "700",
+ color: "#fff",
+ },
+ subtitle: {
+ fontSize: 14,
+ color: "#6b7280",
+ marginTop: 8,
+ },
+ errorContainer: {
+ backgroundColor: "rgba(239,68,68,0.1)",
+ borderWidth: 1,
+ borderColor: "rgba(239,68,68,0.3)",
+ borderRadius: 12,
+ padding: 12,
+ marginBottom: 16,
+ },
+ errorText: {
+ color: "#fca5a5",
+ fontSize: 14,
+ textAlign: "center",
+ },
+ loginSection: {
+ gap: 16,
+ },
+ primaryButton: {
+ width: "100%",
+ paddingVertical: 14,
+ borderRadius: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "#3b82f6",
+ },
+ primaryButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#fff",
+ },
+ divider: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginVertical: 8,
+ },
+ dividerLine: {
+ flex: 1,
+ height: 1,
+ backgroundColor: "rgba(255,255,255,0.1)",
+ },
+ dividerText: {
+ color: "#6b7280",
+ paddingHorizontal: 16,
+ fontSize: 12,
+ },
+ socialGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 12,
+ },
+ socialButton: {
+ width: "47%",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ backgroundColor: "rgba(255,255,255,0.05)",
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.1)",
+ borderRadius: 10,
+ },
+ socialIcon: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: "#e5e7eb",
+ },
+ socialButtonText: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: "#e5e7eb",
+ },
+ spinner: {
+ marginLeft: 8,
+ },
+ walletSection: {
+ gap: 20,
+ },
+ userCard: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 16,
+ padding: 16,
+ backgroundColor: "rgba(255,255,255,0.05)",
+ borderRadius: 12,
+ },
+ avatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ },
+ userInfo: {
+ flex: 1,
+ },
+ userName: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#fff",
+ },
+ userId: {
+ fontSize: 12,
+ color: "#6b7280",
+ marginTop: 4,
+ },
+ addressList: {
+ gap: 12,
+ },
+ addressCard: {
+ padding: 16,
+ backgroundColor: "rgba(0,0,0,0.2)",
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.05)",
+ },
+ addressHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: 8,
+ },
+ chainBadge: {
+ paddingVertical: 4,
+ paddingHorizontal: 12,
+ borderRadius: 6,
+ },
+ chainBadgeText: {
+ fontSize: 12,
+ fontWeight: "600",
+ color: "#fff",
+ },
+ copyButton: {
+ paddingVertical: 4,
+ paddingHorizontal: 8,
+ borderWidth: 1,
+ borderColor: "rgba(255,255,255,0.1)",
+ borderRadius: 6,
+ },
+ copyButtonText: {
+ fontSize: 12,
+ color: "#9ca3af",
+ },
+ addressText: {
+ fontSize: 13,
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+ color: "#9ca3af",
+ },
+ disconnectButton: {
+ width: "100%",
+ paddingVertical: 12,
+ borderWidth: 1,
+ borderColor: "rgba(248,113,113,0.3)",
+ borderRadius: 10,
+ alignItems: "center",
+ },
+ disconnectButtonText: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: "#f87171",
+ },
+ footer: {
+ textAlign: "center",
+ fontSize: 12,
+ color: "#4b5563",
+ marginTop: "auto",
+ paddingBottom: 20,
+ },
+});
diff --git a/examples/react-native/app.json b/examples/react-native/app.json
new file mode 100644
index 0000000..b898cfc
--- /dev/null
+++ b/examples/react-native/app.json
@@ -0,0 +1,30 @@
+{
+ "expo": {
+ "name": "utxos-rn-example",
+ "slug": "utxos-rn-example",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "newArchEnabled": true,
+ "splash": {
+ "image": "./assets/splash-icon.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "ios": {
+ "supportsTablet": true
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false
+ },
+ "web": {
+ "favicon": "./assets/favicon.png"
+ }
+ }
+}
diff --git a/examples/react-native/assets/adaptive-icon.png b/examples/react-native/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/examples/react-native/assets/adaptive-icon.png differ
diff --git a/examples/react-native/assets/favicon.png b/examples/react-native/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/examples/react-native/assets/favicon.png differ
diff --git a/examples/react-native/assets/icon.png b/examples/react-native/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/examples/react-native/assets/icon.png differ
diff --git a/examples/react-native/assets/splash-icon.png b/examples/react-native/assets/splash-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/examples/react-native/assets/splash-icon.png differ
diff --git a/examples/react-native/index.ts b/examples/react-native/index.ts
new file mode 100644
index 0000000..a2ce5e9
--- /dev/null
+++ b/examples/react-native/index.ts
@@ -0,0 +1,12 @@
+// CRITICAL: Import polyfills FIRST before any other imports
+import './shim';
+import 'react-native-get-random-values';
+
+import { registerRootComponent } from 'expo';
+
+import App from './App';
+
+// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
+// It also ensures that whether you load the app in Expo Go or in a native build,
+// the environment is set up appropriately
+registerRootComponent(App);
diff --git a/examples/react-native/metro.config.js b/examples/react-native/metro.config.js
new file mode 100644
index 0000000..f338812
--- /dev/null
+++ b/examples/react-native/metro.config.js
@@ -0,0 +1,43 @@
+const { getDefaultConfig } = require('expo/metro-config');
+const path = require('path');
+
+const config = getDefaultConfig(__dirname);
+
+// Path to the SDK package
+const sdkPath = path.resolve(__dirname, '../..');
+
+// Path to the monorepo root (for resolving shared dependencies)
+const monorepoRoot = path.resolve(__dirname, '../../../..');
+
+// Watch the parent SDK package for changes
+config.watchFolders = [sdkPath, monorepoRoot];
+
+// Configure resolver to find modules from multiple locations
+config.resolver.nodeModulesPaths = [
+ path.resolve(__dirname, 'node_modules'),
+ path.resolve(sdkPath, 'node_modules'),
+ path.resolve(monorepoRoot, 'node_modules'),
+];
+
+// For web, prioritize browser field; for native, prioritize react-native
+config.resolver.resolverMainFields = ['browser', 'main', 'module'];
+
+// Ensure we can resolve the SDK package
+config.resolver.extraNodeModules = {
+ '@utxos/sdk': sdkPath,
+};
+
+// Enable package exports resolution
+config.resolver.unstable_enablePackageExports = true;
+
+// For web builds, use 'browser' condition to get the browser bundle
+// For native builds, use 'react-native' condition
+config.resolver.unstable_conditionNames = ['browser', 'import', 'require', 'default'];
+
+// Don't resolve symlinks (important for linked packages)
+config.resolver.disableHierarchicalLookup = false;
+
+// Add source extensions that Metro should handle
+config.resolver.sourceExts = [...config.resolver.sourceExts, 'cjs', 'mjs'];
+
+module.exports = config;
diff --git a/examples/react-native/package.json b/examples/react-native/package.json
new file mode 100644
index 0000000..4900a44
--- /dev/null
+++ b/examples/react-native/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "utxos-rn-example",
+ "version": "1.0.0",
+ "main": "index.ts",
+ "scripts": {
+ "start": "expo start",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web"
+ },
+ "dependencies": {
+ "@expo/metro-runtime": "~6.1.2",
+ "@meshsdk/bitcoin": "^1.9.0-beta.98",
+ "@meshsdk/common": "^1.9.0-beta.98",
+ "@meshsdk/core-cst": "^1.9.0-beta.98",
+ "@meshsdk/transaction": "^1.9.0-beta.98",
+ "@meshsdk/wallet": "^2.0.0-beta.3",
+ "@peculiar/webcrypto": "^1.5.0",
+ "@react-native-async-storage/async-storage": "^2.2.0",
+ "@utxos/sdk": "file:../..",
+ "axios": "^1.13.4",
+ "base32-encoding": "^1.0.0",
+ "buffer": "^6.0.3",
+ "expo": "~54.0.32",
+ "expo-clipboard": "~7.1.0",
+ "expo-crypto": "^15.0.8",
+ "expo-status-bar": "~3.0.9",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-native": "0.81.5",
+ "react-native-get-random-values": "^2.0.0",
+ "react-native-web": "^0.21.0",
+ "uuid": "^13.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "~19.1.0",
+ "typescript": "~5.9.2"
+ },
+ "private": true
+}
diff --git a/examples/react-native/shim.js b/examples/react-native/shim.js
new file mode 100644
index 0000000..1831ced
--- /dev/null
+++ b/examples/react-native/shim.js
@@ -0,0 +1,56 @@
+// Polyfills for React Native - MUST be imported FIRST before any other imports
+// This file sets up the Node.js-like environment that @meshsdk and crypto libraries expect
+
+// 1. Buffer - CRITICAL: Must be set unconditionally and completely
+import { Buffer } from 'buffer';
+global.Buffer = Buffer;
+globalThis.Buffer = Buffer;
+
+// Also ensure Buffer is on window for web targets
+if (typeof window !== 'undefined') {
+ window.Buffer = Buffer;
+}
+
+// 2. Process - many Node.js libraries check process.env
+if (typeof global.process === 'undefined') {
+ global.process = { env: {}, version: 'v18.0.0', browser: true };
+} else {
+ global.process.browser = true;
+}
+if (typeof globalThis.process === 'undefined') {
+ globalThis.process = global.process;
+}
+
+// 3. TextEncoder/TextDecoder - built-in for React Native 0.72+ but ensure they're global
+if (typeof global.TextEncoder === 'undefined' && typeof TextEncoder !== 'undefined') {
+ global.TextEncoder = TextEncoder;
+}
+if (typeof global.TextDecoder === 'undefined' && typeof TextDecoder !== 'undefined') {
+ global.TextDecoder = TextDecoder;
+}
+
+// 4. Base64 encoding (atob/btoa) - needed for various crypto operations
+if (typeof global.atob === 'undefined') {
+ global.atob = (str) => Buffer.from(str, 'base64').toString('binary');
+}
+if (typeof global.btoa === 'undefined') {
+ global.btoa = (str) => Buffer.from(str, 'binary').toString('base64');
+}
+
+// 5. Ensure globalThis matches global (some libraries use globalThis)
+if (typeof globalThis.atob === 'undefined') {
+ globalThis.atob = global.atob;
+}
+if (typeof globalThis.btoa === 'undefined') {
+ globalThis.btoa = global.btoa;
+}
+
+// 6. Uint8Array extensions that some crypto libraries expect
+if (!Uint8Array.prototype.slice) {
+ Uint8Array.prototype.slice = function(start, end) {
+ return new Uint8Array(Array.prototype.slice.call(this, start, end));
+ };
+}
+
+// Log that polyfills are loaded (helpful for debugging)
+console.log('[shim.js] Polyfills loaded - Buffer:', typeof global.Buffer !== 'undefined');
diff --git a/examples/react-native/tsconfig.json b/examples/react-native/tsconfig.json
new file mode 100644
index 0000000..b9567f6
--- /dev/null
+++ b/examples/react-native/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true
+ }
+}
diff --git a/jest.config.js b/jest.config.js
index e0323e5..9c15a20 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -17,4 +17,5 @@ export default {
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
moduleFileExtensions: ["ts", "js", "json", "node"],
roots: ["/src"],
+ setupFilesAfterEnv: ["/jest.setup.ts"],
};
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000..4ba40dc
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,12 @@
+/**
+ * Jest setup file - initializes platform adapters for testing
+ *
+ * Tests run in Node.js environment, so we use browser adapters
+ * but with Node.js polyfills where needed.
+ */
+
+import { setAdapters } from './src/internal/platform-context';
+import { browserAdapters } from './src/platforms/browser';
+
+// Initialize adapters before any tests run
+setAdapters(browserAdapters);
diff --git a/package.json b/package.json
index 23e989f..e0226d2 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,33 @@
{
"name": "@utxos/sdk",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains",
"main": "./dist/index.cjs",
- "browser": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"exports": {
".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "require": "./dist/index.cjs"
+ "react-native": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.native.js",
+ "require": "./dist/index.native.cjs"
+ },
+ "browser": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "node": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "default": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
},
"./eslint/base": "./src/configs/eslint/base.js",
"./eslint/next.js": "./src/configs/eslint/next.js",
@@ -22,12 +38,14 @@
"./typescript/react-library.json": "./src/configs/typescript/react-library.json",
"./prettier": "./src/configs/prettier/index.js"
},
+ "react-native": "./dist/index.native.js",
+ "browser": "./dist/index.js",
"files": [
"dist/**"
],
"scripts": {
- "build:sdk": "tsup src/index.ts --format esm,cjs --dts",
- "dev": "tsup src/index.ts --format esm,cjs --watch --dts",
+ "build:sdk": "tsup",
+ "dev": "tsup --watch",
"test": "npx jest"
},
"devDependencies": {
@@ -54,6 +72,22 @@
"base32-encoding": "^1.0.0",
"uuid": "^11.1.0"
},
+ "peerDependencies": {
+ "react-native-quick-crypto": ">=0.7.0",
+ "@react-native-async-storage/async-storage": ">=1.19.0",
+ "react-native-inappbrowser-reborn": ">=3.7.0"
+ },
+ "peerDependenciesMeta": {
+ "react-native-quick-crypto": {
+ "optional": true
+ },
+ "@react-native-async-storage/async-storage": {
+ "optional": true
+ },
+ "react-native-inappbrowser-reborn": {
+ "optional": true
+ }
+ },
"publishConfig": {
"access": "public"
},
@@ -64,6 +98,7 @@
"cardano",
"sdk",
"spark",
- "web3"
+ "web3",
+ "react-native"
]
}
diff --git a/src/adapters/index.ts b/src/adapters/index.ts
new file mode 100644
index 0000000..fcb073f
--- /dev/null
+++ b/src/adapters/index.ts
@@ -0,0 +1 @@
+export * from './types';
diff --git a/src/adapters/types.ts b/src/adapters/types.ts
new file mode 100644
index 0000000..467a057
--- /dev/null
+++ b/src/adapters/types.ts
@@ -0,0 +1,253 @@
+/**
+ * Platform adapter interfaces for universal SDK support
+ * These abstractions enable the SDK to work on Browser, React Native, and Node.js
+ */
+
+/**
+ * Cryptographic operations adapter
+ * Abstracts Web Crypto API and platform-specific crypto implementations
+ */
+export interface CryptoAdapter {
+ /**
+ * Generate cryptographically secure random bytes
+ * @param size - Number of random bytes to generate
+ * @returns Uint8Array of random bytes
+ */
+ getRandomBytes(size: number): Uint8Array;
+
+ /**
+ * HMAC signing operation
+ * @param algorithm - Hash algorithm (e.g., 'SHA-256', 'SHA-512')
+ * @param key - HMAC key as bytes
+ * @param data - Data to sign
+ * @returns Promise resolving to HMAC signature bytes
+ */
+ hmacSign(algorithm: string, key: Uint8Array, data: Uint8Array): Promise;
+
+ /**
+ * AES-GCM encryption
+ * @param data - Plaintext data to encrypt
+ * @param key - Encryption key (256-bit for AES-256)
+ * @param iv - Initialization vector (12-16 bytes)
+ * @returns Promise resolving to ciphertext bytes
+ */
+ encrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise;
+
+ /**
+ * AES-GCM decryption
+ * @param data - Ciphertext to decrypt
+ * @param key - Decryption key
+ * @param iv - Initialization vector used during encryption
+ * @returns Promise resolving to plaintext bytes
+ */
+ decrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise;
+
+ /**
+ * Generate ECDH key pair for key exchange
+ * @param algorithm - Algorithm name (default: 'ECDH' with P-256 curve)
+ * @returns Promise resolving to public and private key bytes
+ */
+ generateKeyPair(algorithm?: string): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>;
+
+ /**
+ * Derive shared secret from ECDH key exchange
+ * @param privateKey - Local private key bytes
+ * @param publicKey - Remote public key bytes
+ * @returns Promise resolving to shared secret bytes
+ */
+ deriveSharedSecret(privateKey: Uint8Array, publicKey: Uint8Array): Promise;
+
+ /**
+ * Import raw key for crypto operations
+ * @param keyData - Raw key bytes
+ * @param algorithm - Algorithm for key usage (e.g., 'AES-GCM', 'HMAC')
+ * @param usages - Key usages (e.g., ['encrypt', 'decrypt'])
+ * @returns Promise resolving to CryptoKey object
+ */
+ importKey(keyData: Uint8Array, algorithm: string, usages: string[]): Promise;
+
+ /**
+ * Get native SubtleCrypto interface for advanced operations
+ * @returns SubtleCrypto interface
+ */
+ getSubtleCrypto(): SubtleCrypto;
+
+ /**
+ * Fill array with random values in place
+ * @param array - TypedArray to fill
+ * @returns The same array filled with random values
+ */
+ getRandomValuesInPlace(array: T): T;
+}
+
+/**
+ * Persistent storage adapter
+ * Abstracts localStorage (Browser), AsyncStorage (React Native), and file/memory storage (Node.js)
+ */
+export interface StorageAdapter {
+ /**
+ * Get item from persistent storage
+ * @param key - Storage key
+ * @returns Promise resolving to stored value or null if not found
+ */
+ getItem(key: string): Promise;
+
+ /**
+ * Set item in persistent storage
+ * @param key - Storage key
+ * @param value - Value to store (must be serializable to string)
+ */
+ setItem(key: string, value: string): Promise;
+
+ /**
+ * Remove item from persistent storage
+ * @param key - Storage key to remove
+ */
+ removeItem(key: string): Promise;
+
+ /**
+ * Clear all items from storage
+ * Use with caution - removes all stored data
+ */
+ clear(): Promise;
+}
+
+/**
+ * URL/linking operations adapter
+ * Abstracts window.open (Browser), Linking API (React Native), and external process (Node.js)
+ */
+export interface LinkingAdapter {
+ /**
+ * Open URL in appropriate context
+ * Browser: new tab or popup window
+ * React Native: external browser or in-app browser
+ * Node.js: system default browser
+ * @param url - URL to open
+ */
+ openURL(url: string): Promise;
+
+ /**
+ * Redirect current page/context to URL (same-tab navigation)
+ * Browser: replaces current page via window.location.href
+ * React Native: no-op (use deep linking instead)
+ * Used for auth flow redirects after token storage
+ * @param url - URL to redirect to
+ */
+ redirectURL(url: string): Promise;
+
+ /**
+ * Open OAuth/auth window and wait for callback
+ * Handles popup window lifecycle and callback URL parsing
+ * @param url - Authorization URL to open
+ * @param callbackScheme - Expected callback URL scheme (e.g., 'myapp://')
+ * @returns Promise resolving to parsed callback parameters
+ */
+ openAuthWindow(url: string, callbackScheme: string): Promise;
+
+ /**
+ * Get current URL (browser only)
+ * @returns Current page URL or null if not in browser context
+ */
+ getCurrentURL(): string | null;
+
+ /**
+ * Get URL query parameters
+ * @returns Record of query parameter key-value pairs
+ */
+ getURLParams(): Record;
+
+ /**
+ * Add listener for URL/deep link changes
+ * Optional - not all platforms support URL change listening
+ * @param callback - Function called when URL changes
+ * @returns Cleanup function to remove listener
+ */
+ addURLListener?(callback: (url: string) => void): () => void;
+
+ /**
+ * Get user agent string for device identification
+ * Browser: navigator.userAgent
+ * React Native: Device identifier
+ * @returns User agent string or null if not available
+ */
+ getUserAgent(): string | null;
+}
+
+/**
+ * Result from OAuth/auth callback
+ */
+export interface AuthCallbackResult {
+ /** Authorization code from OAuth flow */
+ code?: string;
+ /** State parameter for CSRF protection */
+ state?: string;
+ /** Error code if authorization failed */
+ error?: string;
+ /** Human-readable error description */
+ errorDescription?: string;
+ /** Full message data for platform-specific payloads (e.g., wallet window results) */
+ data?: unknown;
+}
+
+/**
+ * Encoding/decoding operations adapter
+ * Abstracts Buffer (Node.js) and browser encoding APIs
+ */
+export interface EncodingAdapter {
+ /**
+ * Convert Uint8Array to base64 string
+ * @param bytes - Binary data to encode
+ * @returns Base64 encoded string
+ */
+ bytesToBase64(bytes: Uint8Array): string;
+
+ /**
+ * Convert base64 string to Uint8Array
+ * @param base64 - Base64 encoded string
+ * @returns Decoded binary data
+ */
+ base64ToBytes(base64: string): Uint8Array;
+
+ /**
+ * Convert Uint8Array to hex string
+ * @param bytes - Binary data to encode
+ * @returns Lowercase hex string
+ */
+ bytesToHex(bytes: Uint8Array): string;
+
+ /**
+ * Convert hex string to Uint8Array
+ * @param hex - Hex encoded string (case insensitive)
+ * @returns Decoded binary data
+ */
+ hexToBytes(hex: string): Uint8Array;
+
+ /**
+ * Convert Uint8Array to UTF-8 string
+ * @param bytes - UTF-8 encoded binary data
+ * @returns Decoded string
+ */
+ bytesToUtf8(bytes: Uint8Array): string;
+
+ /**
+ * Convert UTF-8 string to Uint8Array
+ * @param str - String to encode
+ * @returns UTF-8 encoded binary data
+ */
+ utf8ToBytes(str: string): Uint8Array;
+}
+
+/**
+ * Combined platform adapters
+ * Single object containing all platform-specific implementations
+ */
+export interface PlatformAdapters {
+ /** Cryptographic operations */
+ crypto: CryptoAdapter;
+ /** Persistent storage */
+ storage: StorageAdapter;
+ /** URL/linking operations */
+ linking: LinkingAdapter;
+ /** Encoding/decoding utilities */
+ encoding: EncodingAdapter;
+}
diff --git a/src/functions/crypto/encryption.ts b/src/functions/crypto/encryption.ts
index 5a30848..cc92fab 100644
--- a/src/functions/crypto/encryption.ts
+++ b/src/functions/crypto/encryption.ts
@@ -1,4 +1,6 @@
import { crypto } from ".";
+import { getEncoding } from "../../internal/platform-context";
+
const IV_LENGTH = 16;
export async function encryptWithCipher({
@@ -23,9 +25,10 @@ export async function encryptWithCipher({
);
// Return the encrypted data as a base64 string
+ const encoding = getEncoding();
return JSON.stringify({
- iv: Buffer.from(iv).toString("base64"),
- ciphertext: Buffer.from(encrypted).toString("base64"),
+ iv: encoding.bytesToBase64(new Uint8Array(iv)),
+ ciphertext: encoding.bytesToBase64(new Uint8Array(encrypted)),
});
}
export async function decryptWithCipher({
@@ -43,14 +46,15 @@ export async function decryptWithCipher({
} = JSON.parse(encryptedDataJSON);
// Decode the IV and encrypted data from base64
- const decodedIv = Buffer.from(_encryptedData.iv, "base64");
- const decodedEncryptedData = Buffer.from(_encryptedData.ciphertext, "base64");
+ const encoding = getEncoding();
+ const decodedIv = encoding.base64ToBytes(_encryptedData.iv);
+ const decodedEncryptedData = encoding.base64ToBytes(_encryptedData.ciphertext);
- // Decrypt the data
+ // Decrypt the data (slice creates fresh Uint8Array for type compatibility)
const decrypted = await crypto.subtle.decrypt(
- { name: algorithm, iv: decodedIv },
+ { name: algorithm, iv: decodedIv.slice() },
key,
- decodedEncryptedData,
+ decodedEncryptedData.slice(),
);
// Return the decrypted data as a string
@@ -70,9 +74,10 @@ export async function generateKeyPair() {
const publicKey = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const privateKey = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
+ const encoding = getEncoding();
const key = {
- publicKey: Buffer.from(publicKey).toString("base64"),
- privateKey: Buffer.from(privateKey).toString("base64"),
+ publicKey: encoding.bytesToBase64(new Uint8Array(publicKey)),
+ privateKey: encoding.bytesToBase64(new Uint8Array(privateKey)),
};
return key;
@@ -85,11 +90,12 @@ export async function encryptWithPublicKey({
publicKey: string;
data: string;
}) {
- const publicKeyBuffer = Buffer.from(publicKey, "base64");
+ const encoding = getEncoding();
+ const publicKeyBuffer = encoding.base64ToBytes(publicKey);
const _publicKey = await crypto.subtle.importKey(
"spki",
- publicKeyBuffer,
+ publicKeyBuffer.slice(),
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
@@ -129,11 +135,9 @@ export async function encryptWithPublicKey({
};
return JSON.stringify({
- ephemeralPublicKey: Buffer.from(encryptedData.ephemeralPublicKey).toString(
- "base64",
- ),
- iv: Buffer.from(encryptedData.iv).toString("base64"),
- ciphertext: Buffer.from(encryptedData.ciphertext).toString("base64"),
+ ephemeralPublicKey: encoding.bytesToBase64(new Uint8Array(encryptedData.ephemeralPublicKey)),
+ iv: encoding.bytesToBase64(new Uint8Array(encryptedData.iv)),
+ ciphertext: encoding.bytesToBase64(new Uint8Array(encryptedData.ciphertext)),
});
}
@@ -144,7 +148,8 @@ export async function decryptWithPrivateKey({
privateKey: string;
encryptedDataJSON: string;
}) {
- const privateKeyBuffer = Buffer.from(privateKey, "base64");
+ const encoding = getEncoding();
+ const privateKeyBuffer = encoding.base64ToBytes(privateKey);
const _encryptedData: {
ephemeralPublicKey: string;
@@ -153,17 +158,14 @@ export async function decryptWithPrivateKey({
} = JSON.parse(encryptedDataJSON);
const encryptedData = {
- ephemeralPublicKey: Buffer.from(
- _encryptedData.ephemeralPublicKey,
- "base64",
- ),
- iv: Buffer.from(_encryptedData.iv, "base64"),
- ciphertext: Buffer.from(_encryptedData.ciphertext, "base64"),
+ ephemeralPublicKey: encoding.base64ToBytes(_encryptedData.ephemeralPublicKey),
+ iv: encoding.base64ToBytes(_encryptedData.iv),
+ ciphertext: encoding.base64ToBytes(_encryptedData.ciphertext),
};
const _privateKey = await crypto.subtle.importKey(
"pkcs8",
- privateKeyBuffer,
+ privateKeyBuffer.slice(),
{ name: "ECDH", namedCurve: "P-256" },
false,
["deriveKey"],
@@ -171,7 +173,7 @@ export async function decryptWithPrivateKey({
const ephemeralPublicKey = await crypto.subtle.importKey(
"spki",
- encryptedData.ephemeralPublicKey,
+ encryptedData.ephemeralPublicKey.slice(),
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
@@ -186,11 +188,11 @@ export async function decryptWithPrivateKey({
["decrypt"],
);
- // Decrypt the message
+ // Decrypt the message (slice creates fresh Uint8Array for type compatibility)
const decrypted = await crypto.subtle.decrypt(
- { name: "AES-GCM", iv: encryptedData.iv },
+ { name: "AES-GCM", iv: encryptedData.iv.slice() },
sharedSecret,
- encryptedData.ciphertext,
+ encryptedData.ciphertext.slice(),
);
return new TextDecoder().decode(decrypted);
diff --git a/src/functions/crypto/hash.ts b/src/functions/crypto/hash.ts
index e034fb1..b7621f6 100644
--- a/src/functions/crypto/hash.ts
+++ b/src/functions/crypto/hash.ts
@@ -1,14 +1,27 @@
-import * as crypto from "crypto";
+import { getCrypto, getEncoding } from '../../internal/platform-context';
+
+/**
+ * Convert algorithm name from Node.js format to Web Crypto format
+ * e.g., "sha256" -> "SHA-256", "sha512" -> "SHA-512"
+ */
+function normalizeAlgorithm(algorithm: string): string {
+ const upper = algorithm.toUpperCase();
+ // Handle common formats: sha256, SHA256, sha-256, SHA-256
+ if (upper.startsWith('SHA') && !upper.includes('-')) {
+ // Insert hyphen before number: SHA256 -> SHA-256
+ return upper.replace(/^SHA(\d+)$/, 'SHA-$1');
+ }
+ return upper;
+}
export function generateHash({
size = 64,
}: {
size?: number;
}): Promise {
- return new Promise((resolve, reject) => {
- crypto.randomBytes(size, function (err, buffer) {
- resolve(buffer.toString("hex"));
- });
+ return new Promise((resolve) => {
+ const bytes = getCrypto().getRandomBytes(size);
+ resolve(getEncoding().bytesToHex(bytes));
});
}
@@ -21,17 +34,23 @@ export async function hashData({
privateKey?: string;
algorithm?: string;
}): Promise {
- return new Promise((resolve, reject) => {
- const hmac = crypto.createHmac(algorithm, privateKey);
-
- hmac.on("readable", () => {
- const data = hmac.read();
- if (data) {
- resolve(data.toString("hex"));
- }
- });
-
- hmac.write(data);
- hmac.end();
- });
+ const encoding = getEncoding();
+ const crypto = getCrypto();
+
+ // Convert string data to bytes
+ const dataBytes = typeof data === 'string'
+ ? encoding.utf8ToBytes(data)
+ : encoding.utf8ToBytes(String(data));
+
+ // Convert key to bytes
+ const keyBytes = encoding.utf8ToBytes(privateKey);
+
+ // Normalize algorithm name for Web Crypto API
+ const normalizedAlgorithm = normalizeAlgorithm(algorithm);
+
+ // Perform HMAC signing
+ const signatureBytes = await crypto.hmacSign(normalizedAlgorithm, keyBytes, dataBytes);
+
+ // Convert result to hex string
+ return encoding.bytesToHex(signatureBytes);
}
diff --git a/src/functions/crypto/index.ts b/src/functions/crypto/index.ts
index 9ac7b2c..193a692 100644
--- a/src/functions/crypto/index.ts
+++ b/src/functions/crypto/index.ts
@@ -1,17 +1,18 @@
-import { Crypto as WebCrypto } from "@peculiar/webcrypto";
-let crypto: Crypto;
-// Browser environment
-if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
- crypto = window.crypto;
-}
-// Node.js environment
-else if (typeof global !== "undefined") {
- const webCrypto = new WebCrypto();
- crypto = webCrypto as unknown as Crypto;
-} else {
- throw new Error("Web Crypto API is not supported in this environment.");
-}
-export { crypto };
+import { getCrypto } from '../../internal/platform-context';
+
+// Re-export adapter accessor
+export { getCrypto };
+
+// Create a crypto-like interface that uses the adapter
+// This maintains backward compatibility with code using crypto.subtle
+export const crypto = {
+ getRandomValues(array: T): T {
+ return getCrypto().getRandomValuesInPlace(array);
+ },
+ get subtle(): SubtleCrypto {
+ return getCrypto().getSubtleCrypto();
+ }
+};
export * from "./encryption";
export * from "./hash";
diff --git a/src/functions/window/open-window.ts b/src/functions/window/open-window.ts
index 3a2f37d..5f31dd3 100644
--- a/src/functions/window/open-window.ts
+++ b/src/functions/window/open-window.ts
@@ -1,67 +1,43 @@
import { OpenWindowParams } from "../../types/window";
-
-const buildWindowFeatures = () => {
- const sizeDefault = {
- width: 512,
- height: 768,
- };
- const sizeTight = {
- width: 448,
- height: 668,
- };
- const sizeSmall = {
- width: 340,
- height: 546,
- };
-
- const size = sizeTight;
-
- const windowWidth = window.innerWidth || 0;
- const windowHeight = window.innerHeight || 0;
-
- const isMobile = windowWidth < 640;
- const isFullScreen = !!(
- document.fullscreenElement ||
- (document as any).webkitFullscreenElement ||
- (document as any).mozFullScreenElement ||
- (document as any).msFullscreenElement
- );
-
- const shouldDisplayFullScreen = isMobile || isFullScreen;
-
- const width = shouldDisplayFullScreen ? windowWidth : size.width;
- const height = shouldDisplayFullScreen ? windowHeight : size.height;
-
- const windowFeatures = `left=${(windowWidth - width) / 2},top=${(windowHeight - height) / 2},width=${width},height=${height},scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no`;
- return windowFeatures;
-};
-
+import { getLinking } from "../../internal/platform-context";
+
+/**
+ * Open a wallet window for user authentication and operations.
+ *
+ * This function delegates to the platform-specific linking adapter which handles:
+ * - Popup window creation with appropriate sizing
+ * - postMessage communication for receiving results
+ * - Window close detection
+ *
+ * @param params - Method-specific parameters for the wallet operation
+ * @param appUrl - Base URL for the wallet app (defaults to utxos.dev)
+ * @returns Promise resolving to the operation result from the wallet window
+ */
export async function openWindow(
params: OpenWindowParams,
appUrl: string = "https://utxos.dev/",
): Promise {
- const p = new URLSearchParams(params);
- const _url = `${appUrl}/client/wallet?${p.toString()}`;
-
- return new Promise((resolve, reject) => {
- const newWindow = window.open(_url, "utxos", buildWindowFeatures());
-
- if (!newWindow) {
- return reject(new Error("Failed to open window"));
- }
-
- const interval = setInterval(() => {
- if (newWindow.closed) {
- clearInterval(interval);
- resolve({ success: false, message: "Window was closed by the user" });
- }
- }, 500);
-
- window.addEventListener("message", (event) => {
- if (event.data.target === "utxos") {
- clearInterval(interval);
- resolve(event.data);
- }
- });
- });
+ // Build the wallet URL with query parameters
+ const p = new URLSearchParams(params as Record);
+ const url = `${appUrl}/client/wallet?${p.toString()}`;
+
+ // Delegate to platform-specific linking adapter
+ const result = await getLinking().openAuthWindow(url, "utxos://callback");
+
+ // Handle user cancellation (window closed)
+ if (result.error === "cancelled") {
+ return { success: false, message: "Window was closed by the user" };
+ }
+
+ // Handle other errors
+ if (result.error) {
+ return {
+ success: false,
+ message: result.errorDescription || result.error
+ };
+ }
+
+ // Return the full wallet response data
+ // The platform adapter captures the complete postMessage payload
+ return result.data;
}
diff --git a/src/index.native.ts b/src/index.native.ts
new file mode 100644
index 0000000..a400348
--- /dev/null
+++ b/src/index.native.ts
@@ -0,0 +1,35 @@
+// CRITICAL: Initialize adapters BEFORE any other imports
+// This ensures adapters are set before any module tries to use them
+import { setAdapters } from './internal/platform-context';
+import { reactNativeAdapters } from './platforms/react-native';
+
+setAdapters(reactNativeAdapters);
+
+// Re-export everything - SAME API AS BROWSER
+export * from "./chains";
+export * from "./functions";
+export * from "./non-custodial";
+export * from "./sdk";
+export * from "./types";
+export * from "./wallet-user-controlled";
+
+// Re-export Spark utilities to avoid installing full SDK in apps
+export {
+ isValidSparkAddress,
+ decodeSparkAddress,
+ getNetworkFromSparkAddress,
+ type Bech32mTokenIdentifier,
+ encodeBech32mTokenIdentifier,
+ decodeBech32mTokenIdentifier,
+ getNetworkFromBech32mTokenIdentifier,
+ SparkWallet,
+} from "@buildonspark/spark-sdk";
+
+// Re-export our own Spark utilities
+export {
+ extractIdentityPublicKey,
+ getSparkAddressFromPubkey,
+ convertLegacyToNewFormat,
+} from "./chains/spark/utils";
+
+export { type IssuerTokenMetadata } from "@buildonspark/issuer-sdk";
diff --git a/src/index.ts b/src/index.ts
index 6f989d5..6432a11 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,11 @@
+// CRITICAL: Initialize adapters BEFORE any other imports
+// This ensures adapters are set before any module tries to use them
+import { setAdapters } from './internal/platform-context';
+import { browserAdapters } from './platforms/browser';
+
+setAdapters(browserAdapters);
+
+// Re-export everything - NO BREAKING CHANGES
export * from "./chains";
export * from "./functions";
export * from "./non-custodial";
diff --git a/src/internal/index.ts b/src/internal/index.ts
new file mode 100644
index 0000000..a6aabf5
--- /dev/null
+++ b/src/internal/index.ts
@@ -0,0 +1,9 @@
+export {
+ setAdapters,
+ getAdapters,
+ isInitialized,
+ getCrypto,
+ getStorage,
+ getLinking,
+ getEncoding,
+} from './platform-context';
diff --git a/src/internal/platform-context.ts b/src/internal/platform-context.ts
new file mode 100644
index 0000000..932f54e
--- /dev/null
+++ b/src/internal/platform-context.ts
@@ -0,0 +1,79 @@
+/**
+ * Platform Context - Internal singleton that holds platform-specific adapters
+ *
+ * This module is initialized automatically when the SDK is imported.
+ * Browser entry (index.ts) sets browser adapters.
+ * React Native entry (index.native.ts) sets RN adapters.
+ *
+ * Internal use only - not exported from main package.
+ */
+
+import type {
+ PlatformAdapters,
+ CryptoAdapter,
+ StorageAdapter,
+ LinkingAdapter,
+ EncodingAdapter,
+} from '../adapters/types';
+
+// Global singleton - set once at module load time
+let _adapters: PlatformAdapters | null = null;
+let _initialized = false;
+
+/**
+ * Initialize platform adapters. Called automatically by entry points.
+ * @internal
+ */
+export function setAdapters(adapters: PlatformAdapters): void {
+ if (_initialized) {
+ // Allow re-initialization in development/hot reload scenarios
+ if (process.env.NODE_ENV === 'development') {
+ console.warn('@utxos/sdk: Re-initializing adapters (development mode)');
+ } else {
+ console.warn('@utxos/sdk: Adapters already initialized, ignoring');
+ return;
+ }
+ }
+ _adapters = adapters;
+ _initialized = true;
+}
+
+/**
+ * Get all platform adapters
+ * @internal
+ */
+export function getAdapters(): PlatformAdapters {
+ if (!_adapters) {
+ throw new Error(
+ '@utxos/sdk: Platform adapters not initialized. ' +
+ 'This usually means the SDK was imported incorrectly. ' +
+ 'Import from "@utxos/sdk" directly, not from internal paths.'
+ );
+ }
+ return _adapters;
+}
+
+/**
+ * Check if adapters are initialized
+ * @internal
+ */
+export function isInitialized(): boolean {
+ return _initialized;
+}
+
+// Convenience accessors for internal use
+export function getCrypto(): CryptoAdapter {
+ return getAdapters().crypto;
+}
+
+export function getStorage(): StorageAdapter {
+ return getAdapters().storage;
+}
+
+export function getLinking(): LinkingAdapter {
+ return getAdapters().linking;
+}
+
+export function getEncoding(): EncodingAdapter {
+ return getAdapters().encoding;
+}
diff --git a/src/non-custodial/index.ts b/src/non-custodial/index.ts
index 90320b0..285db0e 100644
--- a/src/non-custodial/index.ts
+++ b/src/non-custodial/index.ts
@@ -5,6 +5,7 @@ import {
Web3WalletObject,
Web3AuthProvider,
} from "../";
+import { getStorage, getLinking, getEncoding } from "../internal/platform-context";
export * from "./utils";
const AUTH_KEY = "mesh-web3-services-auth";
@@ -191,6 +192,17 @@ export class Web3NonCustodialProvider {
this.appleOauth2ClientId = params.appleOauth2ClientId;
}
+ private base64Encode(str: string): string {
+ const encoding = getEncoding();
+ return encoding.bytesToBase64(encoding.utf8ToBytes(str));
+ }
+
+ private base64Decode(base64: string): string {
+ const encoding = getEncoding();
+ const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
+ return encoding.bytesToUtf8(encoding.base64ToBytes(normalized));
+ }
+
async checkNonCustodialWalletsOnServer(): Promise<
| { data: Web3WalletObject[]; error: null }
| {
@@ -292,7 +304,7 @@ export class Web3NonCustodialProvider {
}
| { error: null; data: { deviceId: string; walletId: string } }
> {
- const userAgent = navigator.userAgent;
+ const userAgent = getLinking().getUserAgent() ?? 'unknown';
const { data: user, error: userError } = await this.getUser();
if (userError) {
return { error: userError, data: null };
@@ -374,9 +386,7 @@ export class Web3NonCustodialProvider {
if (bodyUnparsed === undefined) {
return { data: null, error: new NotAuthenticatedError() };
}
- const body = JSON.parse(
- atob(bodyUnparsed.replace(/-/g, "+").replace(/_/g, "/")),
- ) as Web3JWTBody;
+ const body = JSON.parse(this.base64Decode(bodyUnparsed)) as Web3JWTBody;
if (body.exp < Date.now()) {
return { data: null, error: new SessionExpiredError() };
@@ -429,7 +439,7 @@ export class Web3NonCustodialProvider {
newDeviceShardEncryptionKey,
);
- const userAgent = navigator.userAgent;
+ const userAgent = getLinking().getUserAgent() ?? 'unknown';
const createDeviceBody: CreateDeviceBody = {
walletId,
@@ -481,7 +491,7 @@ export class Web3NonCustodialProvider {
redirect_uri: this.appOrigin + "/api/auth",
scope:
"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
- state: btoa(googleState),
+ state: this.base64Encode(googleState),
});
const googleAuthorizeUrl =
"https://accounts.google.com/o/oauth2/v2/auth?" +
@@ -499,7 +509,7 @@ export class Web3NonCustodialProvider {
response_type: "code",
redirect_uri: this.appOrigin + "/api/auth",
scope: "identify email",
- state: btoa(discordState),
+ state: this.base64Encode(discordState),
});
const discordAuthorizeUrl =
"https://discord.com/oauth2/authorize?" +
@@ -518,7 +528,7 @@ export class Web3NonCustodialProvider {
client_id: this.twitterOauth2ClientId,
redirect_uri: this.appOrigin + "/api/auth",
scope: "users.read+tweet.read+offline.access+users.email",
- state: btoa(twitterState),
+ state: this.base64Encode(twitterState),
code_challenge: "challenge",
code_challenge_method: "plain",
});
@@ -538,7 +548,7 @@ export class Web3NonCustodialProvider {
redirect_uri: this.appOrigin + "/api/auth",
response_mode: "form_post",
scope: "name email",
- state: btoa(appleState),
+ state: this.base64Encode(appleState),
});
const appleAuthorizeUrl =
"https://appleid.apple.com/auth/authorize?" +
@@ -552,19 +562,20 @@ export class Web3NonCustodialProvider {
}
/** Always place under /auth/mesh */
- handleAuthenticationRoute(): { error: AuthRouteError } | void {
- console.log("Logging params:", window.location.search);
- const params = new URLSearchParams(window.location.search);
- const token = params.get("token");
- const redirect = params.get("redirect");
+ async handleAuthenticationRoute(): Promise<{ error: AuthRouteError } | void> {
+ const linking = getLinking();
+ const urlParams = linking.getURLParams();
+ console.log("Logging params:", urlParams);
+ const token = urlParams["token"] ?? null;
+ const redirect = urlParams["redirect"] ?? null;
console.log(
"Logging from inside handleAuthenticationRoute:",
token,
redirect,
);
if (token && redirect) {
- this.putInStorage(AUTH_KEY, { jwt: token });
- window.location.href = redirect;
+ await this.putInStorage(AUTH_KEY, { jwt: token });
+ await linking.redirectURL(redirect);
return;
}
return {
@@ -639,7 +650,7 @@ export class Web3NonCustodialProvider {
await chrome.storage.sync.set({ [key]: data });
} else if (this.storageLocation === "local_storage") {
// @todo - If this throws try/catch
- localStorage.setItem(key, JSON.stringify(data));
+ await getStorage().setItem(key, JSON.stringify(data));
}
}
private async pushDevice(deviceWallet: {
@@ -713,7 +724,7 @@ export class Web3NonCustodialProvider {
}
} else if (this.storageLocation === "local_storage") {
// @todo - If this throws try/catch
- const data = localStorage.getItem(key);
+ const data = await getStorage().getItem(key);
if (data) {
return {
data: JSON.parse(data) as ObjectType,
@@ -723,7 +734,7 @@ export class Web3NonCustodialProvider {
return {
data: null,
error: new StorageRetrievalError(
- `Unable to retrieve key ${key} from localStorage.`,
+ `Unable to retrieve key ${key} from storage.`,
),
};
}
diff --git a/src/platforms/browser/crypto.browser.ts b/src/platforms/browser/crypto.browser.ts
new file mode 100644
index 0000000..ab54dee
--- /dev/null
+++ b/src/platforms/browser/crypto.browser.ts
@@ -0,0 +1,222 @@
+/**
+ * Browser Crypto Adapter
+ * Implements CryptoAdapter using the Web Crypto API (crypto.subtle)
+ * Works in all modern browsers without polyfills
+ */
+
+import type { CryptoAdapter } from '../../adapters/types';
+
+/**
+ * Map algorithm names to WebCrypto HMAC algorithm identifiers
+ */
+function getHmacAlgorithm(algorithm: string): HmacImportParams {
+ // Normalize algorithm name (handle both "SHA256" and "SHA-256" formats)
+ const normalized = algorithm.toUpperCase().replace(/SHA-?(\d+)/i, 'SHA-$1');
+ return {
+ name: 'HMAC',
+ hash: { name: normalized },
+ };
+}
+
+/**
+ * Browser implementation of CryptoAdapter using Web Crypto API
+ */
+export const cryptoAdapter: CryptoAdapter = {
+ /**
+ * Generate cryptographically secure random bytes using crypto.getRandomValues
+ */
+ getRandomBytes(size: number): Uint8Array {
+ const bytes = new Uint8Array(size);
+ crypto.getRandomValues(bytes);
+ return bytes;
+ },
+
+ /**
+ * HMAC signing using crypto.subtle
+ */
+ async hmacSign(algorithm: string, key: Uint8Array, data: Uint8Array): Promise {
+ const hmacAlgorithm = getHmacAlgorithm(algorithm);
+
+ // Import the key for HMAC signing
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ key,
+ hmacAlgorithm,
+ false,
+ ['sign']
+ );
+
+ // Sign the data
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
+
+ return new Uint8Array(signature);
+ },
+
+ /**
+ * AES-GCM encryption using crypto.subtle
+ */
+ async encrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ // Import the key for AES-GCM encryption
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ key,
+ { name: 'AES-GCM' },
+ false,
+ ['encrypt']
+ );
+
+ // Encrypt the data
+ const ciphertext = await crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv: iv,
+ },
+ cryptoKey,
+ data
+ );
+
+ return new Uint8Array(ciphertext);
+ },
+
+ /**
+ * AES-GCM decryption using crypto.subtle
+ */
+ async decrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ // Import the key for AES-GCM decryption
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ key,
+ { name: 'AES-GCM' },
+ false,
+ ['decrypt']
+ );
+
+ // Decrypt the data
+ const plaintext = await crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv: iv,
+ },
+ cryptoKey,
+ data
+ );
+
+ return new Uint8Array(plaintext);
+ },
+
+ /**
+ * Generate ECDH key pair using P-256 curve
+ * Returns raw public and private key bytes
+ */
+ async generateKeyPair(_algorithm?: string): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> {
+ // Generate ECDH key pair with P-256 curve
+ const keyPair = await crypto.subtle.generateKey(
+ {
+ name: 'ECDH',
+ namedCurve: 'P-256',
+ },
+ true, // extractable
+ ['deriveBits']
+ );
+
+ // Export keys to raw format
+ const publicKeyBuffer = await crypto.subtle.exportKey('raw', keyPair.publicKey);
+ const privateKeyBuffer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
+
+ return {
+ publicKey: new Uint8Array(publicKeyBuffer),
+ privateKey: new Uint8Array(privateKeyBuffer),
+ };
+ },
+
+ /**
+ * Derive shared secret from ECDH key exchange
+ * Uses P-256 curve and returns 256 bits (32 bytes)
+ */
+ async deriveSharedSecret(privateKey: Uint8Array, publicKey: Uint8Array): Promise {
+ // Import the private key (PKCS8 format)
+ const privateKeyObj = await crypto.subtle.importKey(
+ 'pkcs8',
+ privateKey,
+ {
+ name: 'ECDH',
+ namedCurve: 'P-256',
+ },
+ false,
+ ['deriveBits']
+ );
+
+ // Import the public key (raw format - uncompressed point)
+ const publicKeyObj = await crypto.subtle.importKey(
+ 'raw',
+ publicKey,
+ {
+ name: 'ECDH',
+ namedCurve: 'P-256',
+ },
+ false,
+ []
+ );
+
+ // Derive the shared secret (256 bits for P-256)
+ const sharedSecret = await crypto.subtle.deriveBits(
+ {
+ name: 'ECDH',
+ public: publicKeyObj,
+ },
+ privateKeyObj,
+ 256 // P-256 produces 256 bits
+ );
+
+ return new Uint8Array(sharedSecret);
+ },
+
+ /**
+ * Import raw key for crypto operations
+ * Supports AES-GCM and HMAC algorithms
+ */
+ async importKey(keyData: Uint8Array, algorithm: string, usages: string[]): Promise {
+ // Normalize algorithm name
+ const normalizedAlgorithm = algorithm.toUpperCase();
+
+ let algorithmParams: AlgorithmIdentifier | HmacImportParams | AesKeyAlgorithm;
+
+ if (normalizedAlgorithm === 'AES-GCM' || normalizedAlgorithm === 'AESGCM') {
+ algorithmParams = { name: 'AES-GCM' };
+ } else if (normalizedAlgorithm.startsWith('HMAC')) {
+ // Extract hash algorithm if specified (e.g., "HMAC-SHA-256")
+ const hashMatch = normalizedAlgorithm.match(/HMAC[- ]?(SHA[- ]?\d+)?/i);
+ const hashName = hashMatch?.[1]?.replace(/[- ]/g, '-') || 'SHA-256';
+ algorithmParams = {
+ name: 'HMAC',
+ hash: { name: hashName },
+ };
+ } else if (normalizedAlgorithm.includes('SHA')) {
+ // HMAC with specified hash
+ algorithmParams = {
+ name: 'HMAC',
+ hash: { name: normalizedAlgorithm.replace(/SHA-?(\d+)/i, 'SHA-$1') },
+ };
+ } else {
+ // Default to the algorithm name as-is
+ algorithmParams = { name: algorithm };
+ }
+
+ return crypto.subtle.importKey(
+ 'raw',
+ keyData,
+ algorithmParams,
+ false,
+ usages as KeyUsage[]
+ );
+ },
+
+ getSubtleCrypto(): SubtleCrypto {
+ return crypto.subtle;
+ },
+
+ getRandomValuesInPlace(array: T): T {
+ crypto.getRandomValues(array);
+ return array;
+ },
+};
diff --git a/src/platforms/browser/encoding.browser.ts b/src/platforms/browser/encoding.browser.ts
new file mode 100644
index 0000000..4599f8c
--- /dev/null
+++ b/src/platforms/browser/encoding.browser.ts
@@ -0,0 +1,51 @@
+/**
+ * Browser Encoding Adapter
+ * Replaces Node.js Buffer with browser-native APIs
+ */
+
+import type { EncodingAdapter } from '../../adapters/types';
+
+export const encodingAdapter: EncodingAdapter = {
+ bytesToBase64(bytes: Uint8Array): string {
+ // Use btoa with binary string conversion
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ const byte = bytes[i];
+ if (byte !== undefined) {
+ binary += String.fromCharCode(byte);
+ }
+ }
+ return btoa(binary);
+ },
+
+ base64ToBytes(base64: string): Uint8Array {
+ // Handle URL-safe base64
+ const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(normalized);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+ },
+
+ bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+ },
+
+ hexToBytes(hex: string): Uint8Array {
+ const matches = hex.match(/.{1,2}/g);
+ if (!matches) return new Uint8Array(0);
+ return new Uint8Array(matches.map(byte => parseInt(byte, 16)));
+ },
+
+ bytesToUtf8(bytes: Uint8Array): string {
+ return new TextDecoder().decode(bytes);
+ },
+
+ utf8ToBytes(str: string): Uint8Array {
+ return new TextEncoder().encode(str);
+ },
+};
diff --git a/src/platforms/browser/index.ts b/src/platforms/browser/index.ts
new file mode 100644
index 0000000..dd41853
--- /dev/null
+++ b/src/platforms/browser/index.ts
@@ -0,0 +1,17 @@
+export { encodingAdapter } from './encoding.browser';
+export { storageAdapter } from './storage.browser';
+export { linkingAdapter } from './linking.browser';
+export { cryptoAdapter } from './crypto.browser';
+
+import type { PlatformAdapters } from '../../adapters/types';
+import { cryptoAdapter } from './crypto.browser';
+import { storageAdapter } from './storage.browser';
+import { linkingAdapter } from './linking.browser';
+import { encodingAdapter } from './encoding.browser';
+
+export const browserAdapters: PlatformAdapters = {
+ crypto: cryptoAdapter,
+ storage: storageAdapter,
+ linking: linkingAdapter,
+ encoding: encodingAdapter,
+};
diff --git a/src/platforms/browser/linking.browser.ts b/src/platforms/browser/linking.browser.ts
new file mode 100644
index 0000000..22d1336
--- /dev/null
+++ b/src/platforms/browser/linking.browser.ts
@@ -0,0 +1,199 @@
+/**
+ * Browser Linking Adapter
+ * Handles URL opening, OAuth popups, and URL parsing for browsers
+ */
+
+import type { LinkingAdapter, AuthCallbackResult } from '../../adapters/types';
+
+/**
+ * Calculate centered popup position relative to current window
+ */
+function calculatePopupPosition(width: number, height: number): { left: number; top: number } {
+ const screenWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
+ const screenHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
+
+ // Account for window position on multi-monitor setups
+ const windowLeft = window.screenX || window.screenLeft || 0;
+ const windowTop = window.screenY || window.screenTop || 0;
+
+ const left = (screenWidth - width) / 2 + windowLeft;
+ const top = (screenHeight - height) / 2 + windowTop;
+
+ return {
+ left: Math.max(0, left),
+ top: Math.max(0, top)
+ };
+}
+
+/**
+ * Build window features string for popup
+ * Handles mobile/fullscreen scenarios
+ */
+function buildWindowFeatures(width: number, height: number): string {
+ const windowWidth = window.innerWidth || 0;
+ const windowHeight = window.innerHeight || 0;
+
+ const isMobile = windowWidth < 640;
+ const isFullScreen = !!(
+ document.fullscreenElement ||
+ (document as any).webkitFullscreenElement ||
+ (document as any).mozFullScreenElement ||
+ (document as any).msFullscreenElement
+ );
+
+ const shouldDisplayFullScreen = isMobile || isFullScreen;
+
+ const finalWidth = shouldDisplayFullScreen ? windowWidth : width;
+ const finalHeight = shouldDisplayFullScreen ? windowHeight : height;
+
+ const { left, top } = calculatePopupPosition(finalWidth, finalHeight);
+
+ return `left=${left},top=${top},width=${finalWidth},height=${finalHeight},scrollbars=yes,resizable=yes,status=no,location=no,toolbar=no,menubar=no`;
+}
+
+export const linkingAdapter: LinkingAdapter = {
+ /**
+ * Open URL in new browser tab
+ */
+ async openURL(url: string): Promise {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ },
+
+ /**
+ * Redirect current page to URL (same-tab navigation)
+ * Used for auth flow redirects after token storage
+ */
+ async redirectURL(url: string): Promise {
+ window.location.href = url;
+ },
+
+ /**
+ * Open OAuth popup window and wait for callback via postMessage
+ * Handles popup blocked, user close, and message events
+ */
+ async openAuthWindow(url: string, callbackScheme: string): Promise {
+ return new Promise((resolve, reject) => {
+ const width = 448;
+ const height = 668;
+ const features = buildWindowFeatures(width, height);
+
+ const popup = window.open(url, 'utxos', features);
+
+ if (!popup) {
+ reject(new Error('Popup blocked. Please allow popups for this site.'));
+ return;
+ }
+
+ let pollTimer: ReturnType | null = null;
+ let resolved = false;
+
+ const cleanup = () => {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ window.removeEventListener('message', handleMessage);
+ };
+
+ const resolveOnce = (result: AuthCallbackResult) => {
+ if (resolved) return;
+ resolved = true;
+ cleanup();
+ resolve(result);
+ };
+
+ const handleMessage = (event: MessageEvent) => {
+ // Verify message is from our auth flow
+ if (!event.data || typeof event.data !== 'object') {
+ return;
+ }
+
+ const { code, state, error, error_description, target } = event.data;
+
+ // Check for utxos-specific target or OAuth params
+ if (target === 'utxos' || code || error) {
+ if (!popup.closed) {
+ popup.close();
+ }
+ resolveOnce({
+ code,
+ state,
+ error,
+ errorDescription: error_description,
+ // Include full message data for wallet-specific payloads
+ data: event.data,
+ });
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ // Poll for popup close (user manually closed)
+ pollTimer = setInterval(() => {
+ if (popup.closed) {
+ resolveOnce({
+ error: 'cancelled',
+ errorDescription: 'User closed the popup'
+ });
+ }
+ }, 500);
+ });
+ },
+
+ /**
+ * Get current page URL
+ */
+ getCurrentURL(): string | null {
+ if (typeof window === 'undefined') return null;
+ return window.location.href;
+ },
+
+ /**
+ * Parse URL query parameters from current page
+ */
+ getURLParams(): Record {
+ if (typeof window === 'undefined') return {};
+
+ const params = new URLSearchParams(window.location.search);
+ const result: Record = {};
+
+ params.forEach((value, key) => {
+ result[key] = value;
+ });
+
+ // Also check hash params (for OAuth implicit flow)
+ if (window.location.hash) {
+ const hashParams = new URLSearchParams(window.location.hash.slice(1));
+ hashParams.forEach((value, key) => {
+ result[key] = value;
+ });
+ }
+
+ return result;
+ },
+
+ /**
+ * Add listener for URL changes (SPA navigation)
+ * Returns cleanup function to remove listener
+ */
+ addURLListener(callback: (url: string) => void): () => void {
+ if (typeof window === 'undefined') {
+ return () => {};
+ }
+
+ const handler = () => callback(window.location.href);
+
+ window.addEventListener('popstate', handler);
+ window.addEventListener('hashchange', handler);
+
+ return () => {
+ window.removeEventListener('popstate', handler);
+ window.removeEventListener('hashchange', handler);
+ };
+ },
+
+ getUserAgent(): string | null {
+ if (typeof navigator === 'undefined') return null;
+ return navigator.userAgent;
+ },
+};
diff --git a/src/platforms/browser/storage.browser.ts b/src/platforms/browser/storage.browser.ts
new file mode 100644
index 0000000..d776099
--- /dev/null
+++ b/src/platforms/browser/storage.browser.ts
@@ -0,0 +1,42 @@
+/**
+ * Browser Storage Adapter
+ * Wraps localStorage with async interface for cross-platform consistency
+ */
+
+import type { StorageAdapter } from '../../adapters/types';
+
+export const storageAdapter: StorageAdapter = {
+ async getItem(key: string): Promise {
+ try {
+ return localStorage.getItem(key);
+ } catch (error) {
+ console.warn('@utxos/sdk: localStorage.getItem failed:', error);
+ return null;
+ }
+ },
+
+ async setItem(key: string, value: string): Promise {
+ try {
+ localStorage.setItem(key, value);
+ } catch (error) {
+ console.error('@utxos/sdk: localStorage.setItem failed:', error);
+ throw new Error(`Failed to save to storage: ${error}`);
+ }
+ },
+
+ async removeItem(key: string): Promise {
+ try {
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.warn('@utxos/sdk: localStorage.removeItem failed:', error);
+ }
+ },
+
+ async clear(): Promise {
+ try {
+ localStorage.clear();
+ } catch (error) {
+ console.warn('@utxos/sdk: localStorage.clear failed:', error);
+ }
+ },
+};
diff --git a/src/platforms/react-native/crypto.native.ts b/src/platforms/react-native/crypto.native.ts
new file mode 100644
index 0000000..14e4e26
--- /dev/null
+++ b/src/platforms/react-native/crypto.native.ts
@@ -0,0 +1,131 @@
+/**
+ * React Native Crypto Adapter
+ * Uses react-native-quick-crypto (JSI-based) or WebCrypto polyfill
+ */
+
+import type { CryptoAdapter } from '../../adapters/types';
+
+// Try to use react-native-quick-crypto if available (much faster, JSI-based)
+let QuickCrypto: any = null;
+try {
+ QuickCrypto = require('react-native-quick-crypto');
+} catch {
+ // Fall back to global.crypto (requires react-native-get-random-values polyfill)
+}
+
+// Get crypto object - either from quick-crypto or global polyfill
+function getCrypto(): Crypto {
+ if (QuickCrypto?.webcrypto) {
+ return QuickCrypto.webcrypto;
+ }
+ if (typeof global !== 'undefined' && global.crypto?.subtle) {
+ return global.crypto;
+ }
+ throw new Error(
+ '@utxos/sdk: No crypto implementation found. ' +
+ 'Install react-native-quick-crypto or react-native-get-random-values'
+ );
+}
+
+/**
+ * Map algorithm names to WebCrypto HMAC algorithm identifiers
+ */
+function getHmacAlgorithm(algorithm: string): HmacImportParams {
+ // Normalize algorithm name (handle both "SHA256" and "SHA-256" formats)
+ const normalized = algorithm.toUpperCase().replace(/SHA-?(\d+)/i, 'SHA-$1');
+ return {
+ name: 'HMAC',
+ hash: { name: normalized },
+ };
+}
+
+export const cryptoAdapter: CryptoAdapter = {
+ getRandomBytes(size: number): Uint8Array {
+ const bytes = new Uint8Array(size);
+ getCrypto().getRandomValues(bytes);
+ return bytes;
+ },
+
+ async hmacSign(algorithm: string, key: Uint8Array, data: Uint8Array): Promise {
+ const crypto = getCrypto();
+ const hmacAlgorithm = getHmacAlgorithm(algorithm);
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ key,
+ hmacAlgorithm,
+ false,
+ ['sign']
+ );
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
+ return new Uint8Array(signature);
+ },
+
+ async encrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ const crypto = getCrypto();
+ const cryptoKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, data);
+ return new Uint8Array(encrypted);
+ },
+
+ async decrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ const crypto = getCrypto();
+ const cryptoKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['decrypt']);
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, data);
+ return new Uint8Array(decrypted);
+ },
+
+ async generateKeyPair(
+ algorithm = 'ECDH'
+ ): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> {
+ const crypto = getCrypto();
+ const keyPair = await crypto.subtle.generateKey(
+ { name: algorithm, namedCurve: 'P-256' },
+ true,
+ algorithm === 'ECDH' ? ['deriveBits'] : ['sign', 'verify']
+ );
+ const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
+ const privateKey = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
+ return {
+ publicKey: new Uint8Array(publicKey),
+ privateKey: new Uint8Array(privateKey),
+ };
+ },
+
+ async deriveSharedSecret(privateKey: Uint8Array, publicKey: Uint8Array): Promise {
+ const crypto = getCrypto();
+ const privKey = await crypto.subtle.importKey(
+ 'pkcs8',
+ privateKey,
+ { name: 'ECDH', namedCurve: 'P-256' },
+ false,
+ ['deriveBits']
+ );
+ const pubKey = await crypto.subtle.importKey(
+ 'raw',
+ publicKey,
+ { name: 'ECDH', namedCurve: 'P-256' },
+ false,
+ []
+ );
+ const sharedSecret = await crypto.subtle.deriveBits(
+ { name: 'ECDH', public: pubKey },
+ privKey,
+ 256
+ );
+ return new Uint8Array(sharedSecret);
+ },
+
+ async importKey(keyData: Uint8Array, algorithm: string, usages: string[]): Promise {
+ const crypto = getCrypto();
+ return crypto.subtle.importKey('raw', keyData, algorithm, false, usages as KeyUsage[]);
+ },
+
+ getSubtleCrypto(): SubtleCrypto {
+ return getCrypto().subtle;
+ },
+
+ getRandomValuesInPlace(array: T): T {
+ getCrypto().getRandomValues(array);
+ return array;
+ },
+};
diff --git a/src/platforms/react-native/encoding.native.ts b/src/platforms/react-native/encoding.native.ts
new file mode 100644
index 0000000..b09195b
--- /dev/null
+++ b/src/platforms/react-native/encoding.native.ts
@@ -0,0 +1,142 @@
+/**
+ * React Native Encoding Adapter
+ * Uses same implementation as browser (TextEncoder/TextDecoder available in RN)
+ */
+
+import type { EncodingAdapter } from '../../adapters/types';
+
+// React Native has TextEncoder/TextDecoder since React Native 0.72+
+// For older versions, a polyfill may be needed
+
+// Base64 character lookup table
+const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+/**
+ * Manual base64 encode implementation for React Native
+ * React Native doesn't have btoa/atob natively
+ */
+function base64Encode(str: string): string {
+ let result = '';
+ const len = str.length;
+
+ for (let i = 0; i < len; i += 3) {
+ const a = str.charCodeAt(i);
+ const b = i + 1 < len ? str.charCodeAt(i + 1) : 0;
+ const c = i + 2 < len ? str.charCodeAt(i + 2) : 0;
+
+ result += BASE64_CHARS[a >> 2];
+ result += BASE64_CHARS[((a & 3) << 4) | (b >> 4)];
+ result += i + 1 < len ? BASE64_CHARS[((b & 15) << 2) | (c >> 6)] : '=';
+ result += i + 2 < len ? BASE64_CHARS[c & 63] : '=';
+ }
+
+ return result;
+}
+
+/**
+ * Manual base64 decode implementation for React Native
+ * React Native doesn't have btoa/atob natively
+ */
+function base64Decode(str: string): string {
+ // Handle URL-safe base64
+ const normalized = str.replace(/-/g, '+').replace(/_/g, '/');
+
+ // Remove padding
+ const cleanBase64 = normalized.replace(/=/g, '');
+ const len = cleanBase64.length;
+
+ let result = '';
+ for (let i = 0; i < len; i += 4) {
+ const a = BASE64_CHARS.indexOf(cleanBase64[i] ?? '');
+ const b = BASE64_CHARS.indexOf(cleanBase64[i + 1] ?? '');
+ const c = i + 2 < len ? BASE64_CHARS.indexOf(cleanBase64[i + 2] ?? '') : 0;
+ const d = i + 3 < len ? BASE64_CHARS.indexOf(cleanBase64[i + 3] ?? '') : 0;
+
+ result += String.fromCharCode((a << 2) | (b >> 4));
+ if (i + 2 < len) result += String.fromCharCode(((b & 15) << 4) | (c >> 2));
+ if (i + 3 < len) result += String.fromCharCode(((c & 3) << 6) | d);
+ }
+
+ return result;
+}
+
+export const encodingAdapter: EncodingAdapter = {
+ bytesToBase64(bytes: Uint8Array): string {
+ // Convert bytes to binary string then encode
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ const byte = bytes[i];
+ if (byte !== undefined) {
+ binary += String.fromCharCode(byte);
+ }
+ }
+ return base64Encode(binary);
+ },
+
+ base64ToBytes(base64: string): Uint8Array {
+ // Handle URL-safe base64
+ const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
+
+ // Remove padding
+ const cleanBase64 = normalized.replace(/=/g, '');
+ const len = cleanBase64.length;
+ const byteLen = Math.floor((len * 3) / 4);
+ const bytes = new Uint8Array(byteLen);
+
+ let p = 0;
+ for (let i = 0; i < len; i += 4) {
+ const a = BASE64_CHARS.indexOf(cleanBase64[i] ?? '');
+ const b = BASE64_CHARS.indexOf(cleanBase64[i + 1] ?? '');
+ const c = i + 2 < len ? BASE64_CHARS.indexOf(cleanBase64[i + 2] ?? '') : 0;
+ const d = i + 3 < len ? BASE64_CHARS.indexOf(cleanBase64[i + 3] ?? '') : 0;
+
+ bytes[p++] = (a << 2) | (b >> 4);
+ if (p < byteLen) bytes[p++] = ((b & 15) << 4) | (c >> 2);
+ if (p < byteLen) bytes[p++] = ((c & 3) << 6) | d;
+ }
+
+ return bytes;
+ },
+
+ bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join('');
+ },
+
+ hexToBytes(hex: string): Uint8Array {
+ const matches = hex.match(/.{1,2}/g);
+ if (!matches) return new Uint8Array(0);
+ return new Uint8Array(matches.map(byte => parseInt(byte, 16)));
+ },
+
+ bytesToUtf8(bytes: Uint8Array): string {
+ // TextDecoder available in RN 0.72+
+ if (typeof TextDecoder !== 'undefined') {
+ return new TextDecoder().decode(bytes);
+ }
+ // Fallback for older RN versions
+ let str = '';
+ for (let i = 0; i < bytes.length; i++) {
+ const byte = bytes[i];
+ if (byte !== undefined) {
+ str += String.fromCharCode(byte);
+ }
+ }
+ return decodeURIComponent(escape(str));
+ },
+
+ utf8ToBytes(str: string): Uint8Array {
+ // TextEncoder available in RN 0.72+
+ if (typeof TextEncoder !== 'undefined') {
+ return new TextEncoder().encode(str);
+ }
+ // Fallback for older RN versions
+ const encoded = unescape(encodeURIComponent(str));
+ const bytes = new Uint8Array(encoded.length);
+ for (let i = 0; i < encoded.length; i++) {
+ bytes[i] = encoded.charCodeAt(i);
+ }
+ return bytes;
+ },
+};
diff --git a/src/platforms/react-native/index.ts b/src/platforms/react-native/index.ts
new file mode 100644
index 0000000..53fdc11
--- /dev/null
+++ b/src/platforms/react-native/index.ts
@@ -0,0 +1,17 @@
+export { cryptoAdapter } from './crypto.native';
+export { storageAdapter } from './storage.native';
+export { linkingAdapter } from './linking.native';
+export { encodingAdapter } from './encoding.native';
+
+import type { PlatformAdapters } from '../../adapters/types';
+import { cryptoAdapter } from './crypto.native';
+import { storageAdapter } from './storage.native';
+import { linkingAdapter } from './linking.native';
+import { encodingAdapter } from './encoding.native';
+
+export const reactNativeAdapters: PlatformAdapters = {
+ crypto: cryptoAdapter,
+ storage: storageAdapter,
+ linking: linkingAdapter,
+ encoding: encodingAdapter,
+};
diff --git a/src/platforms/react-native/linking.native.ts b/src/platforms/react-native/linking.native.ts
new file mode 100644
index 0000000..f4275de
--- /dev/null
+++ b/src/platforms/react-native/linking.native.ts
@@ -0,0 +1,123 @@
+/**
+ * React Native Linking Adapter
+ * Uses React Native Linking API and optional InAppBrowser for OAuth
+ */
+
+import type { LinkingAdapter, AuthCallbackResult } from '../../adapters/types';
+import type { ReactNativeLinking } from './react-native.types';
+
+// Dynamic import to avoid bundler issues when react-native isn't available
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const Linking = (
+ require('react-native') as { Linking: ReactNativeLinking }
+).Linking;
+
+// Optional: react-native-inappbrowser-reborn for better OAuth UX
+let InAppBrowser: any = null;
+try {
+ InAppBrowser = require('react-native-inappbrowser-reborn').default;
+} catch {
+ // InAppBrowser not installed, will use Linking.openURL
+}
+
+export const linkingAdapter: LinkingAdapter = {
+ async openURL(url: string): Promise {
+ const canOpen = await Linking.canOpenURL(url);
+ if (canOpen) {
+ await Linking.openURL(url);
+ } else {
+ throw new Error(`Cannot open URL: ${url}`);
+ }
+ },
+
+ /**
+ * Redirect to URL - in React Native, this is equivalent to openURL
+ * since there's no browser-style same-tab navigation
+ */
+ async redirectURL(url: string): Promise {
+ await this.openURL(url);
+ },
+
+ async openAuthWindow(url: string, callbackScheme: string): Promise {
+ // If InAppBrowser is available, use it for better UX
+ if (InAppBrowser && (await InAppBrowser.isAvailable())) {
+ try {
+ const result = await InAppBrowser.openAuth(url, callbackScheme, {
+ showTitle: false,
+ enableUrlBarHiding: true,
+ enableDefaultShare: false,
+ ephemeralWebSession: false,
+ });
+
+ if (result.type === 'success' && result.url) {
+ const callbackUrl = new URL(result.url);
+ return {
+ code: callbackUrl.searchParams.get('code') || undefined,
+ state: callbackUrl.searchParams.get('state') || undefined,
+ error: callbackUrl.searchParams.get('error') || undefined,
+ errorDescription: callbackUrl.searchParams.get('error_description') || undefined,
+ };
+ } else if (result.type === 'cancel') {
+ return { error: 'cancelled', errorDescription: 'User cancelled authentication' };
+ }
+ return { error: 'unknown', errorDescription: 'Unknown authentication result' };
+ } catch (error) {
+ return { error: 'failed', errorDescription: String(error) };
+ }
+ }
+
+ // Fallback: Open in external browser and wait for deep link
+ return new Promise((resolve) => {
+ const subscription = Linking.addEventListener('url', (event: { url: string }) => {
+ const callbackUrl = event.url;
+ if (callbackUrl.startsWith(callbackScheme)) {
+ subscription.remove();
+ try {
+ const parsedUrl = new URL(callbackUrl);
+ resolve({
+ code: parsedUrl.searchParams.get('code') || undefined,
+ state: parsedUrl.searchParams.get('state') || undefined,
+ error: parsedUrl.searchParams.get('error') || undefined,
+ errorDescription: parsedUrl.searchParams.get('error_description') || undefined,
+ });
+ } catch {
+ resolve({ error: 'parse_error', errorDescription: 'Failed to parse callback URL' });
+ }
+ }
+ });
+
+ Linking.openURL(url).catch((error: unknown) => {
+ subscription.remove();
+ resolve({ error: 'open_failed', errorDescription: String(error) });
+ });
+ });
+ },
+
+ getCurrentURL(): string | null {
+ // React Native doesn't have a "current URL" like browsers
+ // Initial URL is handled differently
+ return null;
+ },
+
+ getURLParams(): Record {
+ // In React Native, URL params come from deep links, not a global location
+ return {};
+ },
+
+ addURLListener(callback: (url: string) => void): () => void {
+ const subscription = Linking.addEventListener('url', (event: { url: string }) => {
+ callback(event.url);
+ });
+
+ // Also check for initial URL
+ Linking.getInitialURL().then((url: string | null) => {
+ if (url) callback(url);
+ });
+
+ return () => subscription.remove();
+ },
+
+ getUserAgent(): string | null {
+ return 'ReactNative';
+ },
+};
diff --git a/src/platforms/react-native/react-native.types.ts b/src/platforms/react-native/react-native.types.ts
new file mode 100644
index 0000000..d7f539b
--- /dev/null
+++ b/src/platforms/react-native/react-native.types.ts
@@ -0,0 +1,40 @@
+/**
+ * Type declarations for React Native APIs used by the SDK
+ * These are provided locally to avoid requiring @types/react-native as a dependency.
+ * When bundled for React Native, the actual react-native module will provide the implementation.
+ */
+
+/**
+ * Event emitter subscription returned by addEventListener
+ */
+export interface ReactNativeEventSubscription {
+ remove(): void;
+}
+
+/**
+ * URL event from Linking.addEventListener
+ */
+export interface ReactNativeLinkingURLEvent {
+ url: string;
+}
+
+/**
+ * Minimal Linking API types used by this SDK
+ */
+export interface ReactNativeLinking {
+ canOpenURL(url: string): Promise;
+ openURL(url: string): Promise;
+ getInitialURL(): Promise;
+ addEventListener(
+ type: 'url',
+ handler: (event: ReactNativeLinkingURLEvent) => void
+ ): ReactNativeEventSubscription;
+}
+
+/**
+ * Minimal Platform API types used by this SDK
+ */
+export interface ReactNativePlatform {
+ OS: 'ios' | 'android';
+ Version: number;
+}
diff --git a/src/platforms/react-native/storage.native.ts b/src/platforms/react-native/storage.native.ts
new file mode 100644
index 0000000..adae60e
--- /dev/null
+++ b/src/platforms/react-native/storage.native.ts
@@ -0,0 +1,40 @@
+/**
+ * React Native Storage Adapter
+ * Uses @react-native-async-storage/async-storage
+ */
+
+import type { StorageAdapter } from '../../adapters/types';
+
+// Lazy load AsyncStorage to avoid errors if not installed
+let AsyncStorage: any = null;
+function getAsyncStorage() {
+ if (!AsyncStorage) {
+ try {
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
+ } catch {
+ throw new Error(
+ '@utxos/sdk: @react-native-async-storage/async-storage is required for React Native. ' +
+ 'Install it with: npm install @react-native-async-storage/async-storage'
+ );
+ }
+ }
+ return AsyncStorage;
+}
+
+export const storageAdapter: StorageAdapter = {
+ async getItem(key: string): Promise {
+ return getAsyncStorage().getItem(key);
+ },
+
+ async setItem(key: string, value: string): Promise {
+ await getAsyncStorage().setItem(key, value);
+ },
+
+ async removeItem(key: string): Promise {
+ await getAsyncStorage().removeItem(key);
+ },
+
+ async clear(): Promise {
+ await getAsyncStorage().clear();
+ },
+};
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 0000000..56d6518
--- /dev/null
+++ b/tsup.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts', 'src/index.native.ts'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ external: [
+ // React Native dependencies - only available in RN environment
+ 'react-native',
+ 'react-native-quick-crypto',
+ '@react-native-async-storage/async-storage',
+ 'react-native-inappbrowser-reborn',
+ ],
+ // Don't bundle peer dependencies
+ skipNodeModulesBundle: false,
+});