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",