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 && ( + Avatar + )} +
+
+ {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, +});