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.
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:
- Client: Creates blinded secret
- Mint: Signs blinded secret (cannot see original)
- Client: Unblinds signature to get valid proof
- 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:
- Use multiple mints simultaneously
- Swap between mints periodically
- Split large amounts across mints
- 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