diff --git a/.github/workflows/templates.json b/.github/workflows/templates.json index df3e751..d783442 100644 --- a/.github/workflows/templates.json +++ b/.github/workflows/templates.json @@ -2,6 +2,7 @@ "mobile/kit-expo-minimal", "mobile/kit-expo-privy", "mobile/kit-expo-uniwind", + "mobile/kit-expo-wallet", "mobile/web3js-expo", "mobile/web3js-expo-minimal", "mobile/web3js-expo-paper" diff --git a/TEMPLATES.md b/TEMPLATES.md index 054a1e0..a1203ca 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -26,6 +26,14 @@ Solana Mobile Templates `expo` `mobile-wallet-adapter` `react-native` `solana-kit` `uniwind` `tailwind` +### [kit-expo-wallet](mobile/kit-expo-wallet) + +`gh:solana-mobile/templates/mobile/kit-expo-wallet` + +> A Solana mobile app template with Expo, React Native, Solana Kit, Mobile Wallet Adapter actions, and Uniwind. + +`expo` `mobile-wallet-adapter` `react-native` `solana-kit` `tailwind` `uniwind` `wallet` `wallet-ui` + ### [web3js-expo](mobile/web3js-expo) `gh:solana-mobile/templates/mobile/web3js-expo` diff --git a/mobile/kit-expo-wallet/.gitignore b/mobile/kit-expo-wallet/.gitignore new file mode 100644 index 0000000..2199037 --- /dev/null +++ b/mobile/kit-expo-wallet/.gitignore @@ -0,0 +1,43 @@ +# 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 +*.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 +.env + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios +/android diff --git a/mobile/kit-expo-wallet/.prettierignore b/mobile/kit-expo-wallet/.prettierignore new file mode 100644 index 0000000..edfa283 --- /dev/null +++ b/mobile/kit-expo-wallet/.prettierignore @@ -0,0 +1,10 @@ +# Add files here to ignore them from prettier formatting +/.expo +/android +/coverage +/dist +/ios +/tmp +pnpm-lock.yaml +.agents +src/uniwind-types.d.ts diff --git a/mobile/kit-expo-wallet/.prettierrc b/mobile/kit-expo-wallet/.prettierrc new file mode 100644 index 0000000..d6c3437 --- /dev/null +++ b/mobile/kit-expo-wallet/.prettierrc @@ -0,0 +1,7 @@ +{ + "arrowParens": "always", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/mobile/kit-expo-wallet/README.md b/mobile/kit-expo-wallet/README.md new file mode 100644 index 0000000..e784f11 --- /dev/null +++ b/mobile/kit-expo-wallet/README.md @@ -0,0 +1,60 @@ +# kit-expo-wallet + +This is an [Expo](https://expo.dev) development-client app template for Solana Mobile wallets. It uses +[@solana/kit](https://www.solanakit.com/) and [@wallet-ui/react-native-kit](https://wallet-ui.dev/) to connect to a +mobile wallet, read account state, and run example wallet actions. + +## Technologies + +- [@solana/kit](https://www.solanakit.com/) +- [@wallet-ui/react-native-kit](https://wallet-ui.dev/) +- [Expo](https://expo.dev) +- [HeroUI Native](https://github.com/heroui-inc/heroui-native) +- [Uniwind](https://uniwind.dev/) (Tailwind CSS for React Native) + +## Included wallet flows + +- Connect and disconnect a mobile wallet. +- Read the connected account balance and recent activity for the selected cluster. +- Sign a message with the connected account. +- Sign a memo transaction. +- Sign a Solana Sign-In payload. +- Sign and submit a memo transaction after checking the connected account can pay the transaction fee. + +## Get started + +1. Install dependencies. + + ```bash + npm install + ``` + +2. Build and run the Android development client. + + ```bash + npm run android + ``` + +This template depends on native modules and `expo-dev-client`, so use a development build instead of Expo Go. + +You can start developing by editing the files inside the `src` directory. Expo Router routes live in `src/app`, and +feature code lives in `src/features`. + +## Wallet and network notes + +- Devnet and Testnet have default RPC URLs. Localhost and Mainnet are disabled until you add an RPC URL in + Settings > Cluster. +- The app asks the selected mobile wallet to approve connection, message signing, sign-in, transaction signing, and + transaction submission requests. +- The sign-and-send demo creates a Memo Program transaction with the text entered in the app. It checks the wallet + balance for the estimated fee before submitting. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Solana documentation](https://solana.com/docs): Learn how to build on Solana. +- [Solana Kit documentation](https://www.solanakit.com/): Learn how to use the JavaScript SDK for Solana. +- [Uniwind documentation](https://uniwind.dev/): Learn how to style your app with Tailwind CSS. +- [Wallet UI documentation](https://wallet-ui.dev/): Learn how to build wallet-enabled Solana apps on web and mobile. diff --git a/mobile/kit-expo-wallet/app.json b/mobile/kit-expo-wallet/app.json new file mode 100644 index 0000000..aee005c --- /dev/null +++ b/mobile/kit-expo-wallet/app.json @@ -0,0 +1,45 @@ +{ + "expo": { + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "backgroundImage": "./assets/images/android-icon-background.png", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "package": "com.anonymous.kit_expo_wallet", + "predictiveBackGestureEnabled": false + }, + "experiments": { + "reactCompiler": true, + "typedRoutes": true + }, + "icon": "./assets/images/icon.png", + "ios": { + "icon": "./assets/expo.icon" + }, + "name": "Kit Expo Wallet", + "orientation": "portrait", + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "android": { + "image": "./assets/images/splash-icon.png", + "imageWidth": 76 + }, + "backgroundColor": "#208AEF" + } + ], + "expo-status-bar" + ], + "scheme": "kit-expo-wallet", + "slug": "kit-expo-wallet", + "userInterfaceStyle": "automatic", + "web": { + "favicon": "./assets/images/favicon.png", + "output": "static" + } + } +} diff --git a/mobile/kit-expo-wallet/assets/expo.icon/Assets/expo-symbol.svg b/mobile/kit-expo-wallet/assets/expo.icon/Assets/expo-symbol.svg new file mode 100644 index 0000000..51d3676 --- /dev/null +++ b/mobile/kit-expo-wallet/assets/expo.icon/Assets/expo-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/kit-expo-wallet/assets/expo.icon/Assets/grid.png b/mobile/kit-expo-wallet/assets/expo.icon/Assets/grid.png new file mode 100644 index 0000000..eefea24 Binary files /dev/null and b/mobile/kit-expo-wallet/assets/expo.icon/Assets/grid.png differ diff --git a/mobile/kit-expo-wallet/assets/expo.icon/icon.json b/mobile/kit-expo-wallet/assets/expo.icon/icon.json new file mode 100644 index 0000000..8f45a0f --- /dev/null +++ b/mobile/kit-expo-wallet/assets/expo.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "expo-symbol.svg", + "name": "expo-symbol", + "position": { + "scale": 1, + "translation-in-points": [1.1008400065293245e-5, -16.046875] + } + }, + { + "image-name": "grid.png", + "name": "grid" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/mobile/kit-expo-wallet/assets/images/android-icon-background.png b/mobile/kit-expo-wallet/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/android-icon-background.png differ diff --git a/mobile/kit-expo-wallet/assets/images/android-icon-foreground.png b/mobile/kit-expo-wallet/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/android-icon-foreground.png differ diff --git a/mobile/kit-expo-wallet/assets/images/android-icon-monochrome.png b/mobile/kit-expo-wallet/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/android-icon-monochrome.png differ diff --git a/mobile/kit-expo-wallet/assets/images/favicon.png b/mobile/kit-expo-wallet/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/favicon.png differ diff --git a/mobile/kit-expo-wallet/assets/images/icon.png b/mobile/kit-expo-wallet/assets/images/icon.png new file mode 100644 index 0000000..67c777a Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/icon.png differ diff --git a/mobile/kit-expo-wallet/assets/images/splash-icon.png b/mobile/kit-expo-wallet/assets/images/splash-icon.png new file mode 100644 index 0000000..6b1642a Binary files /dev/null and b/mobile/kit-expo-wallet/assets/images/splash-icon.png differ diff --git a/mobile/kit-expo-wallet/eslint.config.js b/mobile/kit-expo-wallet/eslint.config.js new file mode 100644 index 0000000..ea589ba --- /dev/null +++ b/mobile/kit-expo-wallet/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config') +const expoConfig = require('eslint-config-expo/flat') + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, +]) diff --git a/mobile/kit-expo-wallet/index.js b/mobile/kit-expo-wallet/index.js new file mode 100644 index 0000000..f6f5291 --- /dev/null +++ b/mobile/kit-expo-wallet/index.js @@ -0,0 +1,3 @@ +// index.js +import './polyfill' +import 'expo-router/entry' diff --git a/mobile/kit-expo-wallet/metro.config.js b/mobile/kit-expo-wallet/metro.config.js new file mode 100644 index 0000000..dd6f334 --- /dev/null +++ b/mobile/kit-expo-wallet/metro.config.js @@ -0,0 +1,15 @@ +const { getDefaultConfig } = require('expo/metro-config') +const { withUniwindConfig } = require('uniwind/metro') // make sure this import exists + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname) + +// Apply uniwind modifications before exporting +const uniwindConfig = withUniwindConfig(config, { + // relative path to your global.css file + cssEntryFile: './src/global.css', + // optional: path to typings + dtsFile: './src/uniwind-types.d.ts', +}) + +module.exports = uniwindConfig diff --git a/mobile/kit-expo-wallet/og-image.png b/mobile/kit-expo-wallet/og-image.png new file mode 100644 index 0000000..f4e4b75 Binary files /dev/null and b/mobile/kit-expo-wallet/og-image.png differ diff --git a/mobile/kit-expo-wallet/package.json b/mobile/kit-expo-wallet/package.json new file mode 100644 index 0000000..652adc1 --- /dev/null +++ b/mobile/kit-expo-wallet/package.json @@ -0,0 +1,86 @@ +{ + "name": "kit-expo-wallet", + "scripts": { + "android": "expo run:android", + "android:build": "expo prebuild -p android", + "build": "tsc --noEmit && npm run android:build", + "ci": "tsc --noEmit && npm run lint:check && npm run format:check && npm run android:build", + "dev": "expo start --clear --dev-client --reset-cache", + "doctor": "npx -y expo-doctor@latest", + "format": "prettier --write .", + "format:check": "prettier --check .", + "ios": "expo run:ios", + "lint": "expo lint --fix", + "lint:check": "expo lint", + "start": "expo start", + "web": "expo start --web" + }, + "displayName": "Kit Expo Wallet example", + "description": "A Solana mobile app template with Expo, React Native, Solana Kit, Mobile Wallet Adapter actions, and Uniwind.", + "usecase": "Mobile", + "keywords": [ + "expo", + "mobile-wallet-adapter", + "react-native", + "solana-kit", + "tailwind", + "uniwind", + "wallet", + "wallet-ui" + ], + "create-solana-dapp": { + "instructions": [ + "Build the Android app locally:", + "+{pm} run android" + ], + "versions:": { + "adb": "33.0.0" + } + }, + "main": "./index.js", + "version": "0.0.0", + "dependencies": { + "@expo/metro-runtime": "~56.0.12", + "@expo/vector-icons": "^15.0.2", + "@nanostores/react": "^1.1.0", + "@solana-program/memo": "^0.11.0", + "@solana/kit": "^6.1.0", + "@solana/kit-plugin-rpc": "^0.11.1", + "@tanstack/react-query": "^5.100.14", + "@wallet-ui/react-native-kit": "^4.0.1", + "expo": "56", + "expo-constants": "~56.0.15", + "expo-dev-client": "~56.0.15", + "expo-linking": "~56.0.11", + "expo-router": "~56.2.6", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "expo-system-ui": "~56.0.5", + "heroui-native": "^1.0.3", + "nanostores": "^1.2.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "^2.28.0", + "react-native-mmkv": "^4.3.1", + "react-native-nitro-modules": "^0.35.7", + "react-native-quick-crypto": "^1.0.7", + "react-native-reanimated": "4.3.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", + "react-native-worklets": "0.8.3", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "~4.1.16", + "uniwind": "^1.7.0" + }, + "devDependencies": { + "@types/react": "~19.2.14", + "eslint": "^9.25.0", + "eslint-config-expo": "~56.0.4", + "prettier": "^3.8.1", + "typescript": "~6.0.3" + }, + "private": true +} diff --git a/mobile/kit-expo-wallet/polyfill.js b/mobile/kit-expo-wallet/polyfill.js new file mode 100644 index 0000000..5454e3c --- /dev/null +++ b/mobile/kit-expo-wallet/polyfill.js @@ -0,0 +1,4 @@ +// polyfill.js +import { install } from 'react-native-quick-crypto' + +install() diff --git a/mobile/kit-expo-wallet/src/app/(wallet)/_layout.tsx b/mobile/kit-expo-wallet/src/app/(wallet)/_layout.tsx new file mode 100644 index 0000000..43e260c --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/(wallet)/_layout.tsx @@ -0,0 +1,49 @@ +import { Stack } from 'expo-router/stack' + +import { ClusterUiSelect } from '@/features/cluster/ui/cluster-ui-select' +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiHeaderTitle } from '@/features/shell/ui/shell-ui-page-header' + +export default function WalletLayout() { + const { foregroundColor, navigationHeaderOptions, tintColor } = useTheme() + + return ( + + , + headerTitle: () => ( + + ), + title: 'Wallet', + }} + /> + , + headerTitle: () => ( + + ), + title: 'Activity', + }} + /> + + ) +} diff --git a/mobile/kit-expo-wallet/src/app/(wallet)/activity.tsx b/mobile/kit-expo-wallet/src/app/(wallet)/activity.tsx new file mode 100644 index 0000000..3d1a149 --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/(wallet)/activity.tsx @@ -0,0 +1,5 @@ +import { WalletFeatureActivity } from '@/features/wallet/wallet-feature-activity' + +export default function WalletActivity() { + return +} diff --git a/mobile/kit-expo-wallet/src/app/(wallet)/index.tsx b/mobile/kit-expo-wallet/src/app/(wallet)/index.tsx new file mode 100644 index 0000000..6119b6f --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/(wallet)/index.tsx @@ -0,0 +1,5 @@ +import { WalletFeatureEntry } from '@/features/wallet/wallet-feature-entry' + +export default function Wallet() { + return +} diff --git a/mobile/kit-expo-wallet/src/app/_layout.tsx b/mobile/kit-expo-wallet/src/app/_layout.tsx new file mode 100644 index 0000000..afa3f8c --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/_layout.tsx @@ -0,0 +1,62 @@ +import '../global.css' + +import Ionicons from '@expo/vector-icons/Ionicons' +import { Tabs } from 'expo-router/js-tabs' +import { AppProviders } from '@/features/core/data-access/app-providers' +import { useTheme } from '@/features/shell/data-access/use-theme' + +export default function Layout() { + return ( + + + + ) +} + +function AppTabs() { + const { backgroundColor, isDark, mutedColor, tintColor } = useTheme() + + return ( + + ( + + ), + title: 'Wallet', + }} + /> + ( + + ), + title: 'Tools', + }} + /> + ( + + ), + title: 'Settings', + }} + /> + + ) +} diff --git a/mobile/kit-expo-wallet/src/app/settings/_layout.tsx b/mobile/kit-expo-wallet/src/app/settings/_layout.tsx new file mode 100644 index 0000000..0cc07f9 --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/settings/_layout.tsx @@ -0,0 +1,46 @@ +import { Stack } from 'expo-router/stack' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiHeaderTitle } from '@/features/shell/ui/shell-ui-page-header' + +export default function SettingsLayout() { + const { foregroundColor, navigationHeaderOptions, tintColor } = useTheme() + + return ( + + ( + + ), + title: 'Settings', + }} + /> + ( + + ), + title: 'Cluster', + }} + /> + + ) +} diff --git a/mobile/kit-expo-wallet/src/app/settings/cluster.tsx b/mobile/kit-expo-wallet/src/app/settings/cluster.tsx new file mode 100644 index 0000000..59f9950 --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/settings/cluster.tsx @@ -0,0 +1,5 @@ +import { SettingsFeatureCluster } from '@/features/settings/settings-feature-cluster' + +export default function SettingsCluster() { + return +} diff --git a/mobile/kit-expo-wallet/src/app/settings/index.tsx b/mobile/kit-expo-wallet/src/app/settings/index.tsx new file mode 100644 index 0000000..cd35d3f --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/settings/index.tsx @@ -0,0 +1,5 @@ +import { SettingsFeatureEntry } from '@/features/settings/settings-feature-entry' + +export default function Settings() { + return +} diff --git a/mobile/kit-expo-wallet/src/app/tools/_layout.tsx b/mobile/kit-expo-wallet/src/app/tools/_layout.tsx new file mode 100644 index 0000000..4d8a945 --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/tools/_layout.tsx @@ -0,0 +1,48 @@ +import { Stack } from 'expo-router/stack' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiHeaderTitle } from '@/features/shell/ui/shell-ui-page-header' + +export default function ToolsLayout() { + const { foregroundColor, navigationHeaderOptions, tintColor } = useTheme() + + return ( + + ( + + ), + title: 'Tools', + }} + /> + + + ( + + ), + title: 'Wallet actions', + }} + /> + + ) +} diff --git a/mobile/kit-expo-wallet/src/app/tools/index.tsx b/mobile/kit-expo-wallet/src/app/tools/index.tsx new file mode 100644 index 0000000..06897e5 --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/tools/index.tsx @@ -0,0 +1,5 @@ +import { ToolsFeatureEntry } from '@/features/tools/tools-feature-entry' + +export default function Tools() { + return +} diff --git a/mobile/kit-expo-wallet/src/app/tools/wallet-actions.tsx b/mobile/kit-expo-wallet/src/app/tools/wallet-actions.tsx new file mode 100644 index 0000000..be7039b --- /dev/null +++ b/mobile/kit-expo-wallet/src/app/tools/wallet-actions.tsx @@ -0,0 +1,5 @@ +import { ToolsFeatureWalletActions } from '@/features/tools/tools-feature-wallet-actions' + +export default function WalletActionsTools() { + return +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/cache-json.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/cache-json.ts new file mode 100644 index 0000000..7f2046d --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/cache-json.ts @@ -0,0 +1,21 @@ +import { address } from '@solana/kit' + +function cacheReviver(key: string, value: unknown) { + if (key === 'address' && typeof value === 'string') { + return address(value) + } + return value +} + +export function parseCacheValue(storageKey: string, value: string) { + try { + return JSON.parse(value, cacheReviver) as T + } catch (error) { + console.warn(`Failed to parse cached data for key ${storageKey}:`, error) + return undefined + } +} + +export function stringifyCacheValue(value: T) { + return JSON.stringify(value) +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-provider.tsx b/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-provider.tsx new file mode 100644 index 0000000..05fc7ce --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-provider.tsx @@ -0,0 +1,50 @@ +import { useStore } from '@nanostores/react' +import type { SolanaCluster, SolanaClusterId } from '@wallet-ui/react-native-kit' +import type { PropsWithChildren } from 'react' +import { createContext, useContext, useMemo } from 'react' + +import type { AppCluster, ClusterStore } from '@/features/cluster/data-access/cluster-store' +import { createSolanaClient, type SolanaClient } from '@/features/cluster/data-access/create-solana-client' + +export interface ClusterContextValue { + client: SolanaClient + cluster: SolanaCluster + clusters: AppCluster[] + resetClusters(): void + setCluster(cluster: SolanaClusterId): void + updateClusterUrl(cluster: SolanaClusterId, url: string): void +} + +export interface ClusterProviderProps { + store: ClusterStore +} + +const ClusterContext = createContext(undefined) + +export function ClusterProvider({ children, store }: PropsWithChildren) { + const cluster = useStore(store.$cluster) + const clusters = useStore(store.$clusters) + const client = useMemo(() => createSolanaClient(cluster), [cluster]) + + const value = useMemo( + () => ({ + client, + cluster, + clusters, + resetClusters: store.resetClusters, + setCluster: store.setCluster, + updateClusterUrl: store.updateClusterUrl, + }), + [client, cluster, clusters, store.resetClusters, store.setCluster, store.updateClusterUrl], + ) + + return {children} +} + +export function useAppCluster() { + const context = useContext(ClusterContext) + if (!context) { + throw new Error('useAppCluster must be used within AppClusterProvider') + } + return context +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-store.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-store.ts new file mode 100644 index 0000000..5074633 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/cluster-store.ts @@ -0,0 +1,218 @@ +import { atom, computed } from 'nanostores' +import { + createSolanaDevnet, + createSolanaLocalnet, + createSolanaMainnet, + createSolanaTestnet, +} from '@wallet-ui/react-native-kit' +import type { SolanaClusterId } from '@wallet-ui/react-native-kit' + +import type { SyncCache } from '@/features/cluster/data-access/sync-cache' + +export interface AppCluster { + id: SolanaClusterId + isEnabled: boolean + label: string + url: string +} + +export interface ClusterStoreContext { + cache: SyncCache +} + +interface ClusterStoreState { + clusterId: SolanaClusterId + clusters: AppCluster[] +} + +interface StoredCluster { + id: SolanaClusterId + url?: string +} + +interface StoredClusterState { + clusterId?: SolanaClusterId + clusters?: StoredCluster[] +} + +const DEFAULT_CLUSTER_ID = 'solana:devnet' as SolanaClusterId + +export const DEFAULT_CLUSTERS = [ + { + id: 'solana:devnet' as SolanaClusterId, + label: 'Devnet', + url: 'https://api.devnet.solana.com', + }, + { + id: 'solana:testnet' as SolanaClusterId, + label: 'Testnet', + url: 'https://api.testnet.solana.com', + }, + { + id: 'solana:localnet' as SolanaClusterId, + label: 'Localhost', + url: '', + }, + { + id: 'solana:mainnet' as SolanaClusterId, + label: 'Mainnet', + url: '', + }, +] as const satisfies readonly Omit[] + +function createAppCluster(cluster: Omit): AppCluster { + const url = cluster.url.trim() + + return { + ...cluster, + isEnabled: url.length > 0, + url, + } +} + +function createDefaultState(): ClusterStoreState { + return { + clusterId: DEFAULT_CLUSTER_ID, + clusters: DEFAULT_CLUSTERS.map(createAppCluster), + } +} + +function createSolanaCluster(cluster: AppCluster) { + if (!cluster.isEnabled) { + return null + } + + switch (cluster.id) { + case 'solana:devnet': + return createSolanaDevnet({ label: cluster.label, url: cluster.url }) + case 'solana:localnet': + return createSolanaLocalnet({ label: cluster.label, url: cluster.url }) + case 'solana:mainnet': + return createSolanaMainnet({ label: cluster.label, url: cluster.url }) + case 'solana:testnet': + return createSolanaTestnet({ label: cluster.label, url: cluster.url }) + default: + return null + } +} + +function findEnabledCluster(clusters: AppCluster[], clusterId: SolanaClusterId | string | undefined) { + return clusters.find((cluster) => cluster.id === clusterId && cluster.isEnabled) +} + +function findFallbackCluster(clusters: AppCluster[], clusterId: SolanaClusterId | string | undefined) { + return ( + findEnabledCluster(clusters, clusterId) ?? + findEnabledCluster(clusters, DEFAULT_CLUSTER_ID) ?? + clusters.find((cluster) => cluster.isEnabled) + ) +} + +function getEnabledSolanaCluster(clusters: AppCluster[], clusterId: SolanaClusterId | string | undefined) { + const cluster = findFallbackCluster(clusters, clusterId) + const solanaCluster = cluster ? createSolanaCluster(cluster) : null + + if (!solanaCluster) { + throw new Error('At least one cluster must have an RPC URL.') + } + + return solanaCluster +} + +function getStoredClusterState(value: unknown): StoredClusterState { + if (typeof value === 'string') { + return { clusterId: value as SolanaClusterId } + } + + if (!value || typeof value !== 'object') { + return {} + } + + return value as StoredClusterState +} + +function normalizeState(value: unknown) { + const stored = getStoredClusterState(value) + const clusters = DEFAULT_CLUSTERS.map((cluster) => { + const storedCluster = stored.clusters?.find((item) => item.id === cluster.id) + return createAppCluster({ + ...cluster, + url: storedCluster?.url ?? cluster.url, + }) + }) + const fallbackCluster = findFallbackCluster(clusters, stored.clusterId) + + return fallbackCluster ? { clusterId: fallbackCluster.id, clusters } : createDefaultState() +} + +function serializeState(state: ClusterStoreState): StoredClusterState { + return { + clusterId: state.clusterId, + clusters: state.clusters.map(({ id, url }) => ({ id, url })), + } +} + +export function createClusterStore(context: ClusterStoreContext) { + const { cache } = context + const initialState = normalizeState(cache.get()) + const $clusterId = atom(initialState.clusterId) + const $clusters = atom(initialState.clusters) + const $cluster = computed([$clusters, $clusterId], (clusters, clusterId) => + getEnabledSolanaCluster(clusters, clusterId), + ) + + function getState(): ClusterStoreState { + return { + clusterId: $clusterId.get(), + clusters: $clusters.get(), + } + } + + function setState(state: ClusterStoreState) { + if (!state.clusters.some((cluster) => cluster.isEnabled)) { + throw new Error('At least one cluster must have an RPC URL.') + } + + cache.set(serializeState(state)) + $clusters.set(state.clusters) + $clusterId.set(state.clusterId) + } + + function resetClusters() { + setState(createDefaultState()) + } + + function setCluster(clusterId: SolanaClusterId) { + const state = getState() + + if (!findEnabledCluster(state.clusters, clusterId)) { + throw new Error(`Cluster ${clusterId} does not have an RPC URL.`) + } + + setState({ ...state, clusterId }) + } + + function updateClusterUrl(clusterId: SolanaClusterId, url: string) { + const state = getState() + const clusters = state.clusters.map((cluster) => + cluster.id === clusterId ? createAppCluster({ ...cluster, url }) : cluster, + ) + const nextClusterId = findFallbackCluster(clusters, state.clusterId)?.id ?? DEFAULT_CLUSTER_ID + + setState({ + clusterId: nextClusterId, + clusters, + }) + } + + return { + $cluster, + $clusterId, + $clusters, + resetClusters, + setCluster, + updateClusterUrl, + } +} + +export type ClusterStore = ReturnType diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/create-cluster-props.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/create-cluster-props.ts new file mode 100644 index 0000000..7697386 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/create-cluster-props.ts @@ -0,0 +1,19 @@ +import { createMMKV } from 'react-native-mmkv' +import { createClusterStore, type ClusterStore } from '@/features/cluster/data-access/cluster-store' +import { createMmkvCache } from '@/features/cluster/data-access/mmkv-cache' + +export const APP_CLUSTER_STORAGE_KEY = 'wallet-ui:cluster' +export const APP_STORAGE_ID = 'kit-expo-wallet' + +const storage = createMMKV({ id: APP_STORAGE_ID }) + +export interface ClusterProviderConfig { + store: ClusterStore +} + +export function createClusterProps(): ClusterProviderConfig { + const store = createClusterStore({ + cache: createMmkvCache({ storage, storageKey: APP_CLUSTER_STORAGE_KEY }), + }) + return { store } +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/create-solana-client.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/create-solana-client.ts new file mode 100644 index 0000000..9ad046c --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/create-solana-client.ts @@ -0,0 +1,14 @@ +import { createClient } from '@solana/kit' +import { solanaRpcConnection } from '@solana/kit-plugin-rpc' +import type { SolanaCluster } from '@wallet-ui/react-native-kit' + +export function createSolanaClient(cluster: SolanaCluster) { + return createClient().use( + solanaRpcConnection({ + rpcSubscriptionsUrl: cluster.urlWs, + rpcUrl: cluster.url, + }), + ) +} + +export type SolanaClient = ReturnType diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/mmkv-cache.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/mmkv-cache.ts new file mode 100644 index 0000000..9dade10 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/mmkv-cache.ts @@ -0,0 +1,33 @@ +import type { MMKV } from 'react-native-mmkv' + +import { parseCacheValue, stringifyCacheValue } from './cache-json' +import type { SyncCache } from './sync-cache' + +export class MmkvCache implements SyncCache { + constructor( + private readonly storage: MMKV, + private readonly storageKey: string, + ) {} + + clear() { + this.storage.remove(this.storageKey) + } + + get() { + const cached = this.storage.getString(this.storageKey) + return cached ? parseCacheValue(this.storageKey, cached) : undefined + } + + set(value: T) { + this.storage.set(this.storageKey, stringifyCacheValue(value)) + } +} + +export interface CreateMmkvCacheConfig { + storage: MMKV + storageKey: string +} + +export function createMmkvCache({ storage, storageKey }: CreateMmkvCacheConfig) { + return new MmkvCache(storage, storageKey) +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/data-access/sync-cache.ts b/mobile/kit-expo-wallet/src/features/cluster/data-access/sync-cache.ts new file mode 100644 index 0000000..6efed16 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/data-access/sync-cache.ts @@ -0,0 +1,5 @@ +export interface SyncCache { + clear(): void + get(): T | undefined + set(value: T): void +} diff --git a/mobile/kit-expo-wallet/src/features/cluster/ui/cluster-ui-select.tsx b/mobile/kit-expo-wallet/src/features/cluster/ui/cluster-ui-select.tsx new file mode 100644 index 0000000..c477cf9 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/cluster/ui/cluster-ui-select.tsx @@ -0,0 +1,72 @@ +import type { SolanaClusterId } from '@wallet-ui/react-native-kit' +import { useRouter } from 'expo-router' +import type { Href } from 'expo-router' +import { Select } from 'heroui-native/select' +import { View } from 'react-native' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' + +const CLUSTER_SETTINGS_VALUE = 'cluster-settings' +const SETTINGS_CLUSTER_HREF = '/settings/cluster' as Href + +export function ClusterUiSelect({ + contentWidth = 'trigger', + triggerClassName = 'w-full', +}: { + contentWidth?: 'trigger' | number + triggerClassName?: string +}) { + const { cluster: activeCluster, clusters, setCluster } = useAppCluster() + const router = useRouter() + + return ( + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/core/data-access/app-providers.tsx b/mobile/kit-expo-wallet/src/features/core/data-access/app-providers.tsx new file mode 100644 index 0000000..21f9a7f --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/core/data-access/app-providers.tsx @@ -0,0 +1,42 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { AppIdentity } from '@wallet-ui/react-native-kit' +import { MobileWalletProvider } from '@wallet-ui/react-native-kit' +import { HeroUINativeProvider } from 'heroui-native/provider' +import { View } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import type { PropsWithChildren, ReactNode } from 'react' + +import { ClusterProvider, useAppCluster } from '@/features/cluster/data-access/cluster-provider' +import { createClusterProps } from '@/features/cluster/data-access/create-cluster-props' +import { ShellUiThemeStatusBar } from '@/features/shell/ui/shell-ui-theme-status-bar' + +const identity: AppIdentity = { name: 'Kit Expo Wallet', uri: 'kitexpowallet://kit-expo-wallet' } +const queryClient = new QueryClient() +const clusterConfig = createClusterProps() + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + ) +} + +function AppWalletProviders({ children }: PropsWithChildren) { + const { cluster } = useAppCluster() + + return ( + + + {children} + + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/core/ui/app-cluster-switcher.tsx b/mobile/kit-expo-wallet/src/features/core/ui/app-cluster-switcher.tsx new file mode 100644 index 0000000..a98fd61 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/core/ui/app-cluster-switcher.tsx @@ -0,0 +1,89 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import type { SolanaClusterId } from '@wallet-ui/react-native-kit' +import { Chip } from 'heroui-native/chip' +import { Description } from 'heroui-native/description' +import { Label } from 'heroui-native/label' +import { cn } from 'heroui-native/utils' +import { Pressable, View } from 'react-native' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' + +export function AppClusterSwitcher({ + onSelectCluster, + selectedClusterId, +}: { + onSelectCluster?: (clusterId: SolanaClusterId) => void + selectedClusterId?: SolanaClusterId +}) { + const { cluster: activeCluster, clusters, setCluster } = useAppCluster() + const selectedId = selectedClusterId ?? activeCluster.id + + return ( + + {clusters.map((cluster) => { + const isActive = activeCluster.id === cluster.id + const isSelected = selectedId === cluster.id + + return ( + { + onSelectCluster?.(cluster.id) + + if (cluster.isEnabled) { + setCluster(cluster.id) + } + }} + > + + + + {isActive ? : null} + + {cluster.url || 'No RPC URL configured.'} + + + + ) + })} + + ) +} + +function ClusterStatusChip({ isActive, isEnabled }: { isActive: boolean; isEnabled: boolean }) { + if (isActive) { + return ( + + Active + + ) + } + + if (isEnabled) { + return ( + + Ready + + ) + } + + return ( + + Disabled + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/settings/settings-feature-cluster.tsx b/mobile/kit-expo-wallet/src/features/settings/settings-feature-cluster.tsx new file mode 100644 index 0000000..1883e0d --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/settings/settings-feature-cluster.tsx @@ -0,0 +1,100 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import type { SolanaClusterId } from '@wallet-ui/react-native-kit' +import { useState } from 'react' +import { Button } from 'heroui-native/button' +import { Card } from 'heroui-native/card' +import { Input } from 'heroui-native/input' +import { Text, View } from 'react-native' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' +import { AppClusterSwitcher } from '@/features/core/ui/app-cluster-switcher' +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' + +export function SettingsFeatureCluster() { + const { cluster, clusters, resetClusters, updateClusterUrl } = useAppCluster() + const { tintColor } = useTheme() + const [selectedClusterId, setSelectedClusterId] = useState(cluster.id) + const selectedCluster = clusters.find((item) => item.id === selectedClusterId) ?? clusters[0] + const [draftUrls, setDraftUrls] = useState>({}) + const [status, setStatus] = useState(null) + const url = selectedCluster ? (draftUrls[selectedCluster.id] ?? selectedCluster.url) : '' + + return ( + + + + + + Cluster + + RPC and wallet authorization target. + + { + setSelectedClusterId(clusterId) + setStatus(null) + }} + selectedClusterId={selectedClusterId} + /> + + {selectedCluster ? ( + + + + + {selectedCluster.label} URL + + + Leave the URL empty to keep this cluster visible but disabled. + + + { + setDraftUrls((current) => ({ + ...current, + [selectedCluster.id]: value, + })) + }} + placeholder="RPC URL" + value={url} + /> + {status ? {status} : null} + + + + + + ) : null} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/settings/settings-feature-entry.tsx b/mobile/kit-expo-wallet/src/features/settings/settings-feature-entry.tsx new file mode 100644 index 0000000..5dd1ec8 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/settings/settings-feature-entry.tsx @@ -0,0 +1,37 @@ +import Constants from 'expo-constants' +import Ionicons from '@expo/vector-icons/Ionicons' +import { Link } from 'expo-router' +import { Card } from 'heroui-native/card' +import { Pressable, Text, View } from 'react-native' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' +import { ShellUiThemeSwitcher } from '@/features/shell/ui/shell-ui-theme-switcher' +import packageJson from '../../../package.json' + +export function SettingsFeatureEntry() { + const { tintColor } = useTheme() + const appName = Constants.expoConfig?.name ?? 'Kit Expo Wallet' + const appVersion = packageJson.version + + return ( + + + + + + + + Cluster + + + RPC and wallet authorization target. + + + + + + {`${appName} v${appVersion}`} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/shell/data-access/use-theme.ts b/mobile/kit-expo-wallet/src/features/shell/data-access/use-theme.ts new file mode 100644 index 0000000..b1f86aa --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/shell/data-access/use-theme.ts @@ -0,0 +1,79 @@ +import * as SystemUI from 'expo-system-ui' +import { useEffect } from 'react' +import { Uniwind, useUniwind } from 'uniwind' + +export type Theme = 'dark' | 'light' | 'system' +export type ThemeOption = { label: string; name: Theme } + +const ACTIVE_TINT = '#208AEF' +const BG_DARK = '#000000' +const BG_LIGHT = '#FFFFFF' +const FG_DARK = '#FAFAFA' +const FG_LIGHT = '#111827' +const MUTED_DARK = '#A3A3A3' +const MUTED_LIGHT = '#6B7280' + +const themes: ThemeOption[] = [ + { label: 'Dark', name: 'dark' }, + { label: 'Light', name: 'light' }, + { label: 'System', name: 'system' }, +] + +export interface UseThemeResult { + activeTheme: Theme + backgroundColor: string + foregroundColor: string + iconColor: { default: string; selected: string } + indicatorColor: string + isDark: boolean + isLight: boolean + mutedColor: string + navigationHeaderOptions: { + headerShadowVisible: false + headerStyle: { backgroundColor: string } + headerTitleAlign: 'left' + headerTintColor: string + headerTitleStyle: { color: string } + } + theme: 'dark' | 'light' + themes: ThemeOption[] + tintColor: string +} + +export function setTheme(theme: Theme) { + Uniwind.setTheme(theme) +} + +export function useTheme(): UseThemeResult { + const { hasAdaptiveThemes, theme } = useUniwind() + const isDark = theme === 'dark' + const isLight = theme === 'light' + const backgroundColor = isLight ? BG_LIGHT : BG_DARK + const foregroundColor = isLight ? FG_LIGHT : FG_DARK + const mutedColor = isLight ? MUTED_LIGHT : MUTED_DARK + + useEffect(() => { + SystemUI.setBackgroundColorAsync(backgroundColor).catch(() => undefined) + }, [backgroundColor]) + + return { + activeTheme: hasAdaptiveThemes ? 'system' : theme, + backgroundColor, + foregroundColor, + iconColor: { default: mutedColor, selected: ACTIVE_TINT }, + indicatorColor: isLight ? '#E6F4FE' : '#102A43', + isDark, + isLight, + mutedColor, + navigationHeaderOptions: { + headerShadowVisible: false, + headerStyle: { backgroundColor }, + headerTitleAlign: 'left', + headerTintColor: ACTIVE_TINT, + headerTitleStyle: { color: foregroundColor }, + }, + theme, + themes, + tintColor: ACTIVE_TINT, + } +} diff --git a/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page-header.tsx b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page-header.tsx new file mode 100644 index 0000000..4baeac6 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page-header.tsx @@ -0,0 +1,46 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import type { ComponentProps, ReactNode } from 'react' +import { Text, View } from 'react-native' + +export function ShellUiPageHeader({ + description = null, + icon = null, + title, +}: { + description?: ReactNode | string + icon?: ReactNode + title: string +}) { + return ( + + + {icon} + {title} + + {typeof description === 'string' ? ( + {description} + ) : ( + description + )} + + ) +} + +export function ShellUiHeaderTitle({ + foregroundColor, + icon, + tintColor, + title, +}: { + foregroundColor: string + icon: ComponentProps['name'] + tintColor: string + title: string +}) { + return ( + + + {title} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page.tsx b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page.tsx new file mode 100644 index 0000000..802f6bc --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-page.tsx @@ -0,0 +1,28 @@ +import { ScrollView, View } from 'react-native' +import type { PropsWithChildren } from 'react' +import { cn } from 'heroui-native/utils' + +type ShellUiPageProps = PropsWithChildren<{ + centered?: boolean + contentClassName?: string + contentContainerClassName?: string +}> + +export function ShellUiPage({ centered, contentClassName, contentContainerClassName, children }: ShellUiPageProps) { + return ( + + {children} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-status-bar.tsx b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-status-bar.tsx new file mode 100644 index 0000000..f0c0d91 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-status-bar.tsx @@ -0,0 +1,9 @@ +import { StatusBar } from 'expo-status-bar' + +import { useTheme } from '@/features/shell/data-access/use-theme' + +export function ShellUiThemeStatusBar() { + const { isLight } = useTheme() + + return +} diff --git a/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-switcher.tsx b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-switcher.tsx new file mode 100644 index 0000000..f7eef92 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/shell/ui/shell-ui-theme-switcher.tsx @@ -0,0 +1,73 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import { cn } from 'heroui-native/utils' +import type { ComponentProps } from 'react' +import { Pressable, Text, View } from 'react-native' + +import type { Theme } from '@/features/shell/data-access/use-theme' +import { setTheme, useTheme } from '@/features/shell/data-access/use-theme' + +type ThemeIcon = ComponentProps['name'] + +const themeIcons: Record = { + dark: 'moon-outline', + light: 'sunny-outline', + system: 'phone-portrait-outline', +} + +export function ShellUiThemeSwitcher() { + const { activeTheme, themes } = useTheme() + + return ( + + {themes.map((theme) => { + const isSelected = activeTheme === theme.name + + return ( + setTheme(theme.name)} + /> + ) + })} + + ) +} + +function ThemeSwitcherItem({ + icon, + isSelected, + label, + onPress, +}: { + icon: ThemeIcon + isSelected: boolean + label: string + onPress(): void +}) { + const color = isSelected ? '#208AEF' : '#737373' + + return ( + + + + {label} + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-entry.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-entry.tsx new file mode 100644 index 0000000..460608e --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-entry.tsx @@ -0,0 +1,52 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import { Link } from 'expo-router' +import { Card } from 'heroui-native/card' +import type { ComponentProps } from 'react' +import { Pressable, View } from 'react-native' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' + +type ToolIcon = ComponentProps['name'] +type ToolItem = { + description: string + href: `/tools/${string}` + icon: ToolIcon + id: string + summary: string + title: string +} + +const toolItems = [ + { + description: 'Run the example wallet requests for signing in, signing messages, and sending transactions.', + href: '/tools/wallet-actions', + icon: 'wallet-outline', + id: 'wallet-actions', + summary: 'Wallet examples for signing in, signing messages, and sending transactions.', + title: 'Wallet actions', + }, +] as const satisfies readonly ToolItem[] + +export function ToolsFeatureEntry() { + const { tintColor } = useTheme() + + return ( + + {toolItems.map((item) => ( + + + + + + {item.title} + + + {item.description} + + + + ))} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-and-send-transaction.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-and-send-transaction.tsx new file mode 100644 index 0000000..0a5ea36 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-and-send-transaction.tsx @@ -0,0 +1,28 @@ +import { + useWalletSignAndSendTransaction, + type UseWalletSignAndSendTransactionProps, +} from '@/features/wallet/data-access/use-wallet-sign-and-send-transaction' +import { ToolsUiActionCard } from '@/features/tools/ui/tools-ui-action-card' + +export function ToolsFeatureSignAndSendTransaction(props: UseWalletSignAndSendTransactionProps) { + const { isPending, mutateAsync } = useWalletSignAndSendTransaction(props) + + return ( + { + const signature = await mutateAsync(text) + + return { + description: `Signature: ${signature}`, + status: 'success', + title: 'Transaction sent', + } + }} + title="Sign and Send Transaction" + /> + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-in.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-in.tsx new file mode 100644 index 0000000..17ad4d8 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-in.tsx @@ -0,0 +1,36 @@ +import { getBase64Decoder } from '@solana/kit' +import type { Account, SignInOutput, SignInPayload, SolanaClusterId } from '@wallet-ui/react-native-kit' + +import { ToolsUiActionCard } from '@/features/tools/ui/tools-ui-action-card' +import { useWalletSignIn } from '@/features/wallet/data-access/use-wallet-sign-in' + +export function ToolsFeatureSignIn({ + account, + cluster, + signIn, +}: { + account: Account + cluster: SolanaClusterId + signIn: (signInPayload: SignInPayload) => Promise +}) { + const { isPending, mutateAsync } = useWalletSignIn({ account, cluster, signIn }) + + return ( + { + const result = await mutateAsync(statement) + + return { + description: `Signed in as ${result.account.address.toString()}. Signature: ${getBase64Decoder().decode(result.signature)}`, + status: 'success', + title: 'Signed in', + } + }} + title="Sign In" + /> + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-message.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-message.tsx new file mode 100644 index 0000000..b2b9a2a --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-message.tsx @@ -0,0 +1,34 @@ +import { useMutation } from '@tanstack/react-query' +import type { useMobileWallet } from '@wallet-ui/react-native-kit' + +import { ToolsUiActionCard } from '@/features/tools/ui/tools-ui-action-card' +import { executeWalletSignMessage } from '@/features/wallet/util/execute-wallet-sign-message' + +export function ToolsFeatureSignMessage({ + signMessages, +}: { + signMessages: ReturnType['signMessages'] +}) { + const { isPending, mutateAsync } = useMutation({ + mutationFn: (text: string) => executeWalletSignMessage({ text, signMessages }), + }) + + return ( + { + const signedPayload = await mutateAsync(text) + + return { + description: `Signed payload: ${signedPayload}`, + status: 'success', + title: 'Message signed', + } + }} + title="Sign Message" + /> + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-transaction.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-transaction.tsx new file mode 100644 index 0000000..8d6668f --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-sign-transaction.tsx @@ -0,0 +1,39 @@ +import { useMutation } from '@tanstack/react-query' +import type { Account, useMobileWallet } from '@wallet-ui/react-native-kit' + +import type { SolanaClient } from '@/features/cluster/data-access/create-solana-client' +import { ToolsUiActionCard } from '@/features/tools/ui/tools-ui-action-card' +import { executeWalletSignTransaction } from '@/features/wallet/util/execute-wallet-sign-transaction' + +export function ToolsFeatureSignTransaction({ + account, + client, + signTransactions, +}: { + account: Account + client: SolanaClient + signTransactions: ReturnType['signTransactions'] +}) { + const { isPending, mutateAsync } = useMutation({ + mutationFn: (text: string) => executeWalletSignTransaction({ account, client, signTransactions, text }), + }) + + return ( + { + const signature = await mutateAsync(text) + + return { + description: `Signature: ${signature}`, + status: 'success', + title: 'Transaction signed', + } + }} + title="Sign Transaction" + /> + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/tools-feature-wallet-actions.tsx b/mobile/kit-expo-wallet/src/features/tools/tools-feature-wallet-actions.tsx new file mode 100644 index 0000000..fbc424b --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/tools-feature-wallet-actions.tsx @@ -0,0 +1,36 @@ +import { useMobileWallet } from '@wallet-ui/react-native-kit' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' +import { ToolsFeatureSignAndSendTransaction } from '@/features/tools/tools-feature-sign-and-send-transaction' +import { ToolsFeatureSignIn } from '@/features/tools/tools-feature-sign-in' +import { ToolsFeatureSignMessage } from '@/features/tools/tools-feature-sign-message' +import { ToolsFeatureSignTransaction } from '@/features/tools/tools-feature-sign-transaction' +import { WalletUiConnectButton } from '@/features/wallet/ui/wallet-ui-connect-button' + +export function ToolsFeatureWalletActions() { + const wallet = useMobileWallet() + const { client, cluster } = useAppCluster() + const { account, connect } = wallet + + return ( + + {account ? ( + <> + + + + + + ) : ( + + Connect Wallet + + )} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-action-card.tsx b/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-action-card.tsx new file mode 100644 index 0000000..cbc7713 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-action-card.tsx @@ -0,0 +1,73 @@ +import { type ReactNode, useState } from 'react' +import { View } from 'react-native' +import { Button } from 'heroui-native/button' +import { Card } from 'heroui-native/card' +import { Input } from 'heroui-native/input' + +import { formatError } from '@/features/wallet/util/format-error' +import { ToolsUiStatusAlert } from '@/features/tools/ui/tools-ui-status-alert' + +export type ToolsActionStatus = { + description: string + status: 'danger' | 'success' + title: string +} + +export function ToolsUiActionCard({ + actionLabel, + defaultText, + description, + isLoading, + onSubmit, + renderExtra, + title, +}: { + actionLabel: string + defaultText: string + description: string + isLoading: boolean + onSubmit(text: string): Promise + renderExtra?: (text: string) => ReactNode + title: string +}) { + const [status, setStatus] = useState(null) + const [text, setText] = useState(defaultText) + const submitDisabled = !text.trim() || isLoading + + return ( + + + + {title} + {description} + + {renderExtra?.(text)} + + {status ? : null} + + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-status-alert.tsx b/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-status-alert.tsx new file mode 100644 index 0000000..f67fe81 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/tools/ui/tools-ui-status-alert.tsx @@ -0,0 +1,15 @@ +import { Alert } from 'heroui-native' + +import type { ToolsActionStatus } from '@/features/tools/ui/tools-ui-action-card' + +export function ToolsUiStatusAlert({ description, status, title }: ToolsActionStatus) { + return ( + + + + {title} + {description} + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-balance.ts b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-balance.ts new file mode 100644 index 0000000..482ed8e --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-balance.ts @@ -0,0 +1,13 @@ +import type { Address } from '@solana/kit' +import { useQuery } from '@tanstack/react-query' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' + +export function useGetBalance(address: Address) { + const { client, cluster } = useAppCluster() + + return useQuery({ + queryFn: async () => await client.rpc.getBalance(address, { commitment: 'confirmed' }).send(), + queryKey: ['get-balance', cluster.id, cluster.url, address], + }) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-transaction-signatures.ts b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-transaction-signatures.ts new file mode 100644 index 0000000..e477841 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-get-transaction-signatures.ts @@ -0,0 +1,19 @@ +import type { Address } from '@solana/kit' +import { useQuery } from '@tanstack/react-query' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' + +export function useGetTransactionSignatures(address: Address) { + const { client, cluster } = useAppCluster() + + return useQuery({ + queryFn: async () => + await client.rpc + .getSignaturesForAddress(address, { + commitment: 'confirmed', + limit: 25, + }) + .send(), + queryKey: ['get-transaction-signatures', cluster.id, cluster.url, address], + }) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-and-send-transaction.tsx b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-and-send-transaction.tsx new file mode 100644 index 0000000..87f5268 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-and-send-transaction.tsx @@ -0,0 +1,21 @@ +import { Account } from '@wallet-ui/react-native-kit' +import type { SolanaClient } from '@/features/cluster/data-access/create-solana-client' +import { Address, TransactionSendingSigner } from '@solana/kit' +import { useMutation } from '@tanstack/react-query' +import { executeWalletSignAndSendTransaction } from '@/features/wallet/util/execute-wallet-sign-and-send-transaction' + +export interface UseWalletSignAndSendTransactionProps { + account: Account + client: SolanaClient + getTransactionSigner: (address: Address, minContextSlot: bigint) => TransactionSendingSigner +} + +export function useWalletSignAndSendTransaction({ + account, + client, + getTransactionSigner, +}: UseWalletSignAndSendTransactionProps) { + return useMutation({ + mutationFn: (text: string) => executeWalletSignAndSendTransaction({ account, client, getTransactionSigner, text }), + }) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-in.tsx b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-in.tsx new file mode 100644 index 0000000..b482a96 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/data-access/use-wallet-sign-in.tsx @@ -0,0 +1,28 @@ +import { Account, SignInOutput, type SignInPayload, type SolanaClusterId } from '@wallet-ui/react-native-kit' +import { useMutation } from '@tanstack/react-query' +import { createSignInSession } from '@/features/wallet/util/create-sign-in-session' +import { executeWalletSignIn } from '@/features/wallet/util/execute-wallet-sign-in' + +export function useWalletSignIn({ + account, + cluster, + signIn, +}: { + account: Account + cluster: SolanaClusterId + signIn: (signInPayload: SignInPayload) => Promise +}) { + return useMutation({ + mutationFn: (statement: string) => { + const session = createSignInSession() + + return executeWalletSignIn({ + account, + cluster, + session, + signIn, + statement, + }) + }, + }) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-connect-button.tsx b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-connect-button.tsx new file mode 100644 index 0000000..b5e0eac --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-connect-button.tsx @@ -0,0 +1,78 @@ +import { Button, type ButtonRootProps } from 'heroui-native/button' +import { useToast } from 'heroui-native/toast' +import type { PropsWithChildren } from 'react' + +import { formatError } from '@/features/wallet/util/format-error' + +const WALLET_CONNECT_TOAST_ID = 'wallet-connect-error' + +function getErrorCode(error: unknown) { + if (error !== null && typeof error === 'object' && 'code' in error) { + return String(error.code) + } + + return '' +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message + } + + if (error !== null && typeof error === 'object' && 'message' in error) { + return String(error.message) + } + + return typeof error === 'string' ? error : '' +} + +function isWalletConnectionCanceled(error: unknown) { + const code = getErrorCode(error) + const message = getErrorMessage(error) + + return ( + code === 'ERROR_ASSOCIATION_CANCELLED' || + code === 'Session not established: Local association cancelled by user' || + message.includes('CancellationException') || + message.includes('Local association cancelled by user') + ) +} + +export function WalletUiConnectButton({ + children = 'Connect Wallet', + connect, + size, +}: PropsWithChildren<{ connect: () => Promise; size?: ButtonRootProps['size'] }>) { + const { toast } = useToast() + + async function handleConnect() { + try { + toast.hide(WALLET_CONNECT_TOAST_ID) + await connect() + } catch (error) { + const isCanceled = isWalletConnectionCanceled(error) + + toast.show({ + actionLabel: 'Try again', + description: isCanceled + ? 'The wallet connection request was dismissed before authorization completed.' + : formatError(error), + duration: 'persistent', + id: WALLET_CONNECT_TOAST_ID, + label: isCanceled ? 'Wallet connection canceled' : 'Could not connect wallet', + onActionPress: ({ hide }) => { + hide(WALLET_CONNECT_TOAST_ID) + void handleConnect() + }, + placement: 'bottom', + variant: isCanceled ? 'warning' : 'danger', + }) + } + } + + return ( + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-sign-in-payload.tsx b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-sign-in-payload.tsx new file mode 100644 index 0000000..1856521 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-sign-in-payload.tsx @@ -0,0 +1,13 @@ +import type { SignInPayload } from '@wallet-ui/react-native-kit' +import { Text, View } from 'react-native' + +export function WalletUiSignInPayload({ data, label }: { data: SignInPayload; label: string }) { + return ( + + {label} + + {JSON.stringify(data, null, 2)} + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-status-alert.tsx b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-status-alert.tsx new file mode 100644 index 0000000..01ec205 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/ui/wallet-ui-status-alert.tsx @@ -0,0 +1,19 @@ +import { Alert } from 'heroui-native' + +type WalletUiStatusAlertProps = { + description: string + status: 'danger' | 'success' + title: string +} + +export function WalletUiStatusAlert({ description, status, title }: WalletUiStatusAlertProps) { + return ( + + + + {title} + {description} + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/assert-can-pay-transaction-fee.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/assert-can-pay-transaction-fee.tsx new file mode 100644 index 0000000..406e26a --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/assert-can-pay-transaction-fee.tsx @@ -0,0 +1,10 @@ +import type { Lamports } from '@solana/kit' + +export function assertCanPayTransactionFee({ balance, fee }: { balance: Lamports; fee: Lamports | null }) { + if (fee === null) { + throw new Error('Unable to estimate the transaction fee. Try again with a fresh blockhash.') + } + if (balance < fee) { + throw new Error('Not enough SOL to pay transaction fees on this cluster.') + } +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-payload.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-payload.tsx new file mode 100644 index 0000000..a9e294a --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-payload.tsx @@ -0,0 +1,41 @@ +import { Account, type SignInPayload, type SolanaClusterId } from '@wallet-ui/react-native-kit' + +import * as Linking from 'expo-linking' +import { WalletSignInSession } from '@/features/wallet/util/create-sign-in-session' + +export const APP_DOMAIN = 'kit-expo-wallet' +export const APP_URI = Linking.createURL('/') +export const EXPIRES_AT_SECONDS = 60 + +export function createSignInPayload({ + account, + cluster, + session, + statement, +}: { + account: Account + cluster: SolanaClusterId + session: WalletSignInSession + statement: string +}): SignInPayload { + const issuedAt = session.issuedAt.toISOString() + const uri = APP_URI + + return { + address: account.address.toString(), + chainId: cluster, + domain: APP_DOMAIN, + expirationTime: new Date(session.issuedAt.getTime() + EXPIRES_AT_SECONDS * 1000).toISOString(), + issuedAt, + nonce: session.nonce, + notBefore: issuedAt, + requestId: session.requestId, + resources: [createAppResource('/settings'), createAppResource('/tools'), createAppResource('/wallet')].sort(), + statement, + uri, + version: '1', + } +} +function createAppResource(path: string) { + return `${APP_URI.replace(/\/$/, '')}${path}` +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-session.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-session.tsx new file mode 100644 index 0000000..999e0fd --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/create-sign-in-session.tsx @@ -0,0 +1,38 @@ +export type WalletSignInSession = { + issuedAt: Date + nonce: string + requestId: string +} + +export function createSignInSession(): WalletSignInSession { + return { + issuedAt: new Date(), + nonce: createNonce(), + requestId: createRequestId(), + } +} + +function createRequestId() { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join(''), + ].join('-') +} + +function createNonce(length = 16) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + + return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('') +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-and-send-transaction.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-and-send-transaction.tsx new file mode 100644 index 0000000..40c6880 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-and-send-transaction.tsx @@ -0,0 +1,68 @@ +import { Account } from '@wallet-ui/react-native-kit' +import type { SolanaClient } from '@/features/cluster/data-access/create-solana-client' +import { + Address, + appendTransactionMessageInstruction, + assertIsTransactionMessageWithSingleSendingSigner, + compileTransactionMessage, + createTransactionMessage, + getBase58Decoder, + getBase64Decoder, + getCompiledTransactionMessageEncoder, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signAndSendTransactionMessageWithSigners, + type TransactionMessageBytesBase64, + TransactionSendingSigner, +} from '@solana/kit' +import { getAddMemoInstruction } from '@solana-program/memo' +import { assertCanPayTransactionFee } from './assert-can-pay-transaction-fee' + +export async function executeWalletSignAndSendTransaction({ + account, + client, + text, + getTransactionSigner, +}: { + account: Account + client: SolanaClient + text: string + getTransactionSigner: (address: Address, minContextSlot: bigint) => TransactionSendingSigner +}) { + const { + context: { slot: minContextSlot }, + value: latestBlockhash, + } = await client.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send() + const transactionSigner = getTransactionSigner(account.address, minContextSlot) + const message = pipe( + createTransactionMessage({ version: 0 }), + (transactionMessage) => setTransactionMessageFeePayerSigner(transactionSigner, transactionMessage), + (transactionMessage) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, transactionMessage), + (transactionMessage) => + appendTransactionMessageInstruction(getAddMemoInstruction({ memo: text }), transactionMessage), + ) + + assertIsTransactionMessageWithSingleSendingSigner(message) + + const encodedMessage = getCompiledTransactionMessageEncoder().encode(compileTransactionMessage(message)) + const [{ value: balance }, { value: fee }] = await Promise.all([ + client.rpc.getBalance(transactionSigner.address, { commitment: 'confirmed' }).send(), + client.rpc + .getFeeForMessage(getBase64Decoder().decode(encodedMessage) as TransactionMessageBytesBase64, { + commitment: 'confirmed', + }) + .send(), + ]) + + assertCanPayTransactionFee({ balance, fee }) + + const signatureBytes = await signAndSendTransactionMessageWithSigners(message) + const signature = getBase58Decoder().decode(signatureBytes) + + if (!signature) { + throw new Error('Transaction submitted but no signature was returned by the wallet adapter.') + } + + return signature +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-in.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-in.tsx new file mode 100644 index 0000000..5626c10 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-in.tsx @@ -0,0 +1,30 @@ +import { Account, SignInOutput, type SignInPayload, type SolanaClusterId } from '@wallet-ui/react-native-kit' +import { createSignInPayload } from '@/features/wallet/util/create-sign-in-payload' + +export type WalletSignInSession = { + issuedAt: Date + nonce: string + requestId: string +} + +export async function executeWalletSignIn({ + account, + cluster, + session, + signIn, + statement, +}: { + account: Account + cluster: SolanaClusterId + session: WalletSignInSession + signIn: (signInPayload: SignInPayload) => Promise + statement: string +}) { + const result = await signIn(createSignInPayload({ account, cluster, session, statement })) + + if (result.account.address !== account.address) { + throw new Error('Signed-in account does not match the requested account.') + } + + return result +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-message.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-message.tsx new file mode 100644 index 0000000..35b73ed --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-message.tsx @@ -0,0 +1,18 @@ +import { getBase64Decoder } from '@solana/kit' +import { useMobileWallet } from '@wallet-ui/react-native-kit' + +export async function executeWalletSignMessage({ + text, + signMessages, +}: { + text: string + signMessages: ReturnType['signMessages'] +}) { + const signedPayload = await signMessages(new TextEncoder().encode(text)) + + if (!signedPayload) { + throw new Error('Message signed but no signed payload was returned by the wallet adapter.') + } + + return getBase64Decoder().decode(signedPayload) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-transaction.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-transaction.tsx new file mode 100644 index 0000000..2aa7f31 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/execute-wallet-sign-transaction.tsx @@ -0,0 +1,74 @@ +import { Account, useMobileWallet } from '@wallet-ui/react-native-kit' +import type { SolanaClient } from '@/features/cluster/data-access/create-solana-client' +import { + address, + appendTransactionMessageInstruction, + assertIsFullySignedTransaction, + assertIsTransactionWithBlockhashLifetime, + assertIsTransactionWithinSizeLimit, + compileTransaction, + compileTransactionMessage, + createTransactionMessage, + getBase64Decoder, + getCompiledTransactionMessageEncoder, + getSignatureFromTransaction, + getTransactionCodec, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type TransactionMessageBytesBase64, +} from '@solana/kit' +import { getAddMemoInstruction } from '@solana-program/memo' +import { assertCanPayTransactionFee } from './assert-can-pay-transaction-fee' + +export async function executeWalletSignTransaction({ + account, + client, + signTransactions, + text, +}: { + account: Account + client: SolanaClient + signTransactions: ReturnType['signTransactions'] + text: string +}) { + const { value: latestBlockhash } = await client.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send() + const feePayer = address(account.address) + const message = pipe( + createTransactionMessage({ version: 0 }), + (transactionMessage) => setTransactionMessageFeePayer(feePayer, transactionMessage), + (transactionMessage) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, transactionMessage), + (transactionMessage) => + appendTransactionMessageInstruction(getAddMemoInstruction({ memo: text }), transactionMessage), + ) + const encodedMessage = getCompiledTransactionMessageEncoder().encode(compileTransactionMessage(message)) + const [{ value: balance }, { value: fee }] = await Promise.all([ + client.rpc.getBalance(feePayer, { commitment: 'confirmed' }).send(), + client.rpc + .getFeeForMessage(getBase64Decoder().decode(encodedMessage) as TransactionMessageBytesBase64, { + commitment: 'confirmed', + }) + .send(), + ]) + + assertCanPayTransactionFee({ balance, fee }) + + const transaction = compileTransaction(message) + const signedTransactionResult = await signTransactions(transaction) + const transactionCodec = getTransactionCodec() + const signedTransaction = Object.freeze({ + ...transactionCodec.decode(transactionCodec.encode(signedTransactionResult)), + lifetimeConstraint: transaction.lifetimeConstraint, + }) + + assertIsFullySignedTransaction(signedTransaction) + assertIsTransactionWithinSizeLimit(signedTransaction) + assertIsTransactionWithBlockhashLifetime(signedTransaction) + + const signature = getSignatureFromTransaction(signedTransaction) + if (!signature) { + throw new Error('Transaction signed but no signature was returned by the wallet adapter.') + } + + return signature +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/util/format-error.tsx b/mobile/kit-expo-wallet/src/features/wallet/util/format-error.tsx new file mode 100644 index 0000000..a883c95 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/util/format-error.tsx @@ -0,0 +1,12 @@ +export function formatError(error: unknown) { + if (error instanceof Error) { + return error.message + } + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message) + } + if (typeof error === 'string' && error.trim().length > 0) { + return error + } + return 'Unknown error occurred' +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-account.tsx b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-account.tsx new file mode 100644 index 0000000..0a5052c --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-account.tsx @@ -0,0 +1,29 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import { Account } from '@wallet-ui/react-native-kit' +import { Card } from 'heroui-native/card' +import { Pressable, View } from 'react-native' + +import { useTheme } from '@/features/shell/data-access/use-theme' + +export function WalletFeatureAccount({ account, disconnect }: { account: Account; disconnect: () => Promise }) { + const label = account.label ?? 'Mobile wallet' + const { tintColor } = useTheme() + + return ( + + + + {label} + + + + + {account.address.toString()} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-activity.tsx b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-activity.tsx new file mode 100644 index 0000000..0fa6402 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-activity.tsx @@ -0,0 +1,212 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import { getExplorerUrl, useMobileWallet } from '@wallet-ui/react-native-kit' +import type { Account } from '@wallet-ui/react-native-kit' +import * as Linking from 'expo-linking' +import { Button } from 'heroui-native/button' +import { Card } from 'heroui-native/card' +import { Chip } from 'heroui-native/chip' +import { Pressable, Text, View } from 'react-native' + +import { useAppCluster } from '@/features/cluster/data-access/cluster-provider' +import { useTheme } from '@/features/shell/data-access/use-theme' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' +import { useGetTransactionSignatures } from '@/features/wallet/data-access/use-get-transaction-signatures' +import { WalletUiConnectButton } from '@/features/wallet/ui/wallet-ui-connect-button' +import { WalletUiStatusAlert } from '@/features/wallet/ui/wallet-ui-status-alert' +import { formatError } from '@/features/wallet/util/format-error' + +type WalletActivityGroup = { + dateKey: string + title: string + transactions: WalletActivityTransaction[] +} + +type WalletActivityTransaction = NonNullable['data']>[number] + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', +}) + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', +}) + +function ellipsify(value: string, length = 6) { + return value.length > length * 2 ? `${value.slice(0, length)}...${value.slice(-length)}` : value +} + +function formatDate(blockTime: WalletActivityTransaction['blockTime']) { + if (blockTime === null) { + return 'Unknown date' + } + + return dateFormatter.format(new Date(Number(blockTime) * 1000)) +} + +function formatTime(blockTime: WalletActivityTransaction['blockTime']) { + if (blockTime === null) { + return 'Time unavailable' + } + + return timeFormatter.format(new Date(Number(blockTime) * 1000)) +} + +function getDateKey(blockTime: WalletActivityTransaction['blockTime']) { + if (blockTime === null) { + return 'unknown' + } + + return new Date(Number(blockTime) * 1000).toISOString().slice(0, 10) +} + +function groupTransactionsByDate(transactions: readonly WalletActivityTransaction[]) { + return transactions.reduce((groups, transaction) => { + const dateKey = getDateKey(transaction.blockTime) + const lastGroup = groups[groups.length - 1] + + if (lastGroup?.dateKey === dateKey) { + lastGroup.transactions.push(transaction) + return groups + } + + groups.push({ + dateKey, + title: formatDate(transaction.blockTime), + transactions: [transaction], + }) + return groups + }, []) +} + +function getTransactionStatus(transaction: WalletActivityTransaction): { + color: 'danger' | 'default' | 'success' + label: string +} { + if (transaction.err) { + return { color: 'danger', label: 'Failed' } + } + + if (transaction.confirmationStatus === 'finalized') { + return { color: 'success', label: 'Success' } + } + + return { color: 'default', label: transaction.confirmationStatus === 'confirmed' ? 'Confirmed' : 'Pending' } +} + +export function WalletFeatureActivity() { + const wallet = useMobileWallet() + const { account, connect } = wallet + + return ( + + {account ? ( + + ) : ( + Connect Wallet + )} + + ) +} + +function WalletFeatureActivityList({ account }: { account: Account }) { + const { cluster } = useAppCluster() + const { tintColor } = useTheme() + const activity = useGetTransactionSignatures(account.address) + const groups = groupTransactionsByDate(activity.data ?? []) + + return ( + + + + + + + + Activity + + + + + Latest confirmed transactions on {cluster.label}. + + + {activity.isError ? ( + + ) : null} + + + + {activity.isLoading ? ( + Loading activity... + ) : groups.length ? ( + groups.map((group) => ( + + + {group.title} + + + + {group.transactions.map((transaction) => { + const explorerUrl = getExplorerUrl({ + network: { + id: cluster.id, + url: cluster.url, + }, + path: `/tx/${transaction.signature}`, + provider: 'solana', + }) + const transactionStatus = getTransactionStatus(transaction) + + return ( + + + + void Linking.openURL(explorerUrl)} + > + + {ellipsify(transaction.signature)} + + + + + + {transactionStatus.label} + + + + + Slot {transaction.slot.toLocaleString()} + + + {formatTime(transaction.blockTime)} + + + + ) + })} + + + + )) + ) : ( + No recent activity found. + )} + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-balance.tsx b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-balance.tsx new file mode 100644 index 0000000..2dad7d7 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-balance.tsx @@ -0,0 +1,60 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import type { Lamports } from '@solana/kit' +import { Account } from '@wallet-ui/react-native-kit' +import { Card } from 'heroui-native/card' +import { Pressable, Text, View } from 'react-native' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { useGetBalance } from '@/features/wallet/data-access/use-get-balance' +import { formatError } from './util/format-error' +import { WalletUiStatusAlert } from './ui/wallet-ui-status-alert' + +export const LAMPORTS_PER_SOL = 1_000_000_000n +function formatLamports(lamports: Lamports) { + const fractional = lamports % LAMPORTS_PER_SOL + const whole = lamports / LAMPORTS_PER_SOL + + if (fractional === 0n) { + return `${whole.toLocaleString()} SOL` + } + + const fractionalDisplay = fractional.toString().padStart(9, '0').replace(/0+$/, '') + return `${whole.toLocaleString()}.${fractionalDisplay} SOL` +} + +export function WalletFeatureBalance({ account }: { account: Account }) { + const balance = useGetBalance(account.address) + const { tintColor } = useTheme() + const balanceText = + balance.data?.value !== undefined + ? formatLamports(balance.data.value) + : balance.isError + ? 'Unable to load' + : 'Loading...' + + return ( + + + + + + Balance + void balance.refetch()} + > + + + + + + {balanceText} + + {balance.isError ? ( + + ) : null} + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-connected.tsx b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-connected.tsx new file mode 100644 index 0000000..a127e89 --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-connected.tsx @@ -0,0 +1,34 @@ +import Ionicons from '@expo/vector-icons/Ionicons' +import type { Account, useMobileWallet } from '@wallet-ui/react-native-kit' +import { Link } from 'expo-router' +import { Button } from 'heroui-native/button' +import { View } from 'react-native' + +import { useTheme } from '@/features/shell/data-access/use-theme' +import { WalletFeatureAccount } from '@/features/wallet/wallet-feature-account' +import { WalletFeatureBalance } from '@/features/wallet/wallet-feature-balance' + +export function WalletFeatureConnected({ + account, + wallet, +}: { + account: Account + wallet: ReturnType +}) { + const { tintColor } = useTheme() + + return ( + + + + + + + + ) +} diff --git a/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-entry.tsx b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-entry.tsx new file mode 100644 index 0000000..6699ece --- /dev/null +++ b/mobile/kit-expo-wallet/src/features/wallet/wallet-feature-entry.tsx @@ -0,0 +1,21 @@ +import { useMobileWallet } from '@wallet-ui/react-native-kit' +import { ShellUiPage } from '@/features/shell/ui/shell-ui-page' +import { WalletFeatureConnected } from '@/features/wallet/wallet-feature-connected' +import { WalletUiConnectButton } from '@/features/wallet/ui/wallet-ui-connect-button' + +export function WalletFeatureEntry() { + const wallet = useMobileWallet() + const { account, connect } = wallet + + return ( + + {account ? ( + + ) : ( + + Connect Wallet + + )} + + ) +} diff --git a/mobile/kit-expo-wallet/src/global.css b/mobile/kit-expo-wallet/src/global.css new file mode 100644 index 0000000..ff1c6e0 --- /dev/null +++ b/mobile/kit-expo-wallet/src/global.css @@ -0,0 +1,5 @@ +@import 'tailwindcss'; +@import 'uniwind'; + +@import 'heroui-native/styles'; +@source '../node_modules/heroui-native/lib'; diff --git a/mobile/kit-expo-wallet/src/global.d.ts b/mobile/kit-expo-wallet/src/global.d.ts new file mode 100644 index 0000000..5894ae0 --- /dev/null +++ b/mobile/kit-expo-wallet/src/global.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/mobile/kit-expo-wallet/src/uniwind-types.d.ts b/mobile/kit-expo-wallet/src/uniwind-types.d.ts new file mode 100644 index 0000000..cc09941 --- /dev/null +++ b/mobile/kit-expo-wallet/src/uniwind-types.d.ts @@ -0,0 +1,10 @@ +// NOTE: This file is generated by uniwind and it should not be edited manually. +/// + +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } +} + +export {} diff --git a/mobile/kit-expo-wallet/tsconfig.json b/mobile/kit-expo-wallet/tsconfig.json new file mode 100644 index 0000000..e570fe3 --- /dev/null +++ b/mobile/kit-expo-wallet/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@/assets/*": ["./assets/*"] + }, + "strict": true + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} diff --git a/templates.json b/templates.json index 1901a8e..00552bb 100644 --- a/templates.json +++ b/templates.json @@ -53,6 +53,25 @@ "displayName": "Uniwind Mobile Kit Expo example", "usecase": "Mobile" }, + { + "description": "A Solana mobile app template with Expo, React Native, Solana Kit, Mobile Wallet Adapter actions, and Uniwind.", + "id": "gh:solana-mobile/templates/mobile/kit-expo-wallet", + "image": "mobile/kit-expo-wallet/og-image.png", + "keywords": [ + "expo", + "mobile-wallet-adapter", + "react-native", + "solana-kit", + "tailwind", + "uniwind", + "wallet", + "wallet-ui" + ], + "name": "kit-expo-wallet", + "path": "mobile/kit-expo-wallet", + "displayName": "Wallet Mobile Kit Expo example", + "usecase": "Mobile" + }, { "description": "A template for building a Solana mobile app with Expo and React Native.", "id": "gh:solana-mobile/templates/mobile/web3js-expo",