solana-mobile/solana-mobile-skills包含需要注意的行为
SKILL DETAIL
integration-privy
solana-mobile/solana-mobile-skills/integration-privy
Privy 拥有用户身份,提供持久的账户标识和后端可验证的 JWT;Mobile Wallet Adapter 管理密钥。本技能通过 Sign-In-With-Solana 将两者结合:Privy 生成消息,MWA 签名,Privy 交换签名以建立会话。适用于需要跨设备稳定用户记录、服务器可验证会话或除钱包外其他登录方式的应用。仅支持 Android 开发构建,不支持 iOS 和 Expo Go。 实施步骤包括:在 Privy 仪表板创建应用并启用 SVM 钱包;安装 @privy-io/expo 并配置 polyfills、插件和 Metro 解析器;挂载 PrivyProvider 和 MobileWalletProvider;等待 isReady 状态;使用 useLoginWithSiws 和 useMobileWallet 实现登录;同时登出 Privy 和钱包。注意编码细节:传递 base58 地址,使用 base64 签名,不截断字节。
安装量 · 184查看来源
Installation
npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill integration-privy
技能文件
SKILL.md
最近同步 · 2026年8月29日
references/setup.md›
# Privy setup on Expo
Everything the Privy Expo SDK needs beyond `npm install`. Each item here fails at a different
point in the app's life, so skipping one produces a symptom that looks unrelated to the cause.
## Dashboard values
| Value | Where | Used as |
| --- | --- | --- |
| App ID | Organization overview, after creating the app | `EXPO_PUBLIC_PRIVY_APP_ID` |
| Client ID | App settings > Clients | `EXPO_PUBLIC_PRIVY_CLIENT_ID` |
| App secret | App settings | **Server only. Never in the app.** |
Two dashboard settings are load-bearing:
- **User management > Authentication > External wallets > SVM (Solana) wallets** must be
enabled. Off is the default, and while it is off every SIWS login rejects.
- **App settings > Clients > app identifier** must equal `expo.android.package` from
`app.json`. Privy checks the calling package against the client.
Change the Android package later and the client identifier has to change with it, or the app
stops authenticating on the next build.
## Environment variables
```bash
# .env
EXPO_PUBLIC_PRIVY_APP_ID=your-privy-app-id
EXPO_PUBLIC_PRIVY_CLIENT_ID=your-privy-client-id
```
`EXPO_PUBLIC_*` values are inlined into the JS bundle at build time. That is correct for these
two — they are public client identifiers — and disqualifying for the app secret, which grants
server-side control of the Privy app.
Because they are inlined at build time, editing `.env` requires a bundler restart with
`--clear`, not just a reload.
## Entry point polyfills
Privy and `@solana/kit` both need Web Crypto and `TextEncoder`, and both need them before any
other module runs. Point `main` at a wrapper that installs them first:
```json
{ "main": "./index.js" }
```
```js
// index.js
import './polyfill'
import 'expo-router/entry'
```
```js
// polyfill.js
import 'fast-text-encoding'
import { install } from 'react-native-quick-crypto'
install()
```
```bash
npx expo install fast-text-encoding react-native-quick-crypto
```
Importing the polyfills from a root layout is too late — Expo Router's entry has already
evaluated the route tree by then. The failure shows up as signing errors rather than as a
missing-polyfill error, because the first thing to need crypto is a signature.
## `app.json` plugins
```json
{
"expo": {
"android": { "package": "com.example.myapp" },
"plugins": ["expo-router", "expo-secure-store", "expo-web-browser"],
"scheme": "myapp"
}
}
```
`expo-secure-store` backs Privy's token storage; without it sessions do not survive a restart.
`expo-web-browser` backs OAuth login methods. `scheme` is what your SIWS `uri` and the MWA
`AppIdentity.uri` deep link into.
## Metro: the `jose` resolver
Privy pulls in `jose` for JWT handling, which ships several conditional builds. Metro picks the
Node build by default and it does not run in React Native. Force the browser condition:
```js
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config')
const config = getDefaultConfig(__dirname)
const defaultResolveRequest = config.resolver.resolveRequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'jose') {
const ctx = { ...context, unstable_conditionNames: ['browser'] }
return ctx.resolveRequest(ctx, moduleName, platform)
}
return defaultResolveRequest
? defaultResolveRequest(context, moduleName, platform)
: context.resolveRequest(context, moduleName, platform)
}
module.exports = config
```
Chaining through `defaultResolveRequest` rather than replacing it matters when another plugin —
NativeWind, Uniwind, a monorepo resolver — already set one. Overwriting it silently disables
that plugin's resolution.
## Peer dependencies
`@privy-io/expo` declares a wide peer set and it changes between minor versions. Read the peers
for the version you are installing instead of copying a list:
```bash
npm view @privy-io/expo@<version> peerDependencies
```
As of `0.70.x` that includes `@privy-io/expo-native-extensions`, `expo-apple-authentication`,
`expo-application`, `expo-clipboard`, `expo-crypto`, `expo-linking`, `expo-secure-store`,
`expo-web-browser`, `permissionless`, `react-native-passkeys`, `react-native-qrcode-styled`,
`react-native-safe-area-context`, `react-native-svg`, `react-native-webview`, and a pinned
`viem`. Several are pinned to exact versions, so `npx expo install` will not always resolve
them for you — install the pinned version the peer range names.
## Rebuild, then verify
```bash
npx expo run:android
```
Native modules arrived, so a Metro reload is not enough. Once it boots, check the pieces in
order — each depends on the one above it:
1. `usePrivy().isReady` turns `true`. If it stays `false`, the SDK never initialized: check
the app ID and client ID reached the bundle.
2. `usePrivy().error` is `null`. A non-null error here is almost always secure-store access,
meaning the plugin is missing or the app was not rebuilt.
3. `useMobileWallet().connect()` returns an account. If not, this is an MWA problem, not a
Privy one — see the `solana-mobile-wallet` skill.
4. SIWS login succeeds. Failures at this point are covered in
[troubleshooting.md](troubleshooting.md).
references/siws.md›
# Sign-In-With-Solana against Privy
The exchange in full, plus the three variations on it: linking a second wallet, verifying the
session on a server, and driving the raw MWA protocol when Wallet UI is not in the project.
## The exchange
```
generateMessage → Privy builds a SIWS message bound to the address, domain, and uri
signMessages → the wallet app signs it, user approves in the wallet
login → Privy verifies the signature and returns a User
```
Privy owns the nonce and the expiry inside the message, which is the reason to call
`generateMessage` rather than assembling a SIWS message yourself. A hand-built message will not
carry a nonce Privy issued, and `login` rejects it.
## Exact hook signatures
`useLoginWithSiws()` from `@privy-io/expo`:
```ts
generateMessage: (args: {
from: { domain: string; uri: string }
wallet: { address: string }
}) => Promise<{ message: string }>
login: (opts: {
disableSignup?: boolean
message: string
signature: string
wallet?: { connectorType?: string; walletClientType?: string }
}) => Promise<User>
```
Notes that are not obvious from the shape:
- `wallet` on `login` is optional metadata that gets stored on the linked account. Omitting it
is fine; the template does.
- `disableSignup: true` makes `login` reject rather than create an account for an unrecognized
wallet. Use it when a separate onboarding flow owns account creation.
- The hook takes no `onSuccess` or `onError` options, unlike its React web counterpart. Handle
both from the returned promise.
- `login` resolves to a `User`. Reading `usePrivy().user` immediately after is also fine — the
provider state updates as part of the call.
## Encoding, end to end
This is where the integration goes wrong, so trace the types once:
| Step | Value | Encoding |
| --- | --- | --- |
| `account.address` | Kit `Address` | base58 — pass this to `generateMessage` |
| `account.addressBase64` | MWA wire format | base64 — Privy rejects it |
| `generateMessage` result | `{ message: string }` | plain text |
| `signMessages` argument | `Uint8Array` | `new TextEncoder().encode(message)` |
| `signMessages` result | `Uint8Array` | MWA *signed payload*, not a bare signature |
| `login` `signature` | `string` | `fromUint8Array(...)` — base64 |
`fromUint8Array` is exported from `@wallet-ui/react-native-kit` as a re-export of `js-base64`.
Use it rather than `btoa(String.fromCharCode(...bytes))`, which throws
`RangeError: Maximum call stack size exceeded` on payloads of any size.
Do not slice the signed payload down to its trailing 64 bytes. Both the `expo-kit-privy`
template and Privy's own MWA recipe pass the whole thing base64-encoded, and that is what
Privy's verifier expects.
## Choosing `domain` and `uri`
```ts
const siwsDomain = 'myapp.com'
const siwsUri = 'myapp://privy-login'
```
`domain` is an RFC 3986 *authority* — a host, optionally with a port. No scheme, no trailing
path. `uri` is a full URI identifying what is requesting the signature; a deep link into the
app is the natural choice on mobile.
Both are rendered inside the message the user approves in their wallet, so they are the only
thing distinguishing your request from a phishing one. Pick values that read as yours and then
leave them alone — changing them changes what users see at every login.
## Linking a wallet to an existing account
Same shape, different hook. Use this when the user already signed in some other way — email,
OAuth — and is now attaching a wallet:
```tsx
import { useLinkWithSiws } from '@privy-io/expo'
const { generateMessage, link } = useLinkWithSiws()
const { message } = await generateMessage({
from: { domain: siwsDomain, uri: siwsUri },
wallet: { address: address.toString() },
})
const signedPayload = await signMessages(new TextEncoder().encode(message))
await link({ message, signature: fromUint8Array(signedPayload) })
```
`link` takes the same options as `login` minus `disableSignup`, and returns the updated `User`
with the wallet in its linked accounts. Calling `login` here instead would start a second
account owned by the wallet, orphaning the original.
## Verifying the session on a server
The client's `user.id` proves nothing — it is a string the client could invent. Send the access
token and let the server decide:
```ts
const { getAccessToken } = usePrivy()
const token = await getAccessToken()
await fetch('https://api.myapp.com/claim', {
headers: { authorization: `Bearer ${token}` },
method: 'POST',
})
```
Fetch a token per request. It is short-lived and the SDK refreshes it on demand, so a cached
copy expires mid-session.
### Verify the token, and pin who it was issued for
Privy access tokens are ES256 JWTs. The server SDK runs the whole check and is the right
default:
```ts
import { PrivyClient } from '@privy-io/node'
const privy = new PrivyClient({
appId: process.env.PRIVY_APP_ID,
appSecret: process.env.PRIVY_APP_SECRET,
})
const claims = await privy.utils().auth().verifyAccessToken(token)
```
It resolves the app's JWKS for you and checks the audience against the `appId` you constructed
it with.
Without a Node SDK, verify against Privy's JWKS and pass `issuer` and `audience` explicitly:
```ts
import * as jose from 'jose'
const jwks = jose.createRemoteJWKSet(
new URL(`https://api.privy.io/v1/apps/${process.env.PRIVY_APP_ID}/jwks.json`),
)
const { payload } = await jose.jwtVerify(token, jwks, {
algorithms: ['ES256'],
audience: process.env.PRIVY_APP_ID,
issuer: 'privy.io',
})
```
`createRemoteJWKSet` caches the fetch and re-fetches on an unknown key ID, so signing-key
rotation does not need a deploy. The dashboard also exposes a single static verification key
under **Configuration > App settings**, usable via `jose.importSPKI(key, 'ES256')` in place of
`jwks` — but pin it only if you would rather redeploy than allow an outbound call to Privy.
**A valid signature is not proof the token was issued to you.** Privy mints tokens for every app
on the platform, and creating an app takes a minute. Check the signature without pinning
`audience` to your own app ID and a token minted for an attacker's Privy app authenticates
against your server — the signature is genuine, it was simply never issued for you. `audience`
is the claim that makes this authentication rather than a well-formedness check. Pin `issuer`
and `algorithms` in the same breath.
Verification yields the Privy user ID. Read the wallet address from the verified user's linked
accounts rather than from the request body.
Taking the address from the request body instead is the same class of bug the
`seeker-genesis-token` skill covers in detail: a caller supplies someone else's address
alongside their own valid token and inherits their entitlements.
## Without Wallet UI: the raw protocol
Only relevant if the project talks to `@solana-mobile/mobile-wallet-adapter-protocol` directly.
`@wallet-ui/react-native-kit` wraps all of this, so prefer it in new code.
Two things the wrapper is doing that you then have to do yourself:
1. **Address conversion.** `authorizationResult.accounts[0].address` is base64. Privy needs
base58, so decode the bytes and re-encode — with `getBase58Decoder()` from `@solana/kit`, or
`new PublicKey(bytes).toBase58()` on the web3.js stack.
2. **Session management.** Everything runs inside a single `transact` callback, and
authorization is not cached between calls unless you cache it.
Signing still returns a signed payload that you base64-encode and pass to `login` unchanged —
that part does not differ. Privy's recipe has the full component:
https://docs.privy.io/recipes/solana/adding-solana-mwa
Note that the recipe is written against `@solana/web3.js` and `react-native-quick-base64`.
Neither is needed on the kit stack; `@solana/kit`'s codecs cover the same ground.
</content>
references/troubleshooting.md›
# Privy troubleshooting
Failures specific to Privy on top of MWA. For connection and signing failures that would
happen without Privy in the app, see the `solana-mobile-wallet` skill's troubleshooting
reference — start there if the wallet never opens, since Privy is not involved until after a
signature comes back.
## `isReady` never becomes `true`
The SDK did not initialize. In order of likelihood:
1. **`appId` is `undefined`.** `EXPO_PUBLIC_*` values are inlined at bundle time, so editing
`.env` and reloading changes nothing — restart with `npx expo start --clear`. Throwing in
`AppProviders` when either variable is missing turns this into an immediate, obvious error.
2. **The app was not rebuilt** after installing the SDK. Native modules need
`npx expo run:android`, not a reload.
3. **Secure store is missing.** Check `usePrivy().error` — storage failures surface there
rather than as a thrown exception.
## SIWS login rejects even though the wallet signed
The wallet prompt appeared, the user approved, and `login` still throws. Work through these in
order — the first two are dashboard state, not code:
1. **SVM wallets are disabled.** User management > Authentication > External wallets >
**SVM (Solana) wallets**. Off by default. This is the single most common cause.
2. **The app identifier does not match.** App settings > Clients must carry the
`expo.android.package` value from `app.json`. Renaming the Android package without updating
the client breaks login on the next build.
3. **The address is base64.** Passing `account.addressBase64` instead of `account.address`
sends MWA's wire format to a verifier expecting base58.
4. **The signature was re-encoded.** `fromUint8Array` produces base64, which is what Privy
wants. Converting to base58, or slicing the payload to 64 bytes first, both fail
verification.
5. **The message was rebuilt.** Only the exact string from `generateMessage` verifies —
Privy's nonce is inside it. Signing a message you assembled yourself always rejects.
## `Cannot find module 'jose'` or a crypto error from `jose`
Metro resolved the Node build. Add the `jose` browser-condition resolver to
`metro.config.js` — see [setup.md](setup.md) — then restart with `--clear`, since Metro caches
resolution results.
If a resolver is already configured for something else, chain through it rather than
overwriting it; a bare assignment silently disables the other plugin's resolution.
## Signing fails before the wallet ever opens
The crypto polyfill is missing or loaded too late. Privy and `@solana/kit` both need Web
Crypto, and the failure happens before MWA is contacted, so it reads as a Privy bug rather
than a setup bug.
`react-native-quick-crypto`'s `install()` must run from the module `main` points at, before
`expo-router/entry`. Importing it from a root layout is too late.
## The session survives but the wallet does not
`usePrivy().user` is populated on launch while `useMobileWallet().account` is `undefined`.
This is normal, not a bug: Privy persists its session in secure store and MWA persists its
authorization separately, and they expire on different schedules.
Handle it explicitly. A signed-in user with no connected wallet can still read their profile,
but every signing path needs to reconnect first. Treating `user` as proof of a usable wallet
produces a button that throws.
## Sign-out leaves the user signed in
Both sides need clearing:
```tsx
await logout()
await disconnect()
```
`disconnect()` alone leaves a live Privy session. `logout()` alone leaves the wallet
authorized, so the next sign-in completes with no prompt and looks like the sign-out never
happened.
## Login creates a second account instead of linking
`login` was called where `link` belonged. For a user who already has a Privy account from
another method, use `useLinkWithSiws().link` — `useLoginWithSiws().login` starts a fresh
account owned by the wallet and the original becomes unreachable from the app.
Pass `disableSignup: true` to `login` to make this loud: it rejects unknown wallets instead of
signing them up.
## A peer dependency version conflict on install
`@privy-io/expo` pins several peers to exact versions — `viem` and
`@privy-io/expo-native-extensions` in particular — and the pins move between minor releases.
`npx expo install` resolves against the Expo SDK, not against Privy's pins, so the two can
disagree.
Read the peers for the exact version being installed and match them:
```bash
npm view @privy-io/expo@<version> peerDependencies
```
</content>
SKILL.md›
---
name: integration-privy
description: Add Privy authentication to a Solana Expo Android app on top of Mobile Wallet Adapter, using Sign-In-With-Solana. Use when installing @privy-io/expo, mounting PrivyProvider, logging a user in with useLoginWithSiws, linking a wallet to an existing Privy account, reading the Privy access token from a backend, or debugging a Privy plus MWA setup.
---
# Privy on Solana mobile
Privy owns the **user**: a durable account identifier and a JWT a backend can verify. Mobile
Wallet Adapter owns the **keys**. Privy signs nothing in this setup — every signature still
comes from the wallet app.
Sign-In-With-Solana joins the two. Privy generates a message, MWA signs it, Privy exchanges
the signature for a session.
Reach for this when an app needs a stable user record across devices, a server-verifiable
session, or login methods beyond a wallet. An app that only needs a connected address does not
need Privy — use the `solana-mobile-wallet` skill alone.
**Android only, and a development build only.** MWA has no iOS support and does not run in
Expo Go, which caps the whole integration.
## Before you start
| Requirement | Where it comes from |
| --- | --- |
| A working `useMobileWallet()` | `solana-mobile-wallet` skill |
| A development build on Android | `solana-mobile` skill |
| A Privy app ID and client ID | The Privy dashboard — step 1 |
## Step 1: create the Privy app
Do this first. Two of these values are compile-time environment variables, and one dashboard
toggle decides whether login works at all.
1. Sign in at https://dashboard.privy.io and click **New app** on the organization overview
2. Name it, select **Mobile app**, create it, and save the **App ID**
3. Under **User management > Authentication**, in the **External wallets** card, enable
**SVM (Solana) wallets**
4. Under **App settings > Clients**, set the app identifier to the `expo.android.package`
value from `app.json`, and save the **Client ID**
The **SVM wallets** toggle is the one that is easy to skip and expensive to debug — while it is
off, `login` rejects every SIWS attempt even though the wallet signed correctly. The **app
identifier** matters because Privy checks the calling app's package name against the client.
```bash
EXPO_PUBLIC_PRIVY_APP_ID=your-privy-app-id
EXPO_PUBLIC_PRIVY_CLIENT_ID=your-privy-client-id
```
Both are public client-side identifiers, so `EXPO_PUBLIC_` is correct. **The Privy app secret
never belongs in a mobile app** — anything prefixed `EXPO_PUBLIC_` is readable in the shipped
bundle. The secret is for server code only.
## Step 2: install and configure
```bash
npx expo install @privy-io/expo @privy-io/expo-native-extensions
```
`@privy-io/expo` carries a long peer dependency list that shifts between releases — passkeys,
secure store, web browser, crypto, `viem`. Install what the version you picked asks for rather
than copying a list from anywhere, including from here.
Three pieces of native wiring are required, and the SDK fails in a different place for each:
- Crypto and text-encoding polyfills, loaded from the entry module before anything else
- `expo-secure-store` and `expo-web-browser` in `app.json` plugins
- A Metro resolver override so `jose` resolves to its browser build
Full contents for each, and how to confirm they took: [references/setup.md](references/setup.md).
Rebuild natively (`npx expo run:android`) after this step — a JS reload will not pick up the
new native modules.
## Step 3: mount the providers
```tsx
import { PrivyProvider } from '@privy-io/expo'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type AppIdentity, createSolanaDevnet, MobileWalletProvider } from '@wallet-ui/react-native-kit'
import type { ReactNode } from 'react'
const cluster = createSolanaDevnet()
const identity: AppIdentity = { name: 'My App', uri: 'myapp://myapp' }
const privyAppId = process.env.EXPO_PUBLIC_PRIVY_APP_ID
const privyClientId = process.env.EXPO_PUBLIC_PRIVY_CLIENT_ID
const queryClient = new QueryClient()
export function AppProviders({ children }: { children: ReactNode }) {
if (!privyAppId || !privyClientId) {
throw new Error('Missing Privy environment variables')
}
return (
<QueryClientProvider client={queryClient}>
<PrivyProvider appId={privyAppId} clientId={privyClientId}>
<MobileWalletProvider cluster={cluster} identity={identity}>
{children}
</MobileWalletProvider>
</PrivyProvider>
</QueryClientProvider>
)
}
```
`PrivyProvider` and `MobileWalletProvider` do not depend on each other, so their relative
nesting is free — but both must sit above every screen, and `QueryClientProvider` above both
if the hooks below are queries and mutations.
Throwing on missing environment variables is deliberate. Undefined values reach Privy as a
malformed app ID and surface much later as an opaque initialization error.
`clientId` is typed optional in `PrivyProviderProps`, which is misleading here: Privy's mobile
documentation treats it as required, and the dashboard issues one per mobile client. Pass it.
## Step 4: wait for `isReady`
`usePrivy()` returns state that is meaningless until the SDK finishes reading stored tokens:
| Value | Type | Notes |
| --- | --- | --- |
| `isReady` | `boolean` | Everything else is provisional until this is `true` |
| `user` | `User \| null` | `null` when unauthenticated — not `undefined` |
| `error` | `Error \| null` | Initialization failures, typically storage access |
| `logout` | `() => Promise<void>` | No-op when nobody is signed in |
| `getAccessToken` | `() => Promise<string \| null>` | Call per request; never cache the result |
```tsx
const { error, isReady, user } = usePrivy()
if (!isReady) return <Loading />
if (error) return <ErrorCard message={error.message} />
```
Rendering a signed-out state while `isReady` is `false` makes an already-authenticated user
flash through a login screen on every cold start.
## Step 5: sign in with SIWS
The whole integration is this one sequence: generate, sign, exchange.
```tsx
import { useLoginWithSiws } from '@privy-io/expo'
import type { Address } from '@solana/kit'
import { useMutation } from '@tanstack/react-query'
import { fromUint8Array, useMobileWallet } from '@wallet-ui/react-native-kit'
const siwsDomain = 'myapp.com'
const siwsUri = 'myapp://privy-login'
export function usePrivySignInMutation(address: Address) {
const { generateMessage, login } = useLoginWithSiws()
const { signMessages } = useMobileWallet()
return useMutation({
mutationFn: async () => {
const { message } = await generateMessage({
from: { domain: siwsDomain, uri: siwsUri },
wallet: { address: address.toString() },
})
const signedPayload = await signMessages(new TextEncoder().encode(message))
await login({ message, signature: fromUint8Array(signedPayload) })
},
})
}
```
Call it only once a wallet is connected — `useMobileWallet().account` must be defined, since
`signMessages` triggers its own authorization otherwise.
Three encoding details decide whether this works:
1. **Pass `account.address`, which is base58.** `account.addressBase64` also exists; it is
MWA's wire format and Privy will not accept it. Privy's own recipe spends three lines
converting base64 to base58 because it drives the raw protocol — `@wallet-ui/react-native-kit`
has already done that conversion for you.
2. **`fromUint8Array` produces base64, not base58.** It is a re-export of `js-base64`. Privy
wants the base64 string here; base58 fails verification.
3. **Do not slice the bytes.** `signMessages` resolves to MWA's *signed payload*, not a bare
64-byte signature. Base64-encode it whole and hand it over — the template and Privy's
recipe both do exactly this.
`from.domain` is an RFC 3986 authority: a bare host, no scheme and no path. `from.uri` is a
full URI and is normally your app's deep link. Keep both stable — they are embedded in the
signed message the user sees in their wallet.
Linking a wallet to an account that already exists, and verifying the session on a server:
[references/siws.md](references/siws.md).
## Step 6: sign out of both
```tsx
const { logout } = usePrivy()
const { disconnect } = useMobileWallet()
await logout()
await disconnect()
```
Doing one without the other leaves the app in a half-signed-out state. `disconnect()` alone
keeps a live Privy session with no wallet behind it; `logout()` alone leaves the wallet
authorized and re-signs in silently on the next attempt.
## Which side owns what
| Concern | Owner |
| --- | --- |
| Private keys and signing | The wallet app, over MWA |
| Connected address | `useMobileWallet().account` |
| User identity across devices | `usePrivy().user` |
| Server-verifiable session | `usePrivy().getAccessToken()` |
| Sending transactions | `useMobileWallet().sendTransactions` |
There is no Privy signer in this setup. A user is signed in to Privy and connected over MWA as
two independent facts, and the UI has to handle every combination — most usefully "connected
but not signed in", which is where the sign-in button belongs.
## Reference material
- [references/setup.md](references/setup.md) — polyfills, Metro config, `app.json` plugins,
environment variables, and how to verify each one landed
- [references/siws.md](references/siws.md) — the SIWS exchange in depth, linking additional
wallets, server-side token verification, and the raw-protocol variant without Wallet UI
- [references/troubleshooting.md](references/troubleshooting.md) — Privy-specific failures and
their causes
The patterns here follow
[`expo-kit-privy`](https://github.com/solana-mobile/templates/tree/main/mobile/expo-kit-privy),
a complete working app. Read it when this file is ambiguous:
```bash
npx solana-mobile@latest create /tmp/reference-app --template expo-kit-privy --skip-install
```
## Related skills
- `solana-mobile-wallet` — MWA connection, signing, and sending, which this builds on
- `solana-mobile` — development builds, emulators, toolchain checks
- `seeker-genesis-token` — SIWS verified server-side without Privy, when a JWT is overkill
## Links
- Privy Solana MWA recipe: https://docs.privy.io/recipes/solana/adding-solana-mwa
- Privy Expo SIWS login: https://docs.privy.io/guide/expo/authentication/siws
- Privy dashboard: https://dashboard.privy.io