Samui Wallet
Open Source Solana Wallet & Toolbox for Builders
A complete Wallet Standard implementation with transaction building, SPL token support, and multi-platform apps (web, desktop, mobile, extension).
Monorepo Architecture
pnpm workspace structure
Apps
webReact web appdesktopTauri desktop appmobileReact Native/ExpoextensionBrowser extensioncliCommand line interfaceapiBackend APIKey Packages
solana-clientCore Solana utilitiessolana-client-reactReact hooks for Solanawallet-standardWallet Standard implbackgroundSigning servicekeypairKey generation/derivationdbAccount persistenceSolana Client
Core RPC and transaction utilities
Creating the Client
import { createSolanaClient } from '@samui/solana-client'
const client = createSolanaClient({
url: 'https://api.devnet.solana.com',
urlSubscriptions: 'wss://api.devnet.solana.com'
})
// Returns: { rpc, rpcSubscriptions }
// - rpc: For queries and sending transactions
// - rpcSubscriptions: For real-time updatesQuery Functions
getBalance()- SOL balance in lamportsgetAccountInfo()- Account datagetActivity()- Transaction historygetTokenAccounts()- SPL token accountsgetLatestBlockhash()- Current blockhashrequestAirdrop()- Devnet SOL
Transaction Functions
createSolTransferTransaction()createAndSendSolTransaction()createSplTransferTransaction()createAndSendSplTransaction()splTokenCreateTokenMint()
Conversion Utilities
import { lamportsToSol, solToLamports, uiAmountToBigInt } from '@samui/solana-client'
// SOL conversions
lamportsToSol(1_500_000_000n) // → "1.5 SOL"
solToLamports("1.5") // → 1500000000n (Lamports type)
// Token conversions (handles decimals)
uiAmountToBigInt("100.5", 6) // → 100500000n (6 decimal places)
// Fee-aware max calculation
maxAvailableSolAmount(balance, requested) // Subtracts 5000 lamports for feeReact Hooks
TanStack Query integration
Available Hooks
import {
useSolanaClient,
useGetBalance,
useGetActivity,
useGetTokenAccounts,
useGetAccountInfo,
useRequestAirdrop,
useSplTokenCreateTokenMint
} from '@samui/solana-client-react'
// Create client for a network
const client = useSolanaClient({ network: 'solana:devnet' })
// Query balance (auto-caches with React Query)
const { data: balance, isLoading } = useGetBalance({
address: 'So11111111111111111111111111111111111111112',
network: 'solana:devnet'
})
// Query token accounts
const { data: tokens } = useGetTokenAccounts({
address: walletAddress,
network: 'solana:devnet'
})Wallet Connection
Wallet Standard protocol
Connection Flow
import { useWallets } from '@wallet-standard/react'
const wallets = useWallets().filter(w =>
w.chains.some(chain => chain.startsWith('solana:'))
)import { StandardConnect, getWalletFeature } from '@wallet-standard/core'
const { connect } = getWalletFeature(wallet, StandardConnect)
const { accounts } = await connect()
// accounts: WalletAccount[] with address, publicKeyimport { useWalletAccountTransactionSendingSigner } from '@solana/react'
const signer = useWalletAccountTransactionSendingSigner(
account,
'solana:devnet'
)const { disconnect } = getWalletFeature(wallet, StandardDisconnect)
await disconnect()Wallet Standard Features
StandardConnectConnect and get accounts
StandardDisconnectEnd session
SolanaSignTransactionSign without sending
SolanaSignAndSendTransactionSign and broadcast
SolanaSignMessageSign arbitrary messages
SolanaSignInSIWS protocol
Sending Transactions
Build, sign, and broadcast
Transaction Pattern
Uses functional composition with pipe() to build transaction messages step by step.
SOL Transfer Example
import {
pipe,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signAndSendTransactionMessageWithSigners
} from '@solana/kit'
import { getTransferSolInstruction } from '@solana-program/system'
async function sendSol(client, sender, destination, amount) {
// 1. Get current blockhash
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send()
// 2. Build instruction
const instruction = getTransferSolInstruction({
amount: solToLamports(amount),
source: sender,
destination
})
// 3. Build transaction message
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([instruction], tx),
)
// 4. Sign and send
const signature = await signAndSendTransactionMessageWithSigners(message)
return signature
}SPL Token Transfer
import { getTransferCheckedInstruction } from '@solana-program/token'
import { findAssociatedTokenPda, getCreateAssociatedTokenInstruction } from '@solana-program/token'
async function sendToken(client, sender, destination, mint, amount, decimals) {
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send()
// Find Associated Token Accounts
const [sourceAta] = await findAssociatedTokenPda({ mint, owner: sender.address, tokenProgram })
const [destAta] = await findAssociatedTokenPda({ mint, owner: destination, tokenProgram })
const instructions = []
// Check if destination ATA exists, create if not
const destAccount = await client.rpc.getAccountInfo(destAta).send()
if (!destAccount.value) {
instructions.push(getCreateAssociatedTokenInstruction({
payer: sender,
owner: destination,
mint,
ata: destAta
}))
}
// Add transfer instruction
instructions.push(getTransferCheckedInstruction({
source: sourceAta,
mint,
destination: destAta,
authority: sender,
amount: uiAmountToBigInt(amount, decimals),
decimals
}))
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions(instructions, tx),
)
return await signAndSendTransactionMessageWithSigners(message)
}Using with Connected Wallet
import { useWalletAccountTransactionSendingSigner } from '@solana/react'
import { useWallets } from '@wallet-standard/react'
function SendButton() {
const [wallet] = useWallets()
const account = wallet?.accounts[0]
const client = useSolanaClient({ network: 'solana:devnet' })
// Get signer from connected wallet
const signer = useWalletAccountTransactionSendingSigner(
account,
'solana:devnet'
)
const handleSend = async () => {
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send()
const instruction = getTransferSolInstruction({
amount: solToLamports("0.1"),
source: signer,
destination: address("RecipientAddress...")
})
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([instruction], tx),
)
// This triggers wallet popup for approval
const signature = await signAndSendTransactionMessageWithSigners(message)
console.log('Sent!', signature)
}
return <button onClick={handleSend}>Send 0.1 SOL</button>
}Token Freezing (Conceptual)
Time-locked token freezing via program
Note
Samui Wallet doesn't include token freezing. This shows how you would integrate with a freeze program using the same transaction patterns.
Freeze Program Architecture
Program Accounts
- • Vault PDA - Holds frozen tokens
- • Freeze Record PDA - Tracks lock metadata
- • User Token Account - Source/destination ATA
Record Data
- • Owner public key
- • Mint address
- • Amount frozen
- • Unlock timestamp (Unix)
Freeze Tokens Instruction
import { address, getU64Encoder, getAddressEncoder } from '@solana/kit'
const FREEZE_PROGRAM_ID = address('FrzProg111111111111111111111111111111111111')
// Derive PDAs
function getFreezeVaultPda(mint: Address, programId: Address) {
return getProgramDerivedAddress({
programAddress: programId,
seeds: ['vault', getAddressEncoder().encode(mint)]
})
}
function getFreezeRecordPda(owner: Address, mint: Address, programId: Address) {
return getProgramDerivedAddress({
programAddress: programId,
seeds: [
'freeze_record',
getAddressEncoder().encode(owner),
getAddressEncoder().encode(mint)
]
})
}
// Build freeze instruction
function getFreezeTokensInstruction({
owner, // TransactionSigner
mint, // Address
userTokenAccount,// Address (owner's ATA)
amount, // bigint
duration, // number (seconds)
}: FreezeParams) {
const [vault] = getFreezeVaultPda(mint, FREEZE_PROGRAM_ID)
const [freezeRecord] = getFreezeRecordPda(owner.address, mint, FREEZE_PROGRAM_ID)
const unlockTime = BigInt(Math.floor(Date.now() / 1000) + duration)
// Instruction discriminator (first 8 bytes of sha256("global:freeze_tokens"))
const discriminator = new Uint8Array([/* 8 bytes */])
// Encode instruction data
const data = new Uint8Array([
...discriminator,
...getU64Encoder().encode(amount),
...getU64Encoder().encode(unlockTime)
])
return {
programAddress: FREEZE_PROGRAM_ID,
accounts: [
{ address: owner.address, role: AccountRole.WRITABLE_SIGNER },
{ address: mint, role: AccountRole.READONLY },
{ address: userTokenAccount, role: AccountRole.WRITABLE },
{ address: vault, role: AccountRole.WRITABLE },
{ address: freezeRecord, role: AccountRole.WRITABLE },
{ address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY },
{ address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
],
data
}
}Complete Freeze Transaction
async function freezeTokens(
client: SolanaClient,
signer: TransactionSigner,
mint: Address,
amount: string,
durationDays: number
) {
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send()
// Find user's token account
const [userAta] = await findAssociatedTokenPda({
mint,
owner: signer.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS
})
// Build freeze instruction
const freezeIx = getFreezeTokensInstruction({
owner: signer,
mint,
userTokenAccount: userAta,
amount: uiAmountToBigInt(amount, 9), // assuming 9 decimals
duration: durationDays * 24 * 60 * 60 // days to seconds
})
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([freezeIx], tx),
)
return await signAndSendTransactionMessageWithSigners(message)
}Unfreeze Tokens (After Lock Period)
function getUnfreezeTokensInstruction({
owner,
mint,
userTokenAccount,
}: UnfreezeParams) {
const [vault] = getFreezeVaultPda(mint, FREEZE_PROGRAM_ID)
const [freezeRecord] = getFreezeRecordPda(owner.address, mint, FREEZE_PROGRAM_ID)
// Discriminator for unfreeze instruction
const discriminator = new Uint8Array([/* 8 bytes */])
return {
programAddress: FREEZE_PROGRAM_ID,
accounts: [
{ address: owner.address, role: AccountRole.WRITABLE_SIGNER },
{ address: mint, role: AccountRole.READONLY },
{ address: userTokenAccount, role: AccountRole.WRITABLE },
{ address: vault, role: AccountRole.WRITABLE },
{ address: freezeRecord, role: AccountRole.WRITABLE },
{ address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY },
],
data: discriminator
}
}
async function unfreezeTokens(client, signer, mint) {
const { value: latestBlockhash } = await client.rpc.getLatestBlockhash().send()
const [userAta] = await findAssociatedTokenPda({
mint,
owner: signer.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS
})
const unfreezeIx = getUnfreezeTokensInstruction({
owner: signer,
mint,
userTokenAccount: userAta,
})
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([unfreezeIx], tx),
)
// Will fail on-chain if unlock time hasn't passed
return await signAndSendTransactionMessageWithSigners(message)
}React Hook for Freezing
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useFreezeTokens() {
const client = useSolanaClient({ network: 'solana:devnet' })
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
signer,
mint,
amount,
durationDays
}: {
signer: TransactionSigner
mint: Address
amount: string
durationDays: number
}) => {
const signature = await freezeTokens(client, signer, mint, amount, durationDays)
return signature
},
onSuccess: (_, { signer }) => {
// Invalidate token balance queries
queryClient.invalidateQueries({ queryKey: ['getTokenAccounts', signer.address] })
}
})
}
// Usage in component
function FreezeButton({ mint }: { mint: Address }) {
const signer = useWalletAccountTransactionSendingSigner(account, 'solana:devnet')
const { mutate: freeze, isPending } = useFreezeTokens()
return (
<button
onClick={() => freeze({
signer,
mint,
amount: "100",
durationDays: 30
})}
disabled={isPending}
>
{isPending ? 'Freezing...' : 'Freeze 100 tokens for 30 days'}
</button>
)
}Keypair Management
Key generation and derivation
BIP44 Derivation
import {
generateMnemonic,
createKeyPairSignerFromBip44,
validateMnemonic
} from '@samui/keypair'
// Generate new mnemonic (12 or 24 words)
const mnemonic = generateMnemonic({ strength: 128 }) // 12 words
// or strength: 256 for 24 words
// Validate mnemonic
const isValid = validateMnemonic(mnemonic)
// Derive multiple accounts
const signers = await createKeyPairSignerFromBip44({
mnemonic,
from: 0,
to: 10, // Derive 10 accounts
// derivationPath defaults to Solana: m/44'/501'/i'/0'
})
// Each signer has .address and can sign transactionsKey Serialization
import {
convertKeyPairToJson,
createKeyPairSignerFromJson,
createKeyPairFromBase58
} from '@samui/keypair'
// Serialize for storage
const json = convertKeyPairToJson(keyPair)
// Store json in DB...
// Restore from storage
const signer = await createKeyPairSignerFromJson(json)
// Import from base58 (Phantom export format)
const imported = await createKeyPairFromBase58(base58PrivateKey)Network Configuration
Supported Networks
solana:devnetsolana:testnetsolana:mainnetsolana:localnetExplorer URLs
import { getExplorerUrl } from '@samui/solana-client'
// Generate explorer links
getExplorerUrl('address', pubkey, 'solana:devnet', 'solana-explorer')
getExplorerUrl('transaction', signature, 'solana:mainnet', 'solscan')
getExplorerUrl('block', slot, 'solana:devnet', 'helius-orb')
// Supported explorers: 'solana-explorer' | 'solscan' | 'helius-orb'Important Constants
Values
5000 lamports1,000,000,000m/44'/501'/i'/0'Addresses
So111...1112Token...1111Token...2022Key Takeaways
Wallet Standard first
Uses @wallet-standard/core for universal wallet compatibility. Any Wallet Standard wallet works.
Functional transaction building
pipe() composition creates readable, maintainable transaction construction. Each step transforms the message.
Both token programs
Supports SPL Token and Token-2022. Queries both programs when fetching token accounts.
React Query integration
All hooks use TanStack Query for caching, refetching, and query invalidation after mutations.