🏡/repos/samui-build/

samui-wallet

🌴

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).

TypeScript@solana/kitWallet Standard
🏗️

Monorepo Architecture

pnpm workspace structure

Apps

webReact web app
desktopTauri desktop app
mobileReact Native/Expo
extensionBrowser extension
cliCommand line interface
apiBackend API

Key Packages

solana-clientCore Solana utilities
solana-client-reactReact hooks for Solana
wallet-standardWallet Standard impl
backgroundSigning service
keypairKey generation/derivation
dbAccount persistence
🔌

Solana 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 updates

Query Functions

  • getBalance() - SOL balance in lamports
  • getAccountInfo() - Account data
  • getActivity() - Transaction history
  • getTokenAccounts() - SPL token accounts
  • getLatestBlockhash() - Current blockhash
  • requestAirdrop() - 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 fee
⚛️

React 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

1Discover Wallets
import { useWallets } from '@wallet-standard/react'
const wallets = useWallets().filter(w =>
  w.chains.some(chain => chain.startsWith('solana:'))
)
2Connect
import { StandardConnect, getWalletFeature } from '@wallet-standard/core'

const { connect } = getWalletFeature(wallet, StandardConnect)
const { accounts } = await connect()
// accounts: WalletAccount[] with address, publicKey
3Get Signer
import { useWalletAccountTransactionSendingSigner } from '@solana/react'

const signer = useWalletAccountTransactionSendingSigner(
  account,
  'solana:devnet'
)
4Disconnect
const { disconnect } = getWalletFeature(wallet, StandardDisconnect)
await disconnect()

Wallet Standard Features

StandardConnect

Connect and get accounts

StandardDisconnect

End session

SolanaSignTransaction

Sign without sending

SolanaSignAndSendTransaction

Sign and broadcast

SolanaSignMessage

Sign arbitrary messages

SolanaSignIn

SIWS 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 transactions

Key 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

Devnet
solana:devnet
https://api.devnet.solana.com
Testnet
solana:testnet
https://api.testnet.solana.com
Mainnet
solana:mainnet
https://api.mainnet-beta.solana.com
Localnet
solana:localnet
http://localhost:8899

Explorer 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

TRANSACTION_FEE5000 lamports
LAMPORTS_PER_SOL1,000,000,000
Derivation Pathm/44'/501'/i'/0'

Addresses

Native Mint (wSOL)So111...1112
Token ProgramToken...1111
Token-2022Token...2022
💡

Key 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.