Skip to main content

Cashu eCash Integration

SatSigner implements the Cashu protocol for privacy-preserving electronic cash (eCash). Connect to Cashu mints to issue, send, receive, and redeem eCash tokens backed by Bitcoin Lightning payments.

What is Cashu?

Cashu is a free and open-source eCash protocol built on Bitcoin and Lightning Network. It provides:
  • Privacy: Blind signatures prevent mints from tracking spending
  • Instant settlements: Lightning-backed payments
  • Offline transactions: Transfer tokens without internet
  • Interoperability: Standard protocol across implementations

Core Concepts

Mints

Cashu mints are servers that issue eCash tokens:
type EcashMint = {
  url: string              // Mint URL
  name: string             // Mint name
  isConnected: boolean     // Connection status
  keysets: Array<{         // Active keysets
    id: string             // Keyset identifier
    unit: 'sat'            // Unit (satoshis)
    active: boolean        // Active status
  }>
  balance: number          // Your balance at mint
  lastSync: string         // Last sync timestamp
}

Proofs

eCash tokens are cryptographic proofs:
type EcashProof = {
  id: string      // Keyset ID
  amount: number  // Proof amount (sats)
  secret: string  // Blinded secret
  C: string       // Signature (point on curve)
}

Tokens

Proofs are encoded into shareable tokens:
cashuA<base64-encoded-token-data>

Implementation

SatSigner uses the @cashu/cashu-ts library for Cashu operations.

Wallet Management

Wallet Cache: Maintains persistent connections to mints
// apps/mobile/api/ecash.ts:21
const walletCache = new Map<string, Wallet>()

export function getWallet(mintUrl: string): Wallet {
  if (!walletCache.has(mintUrl)) {
    const mint = new Mint(mintUrl)
    const wallet = new Wallet(mint)
    walletCache.set(mintUrl, wallet)
  }
  return walletCache.get(mintUrl)!
}

Connecting to Mints

Adding a Mint

// apps/mobile/api/ecash.ts:95
export async function connectToMint(mintUrl: string): Promise<EcashMint> {
  clearWalletCache(mintUrl)
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  const mintInfo = wallet.getMintInfo()
  const keysets = await getKeysetsFromWallet(wallet)
  
  return {
    url: mintUrl,
    name: mintInfo.name || `Mint ${mintUrl}`,
    isConnected: true,
    keysets: keysets.map(ks => ({
      id: ks.id,
      unit: ks.unit,
      active: ks.active
    })),
    balance: 0,
    lastSync: new Date().toISOString()
  }
}

Fetching Keysets

Keysets are cryptographic key sets used by the mint:
// apps/mobile/api/ecash.ts:29
export async function getKeysetsFromWallet(
  wallet: Wallet
): Promise<{ id: string; unit: 'sat'; active: boolean }[]> {
  // Try getKeysets() method first (v3.0.0)
  if (typeof wallet.getKeysets === 'function') {
    const result = await wallet.getKeysets()
    return result.map(ks => ({
      id: ks.id,
      unit: 'sat' as const,
      active: ks.active !== false
    }))
  }
  
  // Fallback: Fetch from mint API
  const response = await fetch(`${mintUrl}/keysets`)
  const keysets = await response.json()
  return keysets.map(ks => ({
    id: ks.id,
    unit: 'sat' as const,
    active: ks.active !== false
  }))
}

Minting eCash (Receiving)

Flow: Lightning → eCash

Step 1: Create Mint Quote Request invoice from mint:
// apps/mobile/api/ecash.ts:115
export async function createMintQuote(
  mintUrl: string,
  amount: number
): Promise<MintQuote> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  const quote = await wallet.createMintQuote(amount)
  
  return {
    quote: quote.quote,      // Quote ID
    request: quote.request,  // Lightning invoice
    expiry: quote.expiry,    // Expiration time
    paid: false
  }
}
Step 2: Pay Lightning Invoice Pay the returned Lightning invoice through your wallet. Step 3: Check Quote Status
// apps/mobile/api/ecash.ts:130
export async function checkMintQuote(
  mintUrl: string,
  quoteId: string
): Promise<MintQuoteState> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  const quoteStatus = await wallet.checkMintQuote(quoteId)
  return quoteStatus.state // 'PAID' | 'UNPAID' | 'EXPIRED'
}
Step 4: Mint Proofs Once paid, mint the eCash tokens:
// apps/mobile/api/ecash.ts:140
export async function mintProofs(
  mintUrl: string,
  amount: number,
  quoteId: string
): Promise<EcashMintResult> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  const proofs = await wallet.mintProofs(amount, quoteId)
  
  return {
    proofs,           // Array of EcashProof
    totalAmount: amount
  }
}

Sending eCash

Creating Tokens

// apps/mobile/api/ecash.ts:222
export async function sendEcash(
  mintUrl: string,
  amount: number,
  proofs: EcashProof[],
  memo?: string
): Promise<EcashSendResult> {
  // Validate proofs before sending
  const { validProofs, spentProofs } = await validateProofs(mintUrl, proofs)
  
  if (spentProofs.length > 0) {
    throw new Error('Token already spent')
  }
  
  const totalProofAmount = validProofs.reduce(
    (sum, proof) => sum + proof.amount,
    0
  )
  
  if (totalProofAmount < amount) {
    throw new Error('Insufficient balance')
  }
  
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  
  // Split proofs into keep and send
  const { keep, send } = await wallet.send(amount, validProofs, {
    includeFees: true
  })
  
  // Encode proofs into token
  const token = getEncodedTokenV4({
    mint: mintUrl,
    proofs: send,
    unit: 'sat',
    memo
  })
  
  return {
    token,  // Encoded token string
    keep,   // Proofs you keep
    send    // Proofs being sent
  }
}
Token Format (v4):
cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWludC5leGFtcGxlLmNvbSIsInByb29mcyI6W3siYW1vdW50IjoxLCJpZCI6IjAwYWQwMjVhMjY0NTQwNSIsInNlY3JldCI6ImZvbyIsIkMiOiJiYXIifV19XSwibWVtbyI6IlRlc3QgdG9rZW4ifQ==

Receiving eCash

Redeeming Tokens

// apps/mobile/api/ecash.ts:278
export async function receiveEcash(
  mintUrl: string,
  token: string
): Promise<EcashReceiveResult> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  
  // Decode and validate token
  const decodedToken = getDecodedToken(token)
  if (decodedToken.mint !== mintUrl) {
    throw new Error('Token mint URL does not match current mint')
  }
  
  // Receive proofs
  const proofs = await wallet.receive(token)
  const totalAmount = proofs.reduce(
    (sum, proof) => sum + proof.amount,
    0
  )
  
  return {
    proofs,                  // Received proofs
    totalAmount,             // Total amount
    memo: decodedToken.memo  // Optional memo
  }
}

Melting eCash (Spending)

Flow: eCash → Lightning

Step 1: Create Melt Quote
// apps/mobile/api/ecash.ts:154
export async function createMeltQuote(
  mintUrl: string,
  invoice: string  // Lightning invoice to pay
): Promise<MeltQuote> {
  const wallet = getWallet(mintUrl)
  const quote = await wallet.createMeltQuote(invoice)
  
  return {
    quote: quote.quote,           // Quote ID
    amount: quote.amount,         // Amount to pay
    fee_reserve: quote.fee_reserve, // Reserved for fees
    paid: false,
    expiry: quote.expiry
  }
}
Step 2: Melt Proofs Pay the Lightning invoice with your proofs:
// apps/mobile/api/ecash.ts:169
export async function meltProofs(
  mintUrl: string,
  quote: MeltQuote,
  proofs: EcashProof[],
  description?: string,
  originalInvoice?: string
): Promise<EcashMeltResult> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  
  // Recreate quote with original invoice
  const invoiceToUse = originalInvoice || quote.quote
  const meltQuote = await wallet.createMeltQuote(invoiceToUse)
  
  // Melt proofs to pay invoice
  const result = await wallet.meltProofs(meltQuote, proofs)
  
  return {
    paid: true,
    preimage: result.preimage || result.payment_preimage,
    change: result.change  // Unused proofs returned as change
  }
}

Proof Validation

Check if proofs are still valid (unspent):
// apps/mobile/api/ecash.ts:199
export async function validateProofs(
  mintUrl: string,
  proofs: EcashProof[]
): Promise<{ validProofs: EcashProof[]; spentProofs: EcashProof[] }> {
  const wallet = getWallet(mintUrl)
  await wallet.loadMint()
  
  const proofStates = await wallet.checkProofsStates(proofs)
  
  const validProofs: EcashProof[] = []
  const spentProofs: EcashProof[] = []
  
  proofStates.forEach((state, index) => {
    if (state.state === 'UNSPENT' || state.state === 'PENDING') {
      validProofs.push(proofs[index])
    } else if (state.state === 'SPENT') {
      spentProofs.push(proofs[index])
    }
  })
  
  return { validProofs, spentProofs }
}
Proof States:
  • UNSPENT: Proof is valid and spendable
  • PENDING: Proof is being processed
  • SPENT: Proof has already been redeemed

Token Validation

Validate encoded tokens:
// apps/mobile/api/ecash.ts:308
export async function validateEcashToken(
  token: string,
  mintUrl: string
): Promise<{ isValid: boolean; isSpent?: boolean; details?: string }> {
  try {
    const decodedToken = getDecodedToken(token)
    const wallet = getWallet(mintUrl)
    const proofs = decodedToken.proofs || []
    
    if (proofs.length === 0) {
      return { isValid: false, details: 'No proofs found in token' }
    }
    
    const proofStates = await wallet.checkProofsStates(proofs)
    
    const spentProofs = proofStates.filter(s => s.state === 'SPENT')
    const unspentProofs = proofStates.filter(s => s.state === 'UNSPENT')
    const pendingProofs = proofStates.filter(s => s.state === 'PENDING')
    
    if (spentProofs.length === proofs.length) {
      return {
        isValid: true,
        isSpent: true,
        details: 'All proofs have been spent'
      }
    } else if (unspentProofs.length === proofs.length) {
      return {
        isValid: true,
        isSpent: false,
        details: 'All proofs are unspent'
      }
    } else {
      return {
        isValid: true,
        isSpent: false,
        details: `Mixed: ${spentProofs.length} spent, ${unspentProofs.length} unspent`
      }
    }
  } catch {
    return {
      isValid: false,
      details: 'Failed to check proof states'
    }
  }
}

Balance Management

Calculate balance from proofs:
// apps/mobile/api/ecash.ts:301
export async function getMintBalance(
  mintUrl: string,
  proofs: EcashProof[]
): Promise<number> {
  return proofs.reduce((sum, proof) => sum + proof.amount, 0)
}

Privacy Features

Blind Signatures

Cashu uses blind signatures to ensure privacy:
  1. Client: Creates blinded secret
  2. Mint: Signs blinded secret (cannot see original)
  3. Client: Unblinds signature to get valid proof
  4. Mint: Cannot link signature to original request

No Tracking

  • Mints cannot track how tokens are spent
  • Tokens can be split and combined freely
  • No transaction graph like Bitcoin
  • Offline transfers possible

Multi-Mint Strategy

For enhanced privacy:
  1. Use multiple mints simultaneously
  2. Swap between mints periodically
  3. Split large amounts across mints
  4. Diversify custodial risk

Security Considerations

Custodial Risk

eCash is custodial - the mint holds the Bitcoin:
  • Only use trusted mints
  • Don’t store large amounts
  • Monitor mint reputation
  • Diversify across multiple mints

Proof Management

  • Never share proofs - they are bearer assets
  • Backup proofs securely
  • Track spent vs unspent proofs
  • Validate tokens before accepting

Token Security

  • Tokens are like cash - anyone with the token can spend it
  • Send tokens through secure channels
  • Verify mint URL before redeeming
  • Be cautious with public tokens

Error Handling

Common errors and solutions: “Token already spent”
  • Proofs in token have been redeemed
  • Double-spend attempt detected
  • Cannot recover - proofs are spent
“Insufficient balance”
  • Not enough valid proofs for amount
  • Check proof states
  • Mint more tokens or receive from others
“Token mint URL does not match”
  • Token is from a different mint
  • Cannot redeem at current mint
  • Connect to correct mint first

Implementation Reference

Core API: apps/mobile/api/ecash.ts:1 Type Definitions: apps/mobile/types/models/Ecash.ts:1 UI Component: apps/mobile/components/SSEcashTransactionCard.tsx:1

Cashu Protocol Versions

SatSigner supports:
  • Token v4: Current standard with memo support
  • Keyset Fetching: Dynamic keyset discovery
  • Proof States: UNSPENT, PENDING, SPENT tracking

Resources

Build docs developers (and LLMs) love