Skip to main content

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:
store/settings.ts
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:
store/accounts.ts
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:
storage/mmkv.ts
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:
storage/encrypted.ts
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

  1. Type Safety - Always define separate types for state and actions
  2. Immutability - Use Immer’s produce for nested state updates
  3. Selective Subscriptions - Use useShallow when selecting multiple values
  4. Persistence - Use MMKV for fast persistent storage
  5. Encryption - Store sensitive data in encrypted SecureStore
  6. Hydration - Transform data after rehydration (dates, classes)
  7. Actions Only - Keep logic in action functions, not in components
  8. Single Responsibility - Create focused stores (settings, accounts, blockchain)
  9. Async Handling - Handle errors in async actions and return status
  10. Testing - Mock storage and test state transitions

Resources