Skip to content

feat: add multi-unit support (sat, usd, etc.)#88

Closed
4xvgal wants to merge 4 commits intocashubtc:masterfrom
4xvgal:feat-multi-unit
Closed

feat: add multi-unit support (sat, usd, etc.)#88
4xvgal wants to merge 4 commits intocashubtc:masterfrom
4xvgal:feat-multi-unit

Conversation

@4xvgal
Copy link
Copy Markdown
Contributor

@4xvgal 4xvgal commented Feb 6, 2026

Summary

  • This PR adds multi-unit support to the coco, allowing wallets to handle multiple currency units (sat, usd
    etc.) per mint. Each mint can have multiple keysets with different units, and this implementation enables filtering proofs
    and balances by unit.

Key changes:

  • Added unit field to SendOperation and MeltOperation types
  • Updated WalletService to cache wallets per mintUrl:unit combination
  • Added unit-based proof filtering in ProofService
  • Updated all repository implementations to persist the unit field
  • Maintained full backward compatibility with 'sat' as the default unit

Notes to the reviewers

  1. Backward Compatibility: All unit parameters default to 'sat', so existing code continues to work without changes. When
    reading old database rows without a unit field, it defaults to 'sat'.
  2. No Database Migration Required: The unit field is optional in row types and defaults to 'sat' when null/undefined.
    Existing data works seamlessly.
  3. API Changes:
    • WalletApi.send(mintUrl, amount, unit = 'sat') - added optional unit parameter
    • ProofService.selectProofsToSend(mintUrl, amount, unit = 'sat', includeFees) - unit is now the 3rd parameter
    • New methods: getBalancesByUnit(), getSupportedUnits(mintUrl), getReadyProofsByUnit(mintUrl, unit)
  4. Test Coverage: Added multi-unit test suite in ProofService.test.ts with 4 new tests covering unit-based balance and
    proof filtering.

Suggested CHANGELOG Updates

ADDED

  • WalletApi.getBalancesByUnit() - Get balances grouped by mint and unit
  • WalletApi.getSupportedUnits(mintUrl) - Get list of supported units for a mint
  • ProofService.getBalancesByUnit() - Get all balances grouped by mint and unit
  • ProofService.getReadyProofsByUnit(mintUrl, unit) - Get ready proofs filtered by unit
  • unit field to SendOperation and MeltOperation types
  • Multi-unit test suite in ProofService.test.ts

MODIFIED

  • WalletService.getWallet(mintUrl, unit = 'sat') - Added optional unit parameter
  • WalletService.getWalletWithActiveKeysetId(mintUrl, unit = 'sat') - Added optional unit parameter
  • WalletService.clearCache(mintUrl, unit?) - Added optional unit parameter
  • WalletService.refreshWallet(mintUrl, unit = 'sat') - Added optional unit parameter
  • ProofService.getBalance(mintUrl, unit = 'sat') - Added optional unit parameter
  • ProofService.selectProofsToSend(mintUrl, amount, unit = 'sat', includeFees) - Added unit parameter
  • WalletApi.send(mintUrl, amount, unit = 'sat') - Added optional unit parameter
  • SendOperationService.init(mintUrl, amount, unit = 'sat') - Added optional unit parameter
  • SendOperationService.send(mintUrl, amount, unit = 'sat') - Added optional unit parameter
  • MeltOperationService.init(mintUrl, method, methodData, unit = 'sat') - Added optional unit parameter
  • All repository implementations (sqlite3, expo-sqlite, indexeddb) to support unit field

REMOVED

  • None

  Support multiple currency units per mint by adding unit field to
  operations and filtering proofs by keyset unit. Backward compatible
  with 'sat' as default.
@github-project-automation github-project-automation Bot moved this to Backlog in coco Feb 6, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 6, 2026

⚠️ No Changeset found

Latest commit: 8ec98fe

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Collaborator

@Egge21M Egge21M left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for taking on this challenge! It would be amazing to have this in for the stable v1 release.

Comment thread packages/sqlite3/src/repositories/SendOperationRepository.ts
Comment thread packages/core/operations/send/SendOperationService.ts
Comment thread packages/core/services/ProofService.ts Outdated
4xvgal and others added 2 commits February 9, 2026 18:35
 - Add unit parameter to MintQuoteService.createMintQuote (default: 'sat')
  - Update redeemMintQuote to use quote.unit when getting wallet
  - Add unit parameter to QuotesApi.createMintQuote
  - Add unit tests for createMintQuote with different units
  - Complete multi-unit integration tests (remove TODO placeholders)
Comment thread packages/core/services/MintQuoteService.ts
@@ -82,6 +84,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => {
return [
operation.id,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operationToParams() now includes operation.unit, but the create() INSERT column list still omits unit while passing params. That creates a bindings-count mismatch at runtime when persisting melt operations. Add unit to the INSERT columns/placeholders to match params.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Unit column and placeholder added to the INSERT statement.
Coulumn count 18 matches operationToParams()

@@ -82,6 +84,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => {
return [
operation.id,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as sqlite3 repo: operationToParams() includes unit, but create() INSERT still has the old column list. This will fail with a parameter/placeholder mismatch on melt operation creation. Please include unit in INSERT columns/placeholders.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, unit added.

@@ -67,6 +69,7 @@ function operationToParams(op: SendOperation): unknown[] {
return [
op.id,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operationToParams() now returns op.unit, but create() INSERT still omits the unit column. That causes binding mismatch when creating send operations in expo-sqlite. Add unit to the INSERT column list and placeholders.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit added to INSERT column list (13 columns, 13 params)

return results;
}

async getAvailableProofs(mintUrl: string): Promise<CoreProof[]> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProofRepository now requires getAvailableProofs(mintUrl, unit), but this implementation still declares only mintUrl. This breaks assignability/typecheck (and bypasses unit filtering in memory adapter). Please update the signature and filter ready proofs by unit via keyset mapping, consistent with other repositories.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matched Decalres

Signature updated to getAvailableProofts(mintUrl, unit). Filters proofs by resolving proof.id -> keyset -> keyset.unit, consistent with SQL JOIN approach in other repositories

@Egge21M Egge21M requested a review from robwoodgate February 12, 2026 12:29
Copy link
Copy Markdown
Collaborator

@Egge21M Egge21M left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress on multi-unit support, but there are a few correctness gaps we need to tackle before merging. One is technically not a bug, but a "behavior" issue that I think should be fixed

// First, get keyset IDs for this unit
const keysets = (await (this.db as any)
.table('coco_cashu_keysets')
.where('[mintUrl+unit]')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compound index is not part of the schema

if (!unit || unit.trim().length === 0) {
throw new ProofValidationError('unit is required');
}
const keysetIds = await this.getKeysetIdsForUnit(mintUrl, unit);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This turns the getBalance() call into something that relies on networking. Looking at the big picture of supporting as many offline-first use cases as we can, we should avoid network calls when acting on local data.

Balance is the sum of all proofs that we have stored. We should not have any proofs of unknown keysets stored. So I think it would be best to have this act only on local data (even if there is a tiny chance of it being incomplete)

* Gets balances for all mints by summing ready proof amounts.
* @returns An object mapping mint URLs to their balances
*/
async getBalances(): Promise<{ [mintUrl: string]: number }> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will mix units and sum them up into one number. I don't think that is desirable. We should probably add a unit param with default sat for backwards compatibility here

Copy link
Copy Markdown
Collaborator

@Egge21M Egge21M left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a more thorough review and noticed that there are still a few places where coco assumes sat as unit and things will break.

We use CDK for our integration tests and it support custom units. I think it would be easy to spot these issues by running the whole integration suite using a custom unit like "USD" against a USD cdk mint once.

Please let me know if you need help adjusting the integration test to do that

}
}

async createOutputsAndIncrementCounters(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is a critical part of all receive/swap flows and right now it defaults to sat.

const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl);
const unit = quote.unit || 'sat';
const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit);
const { keep } = await this.proofService.createOutputsAndIncrementCounters(mintUrl, {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The created outputs will be for unit: "sat". See: https://github.com/cashubtc/coco/pull/88/changes#r2823058504

this.logger = logger;
}

async receive(token: Token | string): Promise<void> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method still assumes sat for all token

return preparedProofs;
}

async createBlankOutputs(amount: number, mintUrl: string): Promise<OutputData[]> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes sat unit

* @param serializedOutputData - The serialized output data containing secrets and blinding factors
* @returns The recovered proofs (only unspent ones)
*/
async recoverProofsFromOutputData(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes sat unit

const entry: Omit<ReceiveHistoryEntry, 'id'> = {
type: 'receive',
createdAt: Date.now(),
unit: token.unit || 'sat',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will default to sat as TransactionService.receive emits the event without unit

@ye0man ye0man added this to the stable v1 milestone Mar 10, 2026
@ye0man ye0man moved this from Backlog to In Review in coco Mar 10, 2026
@4xvgal 4xvgal changed the title feat: add multi-unit support (sat, usd, auth, etc.) feat: add multi-unit support (sat, usd, etc.) Mar 14, 2026
@a1denvalu3
Copy link
Copy Markdown

a1denvalu3 commented Apr 6, 2026

title: Missing unit parameter in ProofService methods causes critical cross-unit invariant violations and loss of funds
status: confirmed
severity: high
target: cashubtc/coco

Summary

The feat-multi-unit PR adds multi-unit support by threading a unit parameter through high-level APIs (QuotesApi, SendApi, WalletService). However, it completely omits threading this parameter into the internal methods of ProofService (e.g., createOutputsAndIncrementCounters, createBlankOutputs, calculateSendAmountWithFees). As a result, all internal operations silently fall back to generating blinded outputs and calculating fees using the default sat keyset, regardless of the operation's actual unit. When attempting to redeem a PAID mint quote for a non-sat unit (e.g., usd), the client generates sat outputs, causing the mint to reject the mismatch or the client to fail unblinding. This traps the user's funds in the paid invoice permanently. Furthermore, another introduced bug in IdbProofRepository.ts queries an undefined IndexedDB index ([mintUrl+unit]), causing immediate application crashes on web and React Native platforms during any Send operation.

Root Cause

In packages/core/services/ProofService.ts, methods responsible for keyset interaction omit the unit parameter and fetch the default (sat) wallet:

// packages/core/services/ProofService.ts
  async createOutputsAndIncrementCounters(
    mintUrl: string,
    amount: { keep: number; send: number },
    options?: { includeFees?: boolean },
  ) {
    // BUG: Missing unit parameter, defaults to 'sat'
    const { wallet, keys, keysetId } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); 

This omission exists in createOutputsAndIncrementCounters, createBlankOutputs, calculateSendAmountWithFees, and recoverProofsFromOutputData.

When a non-sat mint quote is redeemed via MintQuoteService.redeemMintQuote:

  1. MintQuoteService retrieves the usd wallet.
  2. It calls createOutputsAndIncrementCounters(mintUrl, ...).
  3. ProofService fetches the sat keyset and generates blinded messages targeting the sat keyset ID.
  4. MintQuoteService submits a request to the mint using the usd quote but with sat outputs.

Additionally, in packages/indexeddb/src/repositories/ProofRepository.ts:

  async getAvailableProofs(mintUrl: string, unit: string): Promise<CoreProof[]> {
    const keysets = (await (this.db as any)
      .table('coco_cashu_keysets')
      .where('[mintUrl+unit]') // BUG: Index does not exist in schema.ts

This crashes Dexie immediately.

Attack Steps

  1. The user uses the QuotesApi.createMintQuote to create a usd mint quote.
  2. The user pays the Lightning invoice associated with the quote.
  3. The user's client triggers redeemMintQuote to claim the proofs.
  4. ProofService.createOutputsAndIncrementCounters generates blinded outputs using the sat keyset's public keys.
  5. The cashu-ts library sends the usd quote ID and the sat outputs to the Mint.
  6. The Mint enforces NUT-04 (Quote unit must match output keyset unit) and returns 400 Bad Request.
  7. The redemption fails locally and throws an error. The quote remains in the PAID state forever, and the user's funds are permanently lost.

(Additionally, any attempt to Send or Melt a non-sat unit will fail due to the same mismatch causing Mint 400 Bad Request or unblinding exceptions).

Impact

  • Loss of Funds: Users who pay Lightning invoices for non-default unit mint quotes will never be able to claim their proofs.
  • Complete Feature Breakage: Sending, melting, and fee calculation for any unit other than sat is fundamentally broken.
  • Platform DoS: The IndexedDB index bug completely prevents the use of the Send feature on React Native and Web platforms, even for sat.

Proof of Concept

A simple script demonstrating that ProofService incorrectly uses the default keyset:

import { ProofService } from './packages/core/services/ProofService.ts';

// Mock dependencies
const mockWalletService = {
  getWalletWithActiveKeysetId: async (mintUrl: string, unit?: string) => {
    return {
      keys: { id: unit === 'usd' ? '00usdkeysetid123' : '00satkeysetid123', keys: { 1: "pubkey" } },
      keysetId: unit === 'usd' ? '00usdkeysetid123' : '00satkeysetid123',
      wallet: {}
    };
  }
} as any;
const mockCounterService = { getCounter: async () => ({ counter: 1 }), incrementCounter: async () => {} } as any;
const mockSeedService = { getSeed: async () => new Uint8Array(32) } as any;

const proofService = new ProofService(mockCounterService, {} as any, mockWalletService, {} as any, {} as any, mockSeedService);

async function run() {
  // Simulate a call from MintQuoteService redeeming a USD quote
  const outputs = await proofService.createOutputsAndIncrementCounters('http://mint', { keep: 10, send: 0 });
  
  if (outputs.keep[0].blindedMessage.id === '00satkeysetid123') {
    console.log("VULNERABILITY CONFIRMED: USD operation generated outputs for the 'sat' keyset.");
  }
}
run();

@Egge21M Egge21M closed this Apr 9, 2026
@github-project-automation github-project-automation Bot moved this from In Review to Done in coco Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants