@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.txtfor 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-web3jsFor 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.
| Subpath | Status | What it wraps |
|---|---|---|
@synqid/solana-mobile/react-native | shipping | @solana-mobile/mobile-wallet-adapter-protocol-web3js for native RN apps |
@synqid/solana-mobile/web | roadmap | @solana-mobile/wallet-adapter-mobile for mobile-browser apps that deep-link to wallet apps |
@synqid/solana-mobile/core | shipping | protocol-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.
The link flow, end-to-end
signAndLink() is the centerpiece. It does the whole round trip
in one call:
- Authorize the wallet via MWA if not already (
authorize()under the hood). GET /users/me/wallets/challenge— Synq returns{ message, nonce, expiresAt }.- MWA
signMessageswith the wallet that signed in step 1. POST /users/me/wallets/linkwith the public key, the base64-encoded signature, the signed message, and the nonce.- Synq verifies the signature server-side against the public key,
confirms
noncematches 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
transactthrowsERROR_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'inexpo-build-propertiesfor thereact-native-mobile-wallet-adapterpod to link correctly.
Both
- The
identity.uriyou 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>
| Prop | Type | Default | Notes |
|---|---|---|---|
apiUrl | string | — | required. Synq API base URL (e.g. https://api.synq.id). |
http | SynqHttpClient | — | required. Pre-authenticated HTTP client. From useSynqHttp() in @synqid/react-native, or construct via createSynqHttpClient. |
rpc | string | — | required. 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. |
autoLoadLinkedWallets | boolean | true | Whether 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
signTransactionbefore 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:
| Platform | Channel | Package |
|---|---|---|
| Android (Kotlin) | Maven Central | id.synq:solana-mobile-android |
| iOS (Swift) | Swift Package Manager / CocoaPods | SynqSolanaMobile |
| Flutter | pub.dev | synqid_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>
)
}Letting users link multiple wallets
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>
}Verifying a wallet-link webhook on your backend
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.
Authorization succeeds, signAndLink fails with 401
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
signMessageshonors theaddressesyou pass; if you pass the wrong one, the signature comes back from a different key. The SDK passesactive.addressautomatically — 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.