Skip to content

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

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

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

Conversation

@4xvgal
Copy link

@4xvgal 4xvgal commented Feb 6, 2026

Description

  • This PR adds multi-unit support to the coco, allowing wallets to handle multiple currency units (sat, usd, auth,
    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

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
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.

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)
@@ -82,6 +84,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => {
return [
operation.id,
Copy link
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants