Skip to Content
SDKs@synqid/solana-mobile

@synqid/solana-mobile

The official Synq SDK for the Solana Mobile Stack — Mobile Wallet Adapter (MWA) wallet linking, sign-to-link against Synq’s /users/me/wallets, and transaction signing for product flows.

Companion to @synqid/react-native. Mount under <SynqProvider>, pass an authenticated HTTP client, and the provider hands you typed hooks for everything an MWA app needs.

AI agent? Fetch /llms.txt for the canonical doc index. The MWA flow only works on Android — iOS Solana wallets use Phantom Deeplinks or the wallet’s own SDK, not MWA.

Install

pnpm add @synqid/solana-mobile @synqid/react-native \ @solana/web3.js \ @solana-mobile/mobile-wallet-adapter-protocol \ @solana-mobile/mobile-wallet-adapter-protocol-web3js

For Expo apps using EAS Build, MWA works out of the box on Android. iOS needs the expo-build-properties useFrameworks: 'static' toggle — see iOS specifics below.

Subpaths

This is one package with platform-specific entry points. Customers import from the subpath that matches their runtime; the surface is identical across all of them.

SubpathStatusWhat it wraps
@synqid/solana-mobile/react-nativeshipping@solana-mobile/mobile-wallet-adapter-protocol-web3js for native RN apps
@synqid/solana-mobile/webroadmap@solana-mobile/wallet-adapter-mobile for mobile-browser apps that deep-link to wallet apps
@synqid/solana-mobile/coreshippingprotocol-agnostic Synq wallet-link API client — use from backends or any platform

For native Android (Kotlin) and native iOS (Swift), see the Native platforms section at the bottom.

Three-file setup (React Native)

1. The Synq provider (already in your app)

If you have followed @synqid/react-native, you already have a <SynqProvider> at the root. The Solana Mobile provider mounts under it so it can read the user’s session.

2. The Solana Mobile provider

// App.tsx import { SynqProvider, useSynqHttp } from '@synqid/react-native' import { SolanaMobileProvider } from '@synqid/solana-mobile/react-native' function Root() { return ( <SynqProvider config={synqConfig}> <AppShell /> </SynqProvider> ) } function AppShell() { const http = useSynqHttp() // authenticated client tied to current Synq session return ( <SolanaMobileProvider apiUrl={process.env.EXPO_PUBLIC_SYNQ_API_URL!} http={http} rpc={process.env.EXPO_PUBLIC_SOLANA_RPC_ENDPOINT!} cluster="mainnet-beta" identity={{ name: 'Burn & Claim', uri: 'https://burnandclaim.com', icon: 'favicon.ico', }} > <Navigator /> </SolanaMobileProvider> ) }

identity is what the wallet app shows the user on the connect screen — your brand name, URL, and icon. Keep it accurate; users distrust unfamiliar identifiers.

3. Use the hooks

import { useWallet, useLinkedWallets, useTransactionSigner, } from '@synqid/solana-mobile/react-native' function ConnectScreen() { const { wallet, authorize, deauthorize } = useWallet() const { linkedWallets, signAndLink, refresh } = useLinkedWallets() return ( <View> {wallet ? ( <Text>Connected: {wallet.address.slice(0, 8)}…</Text> ) : ( <Button title="Connect wallet" onPress={authorize} /> )} {wallet && !linkedWallets.some(w => w.publicKey === wallet.address) && ( <Button title="Link this wallet to my Synq account" onPress={async () => { await signAndLink({ label: 'Primary' }) // The linkedWallets list updates automatically. }} /> )} {linkedWallets.map(w => ( <Text key={w.id}> {w.label ?? w.publicKey.slice(0, 8)} {w.isPrimary && '(primary)'} </Text> ))} </View> ) }

Done. Wallet authorization, sign-to-link, and the linked-wallets list — three hooks.

signAndLink() is the centerpiece. It does the whole round trip in one call:

  1. Authorize the wallet via MWA if not already (authorize() under the hood).
  2. GET /users/me/wallets/challenge — Synq returns { message, nonce, expiresAt }.
  3. MWA signMessages with the wallet that signed in step 1.
  4. POST /users/me/wallets/link with the public key, the base64-encoded signature, the signed message, and the nonce.
  5. Synq verifies the signature server-side against the public key, confirms nonce matches the challenge it issued, and creates the link.

The local linkedWallets list updates automatically. No manual refresh needed.

You can pass an optional label at link time:

await signAndLink({ label: 'Treasury wallet' })

If the signature verification fails (user signed with the wrong key, or the nonce expired), the call throws. Handle it:

try { await signAndLink() } catch (err) { // common case: user dismissed the wallet sheet — show a "try again" // edge case: backend signature verification failed — log + alert }

Transaction signing

import { useTransactionSigner, useConnection } from '@synqid/solana-mobile/react-native' import { SystemProgram, Transaction } from '@solana/web3.js' function SendScreen() { const { signTransaction } = useTransactionSigner() const connection = useConnection() const send = async () => { const { blockhash } = await connection.getLatestBlockhash() const tx = new Transaction({ recentBlockhash: blockhash, feePayer: wallet.publicKey }) .add(SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: recipientPubkey, lamports: 1_000_000, })) const signed = await signTransaction(tx) const sig = await connection.sendRawTransaction(signed.serialize()) await connection.confirmTransaction(sig, 'confirmed') } return <Button title="Send" onPress={send} /> }

Works with both legacy Transaction and VersionedTransaction. For batch signing:

const { signAllTransactions } = useTransactionSigner() const [tx1Signed, tx2Signed, tx3Signed] = await signAllTransactions([tx1, tx2, tx3])

One MWA session, three signatures, no extra prompts.

Multi-wallet (primary vs secondary)

Synq supports any number of linked wallets per user, with one flagged as the primary. Most product flows should default to the primary unless the user explicitly picks a different one.

import { useLinkedWallets } from '@synqid/solana-mobile/react-native' function PrimaryPicker() { const { linkedWallets, signAndLink } = useLinkedWallets() const primary = linkedWallets.find(w => w.isPrimary) return ( <View> {linkedWallets.map(w => ( <Button key={w.id} title={w.publicKey + (w.isPrimary ? ' (primary)' : '')} onPress={async () => { // Imperative API for promoting / unlinking lives on the wallets client. // For now, route through your own backend or use the core client directly. }} /> ))} </View> ) }

For programmatic promotion/unlinking, use the core subpath:

import { SynqSolanaWalletsClient } from '@synqid/solana-mobile/core' const wallets = new SynqSolanaWalletsClient(http) await wallets.setPrimary('wallet_abc123') await wallets.unlink('wallet_xyz789') await wallets.setLabel('wallet_abc123', 'Treasury')

These methods will be exposed as React hooks in a follow-up; for now, the core client is the path.

iOS vs Android

MWA is Android-native via implicit intents; iOS uses universal links. Both work; a few notes.

Android

  • Works on any Android 8+ device with a wallet app installed.
  • Saga (Solana Mobile’s hardware) exposes Seed Vault as an MWA endpoint that bypasses external wallet apps. This SDK uses the same MWA API; Seed Vault appears as just another available signer.
  • If the user has no wallet installed, MWA’s transact throws ERROR_WALLET_NOT_FOUND. Catch it and prompt the user to install a wallet from the Play Store. We recommend Phantom Mobile as the default suggestion.

iOS

  • Works on iOS 16+ via universal links.
  • Requires the user to have a wallet app installed that registers the MWA universal link (Phantom, Solflare, Backpack are the current set).
  • Apple’s review team will sometimes flag MWA usage as “external authentication” — link to the Solana Mobile docs  and the MWA spec  in your app review submission to head this off.
  • Expo apps need useFrameworks: 'static' in expo-build-properties for the react-native-mobile-wallet-adapter pod to link correctly.

Both

  • The identity.uri you pass into <SolanaMobileProvider> is what the wallet app verifies against the user-facing brand on its connect screen. Use a stable, brand-owned domain. Localhost works in dev but is rejected by some wallets in production.

The core subpath

For server-side wallet operations (e.g. ingesting webhooks that fire on user.wallet.linked and reconciling product state), use the protocol-agnostic core client:

import { SynqSolanaWalletsClient, createSynqHttpClient, } from '@synqid/solana-mobile/core' const http = createSynqHttpClient('https://api.synq.id', fetch, { headers: async () => ({ authorization: `Bearer ${accessToken}` }), }) const client = new SynqSolanaWalletsClient(http) const wallets = await client.list()

Methods on the core client:

challenge(): Promise<WalletLinkChallenge> link(req: WalletLinkRequest): Promise<LinkedWallet> list(): Promise<LinkedWallet[]> setPrimary(walletId: string): Promise<LinkedWallet> unlink(walletId: string): Promise<void> setLabel(walletId: string, label: string | null): Promise<LinkedWallet>

Signature shape and the API are the same the React Native provider uses internally. Plain typed HTTP — no React, no MWA.

API reference

<SolanaMobileProvider>

PropTypeDefaultNotes
apiUrlstringrequired. Synq API base URL (e.g. https://api.synq.id).
httpSynqHttpClientrequired. Pre-authenticated HTTP client. From useSynqHttp() in @synqid/react-native, or construct via createSynqHttpClient.
rpcstringrequired. Solana RPC endpoint.
cluster'mainnet-beta' | 'devnet' | 'testnet''mainnet-beta'Cluster matching rpc.
identity{ name, uri?, icon? }required. Broadcast to wallet apps on the connect screen.
autoLoadLinkedWalletsbooleantrueWhether to fetch /users/me/wallets on mount.

useWallet()

Returns:

{ wallet: ConnectedWallet | null, authorize: () => Promise<ConnectedWallet>, deauthorize: () => Promise<void>, }

authorize is idempotent — if a wallet is already connected, it reauthorizes the same one (lighter UX, no re-prompt on most wallets). deauthorize terminates the MWA session but does NOT unlink the wallet from Synq.

useLinkedWallets()

{ linkedWallets: LinkedWallet[], signAndLink: (opts?: { label?: string }) => Promise<LinkedWallet>, refresh: () => Promise<void>, }

useTransactionSigner()

{ signTransaction: <T extends Transaction | VersionedTransaction>(tx: T) => Promise<T>, signAllTransactions: <T extends Transaction | VersionedTransaction>(txs: T[]) => Promise<T[]>, }

useConnection()

Returns the @solana/web3.js Connection bound to your configured RPC. Convenient for read-only queries:

const connection = useConnection() const balance = await connection.getBalance(wallet.publicKey)

Types

interface LinkedWallet { id: string publicKey: string // base58 label?: string isPrimary: boolean linkedAt: string // ISO walletApp?: { id: string; name?: string; icon?: string } } interface WalletLinkChallenge { message: string nonce: string expiresAt: number // unix seconds } interface WalletLinkRequest { publicKey: string // base58 signature: string // base64 signedMessage: string // verbatim challenge.message nonce: string label?: string walletApp?: LinkedWallet['walletApp'] }

What this SDK does NOT do

  • It does not pop a chooser between linked wallets at sign-in. The signed-in Synq user is identity-first; wallet linking is a feature on top, not the auth mechanism.
  • It does not store private keys. MWA delegates signing to wallet apps; no key material crosses your process boundary.
  • It does not handle gas / fee abstraction. Use a relayer / Octane / your own service for that and pass the transaction to signTransaction before submission.
  • It does not opine on which wallet app to use. Phantom, Solflare, Backpack, Seed Vault — all work the same way through MWA.

Mobile-browser support (roadmap)

The /web subpath ships today as a no-op provider with stub hooks that throw on call. The intended implementation will wrap @solana-mobile/wallet-adapter-mobile for browsers running on Android / iOS that can deep-link to wallet apps via the MWA universal-link protocol — useful for hybrid web apps that don’t ship a native binary.

The surface is deliberately identical to the /react-native provider, so the only migration cost when the implementation lands is the import path:

- import { SolanaMobileProvider } from '@synqid/solana-mobile/react-native' + import { SolanaMobileProvider } from '@synqid/solana-mobile/web'

Native platforms

Native Android (Kotlin) and native iOS (Swift) apps cannot use this npm package — those are non-npm ecosystems. When Synq ships SDKs for them, the channels will be:

PlatformChannelPackage
Android (Kotlin)Maven Centralid.synq:solana-mobile-android
iOS (Swift)Swift Package Manager / CocoaPodsSynqSolanaMobile
Flutterpub.devsynqid_solana_mobile

Track the roadmap or reach out if you need one urgently.

Common patterns

Linking a wallet at sign-up

For products where every user should have at least one wallet linked, drive the signAndLink flow as part of onboarding:

function OnboardingWalletStep({ onComplete }: { onComplete: () => void }) { const { wallet, authorize } = useWallet() const { signAndLink } = useLinkedWallets() const [error, setError] = useState<string | null>(null) const run = async () => { try { setError(null) if (!wallet) await authorize() await signAndLink({ label: 'Primary' }) onComplete() } catch (e) { setError(e instanceof Error ? e.message : 'Could not link wallet') } } return ( <View> <Button title="Connect & link wallet" onPress={run} /> {error && <Text style={{ color: 'red' }}>{error}</Text>} </View> ) }

Synq allows N wallets per user. Just call signAndLink again with a different wallet authorized — the MWA prompt will let the user pick a different account.

const linkAnother = async () => { await deauthorize() await authorize() await signAndLink({ label: 'Trading wallet' }) }

Gating product actions to linked wallets

const { wallet } = useWallet() const { linkedWallets } = useLinkedWallets() const isCurrentWalletLinked = wallet && linkedWallets.some(w => w.publicKey === wallet.address) if (!isCurrentWalletLinked) { return <Text>Link your wallet to continue.</Text> }

Synq fires user.wallet.linked webhooks when signAndLink succeeds. Verify the signature and reconcile your product DB:

import { verifyWebhookSignature } from '@synqid/js' export async function POST(req: Request) { const body = await req.text() const sig = req.headers.get('synq-signature')! if (!verifyWebhookSignature({ signature: sig, payload: body, secret: WEBHOOK_SECRET, })) return new Response(null, { status: 401 }) const event = JSON.parse(body) if (event.type === 'user.wallet.linked') { await db.userWallets.upsert({ userId: event.data.userId, walletId: event.data.walletId, publicKey: event.data.publicKey, }) } return new Response(null, { status: 200 }) }

See the webhook events reference for all wallet-related events.

Troubleshooting

”ERROR_WALLET_NOT_FOUND”

User has no MWA-compatible wallet installed. Recommend Phantom Mobile from the Play Store / App Store and surface the install prompt in your UI.

The Synq HTTP client passed via http is not authenticated. Verify your <SynqProvider> is above <SolanaMobileProvider> in the tree and that the user is signed in. The useSynqHttp hook will throw otherwise.

Signature verification fails server-side

Two causes are common:

  • The user signed with a different wallet than the one they authorized with. MWA’s signMessages honors the addresses you pass; if you pass the wrong one, the signature comes back from a different key. The SDK passes active.address automatically — if you’re hand-rolling, double-check.
  • The challenge nonce expired. Synq’s challenges live for ~120 seconds. If users have an MWA prompt open longer than that, the link call fails and you need to request a fresh challenge.

iOS Expo build can’t find the MWA pod

Add to app.json:

{ "expo": { "plugins": [ ["expo-build-properties", { "ios": { "useFrameworks": "static" } }] ] } }

Then eas build --clear-cache and the MWA pods link cleanly.


Building a wallet-linked feature and want sign-off on the flow before you ship? Email support@synq.id — we review and respond within one business day.

Last updated on