Documentation Index
Fetch the complete documentation index at: https://mintlify.com/satsigner/satsigner/llms.txt
Use this file to discover all available pages before exploring further.
Overview
SatSigner uses Zustand for state management, providing a lightweight, TypeScript-friendly alternative to Redux. State is persisted to encrypted storage using MMKV, and complex updates use Immer for immutable state changes.
Store Architecture
Store Structure
Stores are located in apps/mobile/store/ and follow a consistent pattern:
store/
├── accounts.ts # Account, wallet, and key management
├── accountBuilder.ts # Account creation wizard state
├── transactionBuilder.ts # Transaction creation state
├── auth.ts # Authentication and PIN management
├── blockchain.ts # Blockchain connection settings
├── settings.ts # User preferences
├── price.ts # Fiat price data
├── nostr.ts # Nostr protocol state
└── wallets.ts # Multi-wallet management
Basic Store Pattern
Every store follows this TypeScript pattern:
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import mmkvStorage from '@/storage/mmkv'
// State type definition
type SettingsState = {
mnemonicWordList: WordListName
useZeroPadding: boolean
currencyUnit: 'sats' | 'btc'
showWarning: boolean
skipSeedConfirmation: boolean
}
// Actions type definition
type SettingsAction = {
setCurrencyUnit: (currencyUnit: SettingsState['currencyUnit']) => void
setUseZeroPadding: (useZeroPadding: SettingsState['useZeroPadding']) => void
setShowWarning: (showWarning: SettingsState['showWarning']) => void
setSkipSeedConfirmation: (skip: SettingsState['skipSeedConfirmation']) => void
setMnemonicWordList: (wordList: SettingsState['mnemonicWordList']) => void
}
// Create store with persistence
const useSettingsStore = create<SettingsState & SettingsAction>()()
persist(
(set) => ({
// Initial state
currencyUnit: 'sats',
useZeroPadding: false,
showWarning: true,
skipSeedConfirmation: true,
mnemonicWordList: DEFAULT_WORD_LIST,
// Actions
setCurrencyUnit: (currencyUnit) => {
set({ currencyUnit })
},
setUseZeroPadding: (useZeroPadding) => {
set({ useZeroPadding })
},
setMnemonicWordList: (mnemonicWordList) => {
set({ mnemonicWordList })
},
setShowWarning: (showWarning) => {
set({ showWarning })
},
setSkipSeedConfirmation: (skipSeedConfirmation) => {
set({ skipSeedConfirmation })
}
}),
{
name: 'settings-store',
storage: createJSONStorage(() => mmkvStorage)
}
)
)
export { useSettingsStore }
Complex State Updates with Immer
For nested state updates, use Immer for immutable updates:
import { produce } from 'immer'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import mmkvStorage from '@/storage/mmkv'
type AccountsState = {
accounts: Account[]
tags: string[]
}
type AccountsAction = {
addAccount: (account: Account) => void
updateAccount: (account: Account) => void
updateAccountName: (id: Account['id'], newName: string) => void
deleteAccount: (id: Account['id']) => void
setAddrLabel: (accountId: string, addr: string, label: string) => Account | undefined
}
const useAccountsStore = create<AccountsState & AccountsAction>()()
persist(
(set, get) => ({
accounts: [],
tags: [],
addAccount: (account) => {
set(
produce((state: AccountsState) => {
state.accounts.push(account)
})
)
},
updateAccountName: (id, newName) => {
set(
produce((state: AccountsState) => {
const index = state.accounts.findIndex(
(account) => account.id === id
)
if (index !== -1) state.accounts[index].name = newName
})
)
},
deleteAccount: (id) => {
set(
produce((state: AccountsState) => {
const index = state.accounts.findIndex(
(account) => account.id === id
)
if (index !== -1) {
state.accounts.splice(index, 1)
}
})
)
},
setAddrLabel: (accountId, addr, label) => {
const account = get().accounts.find(
(account) => account.id === accountId
)
if (!account) return undefined
let updatedAccount = { ...account }
set(
produce((state) => {
const index = state.accounts.findIndex(
(account: Account) => account.id === accountId
)
// Update label in labels map
state.accounts[index].labels[addr] = {
type: 'addr',
ref: addr,
label
}
// Update address object
const addrIndex = state.accounts[index].addresses.findIndex(
(address) => address.address === addr
)
if (addrIndex !== -1) {
state.accounts[index].addresses[addrIndex].label = label
}
// Cascade label to related UTXOs
state.accounts[index].utxos = state.accounts[index].utxos.map(
(utxo: Utxo) => {
if (utxo.addressTo === addr) {
const utxoRef = `${utxo.txid}:${utxo.vout}`
const utxoHasLabel = state.accounts[index].labels[utxoRef]
if (!utxoHasLabel) {
state.accounts[index].labels[utxoRef] = {
type: 'output',
ref: utxoRef,
label
}
return { ...utxo, label }
}
}
return utxo
}
)
updatedAccount = { ...state.accounts[index] }
})
)
return updatedAccount
}
}),
{
name: 'satsigner-accounts',
storage: createJSONStorage(() => mmkvStorage),
partialize: (state) => state,
onRehydrateStorage: () => (state) => {
// Convert string dates back to Date objects after rehydration
if (state?.accounts) {
state.accounts.forEach((account) => {
if (account.createdAt && typeof account.createdAt === 'string') {
account.createdAt = new Date(account.createdAt)
}
if (account.lastSyncedAt && typeof account.lastSyncedAt === 'string') {
account.lastSyncedAt = new Date(account.lastSyncedAt)
}
})
}
}
}
)
)
export { useAccountsStore }
Using Stores in Components
Basic Usage
import { useSettingsStore } from '@/store/settings'
function MyComponent() {
// Select single value
const currencyUnit = useSettingsStore((state) => state.currencyUnit)
// Get action
const setCurrencyUnit = useSettingsStore((state) => state.setCurrencyUnit)
return (
<button onClick={() => setCurrencyUnit('btc')}>
Switch to BTC (current: {currencyUnit})
</button>
)
}
Shallow Selector for Multiple Values
Use useShallow to prevent unnecessary re-renders when selecting multiple values:
import { useShallow } from 'zustand/react/shallow'
import { usePriceStore } from '@/store/price'
import { useSettingsStore } from '@/store/settings'
function BalanceDisplay() {
// Only re-render when these specific values change
const [fiatCurrency, satsToFiat] = usePriceStore(
useShallow((state) => [state.fiatCurrency, state.satsToFiat])
)
const [currencyUnit, useZeroPadding] = useSettingsStore(
useShallow((state) => [state.currencyUnit, state.useZeroPadding])
)
const balance = 100000 // sats
const fiatValue = satsToFiat(balance)
return (
<div>
<p>{currencyUnit === 'sats' ? `${balance} sats` : `${balance / 100000000} BTC`}</p>
<p>{fiatValue} {fiatCurrency}</p>
</div>
)
}
Direct Store Access
Access store outside React components:
import { useAccountsStore } from '@/store/accounts'
// Get current state
const accounts = useAccountsStore.getState().accounts
// Call action
useAccountsStore.getState().addAccount(newAccount)
// Subscribe to changes
const unsubscribe = useAccountsStore.subscribe(
(state) => state.accounts,
(accounts) => {
console.log('Accounts updated:', accounts)
}
)
Persistent Storage with MMKV
MMKV Configuration
MMKV provides fast, encrypted key-value storage:
import { MMKV } from 'react-native-mmkv'
const storage = new MMKV({
id: 'satsigner-storage',
encryptionKey: 'your-encryption-key'
})
const mmkvStorage = {
setItem: (name: string, value: string) => {
storage.set(name, value)
},
getItem: (name: string) => {
const value = storage.getString(name)
return value ?? null
},
removeItem: (name: string) => {
storage.delete(name)
}
}
export default mmkvStorage
Encrypted Storage for Secrets
Sensitive data uses additional encryption:
import * as SecureStore from 'expo-secure-store'
export async function setItem(key: string, value: string) {
await SecureStore.setItemAsync(key, value)
}
export async function getItem(key: string): Promise<string | null> {
return await SecureStore.getItemAsync(key)
}
export async function deleteItem(key: string) {
await SecureStore.deleteItemAsync(key)
}
State Hydration
Handle state rehydration from storage:
const useAccountsStore = create<AccountsState & AccountsAction>()()
persist(
(set, get) => ({
// ... state and actions
}),
{
name: 'satsigner-accounts',
storage: createJSONStorage(() => mmkvStorage),
onRehydrateStorage: () => (state) => {
// Transform persisted data after loading
if (state?.accounts) {
state.accounts.forEach((account) => {
// Convert ISO date strings back to Date objects
if (account.createdAt && typeof account.createdAt === 'string') {
account.createdAt = new Date(account.createdAt)
}
if (account.lastSyncedAt && typeof account.lastSyncedAt === 'string') {
account.lastSyncedAt = new Date(account.lastSyncedAt)
}
})
}
}
}
)
)
Async Actions
Handle async operations in store actions:
type AccountsAction = {
dropSeedFromKey: (
accountId: Account['id'],
keyIndex: number
) => Promise<{ success: boolean; message: string }>
}
const useAccountsStore = create<AccountsState & AccountsAction>()()
persist(
(set, get) => ({
accounts: [],
dropSeedFromKey: async (accountId, keyIndex) => {
const state = get()
const account = state.accounts.find((acc) => acc.id === accountId)
if (!account || !account.keys[keyIndex]) {
return { success: false, message: 'Account or key not found' }
}
try {
const pin = await getItem(PIN_KEY)
if (!pin) {
return { success: false, message: 'PIN not found' }
}
// Decrypt, modify, re-encrypt
const decryptedSecret = await aesDecrypt(
account.keys[keyIndex].secret,
pin,
account.keys[keyIndex].iv
)
const cleanedSecret = {
extendedPublicKey: decryptedSecret.extendedPublicKey,
fingerprint: decryptedSecret.fingerprint
// Seed removed
}
const encryptedSecret = await aesEncrypt(
JSON.stringify(cleanedSecret),
pin,
account.keys[keyIndex].iv
)
// Update state
set(
produce((state) => {
const accountIndex = state.accounts.findIndex(
(acc: Account) => acc.id === accountId
)
if (accountIndex !== -1) {
state.accounts[accountIndex].keys[keyIndex].secret = encryptedSecret
}
})
)
return { success: true, message: 'Seed dropped successfully' }
} catch {
return { success: false, message: 'Failed to drop seed' }
}
}
}),
{
name: 'satsigner-accounts',
storage: createJSONStorage(() => mmkvStorage)
}
)
)
Testing Stores
Mock stores in tests (see Testing):
tests/unit/store/nostr.test.ts
import { useNostrStore } from '@/store/nostr'
jest.mock('@/storage/mmkv', () => {
const storage: Record<string, string> = {}
return {
__esModule: true,
default: {
setItem: jest.fn((name: string, value: string) => {
storage[name] = value
}),
getItem: jest.fn((name: string) => storage[name] ?? null),
removeItem: jest.fn((name: string) => {
delete storage[name]
})
}
}
})
describe('nostr store', () => {
beforeEach(() => {
useNostrStore.setState({
members: {},
processedMessageIds: {},
processedEvents: {}
})
})
it('adds member with generated color', async () => {
const { addMember, getMembers } = useNostrStore.getState()
await addMember(accountId, npub)
const members = getMembers(accountId)
expect(members).toHaveLength(1)
expect(members[0]).toEqual({
npub,
color: '#ff5500'
})
})
})
Best Practices
- Type Safety - Always define separate types for state and actions
- Immutability - Use Immer’s
produce for nested state updates
- Selective Subscriptions - Use
useShallow when selecting multiple values
- Persistence - Use MMKV for fast persistent storage
- Encryption - Store sensitive data in encrypted SecureStore
- Hydration - Transform data after rehydration (dates, classes)
- Actions Only - Keep logic in action functions, not in components
- Single Responsibility - Create focused stores (settings, accounts, blockchain)
- Async Handling - Handle errors in async actions and return status
- Testing - Mock storage and test state transitions
Resources