Yesterday the template opened. Today we name the app, give it three tabs, and make Diagnostics ask devnet one question: are you up? Finish when you can point at the three tabs and the Devnet RPC chip says Connected or Unreachable.
Your phone does not store the chain. It asks a server. That request is an RPC (remote procedure call): a JSON body over HTTPS, one method name, one answer.
phone (Diagnostics tab)
| POST { "jsonrpc":"2.0", "id":1, "method":"getHealth" }
v
https://api.devnet.solana.com
| { "result": "ok" } → green chip "Connected"
| anything else, or the request fails → red chip "Unreachable"
Devnet is Solana's public practice cluster. The SOL there is not the SOL people trade. We point the first check at https://api.devnet.solana.com so a broken experiment costs nothing. Mainnet is the cluster with real value; we only read it later.
An account is one record on the cluster, looked up by an address:
account address the public key you look up owner the program allowed to change its data lamports the balance (1 SOL = 1,000,000,000 lamports) data bytes the owner program stores
A wallet address is an account. A transaction is a signed list of changes to accounts. Today we do not read an account and we do not send a transaction. We only ask the cluster if its RPC is healthy. The wallet account shows up in the next module.
@solana/web3.js 1.99.0. That package's Connection class has no getHealth method (it is not in the installed package). The check uses fetch and posts the method name getHealth. A healthy node returns result: "ok".
This build started from the public template @solana-mobile/solana-mobile-expo-template version 3.0.0 (Expo 52, React Native 0.76, React Navigation bottom tabs, React Native Paper). It already includes @solana-mobile/mobile-wallet-adapter-protocol-web3js. You do not add a Solana library for this module.
npx create-expo-app ship-to-seeker-app --template @solana-mobile/solana-mobile-expo-template cd ship-to-seeker-app npm install
npm run android is how you put this on an emulator or a Seeker. You can still read the TypeScript on your laptop before that build finishes.
In app.json, set the display name and the package id:
"name": "Ship to Seeker", "slug": "ship-to-seeker"
and under android / ios:
"package": "com.biboux.shiptoseeker" "bundleIdentifier": "com.biboux.shiptoseeker"
If this is your own app, pick your own package name. Copying com.biboux.shiptoseeker only matches this repo.
Add an MIT LICENSE and, in the README, one line that the project is independent and is not affiliated with Solana Mobile, Radiants, or the Solana Foundation. Credit the template by name.
The template's stack in src/navigators/AppNavigator.tsx already has a home route and a Settings screen. Leave Settings there. The template top bar typechecks because navigation.navigate("Settings") still exists. The three tabs set headerShown: false, so that top bar is not on these screens.
<Stack.Screen name="HomeStack" component={HomeNavigator} />
<Stack.Screen name="Settings" component={SettingsScreen} />
Replace src/navigators/HomeNavigator.tsx with three tabs. Icons are Feather from @expo/vector-icons (already installed with Expo: activity, list, message-circle).
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import React from "react";
import Feather from "@expo/vector-icons/Feather";
import { BuildPathScreen } from "../screens/BuildPathScreen";
import { DiagnosticsScreen } from "../screens/DiagnosticsScreen";
import { ExplainerScreen } from "../screens/ExplainerScreen";
const Tab = createBottomTabNavigator();
type TabName = "Diagnostics" | "Build Path" | "Explainer";
const TAB_ICONS: Record<TabName, React.ComponentProps<typeof Feather>["name"]> = {
Diagnostics: "activity",
"Build Path": "list",
Explainer: "message-circle",
};
export function HomeNavigator() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarIcon: ({ color, size }) => (
<Feather
name={TAB_ICONS[route.name as TabName]}
size={size}
color={color}
/>
),
})}
>
<Tab.Screen name="Diagnostics" component={DiagnosticsScreen} />
<Tab.Screen name="Build Path" component={BuildPathScreen} />
<Tab.Screen name="Explainer" component={ExplainerScreen} />
</Tab.Navigator>
);
}
Shared title block, src/components/ScreenIntro.tsx:
import React from "react";
import { StyleSheet, View } from "react-native";
import { Text } from "react-native-paper";
const INTRO_GAP = 4;
export function ScreenIntro({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) {
return (
<View style={styles.intro}>
<Text variant="headlineMedium">{title}</Text>
<Text variant="bodyLarge">{subtitle}</Text>
</View>
);
}
const styles = StyleSheet.create({
intro: {
gap: INTRO_GAP,
marginBottom: 20,
},
});
Build Path and Explainer are empty on purpose. The checklist and the error box come on later days.
// src/screens/BuildPathScreen.tsx
import React from "react";
import { StyleSheet, View } from "react-native";
import { ScreenIntro } from "../components/ScreenIntro";
const SCREEN_PADDING = 16;
export function BuildPathScreen() {
return (
<View style={styles.screen}>
<ScreenIntro title="Build Path" subtitle="Your 7-step build journey" />
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
padding: SCREEN_PADDING,
},
});
// src/screens/ExplainerScreen.tsx
import React from "react";
import { StyleSheet, View } from "react-native";
import { ScreenIntro } from "../components/ScreenIntro";
const SCREEN_PADDING = 16;
export function ExplainerScreen() {
return (
<View style={styles.screen}>
<ScreenIntro title="Explainer" subtitle="Paste an error, get a fix" />
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
padding: SCREEN_PADDING,
},
});
src/screens/index.ts re-exports the three screens. Leave the template's HomeScreen.tsx on disk. We stopped using it as the first screen; the Connect button comes back when we wire the wallet.
src/diagnostics/devnetRpc.ts. Eight second timeout. Green only when HTTP is 2xx, the JSON has no error, and result is exactly "ok".
export const DEVNET_RPC_URL = "https://api.devnet.solana.com";
export const RPC_HEALTH_TIMEOUT_MS = 8_000;
export const RPC_UNREACHABLE_HINT =
"Check your internet connection or try a different RPC endpoint";
export const RPC_HEALTH_METHOD = "getHealth";
export type DevnetRpcChip = "connected" | "unreachable";
type JsonRpcHealthBody = {
result?: unknown;
error?: unknown;
};
export function chipFromHealthResponse(
httpStatus: number,
body: JsonRpcHealthBody | null,
): DevnetRpcChip {
if (httpStatus < 200 || httpStatus >= 300) {
return "unreachable";
}
if (body == null || body.error != null) {
return "unreachable";
}
return body.result === "ok" ? "connected" : "unreachable";
}
export async function checkDevnetRpcHealth(
fetchImpl: typeof fetch = fetch,
endpoint: string = DEVNET_RPC_URL,
): Promise<DevnetRpcChip> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), RPC_HEALTH_TIMEOUT_MS);
try {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: RPC_HEALTH_METHOD,
}),
signal: controller.signal,
});
let body: JsonRpcHealthBody | null = null;
try {
body = (await response.json()) as JsonRpcHealthBody;
} catch (parseError) {
console.warn("Devnet RPC health response was not JSON", parseError);
return "unreachable";
}
return chipFromHealthResponse(response.status, body);
} catch (error) {
console.warn("Devnet RPC health check failed", error);
return "unreachable";
} finally {
clearTimeout(timer);
}
}
src/screens/DiagnosticsScreen.tsx runs that check when the screen mounts. Retry bumps a counter so the effect runs again. The chip is green Connected or red Unreachable. The hint shows only when the check fails.
import React, { useCallback, useEffect, useState } from "react";
import { StyleSheet, View } from "react-native";
import { Button, Card, Chip, Text } from "react-native-paper";
import { ScreenIntro } from "../components/ScreenIntro";
import {
RPC_UNREACHABLE_HINT,
checkDevnetRpcHealth,
type DevnetRpcChip,
} from "../diagnostics/devnetRpc";
type CheckState = "checking" | DevnetRpcChip;
const SCREEN_PADDING = 16;
const CHIP_CONNECTED = "#1B7F3A";
const CHIP_UNREACHABLE = "#B42318";
const CHIP_CHECKING = "#5C6570";
const CHIP_LABEL = "#FFFFFF";
export function DiagnosticsScreen() {
const [attempt, setAttempt] = useState(0);
const [status, setStatus] = useState<CheckState>("checking");
useEffect(() => {
let cancelled = false;
setStatus("checking");
checkDevnetRpcHealth()
.then((next) => {
if (!cancelled) {
setStatus(next);
}
})
.catch((error: unknown) => {
console.warn("Devnet RPC check did not settle", error);
if (!cancelled) {
setStatus("unreachable");
}
});
return () => {
cancelled = true;
};
}, [attempt]);
const retry = useCallback(() => {
setAttempt((current) => current + 1);
}, []);
const chipLabel =
status === "checking"
? "Checking"
: status === "connected"
? "Connected"
: "Unreachable";
const chipColor =
status === "connected"
? CHIP_CONNECTED
: status === "unreachable"
? CHIP_UNREACHABLE
: CHIP_CHECKING;
return (
<View style={styles.screen}>
<ScreenIntro
title="Diagnostics"
subtitle="Check your Solana Mobile setup"
/>
<Card mode="outlined">
<Card.Title title="Devnet RPC" subtitle="getHealth on api.devnet.solana.com" />
<Card.Content>
<Chip
style={{ backgroundColor: chipColor }}
textStyle={styles.chipText}
>
{chipLabel}
</Chip>
{status === "unreachable" ? (
<Text variant="bodyMedium" style={styles.hint}>
{RPC_UNREACHABLE_HINT}
</Text>
) : null}
</Card.Content>
<Card.Actions>
<Button
mode="contained-tonal"
onPress={retry}
disabled={status === "checking"}
loading={status === "checking"}
>
Retry
</Button>
</Card.Actions>
</Card>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
padding: SCREEN_PADDING,
},
chipText: {
color: CHIP_LABEL,
},
hint: {
marginTop: 12,
},
});
Optional check on your laptop, same rules as src/diagnostics/devnetRpc.test.ts:
npm test npx tsc --noEmit
You are done with Module 01 when you can say the three tab names out loud, and Diagnostics shows a chip for Devnet RPC:
Unreachable with the hint "Check your internet connection or try a different RPC endpoint" still counts. That means the request failed or the body was not result: "ok". Public devnet is sometimes busy. Hit Retry.
api.devnet.solana.com, or the node answered with an error. The chip logic treats any result other than "ok" as unreachable.navigate("Settings") → Settings is still a screen on the root stack. Put it back; do not delete it to "clean up" the tabs.Module 02 connects a wallet with Mobile Wallet Adapter and shows the account address on Diagnostics. That account is the one from step 1: an address, an owner, a lamport balance.