Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/many-units-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@cashu/coco-core': minor
'@cashu/coco-react': minor
'@cashu/coco-indexeddb': minor
'@cashu/coco-expo-sqlite': minor
'@cashu/coco-sqlite': minor
'@cashu/coco-sqlite-bun': minor
'@cashu/coco-adapter-tests': minor
---

Add first-class custom Cashu unit support across core APIs, React balance hooks,
operation recovery, and storage adapters.

Bare amount inputs continue to default to sats, while object-form amount inputs
carry an explicit unit. Proofs, balances, quotes, operations, history, tokens,
restore/sweep flows, and adapter persistence now preserve normalized unit
metadata, with migrations and contract tests covering legacy sat fallback and
custom-unit rows.
167 changes: 166 additions & 1 deletion packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type MeltOperation,
type MintOperation,
type ReceiveOperation,
type SendOperation,
type AuthSession,
} from '@cashu/coco-core';

Expand Down Expand Up @@ -192,23 +193,81 @@ export function createDummyProof(overrides?: Partial<CoreProof>): CoreProof {
secret: 'secret',
C: 'C',
mintUrl: 'https://mint.test',
unit: 'sat',
state: 'ready',
...overrides,
} satisfies CoreProof;
}

export function createDummyMeltOperation(): MeltOperation {
type InitMeltOperation = Extract<MeltOperation, { state: 'init' }>;

export function createDummyMeltOperation(
overrides?: Partial<InitMeltOperation>,
): InitMeltOperation {
return {
id: 'melt-op',
state: 'init',
mintUrl: 'https://mint.test',
unit: 'sat',
method: 'bolt11',
methodData: { invoice: 'lnbc1test', amountSats: Amount.from(1) },
createdAt: 0,
updatedAt: 0,
...overrides,
} satisfies MeltOperation;
}

type PreparedSendOperation = Extract<SendOperation, { state: 'prepared' }>;

function createDummyPreparedSendOperation(
overrides?: Partial<PreparedSendOperation>,
): PreparedSendOperation {
return {
id: 'send-op',
state: 'prepared',
mintUrl: 'https://mint.test',
amount: Amount.from(3),
unit: 'sat',
method: 'default',
methodData: {},
createdAt: 0,
updatedAt: 0,
needsSwap: false,
fee: Amount.zero(),
inputAmount: Amount.from(3),
inputProofSecrets: ['send-secret-1'],
...overrides,
} satisfies PreparedSendOperation;
}

function createDummySendOperationsByState(unit: string): SendOperation[] {
const prepared = createDummyPreparedSendOperation({ id: 'send-prepared', unit });
const token = {
mint: prepared.mintUrl,
unit,
proofs: [{ id: 'keyset-id', amount: Amount.from(3), secret: 'token-secret', C: 'C-token' }],
};
return [
{
id: 'send-init',
state: 'init',
mintUrl: prepared.mintUrl,
amount: prepared.amount,
unit,
method: 'default',
methodData: {},
createdAt: 0,
updatedAt: 0,
},
prepared,
{ ...prepared, id: 'send-executing', state: 'executing' },
{ ...prepared, id: 'send-pending', state: 'pending', token },
{ ...prepared, id: 'send-finalized', state: 'finalized', token },
{ ...prepared, id: 'send-rolling-back', state: 'rolling_back', token },
{ ...prepared, id: 'send-rolled-back', state: 'rolled_back', token },
] satisfies SendOperation[];
}

type PendingMintOperation = Extract<MintOperation, { state: 'pending' }>;

export function createDummyMintOperation(
Expand Down Expand Up @@ -337,6 +396,68 @@ export async function runReceiveOperationRepositoryContract(
});
}

export async function runSendOperationRepositoryContract(
options: ContractOptions,
runner: ContractRunner,
): Promise<void> {
const { describe, it, expect } = runner;

describe('SendOperationRepository contract', () => {
it('round-trips custom-unit send operations in every state', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const operations = createDummySendOperationsByState('usd');
for (const operation of operations) {
await repositories.sendOperationRepository.create(operation);
}

for (const operation of operations) {
const stored = await repositories.sendOperationRepository.getById(operation.id);
expect(stored).toBeDefined();
expect(stored!.unit).toBe('usd');
}

const pending = await repositories.sendOperationRepository.getByState('pending');
expect(pending).toHaveLength(1);
expect(pending[0]!.unit).toBe('usd');

const inFlight = await repositories.sendOperationRepository.getPending();
expect(inFlight).toHaveLength(3);
for (const operation of inFlight) {
expect(operation.unit).toBe('usd');
}
} finally {
await dispose();
}
});
});
}

export async function runMeltOperationRepositoryContract(
options: ContractOptions,
runner: ContractRunner,
): Promise<void> {
const { describe, it, expect } = runner;

describe('MeltOperationRepository contract', () => {
it('round-trips custom-unit init melt operations', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const operation = createDummyMeltOperation({ unit: 'usd' });
await repositories.meltOperationRepository.create(operation);

const stored = await repositories.meltOperationRepository.getById(operation.id);

expect(stored).toBeDefined();
expect(stored!.state).toBe('init');
expect(stored!.unit).toBe('usd');
} finally {
await dispose();
}
});
});
}

export async function runAuthSessionRepositoryContract(
options: ContractOptions,
runner: ContractRunner,
Expand Down Expand Up @@ -558,6 +679,50 @@ export async function runProofRepositoryContract(
await dispose();
}
});

it('round-trips proof units and filters ready proofs by unit', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
await repositories.proofRepository.saveProofs('https://mint.test', [
createDummyProof({ secret: 'sat-secret', C: 'C-sat', unit: 'sat' }),
createDummyProof({ secret: 'usd-secret', C: 'C-usd', unit: 'USD' }),
]);

const satProofs = await repositories.proofRepository.getReadyProofs('https://mint.test', {
unit: 'sat',
});
const usdProofs = await repositories.proofRepository.getAvailableProofs(
'https://mint.test',
{ unit: 'usd' },
);
const allUsd = await repositories.proofRepository.getAllReadyProofs({ units: ['usd'] });

expect(satProofs).toHaveLength(1);
expect(usdProofs).toHaveLength(1);
expect(allUsd).toHaveLength(1);
expect(usdProofs[0]?.unit).toBe('usd');
} finally {
await dispose();
}
});

it('rejects proofs without a unit', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const proof = createDummyProof({ secret: 'missing-unit' }) as unknown as Omit<
CoreProof,
'unit'
>;
delete (proof as { unit?: string }).unit;

await expectThrows(
() => repositories.proofRepository.saveProofs('https://mint.test', [proof as CoreProof]),
expect,
);
} finally {
await dispose();
}
});
});
}

Expand Down
Loading
Loading