Skills に戻る
solana-mobile/solana-mobile-skills実行前に内容を確認

SKILL DETAIL

solana-mobile-wallet

solana-mobile/solana-mobile-skills/solana-mobile-wallet

This skill enables wallet connection and transaction signing through Mobile Wallet Adapter (MWA), wrapped by @wallet-ui/react-native-kit (or @wallet-ui/react-native-web3js on the legacy stack). It is used when adding a connect wallet button, showing a connected address, disconnecting, signing messages, sign-in with Solana, transferring SOL, or sending any transaction from a Solana mobile app. Note: MWA requires a development build on Android; Expo Go will not work. If the project has no development build yet, or does not exist, start with the solana-mobile skill.

インストール · 184出典を見る

Installation

npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill solana-mobile-wallet

スキルファイル

SKILL.md

最終同期 · 2026/08/29

references/kit.md
# Kit stack reference

For projects using `@wallet-ui/react-native-kit` with `@solana/kit`. This is the current
default. For `@wallet-ui/react-native-web3js`, see [web3js.md](web3js.md) instead.

## Dependencies

What `expo-kit-minimal` ships, which is the smallest working set:

```bash
npm install @wallet-ui/react-native-kit @solana/kit @tanstack/react-query react-native-quick-crypto
```

Add `@solana-program/memo` (or whichever program clients you need) for instruction builders.

`react-native-quick-crypto` needs a native rebuild after install (`expo run:android`), not just
a Metro restart.

## Crypto polyfill

Kit needs Web Crypto. Install it before anything else runs, in its own module so import
ordering cannot be reshuffled by a formatter or linter:

```js
// polyfill.js
import { install } from 'react-native-quick-crypto'

install()
```

```js
// index.js
import './polyfill'
import 'expo-router/entry'
```

Point `package.json` `"main"` at `./index.js`. Getting this wrong produces signing failures
that look like wallet bugs — the crypto calls fail before the wallet is ever reached.

## Clusters and config

Build clusters with the `createSolana*` helpers. A `SolanaCluster` needs `id`, `label`, and
`url`, and the helpers fill in everything but the URL:

```ts
import {
  type AppIdentity,
  createSolanaDevnet,
  createSolanaTestnet,
  type SolanaCluster,
} from '@wallet-ui/react-native-kit'

export class AppConfig {
  static identity: AppIdentity = { name: 'my-app' }
  static networks: SolanaCluster[] = [
    createSolanaDevnet({ url: 'https://api.devnet.solana.com' }),
    createSolanaTestnet({ url: 'https://api.testnet.solana.com' }),
  ]
}
```

`createSolanaDevnet`, `createSolanaTestnet`, and `createSolanaLocalnet` take optional props.
`createSolanaMainnet` requires a `url` — there is no public default worth shipping for mainnet.
Keep any paid RPC key server-side; an `EXPO_PUBLIC_*` variable is readable from the APK.

## Reading chain data

Use the `client` the provider already built, reachable from the hook:

```ts
import type { Address } from '@solana/kit'
import { useQuery } from '@tanstack/react-query'
import { useMobileWallet } from '@wallet-ui/react-native-kit'

export function useAccountGetBalance({ address }: { address: Address }) {
  const { chain, client } = useMobileWallet()

  return useQuery({
    queryFn: () => client.rpc.getBalance(address).send(),
    queryKey: ['get-balance', chain, address],
  })
}
```

`chain` belongs in the query key. Without it, switching cluster serves the previous network's
cached values — which presents as a wallet bug rather than a caching one.

Kit RPC calls are lazy: `client.rpc.someMethod(...)` builds a request, `.send()` executes it.
Forgetting `.send()` leaves you holding a pending request object — nothing errors, the data is
simply never fetched.

Balances come back as `bigint` lamports:

```ts
export function lamportsToSol(lamports: bigint) {
  return Number(lamports) / 1e9
}
```

Convert at the display boundary only, and never with `parseFloat`.

### Building your own client

Only needed for a cluster the provider does not hold, or a custom transport. This pulls in
`@solana/kit-plugin-rpc`, which the minimal template does not use:

```ts
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<typeof createSolanaClient>
```

## Sending a transaction: the short path

```tsx
import { getAddMemoInstruction } from '@solana-program/memo'
import type { Address, Instruction } from '@solana/kit'
import { useMobileWallet } from '@wallet-ui/react-native-kit'

export function useSendMemo({ address }: { address: Address }) {
  const { sendTransactions } = useMobileWallet()

  return async function send(memo: string) {
    const instructions: Instruction[] = [getAddMemoInstruction({ memo })]

    return sendTransactions(instructions)
  }
}
```

`sendTransactions` handles blockhash, `minContextSlot`, fee payer, and signature decoding, and
returns the signature as a string. Use it unless you need control over one of those.

## Sending a transaction: the explicit path

Needed when you want a fee pre-check, a specific lifetime, or multiple instructions with a
non-default fee payer.

```ts
import {
  type Address,
  appendTransactionMessageInstruction,
  assertIsTransactionMessageWithSingleSendingSigner,
  compileTransactionMessage,
  createTransactionMessage,
  getBase58Decoder,
  getBase64Decoder,
  getCompiledTransactionMessageEncoder,
  pipe,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signAndSendTransactionMessageWithSigners,
  type TransactionMessageBytesBase64,
  type TransactionSendingSigner,
} from '@solana/kit'
import { getAddMemoInstruction } from '@solana-program/memo'

export async function sendMemo({
  address,
  client,
  getTransactionSigner,
  text,
}: {
  address: Address
  client: SolanaClient
  getTransactionSigner: (address: Address, minContextSlot: bigint) => TransactionSendingSigner
  text: string
}) {
  const {
    context: { slot: minContextSlot },
    value: latestBlockhash,
  } = await client.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send()

  const signer = getTransactionSigner(address, minContextSlot)

  const message = pipe(
    createTransactionMessage({ version: 0 }),
    (m) => setTransactionMessageFeePayerSigner(signer, m),
    (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
    (m) => appendTransactionMessageInstruction(getAddMemoInstruction({ memo: text }), m),
  )

  assertIsTransactionMessageWithSingleSendingSigner(message)

  const signatureBytes = await signAndSendTransactionMessageWithSigners(message)
  return getBase58Decoder().decode(signatureBytes)
}
```

Points that matter:

- `getTransactionSigner(address, minContextSlot)` comes from `useMobileWallet()`. The
  `minContextSlot` must come from the same `getLatestBlockhash` response used for the
  lifetime — mismatching them causes the wallet to reject the payload.
- `signAndSendTransactionMessageWithSigners` returns raw bytes. Decode with
  `getBase58Decoder()` to get a signature string.
- The `assertIsTransactionMessageWithSingleSendingSigner` call is not optional ceremony; it
  is what narrows the type so the send function accepts the message.

### Checking the fee before sending

Failing early with a clear message beats a wallet-side rejection:

```ts
const encoded = getCompiledTransactionMessageEncoder().encode(compileTransactionMessage(message))

const [{ value: balance }, { value: fee }] = await Promise.all([
  client.rpc.getBalance(signer.address, { commitment: 'confirmed' }).send(),
  client.rpc
    .getFeeForMessage(getBase64Decoder().decode(encoded) as TransactionMessageBytesBase64, {
      commitment: 'confirmed',
    })
    .send(),
])

if (fee === null) throw new Error('Could not estimate the transaction fee.')
if (balance < fee) {
  throw new Error(`Balance ${balance} lamports is below the ${fee} lamport fee.`)
}
```

Both are `bigint`, so compare them directly — no `Number()` conversion, which would lose
precision on large balances.

## Signing a message

```ts
const { signMessages } = useMobileWallet()

const signature = await signMessages(new TextEncoder().encode('hello'))
```

Takes and returns `Uint8Array`. To display or transmit the result, use kit's decoders
(`getBase58Decoder()`, `getBase64Decoder()`) or `fromUint8Array` re-exported from
`@wallet-ui/react-native-kit`.

Do **not** reach for `btoa(String.fromCharCode(...bytes))`. Spreading a large array into
arguments throws `RangeError: Maximum call stack size exceeded`, and it is unnecessary when
kit ships decoders.

## Sign-in with Solana

`signIn` authorizes and proves wallet ownership in one round trip, which is fewer prompts than
`connect()` followed by a message signature. It also works with no connected account, in which
case it connects and signs in together.

### The minimal form

For in-app UX where nothing server-side depends on the result, most payload fields are
optional. Take `chain` and `identity` from the hook:

```tsx
import { type Account, useMobileWallet } from '@wallet-ui/react-native-kit'

export function SignInButton({ account }: { account?: Account }) {
  const { chain, identity, signIn } = useMobileWallet()

  return (
    <Button
      onPress={async () => {
        const result = await signIn({
          address: account?.address.toString(),
          chainId: chain,
          uri: identity.uri,
        })

        console.log('signed in as', result.account.address)
      }}
      title={account ? `Sign in with ${account.label}` : 'Sign in and connect'}
    />
  )
}
```

Omitting `address` lets the wallet choose the account, which is what you want for the
connect-and-sign-in-together case.

### The verified form

**As soon as a backend grants anything based on the result, the minimal form is not enough.**
Without a server-issued nonce the signature is replayable, and without a domain the signature
could have been farmed by another site. Build the full payload:

```ts
import { type SignInPayload, type SolanaClusterId } from '@wallet-ui/react-native-kit'
import * as Linking from 'expo-linking'

const APP_DOMAIN = 'myapp'
const APP_URI = Linking.createURL('/')

export function createSignInPayload({
  address,
  cluster,
  nonce,
  requestId,
  statement,
}: {
  address: string
  cluster: SolanaClusterId
  nonce: string
  requestId: string
  statement: string
}): SignInPayload {
  const issuedAt = new Date()

  return {
    address,
    chainId: cluster,
    domain: APP_DOMAIN,
    expirationTime: new Date(issuedAt.getTime() + 60_000).toISOString(),
    issuedAt: issuedAt.toISOString(),
    nonce,
    notBefore: issuedAt.toISOString(),
    requestId,
    statement,
    uri: APP_URI,
    version: '1',
  }
}
```

```ts
const { signIn } = useMobileWallet()
const output = await signIn(createSignInPayload({ ... }))
```

The `nonce` must be generated server-side and be single-use, otherwise the signature is
replayable and the whole exercise proves nothing. Verify the returned signature on your
backend — see the `seeker-genesis-token` skill for the verification half.

## Telling cancellation apart from failure

Dismissing the wallet picker rejects with an association error. Surfacing that as "wallet
connection failed" is misleading, so branch on it:

```ts
function isWalletConnectionCanceled(error: unknown) {
  const code = error !== null && typeof error === 'object' && 'code' in error ? String(error.code) : ''
  const message = error instanceof Error ? error.message : ''

  return (
    code === 'ERROR_ASSOCIATION_CANCELLED' ||
    message.includes('CancellationException') ||
    message.includes('Local association cancelled by user')
  )
}
```

Treat cancellation as a no-op with a retry affordance; treat everything else as an error
worth reporting.

For everything else, normalise the value before showing it — thrown values are not always
`Error` instances:

```ts
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'
}
```

Rendering a raw non-`Error` throw gives the user `[object Object]`.
references/troubleshooting.md
# Wallet troubleshooting

Failures specific to wallet connection and signing. For build and toolchain failures, see
the `solana-mobile` skill's troubleshooting reference.

## "must be used in a secure context (`https`)"

```
SolanaMobileWalletAdapterError: The mobile wallet adapter protocol must be used in a secure context (`https`).
```

The bundler resolved the protocol package's browser/ESM build instead of its React Native
build. The web build checks for a secure browser context, which never holds in an app.

Confirm the app is running as a development build and not in Expo Go first — that is the
more common cause of this error than any resolution problem.

If it is a genuine resolution issue, force native resolution by removing the ESM output:

```bash
rm -rf node_modules/@solana-mobile/mobile-wallet-adapter-protocol/lib/esm
npx expo run:android
```

This is a workaround, not a fix: it is undone by the next `npm install`. Prefer correcting
the Metro `resolverMainFields` order so the React Native entry point wins, which survives
reinstalls.

## Nothing happens when tapping connect

No wallet app supporting MWA is installed. The tap does register and MWA does start a session
— it then fails to hand off, and by default nothing surfaces in the UI. Confirm in logcat
rather than guessing:

```bash
adb logcat -d --pid=$(adb shell pidof com.your.package) | grep SolanaMobileWalletAdapter
```

The signature is unambiguous:

```
D SolanaMobileWalletAdapterModule: startSession with config null
V LocalAssociationScenario: Creating local association scenario for ws://127.0.0.1:PORT/solana-wallet
E SolanaMobileWalletAdapterModule: Found no installed wallet that supports the mobile wallet protocol
E SolanaMobileWalletAdapterModule: android.content.ActivityNotFoundException: No Activity found
  to handle Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE]
  dat=solana-wallet:/... }
```

MWA dispatches an `android.intent.action.VIEW` intent for the `solana-wallet:` scheme. Check
whether anything on the device claims it:

```bash
adb shell pm query-activities --brief -a android.intent.action.VIEW -d "solana-wallet://"
```

`No activities found` means no wallet is installed. Install an MWA-compatible wallet APK, or
test on a physical Android device that has one.

Because the rejection is invisible by default, catch it and show something — a bare
`await connect()` inside a try/catch that only logs looks identical to a dead button.

## Connect rejects immediately

Usually the user dismissing the wallet picker. Check the error before reporting a failure:

```ts
code === 'ERROR_ASSOCIATION_CANCELLED'
message.includes('CancellationException')
message.includes('Local association cancelled by user')
```

Any of these means cancellation. Offer a retry rather than an error dialog.

## "payloads invalid for signing"

In order of likelihood:

1. **Expired blockhash.** They last roughly 60–90 seconds. Fetch a fresh one and rebuild the
   transaction; do not reuse a blockhash across a retry.
2. **`minContextSlot` missing or mismatched.** `signAndSendTransaction` takes it as a second
   argument, and it must come from the same response as the blockhash. On web3.js use
   `getLatestBlockhashAndContext()`; on kit read `context.slot` from `getLatestBlockhash()`.
3. **Malformed transaction.** No fee payer set, or no instructions.

## Signing fails but the wallet never appears

The crypto polyfill is missing or loaded too late. Kit and the MWA protocol both need Web
Crypto, and the failure happens before the wallet is contacted, so it presents as a signing
bug rather than a setup bug.

Check that `react-native-quick-crypto`'s `install()` runs in a module imported before
anything else — see the polyfill sections in [kit.md](kit.md) and [web3js.md](web3js.md) —
and that the app was rebuilt natively after installing it.

## Address renders as garbled text

```
+9pgyt LK...MIiSdpI=
```

On the web3.js stack `account.address` is a `PublicKey` object. Call `.toString()` before
rendering. On the kit stack it is already a string, so this error does not occur.

## Transaction fails only for some recipients

Some wallets refuse transfers to accounts that do not yet exist on chain, since the transfer
must also cover rent for account creation. Test against a known funded address to confirm,
then make sure the transfer amount clears the rent-exempt minimum.

## `RangeError: Maximum call stack size exceeded` when encoding

From `btoa(String.fromCharCode(...bytes))` — spreading a large array exceeds the argument
limit. Use `fromUint8Array` from the wallet package, or kit's `getBase64Decoder()`.

## Session lost on every app restart

Authorization is cached automatically. Losing it each launch means the cache is not
persisting — check that `@react-native-async-storage/async-storage` (or the storage backend
a custom `cache` prop uses) is installed and linked, and that a custom `cache` implementation
is not returning `undefined` unconditionally.
references/web3js.md
# Legacy web3.js stack reference

**Only use this file when the project's Solana client is already `@solana/web3.js`, or the user
asked for it directly.** Anything else — a new app, a project with no Solana client yet, a kit
project gaining a wallet feature — belongs on kit. See [kit.md](kit.md).

Being here is not a reason to migrate mid-task either. Match what the project has, ship the
feature, and raise migration separately if it looks worthwhile. The
[migration sketch](#migrating-to-kit) at the end is for that conversation, not for doing it
opportunistically.

The two stacks differ in provider props, hook return values, and transaction construction, so
code from one silently fails on the other. Do not mix them in one app.

## Dependencies

```bash
npm install @wallet-ui/react-native-web3js @solana/web3.js @tanstack/react-query react-native-quick-crypto
```

## Crypto polyfill

```js
// polyfill.js
import { install } from 'react-native-quick-crypto'

install()
```

```js
// index.js
import './polyfill'
import 'expo-router/entry'
```

Keep the polyfill in its own module imported first, so import ordering cannot be reshuffled.
Older guides used `react-native-get-random-values`; `react-native-quick-crypto` is what the
current templates ship and it covers more of the Web Crypto surface. A native rebuild is
required after installing it.

## Provider

Unlike the kit provider, this one takes `chain` and `endpoint`:

```tsx
import { MobileWalletProvider } from '@wallet-ui/react-native-web3js'
import type { Chain } from '@solana-mobile/mobile-wallet-adapter-protocol'

const chain: Chain = 'solana:devnet'

<MobileWalletProvider
  chain={chain}
  endpoint="https://api.devnet.solana.com"
  identity={{ name: 'My App', uri: 'myapp://myapp' }}
>
  {children}
</MobileWalletProvider>
```

Optional props: `cache`, `commitmentOrConfig`.

## Hook surface

```tsx
const { account, connect, disconnect, connection, signAndSendTransaction } = useMobileWallet()
```

| Value | Type | Notes |
| --- | --- | --- |
| `account` | `Account \| undefined` | `undefined` when disconnected |
| `accounts` | `Account[] \| null` | |
| `connect` | `() => Promise<Account>` | |
| `disconnect` | `() => Promise<void>` | |
| `connection` | `Connection` | web3.js connection — kit exposes `client` instead |
| `signAndSendTransaction` | `(tx, minContextSlot: number) => Promise<SignatureBytes>` | Second argument required |
| `signTransaction` | `(tx) => Promise<tx>` | Sign without broadcasting |
| `signMessage` | `(msg: Uint8Array) => Promise<Uint8Array>` | |
| `signIn` | `(payload) => Promise<SignInOutput>` | See [Sign-in with Solana](#sign-in-with-solana) |

There is **no `connected` boolean**. Derive it with `const connected = !!account`.

### account.address is a PublicKey here

On this stack `account.address` is a web3.js `PublicKey`, not a string. Call `.toString()`
whenever you display or interpolate it, or React renders the object and you get output like
`+9pgyt LK...MIiSdpI=`:

```tsx
<Text>{account.address.toString()}</Text>
```

`account.publicKey` also exists but is **deprecated in favour of `address`**. Prefer
`address`.

## Connect and disconnect

```tsx
import { Pressable, Text } from 'react-native'
import { useMobileWallet } from '@wallet-ui/react-native-web3js'

export function ConnectButton() {
  const { account, connect, disconnect } = useMobileWallet()

  async function onPress() {
    try {
      if (account) await disconnect()
      else await connect()
    } catch (error) {
      // Dismissing the wallet picker lands here — a normal outcome, not a crash.
      console.error(error)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>{account ? 'Disconnect' : 'Connect Wallet'}</Text>
    </Pressable>
  )
}
```

Authorization is cached, so the app reconnects on restart without a fresh prompt.

## Transferring SOL

```tsx
import { useMobileWallet } from '@wallet-ui/react-native-web3js'
import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'

export function useTransferSol() {
  const { account, connection, signAndSendTransaction } = useMobileWallet()

  return async function transfer({ amount, to }: { amount: number; to: string }) {
    if (!account) throw new Error('Connect a wallet first.')

    const { context, value: latestBlockhash } = await connection.getLatestBlockhashAndContext('confirmed')

    const transaction = new Transaction({
      blockhash: latestBlockhash.blockhash,
      feePayer: account.address,
      lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
    }).add(
      SystemProgram.transfer({
        fromPubkey: account.address,
        lamports: Math.round(amount * LAMPORTS_PER_SOL),
        toPubkey: new PublicKey(to),
      }),
    )

    const signature = await signAndSendTransaction(transaction, context.slot)

    await connection.confirmTransaction({
      blockhash: latestBlockhash.blockhash,
      lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
      signature: signature.toString(),
    })

    return signature
  }
}
```

Points that matter:

- **`signAndSendTransaction` needs `minContextSlot`.** Use
  `getLatestBlockhashAndContext()` rather than `getLatestBlockhash()` so you get the
  blockhash and its slot from one call — the two must agree or the wallet rejects the
  payload.
- **Use the hook's `connection`.** Constructing `new Connection(...)` separately gives you a
  client pointed at a possibly different cluster than the wallet authorized against.
- **Get a fresh blockhash per attempt.** They expire in roughly 60–90 seconds. Reusing one
  across a retry is a common cause of "payloads invalid for signing".
- Use `Math.round`, not `Math.floor`, when converting SOL to lamports, so `0.1` does not
  silently become one lamport short.

## Encoding bytes

`Buffer` is not present in React Native by default. Use the base64 helpers re-exported from
the wallet package:

```ts
import { fromUint8Array, toUint8Array } from '@wallet-ui/react-native-web3js'

const encoded = fromUint8Array(bytes)
const decoded = toUint8Array(encoded)
```

Avoid `btoa(String.fromCharCode(...bytes))`. Spreading a large array into arguments throws
`RangeError: Maximum call stack size exceeded`.

## Sign-in with Solana

`signIn` behaves the same here as on kit: it authorizes and proves wallet ownership in one round
trip. The payload and the rules around it are identical across both stacks, so
[kit.md](kit.md#sign-in-with-solana) is the full account — only the imports differ.

The part that does not change with the stack: **as soon as a backend grants anything based on
the result, the minimal payload is not enough.** Without a server-issued, single-use `nonce` the
signature is replayable, and without a `domain` it could have been farmed by another site. Build
the full payload — `domain`, `nonce`, `issuedAt`, `expirationTime`, `uri`, `version` — and
verify the signature server-side against the address that signed. The `seeker-genesis-token`
skill covers the verification half.

## Migrating to kit

Worth doing when you touch this code substantially. The rough shape:

1. Swap `@wallet-ui/react-native-web3js` for `@wallet-ui/react-native-kit`, and
   `@solana/web3.js` for `@solana/kit`.
2. Provider: replace `chain` and `endpoint` with a single `cluster`, built with
   `createSolanaDevnet()` or a sibling helper.
3. Replace `connection` with `client`, and `connection.getX()` with
   `client.rpc.getX().send()`. Add `chain` from the hook to your React Query keys.
4. Replace `Transaction`/`SystemProgram` construction with `sendTransactions(instructions)`,
   falling back to kit's `pipe` builders where you need fee-payer or lifetime control.
5. `account.address` becomes a branded string rather than a `PublicKey`, so drop the
   `.toString()` calls. Stop using `account.publicKey`, which is deprecated here too.
6. Lamport values become `bigint`. Audit every arithmetic site — mixing `bigint` and `number`
   throws at runtime rather than coercing, so this is where a sloppy migration breaks.

See [kit.md](kit.md) for the target shapes, and
[`expo-kit-minimal`](https://github.com/solana-mobile/templates/tree/main/mobile/expo-kit-minimal)
for a whole app in the shape you are migrating toward.
SKILL.md
---
name: solana-mobile-wallet
description: Connect Solana wallets and sign or send transactions in React Native Expo apps using Mobile Wallet Adapter and Wallet UI. Use when adding a connect wallet button, showing a connected address, disconnecting, signing messages, sign-in with Solana, transferring SOL, or sending any transaction from a Solana mobile app.
---

# Solana wallets on mobile

Wallet connection and transaction signing through Mobile Wallet Adapter (MWA), wrapped by
`@wallet-ui/react-native-kit` (or `@wallet-ui/react-native-web3js` on the legacy stack).

**MWA requires a development build on Android. Expo Go will not work.** If the project has
no development build yet, or does not exist, start with the `solana-mobile` skill.

## Step 1: pick the stack — do this before writing any code

**Write kit code.** `@solana/kit` with `@wallet-ui/react-native-kit` is the stack to reach for,
and everything in this file describes it.

There is exactly one reason to write `@solana/web3.js` instead: the project already runs on it.
Check `package.json` first.

| `package.json` says | Do this |
| --- | --- |
| `@wallet-ui/react-native-kit`, or no Solana client yet | Kit. This file, plus [references/kit.md](references/kit.md) |
| `@wallet-ui/react-native-web3js` is the app's Solana client | [references/web3js.md](references/web3js.md) |
| The user explicitly asked for web3.js | [references/web3js.md](references/web3js.md), and say why kit would be better |

Do not introduce web3.js into a kit project, or mix the two in one app. Their provider props,
hook return values, and transaction construction all differ, so code from one silently fails on
the other. If a project has no Solana client at all, that is a new build — use kit.

Adding a *new* wallet feature to an existing web3.js app is not a reason to migrate mid-task.
Match what is there, and mention migration as a follow-up if it seems worth it.

## Step 2: confirm the provider is mounted

`useMobileWallet` returns empty state without `MobileWalletProvider` above it. Look for it in
the root layout or an app-providers module.

Build the cluster with the `createSolana*` helpers rather than by hand — a `SolanaCluster` also
needs a `label`, which the helpers fill in:

```tsx
import {
  type AppIdentity,
  createSolanaDevnet,
  MobileWalletProvider,
  type SolanaCluster,
} from '@wallet-ui/react-native-kit'

const identity: AppIdentity = { name: 'My App' }
const cluster: SolanaCluster = createSolanaDevnet({ url: 'https://api.devnet.solana.com' })

<MobileWalletProvider cluster={cluster} identity={identity}>
  {children}
</MobileWalletProvider>
```

`createSolanaDevnet`, `createSolanaTestnet`, and `createSolanaLocalnet` take optional props;
`createSolanaMainnet` requires a `url`, since there is no sensible public default for mainnet.

The provider props are `cluster`, `identity`, and optional `cache`, `createClient`, `children`.
There is **no `chain` prop and no `endpoint` prop** — passing those does nothing.

Every `AppIdentity` field is optional. `name` alone is enough to get started; add `uri` as a
real deep link for anything shipping, since wallets display it during authorization and a
placeholder can read as a phishing attempt.

Put `QueryClientProvider` from `@tanstack/react-query` above the wallet provider — the hook
patterns below are queries and mutations.

## Step 3: use the hook

```tsx
import { useMobileWallet } from '@wallet-ui/react-native-kit'

const { account, connect, disconnect, client } = useMobileWallet()
```

What the hook actually returns on the kit stack:

| Value | Type | Notes |
| --- | --- | --- |
| `account` | `Account \| undefined` | `undefined` when disconnected, not `null` |
| `accounts` | `Account[] \| null` | All authorized accounts |
| `connect` | `() => Promise<Account>` | Opens the wallet picker |
| `disconnect` | `() => Promise<void>` | |
| `client` | `Client` | Kit client — use `client.rpc` for RPC calls |
| `chain` | `SolanaClusterId` | The active cluster. Put it in query keys |
| `sendTransactions` | `(instructions: Instruction[]) => Promise<string>` | Simplest send path |
| `signAndSendTransaction` | `(tx, minContextSlot) => Promise<SignatureBytes>` | Note the second argument |
| `signTransaction` | `(tx) => Promise<Transaction>` | Sign without broadcasting |
| `signMessages` | `(msg: Uint8Array) => Promise<Uint8Array>` | |
| `signIn` | `(payload) => Promise<SignInOutput>` | Sign-in with Solana; can also connect |
| `identity`, `store` | | Config and authorization store |

Singular aliases exist for several of these — `sendTransaction`, `signMessage`,
`signTransactions` — with the same signatures. The templates use the plural forms; either
works, so follow whatever the project already uses.

Four things that trip people up:

1. **There is no `connected` boolean.** Derive it: `const connected = !!account`.
2. **`signAndSendTransaction` takes `minContextSlot` as a second argument.** Calling it with
   only a transaction fails. Get the slot from `getLatestBlockhash`, or use
   `sendTransactions(instructions)`, which handles this for you.
3. **The kit hook exposes `client`, not `connection`.** `connection` only exists on the
   web3.js stack.
4. **Include `chain` in every React Query key that holds chain data.** Otherwise switching
   cluster serves the previous network's cached balances, which looks like a wallet bug.

`account.address` is a kit `Address` (a branded string), so it interpolates into text directly.
`account.label` is the wallet-supplied name and may be undefined.

## Connect and disconnect

```tsx
import { Pressable, Text } from 'react-native'
import { useMobileWallet } from '@wallet-ui/react-native-kit'

export function ConnectButton() {
  const { account, connect, disconnect } = useMobileWallet()

  async function onPress() {
    try {
      if (account) await disconnect()
      else await connect()
    } catch (error) {
      // The user dismissing the wallet picker lands here. Do not treat it as a crash.
      console.error(error)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>{account ? 'Disconnect' : 'Connect Wallet'}</Text>
    </Pressable>
  )
}
```

Always wrap `connect()` in try/catch — cancelling the wallet picker rejects the promise.
Cancellation is a normal outcome, not an error state worth alarming the user about; see
[references/kit.md](references/kit.md) for how to tell cancellation apart from real
failures.

Authorization is cached, so the app reconnects on restart without a new prompt.

## Read chain data

Use `client` from the hook. There is no need to build a client of your own:

```tsx
import type { Address } from '@solana/kit'
import { useQuery } from '@tanstack/react-query'
import { useMobileWallet } from '@wallet-ui/react-native-kit'

export function useGetBalance({ address }: { address: Address }) {
  const { chain, client } = useMobileWallet()

  return useQuery({
    queryFn: () => client.rpc.getBalance(address).send(),
    queryKey: ['get-balance', chain, address],
  })
}
```

Kit RPC calls are lazy: `client.rpc.someMethod(...)` builds a request and `.send()` runs it.
Forget `.send()` and nothing errors — the data simply never arrives.

Balances come back as `bigint` lamports. Convert deliberately, and never with `parseFloat`:

```ts
export function lamportsToSol(lamports: bigint) {
  return Number(lamports) / 1e9
}
```

## Send a transaction

Build instructions and hand them over. This covers most cases:

```tsx
import { getAddMemoInstruction } from '@solana-program/memo'
import type { Instruction } from '@solana/kit'

const { sendTransactions } = useMobileWallet()

const instructions: Instruction[] = [getAddMemoInstruction({ memo: 'gm' })]
const signature = await sendTransactions(instructions)
```

`sendTransactions` handles blockhash, `minContextSlot`, fee payer, and signature decoding.

Reach for the explicit `pipe` form only when you need fee-payer control, a specific blockhash
lifetime, or a fee pre-check. Full worked example, with the balance-versus-fee assertion and
signature decoding: [references/kit.md](references/kit.md).

## Reference material

- [references/kit.md](references/kit.md) — kit stack: clusters and config, reading chain data,
  transactions, sign-in with Solana, message signing, error handling
- [references/web3js.md](references/web3js.md) — legacy `@solana/web3.js` stack, and a migration
  sketch
- [references/troubleshooting.md](references/troubleshooting.md) — connection and signing
  failures with known causes

When something here is ambiguous, read the template. The patterns in this skill follow
[`expo-kit-minimal`](https://github.com/solana-mobile/templates/tree/main/mobile/expo-kit-minimal),
which is a complete working app and stays current in a way prose does not:

```bash
npx solana-mobile@latest create /tmp/reference-app --template expo-kit-minimal --skip-install
```

## Related skills

- `solana-mobile` — project setup, templates, emulators, development builds
- `integration-privy` — add Privy accounts and sessions on top of this wallet connection
- `seeker-genesis-token` — verify Seeker device ownership after connecting
- `seeker-domains` — display `.skr` names instead of raw addresses

## Links

- Wallet UI: https://wallet-ui.dev
- MWA docs: https://docs.solanamobile.com/react-native/overview
- Solana Kit: https://www.solanakit.com
solana-mobile-wallet · 人気上昇中の Agent Skills | Mengbi