From 624c78d00454788f4fcf5b43d3cf2be06f1b6f56 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 00:08:30 +0800 Subject: [PATCH 1/6] init react native --- examples/nextjs/.env.example | 8 + examples/nextjs/.gitignore | 30 + examples/nextjs/app/layout.tsx | 27 + examples/nextjs/app/page.tsx | 394 +++++++++++++ examples/nextjs/next.config.js | 6 + examples/nextjs/package.json | 23 + examples/nextjs/tsconfig.json | 26 + examples/react-native/.gitignore | 41 ++ examples/react-native/App.tsx | 516 ++++++++++++++++++ examples/react-native/app.json | 30 + .../react-native/assets/adaptive-icon.png | Bin 0 -> 17547 bytes examples/react-native/assets/favicon.png | Bin 0 -> 1466 bytes examples/react-native/assets/icon.png | Bin 0 -> 22380 bytes examples/react-native/assets/splash-icon.png | Bin 0 -> 17547 bytes examples/react-native/index.ts | 12 + examples/react-native/metro.config.js | 33 ++ examples/react-native/package.json | 39 ++ examples/react-native/shim.js | 23 + examples/react-native/tsconfig.json | 6 + jest.config.js | 1 + jest.setup.ts | 12 + package.json | 49 +- src/adapters/index.ts | 1 + src/adapters/types.ts | 223 ++++++++ src/functions/crypto/encryption.ts | 40 +- src/functions/crypto/hash.ts | 55 +- src/functions/window/open-window.ts | 98 ++-- src/index.native.ts | 35 ++ src/index.ts | 8 + src/internal/index.ts | 9 + src/internal/platform-context.ts | 79 +++ src/non-custodial/index.ts | 22 +- src/platforms/browser/crypto.browser.ts | 213 ++++++++ src/platforms/browser/encoding.browser.ts | 51 ++ src/platforms/browser/index.ts | 17 + src/platforms/browser/linking.browser.ts | 186 +++++++ src/platforms/browser/storage.browser.ts | 42 ++ src/platforms/react-native/crypto.native.ts | 109 ++++ src/platforms/react-native/encoding.native.ts | 142 +++++ src/platforms/react-native/index.ts | 17 + src/platforms/react-native/linking.native.ts | 111 ++++ .../react-native/react-native.types.ts | 40 ++ src/platforms/react-native/storage.native.ts | 40 ++ tsup.config.ts | 16 + 44 files changed, 2715 insertions(+), 115 deletions(-) create mode 100644 examples/nextjs/.env.example create mode 100644 examples/nextjs/.gitignore create mode 100644 examples/nextjs/app/layout.tsx create mode 100644 examples/nextjs/app/page.tsx create mode 100644 examples/nextjs/next.config.js create mode 100644 examples/nextjs/package.json create mode 100644 examples/nextjs/tsconfig.json create mode 100644 examples/react-native/.gitignore create mode 100644 examples/react-native/App.tsx create mode 100644 examples/react-native/app.json create mode 100644 examples/react-native/assets/adaptive-icon.png create mode 100644 examples/react-native/assets/favicon.png create mode 100644 examples/react-native/assets/icon.png create mode 100644 examples/react-native/assets/splash-icon.png create mode 100644 examples/react-native/index.ts create mode 100644 examples/react-native/metro.config.js create mode 100644 examples/react-native/package.json create mode 100644 examples/react-native/shim.js create mode 100644 examples/react-native/tsconfig.json create mode 100644 jest.setup.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/types.ts create mode 100644 src/index.native.ts create mode 100644 src/internal/index.ts create mode 100644 src/internal/platform-context.ts create mode 100644 src/platforms/browser/crypto.browser.ts create mode 100644 src/platforms/browser/encoding.browser.ts create mode 100644 src/platforms/browser/index.ts create mode 100644 src/platforms/browser/linking.browser.ts create mode 100644 src/platforms/browser/storage.browser.ts create mode 100644 src/platforms/react-native/crypto.native.ts create mode 100644 src/platforms/react-native/encoding.native.ts create mode 100644 src/platforms/react-native/index.ts create mode 100644 src/platforms/react-native/linking.native.ts create mode 100644 src/platforms/react-native/react-native.types.ts create mode 100644 src/platforms/react-native/storage.native.ts create mode 100644 tsup.config.ts 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/.gitignore b/examples/react-native/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/examples/react-native/.gitignore @@ -0,0 +1,41 @@ +# 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*.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..4731f7e --- /dev/null +++ b/examples/react-native/App.tsx @@ -0,0 +1,516 @@ +import { StatusBar } from 'expo-status-bar'; +import { useState, useCallback } from 'react'; +import { + StyleSheet, + Text, + View, + TouchableOpacity, + ActivityIndicator, + SafeAreaView, + Alert, + Platform, +} from 'react-native'; + +// Demo mode - set to false when SDK polyfills are fully working +const DEMO_MODE = true; + +// UTXOS Configuration +const UTXOS_CONFIG = { + networkId: 0, // 0: preprod, 1: mainnet + projectId: 'demo-project', // Replace with your project ID from https://utxos.dev/dashboard +}; + +type WalletState = { + isConnecting: boolean; + isConnected: boolean; + address: string | null; + error: string | null; +}; + +export default function App() { + const [wallet, setWallet] = useState({ + isConnecting: false, + isConnected: false, + address: null, + error: null, + }); + + const connectWallet = useCallback(async (provider?: string) => { + setWallet(prev => ({ ...prev, isConnecting: true, error: null })); + + try { + if (DEMO_MODE) { + // Demo mode - simulate wallet connection + await new Promise(resolve => setTimeout(resolve, 1500)); + const demoAddress = 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'; + + setWallet({ + isConnecting: false, + isConnected: true, + address: demoAddress, + error: null, + }); + + if (Platform.OS !== 'web') { + Alert.alert('Demo Mode', `Connected via ${provider || 'wallet'}!\nAddress: ${demoAddress.substring(0, 20)}...`); + } + return; + } + + // Production mode - use actual SDK + const { Web3Wallet } = await import('@utxos/sdk'); + const web3Wallet = await Web3Wallet.enable({ + ...UTXOS_CONFIG, + }); + + const addresses = await web3Wallet.getUsedAddresses(); + const address = addresses[0] || null; + + setWallet({ + isConnecting: false, + isConnected: true, + address, + error: null, + }); + + if (Platform.OS !== 'web') { + Alert.alert('Success', `Connected to wallet!\nAddress: ${address?.substring(0, 20)}...`); + } + } catch (error) { + console.error('Wallet connection error:', error); + setWallet(prev => ({ + ...prev, + isConnecting: false, + error: error instanceof Error ? error.message : 'Failed to connect wallet', + })); + } + }, []); + + const disconnectWallet = useCallback(() => { + setWallet({ + isConnecting: false, + isConnected: false, + address: null, + error: null, + }); + }, []); + + return ( + + + + {/* Header */} + + + UTXOS + + Web3 Wallet + Wallet as a Service + + + {/* Main Content */} + + {!wallet.isConnected ? ( + <> + {/* Welcome Card */} + + Welcome + + Connect your wallet using social login or other authentication methods. + + + + {/* Network Badge */} + + + + {UTXOS_CONFIG.networkId === 0 ? 'Preprod Network' : 'Mainnet'} + + + + {/* Social Login Buttons */} + + Sign in with + + connectWallet('google')} + disabled={wallet.isConnecting} + > + G + Continue with Google + + + connectWallet('apple')} + disabled={wallet.isConnecting} + > + 🍎 + + Continue with Apple + + + + connectWallet('discord')} + disabled={wallet.isConnecting} + > + 💬 + + Continue with Discord + + + + + + or + + + + connectWallet()} + disabled={wallet.isConnecting} + > + 🔗 + Connect Wallet + + + + {/* Loading Indicator */} + {wallet.isConnecting && ( + + + Connecting... + + )} + + {/* Error Message */} + {wallet.error && ( + + {wallet.error} + + )} + + ) : ( + <> + {/* Connected State */} + + + + + Wallet Connected + Your wallet is ready to use + + + Address + + {wallet.address || 'No address found'} + + + + + Balance + Loading... + + + + {/* Action Buttons */} + + + 📤 + Send + + + + 📥 + Receive + + + + 📜 + History + + + + {/* Disconnect Button */} + + Disconnect Wallet + + + )} + + + {/* Footer */} + + Powered by UTXOS • Wallet as a Service + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f172a', + }, + header: { + alignItems: 'center', + paddingTop: 20, + paddingBottom: 20, + }, + logoContainer: { + width: 60, + height: 60, + borderRadius: 16, + backgroundColor: '#3b82f6', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + }, + logoText: { + fontSize: 14, + fontWeight: 'bold', + color: '#fff', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#fff', + }, + subtitle: { + fontSize: 14, + color: '#64748b', + marginTop: 4, + }, + content: { + flex: 1, + paddingHorizontal: 24, + }, + card: { + backgroundColor: '#1e293b', + borderRadius: 16, + padding: 20, + marginBottom: 16, + }, + cardTitle: { + fontSize: 20, + fontWeight: '600', + color: '#fff', + marginBottom: 8, + }, + cardText: { + fontSize: 14, + color: '#94a3b8', + lineHeight: 20, + }, + networkBadge: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + backgroundColor: '#1e293b', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + marginBottom: 24, + }, + networkDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#22c55e', + marginRight: 8, + }, + networkText: { + fontSize: 12, + color: '#94a3b8', + }, + loginSection: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 14, + color: '#64748b', + marginBottom: 16, + textAlign: 'center', + }, + socialButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + marginBottom: 12, + }, + googleButton: { + backgroundColor: '#fff', + }, + appleButton: { + backgroundColor: '#000', + }, + discordButton: { + backgroundColor: '#5865F2', + }, + walletButton: { + backgroundColor: '#3b82f6', + }, + socialIcon: { + fontSize: 18, + marginRight: 12, + }, + socialButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#1f2937', + }, + appleButtonText: { + color: '#fff', + }, + discordButtonText: { + color: '#fff', + }, + divider: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 16, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: '#334155', + }, + dividerText: { + color: '#64748b', + paddingHorizontal: 16, + fontSize: 12, + }, + loadingContainer: { + alignItems: 'center', + marginTop: 20, + }, + loadingText: { + color: '#94a3b8', + marginTop: 12, + fontSize: 14, + }, + errorContainer: { + backgroundColor: '#450a0a', + borderRadius: 12, + padding: 16, + marginTop: 16, + }, + errorText: { + color: '#fca5a5', + fontSize: 14, + textAlign: 'center', + }, + connectedCard: { + backgroundColor: '#1e293b', + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 24, + }, + successIcon: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: '#14532d', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + successEmoji: { + fontSize: 32, + }, + connectedTitle: { + fontSize: 22, + fontWeight: 'bold', + color: '#fff', + marginBottom: 4, + }, + connectedSubtitle: { + fontSize: 14, + color: '#94a3b8', + marginBottom: 20, + }, + addressContainer: { + width: '100%', + backgroundColor: '#0f172a', + borderRadius: 12, + padding: 16, + marginBottom: 12, + }, + addressLabel: { + fontSize: 12, + color: '#64748b', + marginBottom: 4, + }, + addressText: { + fontSize: 14, + color: '#fff', + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + balanceContainer: { + width: '100%', + backgroundColor: '#0f172a', + borderRadius: 12, + padding: 16, + }, + balanceLabel: { + fontSize: 12, + color: '#64748b', + marginBottom: 4, + }, + balanceText: { + fontSize: 24, + fontWeight: 'bold', + color: '#fff', + }, + actionButtons: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 24, + }, + actionButton: { + alignItems: 'center', + backgroundColor: '#1e293b', + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: 12, + minWidth: 90, + }, + actionIcon: { + fontSize: 24, + marginBottom: 8, + }, + actionButtonText: { + fontSize: 14, + color: '#fff', + fontWeight: '500', + }, + disconnectButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#ef4444', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + disconnectButtonText: { + color: '#ef4444', + fontSize: 16, + fontWeight: '600', + }, + footer: { + paddingVertical: 16, + alignItems: 'center', + }, + footerText: { + fontSize: 12, + color: '#475569', + }, +}); 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 0000000000000000000000000000000000000000..03d6f6b6c6727954aec1d8206222769afd178d8d GIT binary patch literal 17547 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 literal 0 HcmV?d00001 diff --git a/examples/react-native/assets/icon.png b/examples/react-native/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b1526fc7b78680fd8d733dbc6113e1af695487 GIT binary patch literal 22380 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 literal 0 HcmV?d00001 diff --git a/examples/react-native/assets/splash-icon.png b/examples/react-native/assets/splash-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..03d6f6b6c6727954aec1d8206222769afd178d8d GIT binary patch literal 17547 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 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..6456907 --- /dev/null +++ b/examples/react-native/metro.config.js @@ -0,0 +1,33 @@ +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'), +]; + +// Handle the react-native conditional export +config.resolver.resolverMainFields = ['react-native', 'browser', 'main']; + +// Ensure we can resolve the SDK package +config.resolver.extraNodeModules = { + '@utxos/sdk': sdkPath, +}; + +// Don't resolve symlinks (important for linked packages) +config.resolver.disableHierarchicalLookup = false; + +module.exports = config; diff --git a/examples/react-native/package.json b/examples/react-native/package.json new file mode 100644 index 0000000..c631548 --- /dev/null +++ b/examples/react-native/package.json @@ -0,0 +1,39 @@ +{ + "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-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..f488753 --- /dev/null +++ b/examples/react-native/shim.js @@ -0,0 +1,23 @@ +// Polyfills for React Native - MUST be imported first +import { Buffer } from 'buffer'; + +// Make Buffer global +if (typeof global.Buffer === 'undefined') { + global.Buffer = Buffer; +} + +// Polyfill process +if (typeof global.process === 'undefined') { + global.process = { env: {}, version: '' }; +} + +// TextEncoder/TextDecoder are built-in for React Native 0.72+ +// No polyfill needed for modern Expo/React Native + +// Polyfill atob/btoa for React Native +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'); +} 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 36ad2f7..434ef9c 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,31 @@ "version": "0.1.1", "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..070d100 --- /dev/null +++ b/src/adapters/types.ts @@ -0,0 +1,223 @@ +/** + * 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; +} + +/** + * 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; + + /** + * 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; +} + +/** + * 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..189d654 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,8 +46,9 @@ 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 const decrypted = await crypto.subtle.decrypt( @@ -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,7 +90,8 @@ 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", @@ -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,12 +158,9 @@ 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( 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/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 a95f6f2..f1192ab 100644 --- a/src/non-custodial/index.ts +++ b/src/non-custodial/index.ts @@ -5,6 +5,7 @@ import { Web3WalletObject, Web3AuthProvider, } from "../"; +import { getStorage, getLinking } from "../internal/platform-context"; export * from "./utils"; const AUTH_KEY = "mesh-web3-services-auth"; @@ -549,19 +550,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.openURL(redirect); return; } return { @@ -636,7 +638,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: { @@ -710,7 +712,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, @@ -720,7 +722,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..14bac68 --- /dev/null +++ b/src/platforms/browser/crypto.browser.ts @@ -0,0 +1,213 @@ +/** + * 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[] + ); + }, +}; 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..e548a0c --- /dev/null +++ b/src/platforms/browser/linking.browser.ts @@ -0,0 +1,186 @@ +/** + * 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'); + }, + + /** + * 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_auth', 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' || target === 'utxos_auth' || 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); + }; + }, +}; 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..b704d58 --- /dev/null +++ b/src/platforms/react-native/crypto.native.ts @@ -0,0 +1,109 @@ +/** + * 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' + ); +} + +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 cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: algorithm }, + 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[]); + }, +}; 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..af3fb53 --- /dev/null +++ b/src/platforms/react-native/linking.native.ts @@ -0,0 +1,111 @@ +/** + * 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}`); + } + }, + + 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(); + }, +}; 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, +}); From d177a8d5ec16ee4be9a99a427da7182197f1c96e Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 00:12:05 +0800 Subject: [PATCH 2/6] nextjs works --- src/platforms/browser/linking.browser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platforms/browser/linking.browser.ts b/src/platforms/browser/linking.browser.ts index e548a0c..088883e 100644 --- a/src/platforms/browser/linking.browser.ts +++ b/src/platforms/browser/linking.browser.ts @@ -69,7 +69,7 @@ export const linkingAdapter: LinkingAdapter = { const height = 668; const features = buildWindowFeatures(width, height); - const popup = window.open(url, 'utxos_auth', features); + const popup = window.open(url, 'utxos', features); if (!popup) { reject(new Error('Popup blocked. Please allow popups for this site.')); @@ -103,7 +103,7 @@ export const linkingAdapter: LinkingAdapter = { const { code, state, error, error_description, target } = event.data; // Check for utxos-specific target or OAuth params - if (target === 'utxos' || target === 'utxos_auth' || code || error) { + if (target === 'utxos' || code || error) { if (!popup.closed) { popup.close(); } From 49e6950222ac0616f31157387040a968ccfaf5e9 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 00:35:39 +0800 Subject: [PATCH 3/6] react native working --- examples/react-native/.env.example | 7 + examples/react-native/.gitignore | 1 + examples/react-native/App.tsx | 737 ++++++++++++-------------- examples/react-native/metro.config.js | 14 +- examples/react-native/package.json | 1 + examples/react-native/shim.js | 51 +- 6 files changed, 394 insertions(+), 417 deletions(-) create mode 100644 examples/react-native/.env.example 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 index d914c32..da9569c 100644 --- a/examples/react-native/.gitignore +++ b/examples/react-native/.gitignore @@ -31,6 +31,7 @@ yarn-error.* *.pem # local env files +.env .env*.local # typescript diff --git a/examples/react-native/App.tsx b/examples/react-native/App.tsx index 4731f7e..87fb57d 100644 --- a/examples/react-native/App.tsx +++ b/examples/react-native/App.tsx @@ -1,5 +1,5 @@ -import { StatusBar } from 'expo-status-bar'; -import { useState, useCallback } from 'react'; +import { StatusBar } from "expo-status-bar"; +import { useState } from "react"; import { StyleSheet, Text, @@ -7,510 +7,435 @@ import { TouchableOpacity, ActivityIndicator, SafeAreaView, - Alert, + Image, Platform, -} from 'react-native'; - -// Demo mode - set to false when SDK polyfills are fully working -const DEMO_MODE = true; - -// UTXOS Configuration -const UTXOS_CONFIG = { - networkId: 0, // 0: preprod, 1: mainnet - projectId: 'demo-project', // Replace with your project ID from https://utxos.dev/dashboard +} 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 = { - isConnecting: boolean; - isConnected: boolean; - address: string | null; - error: string | null; + 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({ - isConnecting: false, - isConnected: false, - address: null, - error: null, - }); + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(null); - const connectWallet = useCallback(async (provider?: string) => { - setWallet(prev => ({ ...prev, isConnecting: true, error: null })); + const handleConnect = async (provider?: Web3AuthProvider) => { + setLoading(provider || "any"); + setError(null); try { - if (DEMO_MODE) { - // Demo mode - simulate wallet connection - await new Promise(resolve => setTimeout(resolve, 1500)); - const demoAddress = 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp'; - - setWallet({ - isConnecting: false, - isConnected: true, - address: demoAddress, - error: null, - }); + const options: EnableWeb3WalletOptions = { + networkId: UTXOS_CONFIG.networkId as 0 | 1, + projectId: UTXOS_CONFIG.projectId, + directTo: provider, + }; - if (Platform.OS !== 'web') { - Alert.alert('Demo Mode', `Connected via ${provider || 'wallet'}!\nAddress: ${demoAddress.substring(0, 20)}...`); - } - return; - } + const web3Wallet = await Web3Wallet.enable(options); - // Production mode - use actual SDK - const { Web3Wallet } = await import('@utxos/sdk'); - const web3Wallet = await Web3Wallet.enable({ - ...UTXOS_CONFIG, - }); + const user = web3Wallet.getUser(); - const addresses = await web3Wallet.getUsedAddresses(); - const address = addresses[0] || null; + 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({ - isConnecting: false, - isConnected: true, - address, - error: null, + user, + cardanoAddress, + bitcoinAddress, + sparkAddress, }); - - if (Platform.OS !== 'web') { - Alert.alert('Success', `Connected to wallet!\nAddress: ${address?.substring(0, 20)}...`); - } - } catch (error) { - console.error('Wallet connection error:', error); - setWallet(prev => ({ - ...prev, - isConnecting: false, - error: error instanceof Error ? error.message : 'Failed to connect wallet', - })); + } catch (err) { + setError(err instanceof Error ? err.message : "Connection failed"); + } finally { + setLoading(null); } - }, []); + }; - const disconnectWallet = useCallback(() => { - setWallet({ - isConnecting: false, - isConnected: false, - address: null, - error: null, - }); - }, []); + const handleDisconnect = () => { + setWallet(null); + }; return ( - {/* Header */} - - - UTXOS - - Web3 Wallet - Wallet as a Service - - - {/* Main Content */} - {!wallet.isConnected ? ( - <> - {/* Welcome Card */} - - Welcome - - Connect your wallet using social login or other authentication methods. - - - - {/* Network Badge */} - - - - {UTXOS_CONFIG.networkId === 0 ? 'Preprod Network' : 'Mainnet'} - - - - {/* Social Login Buttons */} - - Sign in with - - connectWallet('google')} - disabled={wallet.isConnecting} - > - G - Continue with Google - - - connectWallet('apple')} - disabled={wallet.isConnecting} - > - 🍎 - - Continue with Apple - - + {/* Header */} + + UTXOS Wallet + Web3 Wallet as a Service + - connectWallet('discord')} - disabled={wallet.isConnecting} - > - 💬 - - Continue with Discord - - + {/* Error Message */} + {error && ( + + {error} + + )} - - - or - - + {!wallet ? ( + + {/* Primary Connect Button */} + handleConnect()} + disabled={loading !== null} + > + {loading === "any" ? ( + + ) : ( + Connect Wallet + )} + - connectWallet()} - disabled={wallet.isConnecting} - > - 🔗 - Connect Wallet - + {/* Divider */} + + + or continue with + - {/* Loading Indicator */} - {wallet.isConnecting && ( - - - Connecting... - - )} - - {/* Error Message */} - {wallet.error && ( - - {wallet.error} - - )} - + {/* Social Grid */} + + {PROVIDERS.map((p) => ( + handleConnect(p.id)} + disabled={loading !== null} + > + {p.icon} + {p.label} + {loading === p.id && ( + + )} + + ))} + + ) : ( - <> - {/* Connected State */} - - - - - Wallet Connected - Your wallet is ready to use - - - Address - - {wallet.address || 'No address found'} - + + {/* User Card */} + {wallet.user && ( + + {wallet.user.avatarUrl && ( + + )} + + + {wallet.user.username || wallet.user.email || "User"} + + + ID: {wallet.user.id.slice(0, 8)}... + + + )} - - Balance - Loading... - - - - {/* Action Buttons */} - - - 📤 - Send - - - - 📥 - Receive - - - - 📜 - History - + {/* Address Cards */} + + + + {/* Disconnect Button */} - - Disconnect Wallet + + Disconnect - + )} - - {/* Footer */} - - Powered by UTXOS • Wallet as a Service + {/* 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: '#0f172a', + backgroundColor: "#0f0f1a", }, - header: { - alignItems: 'center', - paddingTop: 20, - paddingBottom: 20, - }, - logoContainer: { - width: 60, - height: 60, - borderRadius: 16, - backgroundColor: '#3b82f6', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 12, + content: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 40, }, - logoText: { - fontSize: 14, - fontWeight: 'bold', - color: '#fff', + header: { + alignItems: "center", + marginBottom: 32, }, title: { fontSize: 28, - fontWeight: 'bold', - color: '#fff', + fontWeight: "700", + color: "#fff", }, subtitle: { fontSize: 14, - color: '#64748b', - marginTop: 4, + color: "#6b7280", + marginTop: 8, }, - content: { - flex: 1, - paddingHorizontal: 24, - }, - card: { - backgroundColor: '#1e293b', - borderRadius: 16, - padding: 20, + errorContainer: { + backgroundColor: "rgba(239,68,68,0.1)", + borderWidth: 1, + borderColor: "rgba(239,68,68,0.3)", + borderRadius: 12, + padding: 12, marginBottom: 16, }, - cardTitle: { - fontSize: 20, - fontWeight: '600', - color: '#fff', - marginBottom: 8, - }, - cardText: { + errorText: { + color: "#fca5a5", fontSize: 14, - color: '#94a3b8', - lineHeight: 20, - }, - networkBadge: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'center', - backgroundColor: '#1e293b', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - marginBottom: 24, - }, - networkDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#22c55e', - marginRight: 8, - }, - networkText: { - fontSize: 12, - color: '#94a3b8', + textAlign: "center", }, loginSection: { - marginBottom: 20, - }, - sectionTitle: { - fontSize: 14, - color: '#64748b', - marginBottom: 16, - textAlign: 'center', + gap: 16, }, - socialButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', + primaryButton: { + width: "100%", paddingVertical: 14, - paddingHorizontal: 20, borderRadius: 12, - marginBottom: 12, - }, - googleButton: { - backgroundColor: '#fff', - }, - appleButton: { - backgroundColor: '#000', - }, - discordButton: { - backgroundColor: '#5865F2', + alignItems: "center", + justifyContent: "center", + backgroundColor: "#3b82f6", }, - walletButton: { - backgroundColor: '#3b82f6', - }, - socialIcon: { - fontSize: 18, - marginRight: 12, - }, - socialButtonText: { + primaryButtonText: { fontSize: 16, - fontWeight: '600', - color: '#1f2937', - }, - appleButtonText: { - color: '#fff', - }, - discordButtonText: { - color: '#fff', + fontWeight: "600", + color: "#fff", }, divider: { - flexDirection: 'row', - alignItems: 'center', - marginVertical: 16, + flexDirection: "row", + alignItems: "center", + marginVertical: 8, }, dividerLine: { flex: 1, height: 1, - backgroundColor: '#334155', + backgroundColor: "rgba(255,255,255,0.1)", }, dividerText: { - color: '#64748b', + color: "#6b7280", paddingHorizontal: 16, fontSize: 12, }, - loadingContainer: { - alignItems: 'center', - marginTop: 20, + socialGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, }, - loadingText: { - color: '#94a3b8', - marginTop: 12, - fontSize: 14, + 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, }, - errorContainer: { - backgroundColor: '#450a0a', - borderRadius: 12, - padding: 16, - marginTop: 16, + socialIcon: { + fontSize: 16, + fontWeight: "700", + color: "#e5e7eb", }, - errorText: { - color: '#fca5a5', + socialButtonText: { fontSize: 14, - textAlign: 'center', - }, - connectedCard: { - backgroundColor: '#1e293b', - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 24, - }, - successIcon: { - width: 64, - height: 64, - borderRadius: 32, - backgroundColor: '#14532d', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 16, - }, - successEmoji: { - fontSize: 32, + fontWeight: "500", + color: "#e5e7eb", }, - connectedTitle: { - fontSize: 22, - fontWeight: 'bold', - color: '#fff', - marginBottom: 4, + spinner: { + marginLeft: 8, }, - connectedSubtitle: { - fontSize: 14, - color: '#94a3b8', - marginBottom: 20, + walletSection: { + gap: 20, }, - addressContainer: { - width: '100%', - backgroundColor: '#0f172a', - borderRadius: 12, + userCard: { + flexDirection: "row", + alignItems: "center", + gap: 16, padding: 16, - marginBottom: 12, + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 12, }, - addressLabel: { - fontSize: 12, - color: '#64748b', - marginBottom: 4, + avatar: { + width: 48, + height: 48, + borderRadius: 24, }, - addressText: { - fontSize: 14, - color: '#fff', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + userInfo: { + flex: 1, }, - balanceContainer: { - width: '100%', - backgroundColor: '#0f172a', - borderRadius: 12, - padding: 16, + userName: { + fontSize: 16, + fontWeight: "600", + color: "#fff", }, - balanceLabel: { + userId: { fontSize: 12, - color: '#64748b', - marginBottom: 4, - }, - balanceText: { - fontSize: 24, - fontWeight: 'bold', - color: '#fff', - }, - actionButtons: { - flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: 24, - }, - actionButton: { - alignItems: 'center', - backgroundColor: '#1e293b', - paddingVertical: 16, - paddingHorizontal: 24, + color: "#6b7280", + marginTop: 4, + }, + addressList: { + gap: 12, + }, + addressCard: { + padding: 16, + backgroundColor: "rgba(0,0,0,0.2)", borderRadius: 12, - minWidth: 90, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.05)", }, - actionIcon: { - fontSize: 24, + addressHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", marginBottom: 8, }, - actionButtonText: { - fontSize: 14, - color: '#fff', - fontWeight: '500', + 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: { - backgroundColor: 'transparent', + width: "100%", + paddingVertical: 12, borderWidth: 1, - borderColor: '#ef4444', - paddingVertical: 14, - borderRadius: 12, - alignItems: 'center', + borderColor: "rgba(248,113,113,0.3)", + borderRadius: 10, + alignItems: "center", }, disconnectButtonText: { - color: '#ef4444', - fontSize: 16, - fontWeight: '600', + fontSize: 14, + fontWeight: "500", + color: "#f87171", }, footer: { - paddingVertical: 16, - alignItems: 'center', - }, - footerText: { + textAlign: "center", fontSize: 12, - color: '#475569', + color: "#4b5563", + marginTop: "auto", + paddingBottom: 20, }, }); diff --git a/examples/react-native/metro.config.js b/examples/react-native/metro.config.js index 6456907..f338812 100644 --- a/examples/react-native/metro.config.js +++ b/examples/react-native/metro.config.js @@ -19,15 +19,25 @@ config.resolver.nodeModulesPaths = [ path.resolve(monorepoRoot, 'node_modules'), ]; -// Handle the react-native conditional export -config.resolver.resolverMainFields = ['react-native', 'browser', 'main']; +// 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 index c631548..4900a44 100644 --- a/examples/react-native/package.json +++ b/examples/react-native/package.json @@ -22,6 +22,7 @@ "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", diff --git a/examples/react-native/shim.js b/examples/react-native/shim.js index f488753..1831ced 100644 --- a/examples/react-native/shim.js +++ b/examples/react-native/shim.js @@ -1,23 +1,56 @@ -// Polyfills for React Native - MUST be imported first +// 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; -// Make Buffer global -if (typeof global.Buffer === 'undefined') { - global.Buffer = Buffer; +// Also ensure Buffer is on window for web targets +if (typeof window !== 'undefined') { + window.Buffer = Buffer; } -// Polyfill process +// 2. Process - many Node.js libraries check process.env if (typeof global.process === 'undefined') { - global.process = { env: {}, version: '' }; + global.process = { env: {}, version: 'v18.0.0', browser: true }; +} else { + global.process.browser = true; +} +if (typeof globalThis.process === 'undefined') { + globalThis.process = global.process; } -// TextEncoder/TextDecoder are built-in for React Native 0.72+ -// No polyfill needed for modern Expo/React Native +// 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; +} -// Polyfill atob/btoa for React Native +// 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'); From 8a10718b7c977832f9537732b6e555ff4e15b131 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 23:19:12 +0800 Subject: [PATCH 4/6] fix redirect URL --- package.json | 2 +- src/adapters/types.ts | 9 +++++++++ src/non-custodial/index.ts | 2 +- src/platforms/browser/linking.browser.ts | 8 ++++++++ src/platforms/react-native/linking.native.ts | 8 ++++++++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 96266df..e0226d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utxos/sdk", - "version": "0.1.2", + "version": "0.1.3", "description": "UTXOS SDK - Web3 infrastructure platform for UTXO blockchains", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 070d100..37404e0 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -113,6 +113,15 @@ export interface LinkingAdapter { */ 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 diff --git a/src/non-custodial/index.ts b/src/non-custodial/index.ts index 12c52b0..3b3762c 100644 --- a/src/non-custodial/index.ts +++ b/src/non-custodial/index.ts @@ -566,7 +566,7 @@ export class Web3NonCustodialProvider { ); if (token && redirect) { await this.putInStorage(AUTH_KEY, { jwt: token }); - await linking.openURL(redirect); + await linking.redirectURL(redirect); return; } return { diff --git a/src/platforms/browser/linking.browser.ts b/src/platforms/browser/linking.browser.ts index 088883e..b5d5fd3 100644 --- a/src/platforms/browser/linking.browser.ts +++ b/src/platforms/browser/linking.browser.ts @@ -59,6 +59,14 @@ export const linkingAdapter: LinkingAdapter = { 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 diff --git a/src/platforms/react-native/linking.native.ts b/src/platforms/react-native/linking.native.ts index af3fb53..603d8b8 100644 --- a/src/platforms/react-native/linking.native.ts +++ b/src/platforms/react-native/linking.native.ts @@ -30,6 +30,14 @@ export const linkingAdapter: LinkingAdapter = { } }, + /** + * 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())) { From e0de42c8570ddbaf33de08dbc2f3e036a16d62a2 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 23:36:24 +0800 Subject: [PATCH 5/6] critical fixes --- examples/react-native/App.tsx | 2 ++ src/adapters/types.ts | 21 ++++++++++++++ src/functions/crypto/index.ts | 29 ++++++++++---------- src/non-custodial/index.ts | 29 +++++++++++++------- src/platforms/browser/crypto.browser.ts | 9 ++++++ src/platforms/browser/linking.browser.ts | 5 ++++ src/platforms/react-native/crypto.native.ts | 24 +++++++++++++++- src/platforms/react-native/linking.native.ts | 4 +++ 8 files changed, 98 insertions(+), 25 deletions(-) diff --git a/examples/react-native/App.tsx b/examples/react-native/App.tsx index 87fb57d..5dd9777 100644 --- a/examples/react-native/App.tsx +++ b/examples/react-native/App.tsx @@ -55,6 +55,8 @@ export default function App() { networkId: UTXOS_CONFIG.networkId as 0 | 1, projectId: UTXOS_CONFIG.projectId, directTo: provider, + + appUrl: 'http://localhost:3000', }; const web3Wallet = await Web3Wallet.enable(options); diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 37404e0..467a057 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -65,6 +65,19 @@ export interface CryptoAdapter { * @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; } /** @@ -150,6 +163,14 @@ export interface LinkingAdapter { * @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; } /** 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/non-custodial/index.ts b/src/non-custodial/index.ts index 3b3762c..285db0e 100644 --- a/src/non-custodial/index.ts +++ b/src/non-custodial/index.ts @@ -5,7 +5,7 @@ import { Web3WalletObject, Web3AuthProvider, } from "../"; -import { getStorage, getLinking } from "../internal/platform-context"; +import { getStorage, getLinking, getEncoding } from "../internal/platform-context"; export * from "./utils"; const AUTH_KEY = "mesh-web3-services-auth"; @@ -192,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 } | { @@ -293,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 }; @@ -375,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() }; @@ -430,7 +439,7 @@ export class Web3NonCustodialProvider { newDeviceShardEncryptionKey, ); - const userAgent = navigator.userAgent; + const userAgent = getLinking().getUserAgent() ?? 'unknown'; const createDeviceBody: CreateDeviceBody = { walletId, @@ -482,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?" + @@ -500,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?" + @@ -519,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", }); @@ -539,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?" + diff --git a/src/platforms/browser/crypto.browser.ts b/src/platforms/browser/crypto.browser.ts index 14bac68..ab54dee 100644 --- a/src/platforms/browser/crypto.browser.ts +++ b/src/platforms/browser/crypto.browser.ts @@ -210,4 +210,13 @@ export const cryptoAdapter: CryptoAdapter = { usages as KeyUsage[] ); }, + + getSubtleCrypto(): SubtleCrypto { + return crypto.subtle; + }, + + getRandomValuesInPlace(array: T): T { + crypto.getRandomValues(array); + return array; + }, }; diff --git a/src/platforms/browser/linking.browser.ts b/src/platforms/browser/linking.browser.ts index b5d5fd3..22d1336 100644 --- a/src/platforms/browser/linking.browser.ts +++ b/src/platforms/browser/linking.browser.ts @@ -191,4 +191,9 @@ export const linkingAdapter: LinkingAdapter = { window.removeEventListener('hashchange', handler); }; }, + + getUserAgent(): string | null { + if (typeof navigator === 'undefined') return null; + return navigator.userAgent; + }, }; diff --git a/src/platforms/react-native/crypto.native.ts b/src/platforms/react-native/crypto.native.ts index b704d58..14e4e26 100644 --- a/src/platforms/react-native/crypto.native.ts +++ b/src/platforms/react-native/crypto.native.ts @@ -27,6 +27,18 @@ function getCrypto(): Crypto { ); } +/** + * 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); @@ -36,10 +48,11 @@ export const cryptoAdapter: CryptoAdapter = { async hmacSign(algorithm: string, key: Uint8Array, data: Uint8Array): Promise { const crypto = getCrypto(); + const hmacAlgorithm = getHmacAlgorithm(algorithm); const cryptoKey = await crypto.subtle.importKey( 'raw', key, - { name: 'HMAC', hash: algorithm }, + hmacAlgorithm, false, ['sign'] ); @@ -106,4 +119,13 @@ export const cryptoAdapter: CryptoAdapter = { 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/linking.native.ts b/src/platforms/react-native/linking.native.ts index 603d8b8..f4275de 100644 --- a/src/platforms/react-native/linking.native.ts +++ b/src/platforms/react-native/linking.native.ts @@ -116,4 +116,8 @@ export const linkingAdapter: LinkingAdapter = { return () => subscription.remove(); }, + + getUserAgent(): string | null { + return 'ReactNative'; + }, }; From 44ec79e5637bb795d5a0a1fbbce28baf7419ca81 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 31 Jan 2026 23:44:59 +0800 Subject: [PATCH 6/6] fix build error --- src/functions/crypto/encryption.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/functions/crypto/encryption.ts b/src/functions/crypto/encryption.ts index 189d654..cc92fab 100644 --- a/src/functions/crypto/encryption.ts +++ b/src/functions/crypto/encryption.ts @@ -50,11 +50,11 @@ export async function decryptWithCipher({ 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 @@ -95,7 +95,7 @@ export async function encryptWithPublicKey({ const _publicKey = await crypto.subtle.importKey( "spki", - publicKeyBuffer, + publicKeyBuffer.slice(), { name: "ECDH", namedCurve: "P-256" }, false, [], @@ -165,7 +165,7 @@ export async function decryptWithPrivateKey({ const _privateKey = await crypto.subtle.importKey( "pkcs8", - privateKeyBuffer, + privateKeyBuffer.slice(), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveKey"], @@ -173,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, [], @@ -188,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);