Skip to main content

Overview

Sovran implements the Cashu ecash protocol through the coco-cashu-core and coco-cashu-react libraries. Ecash tokens are digital bearer instruments that can be sent, received, and validated cryptographically.

Token Validation

The helper/coco/utils.ts module provides token validation utilities:

Validate Token Format

export function isValidEcashToken(token: string): boolean {
  try {
    getDecodedToken(token);
    return true;
  } catch {
    return false;
  }
}
From helper/coco/utils.ts:55-62.

Decode Token

Tokens are decoded using coco-cashu-core:
import { getDecodedToken, type ReceiveHistoryEntry } from 'coco-cashu-core';

const decodedToken = getDecodedToken(rawToken);
// Returns: { proofs, mint, unit, memo }

Send Operations

The useSendWithHistory hook manages ecash token creation:

Two-Step Send Flow

export function useSendWithHistory() {
  const manager = useManager();
  
  const send = useCallback(
    async (mintUrl: string, amount: number, opts: SendOptions = {}): Promise<SendResult> => {
      // Step 1: Prepare send operation
      const prepared = await manager.send.prepareSend(mintUrl, amount);
      targetOperationId = prepared.id;
      preparedOperationId = prepared.id;

      // Step 2: Execute prepared send
      const { token, operation } = await manager.send.executePreparedSend(prepared.id);
      
      // Step 3: Wait for history entry via event
      const historyEntry = await Promise.race([entryPromise, timeoutPromise]);
      
      return { token, historyEntry, operationId: operation.id };
    },
    [manager]
  );
}
From hooks/coco/useSendWithHistory.ts:38-113.

Automatic Rollback

Failed send operations are automatically rolled back:
try {
  const prepared = await manager.send.prepareSend(mintUrl, amount);
  const { token, operation } = await manager.send.executePreparedSend(prepared.id);
  return { token, historyEntry, operationId: operation.id };
} catch (e) {
  // Rollback on failure
  if (preparedOperationId) {
    const operation = await manager.send.getOperation(preparedOperationId);
    if (operation && ['prepared', 'executing', 'pending'].includes(operation.state)) {
      await manager.send.rollback(preparedOperationId);
    }
  }
  throw err;
}
From hooks/coco/useSendWithHistory.ts:114-138.

Receive Operations

Build Receive History Entry

Centralized receive entry construction:
export function buildReceiveHistoryEntry(
  rawToken: string,
  unitOverride?: string
): ReceiveHistoryEntry {
  const decodedToken = getDecodedToken(rawToken);
  return {
    id: `receive-${Date.now()}`,
    type: 'receive',
    amount: sumProofAmounts(decodedToken.proofs),
    unit: unitOverride ?? decodedToken.unit ?? 'sat',
    mintUrl: decodedToken.mint,
    createdAt: Date.now(),
    metadata: { rawToken },
    token: decodedToken,
  };
}
From helper/coco/utils.ts:334-349.

Sum Proof Amounts

Utility to calculate total token value:
function sumProofAmounts(proofs: ReadonlyArray<{ amount: number }>): number {
  let total = 0;
  for (const p of proofs) total += p.amount;
  return total;
}
From helper/coco/utils.ts:319-323.

Token Processing

The useProcessPaymentString hook handles token detection and routing:

Regular Ecash Tokens

if (isValidEcashToken(scanning.data)) {
  addScan(scanning.data, scanning.data, 'ecash', source);

  router.navigate({
    pathname: '/(receive-flow)/receiveToken',
    params: {
      receiveHistoryEntry: JSON.stringify(buildReceiveHistoryEntry(scanning.data)),
    },
  });
  return { urInProgress: false };
}
From hooks/coco/useProcessPaymentString.ts:203-212.

UR-Encoded Tokens

Support for animated QR codes (UR codes):
if (scanning.data.startsWith('ur:')) {
  const prevPer = urDecoder.getProgress();
  urDecoder.receivePart(scanning.data);
  const nextPer = urDecoder.getProgress();
  onProgress?.(nextPer);

  // Haptic feedback based on progress
  if (prevPer !== nextPer) {
    if (nextPer < 0.33) {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
    } else if (nextPer < 0.66) {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
    } else if (nextPer < 1) {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
    } else {
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
    }
  }

  if (urDecoder.isComplete() && urDecoder.isSuccess()) {
    const ur = urDecoder.resultUR();
    const decoded = ur.decodeCBOR();
    const tokenString = new TextDecoder().decode(decoded);
    
    router.navigate({
      pathname: '/(receive-flow)/receiveToken',
      params: {
        receiveHistoryEntry: JSON.stringify(buildReceiveHistoryEntry(tokenString)),
      },
    });

    setUrDecoder(new URDecoder());
    return { urInProgress: false };
  }

  return { urInProgress: true, progress: nextPer };
}
From hooks/coco/useProcessPaymentString.ts:153-199.

NUT-18 Payment Requests

Support for Nostr-transported payment requests:

Detection

const isNostrPaymentRequest = (data: string): boolean => {
  const trimmed = data.trim();
  if (!trimmed.startsWith('creqA')) {
    return false;
  }

  try {
    const decoded = decodePaymentRequest(trimmed);
    const nostrTransport = decoded.transport?.find(
      (t) => t.type === PaymentRequestTransportType.NOSTR
    );
    return !!nostrTransport;
  } catch {
    return false;
  }
};
From hooks/coco/useProcessPaymentString.ts:29-45.

Routing Logic

Payment requests are routed based on available data:
MintsAmountValid MintsFlow
NoNoAnycurrency.tsx (pick mint + amount)
NoYes1SendTokenScreen (direct)
NoYes2+mintSelect.tsx → SendTokenScreen
YesNo1+currency.tsx (amount only)
YesYes1SendTokenScreen (PR mode, direct)
YesYes2+mintSelect.tsx → SendTokenScreen
From hooks/coco/useProcessPaymentString.ts:231-240.

Mint Validation

const getValidMints = useCallback(
  (allowedMints: string[] | undefined, minAmount: number | undefined) => {
    return trustedMints.filter((mint) => {
      const balance = mintBalances[mint.mintUrl] || 0;
      
      // If allowedMints specified, mint must be in the list
      if (allowedMints && allowedMints.length > 0 && !allowedMints.includes(mint.mintUrl)) {
        return false;
      }
      
      // If minAmount specified, mint must have sufficient balance
      if (minAmount !== undefined && minAmount > 0 && balance < minAmount) {
        return false;
      }
      
      // Must have some balance for sending
      if (balance === 0) {
        return false;
      }
      
      return true;
    });
  },
  [trustedMints, mintBalances]
);
From hooks/coco/useProcessPaymentString.ts:113-133.

Token Encoding

Tokens can be encoded in multiple versions:

V4 Encoding (NFC)

import { getEncodedTokenV4 } from '@cashu/cashu-ts';

const encodedToken = getEncodedTokenV4(token);
// Returns: cashuB... (base64url encoded)
From hooks/useNfcEcashPayment.tsx:133.

Standard Encoding

import { getEncodedToken } from 'coco-cashu-core';

const encodedToken = getEncodedToken(token);
// Returns: cashuA... (standard encoding)

Token State Management

Pending Tokens

Tokens in pending state:
const pendingSends = history.filter(
  (entry): entry is SendHistoryEntry =>
    entry.type === 'send' && 
    (entry.state === 'pending' || entry.state === 'prepared')
);

Reserved Proofs

Proofs locked during operations:
const { reservedTotal } = useReservedProofs();

Recovery Operations

const manager = CocoManager.getInstance();

// Recover pending operations
await manager.recoverPendingSendOperations();
await manager.recoverPendingMeltOperations();

// Force free all reserved proofs
const result = await CocoManager.freeAllReservedProofs();
// Returns:
// {
//   totalReservedProofs,
//   rolledBackSendOperations,
//   rolledBackMeltOperations,
//   releasedOrphanedReservations,
//   errors
// }
From components/blocks/PrimaryBalance.tsx:188-222.

Coco-Cashu Integration

Core Principles

Never redefine types from coco-cashu-core. Always import from the source library.
// ✅ Correct
import type { HistoryEntry, SendHistoryEntry, MeltHistoryEntry } from 'coco-cashu-core';
import { useManager, useBalanceContext, usePaginatedHistory } from 'coco-cashu-react';

// ❌ Wrong - Don't redefine coco types
interface MyHistoryEntry { ... }

Manager Usage

const manager = useManager();

// Send operations
await manager.send.prepareSend(mintUrl, amount);
await manager.send.executePreparedSend(operationId);
await manager.send.rollback(operationId);

// Melt operations
await manager.melt.createQuote(mintUrl, invoice);
await manager.melt.executeMelt(quoteId);

// History
const history = await manager.history.getPaginatedHistory(offset, limit);

Event Subscription

const unsubscribe = manager.on('history:updated', ({ entry }) => {
  if (entry.type === 'send') {
    const sendEntry = entry as SendHistoryEntry;
    // Handle send entry update
  }
});

return () => unsubscribe();
From hooks/coco/useSendWithHistory.ts:65-73.

Key Features

Token Validation

Cryptographic validation of ecash tokens before receive

Two-Step Send

Prepare and execute pattern with automatic rollback on failure

UR Code Support

Animated QR codes for large tokens with progress tracking

Payment Requests

NUT-18 payment requests with Nostr transport

Build docs developers (and LLMs) love