⚠️ Don't be reckless: This project is in early development, it does however work with real sats! Always use amounts you don't mind losing.
Cashu TS is a JavaScript library for Cashu wallets written in TypeScript.
Wallet Features:
Implemented NUTs:
Supported token formats:
Go to the docs for detailed usage, or have a look at the integration tests for examples on how to implement a wallet.
npm i @cashu/cashu-ts
There are a number of ways to instantiate a wallet, depending on your needs.
Wallet classes are mostly stateless, so you can instantiate and throw them away as needed. Your app must therefore manage state, such as fetching and storing proofs in a database.
NB: You must always call loadMint() after instantiating a wallet.
import { Wallet } from '@cashu/cashu-ts';
// Simplest: With a mint URL
const mintUrl = 'http://localhost:3338';
const wallet1 = new Wallet(mintUrl); // unit is 'sat'
await wallet1.loadMint(); // wallet is now ready to use
const cache = wallet1.keyChain.getCache(); // persist mint data in your app
// Advanced: With cached mint data (reduces API calls)
const wallet2 = new Wallet(cache.mintUrl, {
unit: cache.unit,
keysets: cache.keysets,
keys: cache.keys,
});
await wallet2.loadMint(); // wallet2 is now ready to use
By default, cashu-ts does not log to the console. If you want to enable logging for debugging purposes, you can set the logger option when creating a wallet or mint. A ConsoleLogger is provided, or you can wrap your existing logger to conform to the Logger interface:
import { Mint, Wallet, ConsoleLogger, LogLevel } from '@cashu/cashu-ts';
const mintUrl = 'http://localhost:3338';
const mintLogger = new ConsoleLogger('error');
const mint = new Mint(mintUrl, undefined, { logger: mintLogger }); // Enable logging for the mint
const walletLogger = new ConsoleLogger('debug');
const wallet = new Wallet(mint, { logger: walletLogger }); // Enable logging for the wallet
await wallet.loadMint(); // wallet with logging is now ready to use
import { Wallet, MintQuoteState } from '@cashu/cashu-ts';
const mintUrl = 'http://localhost:3338';
const wallet = new Wallet(mintUrl);
await wallet.loadMint(); // wallet is now ready to use
const mintQuote = await wallet.createMintQuoteBolt11(64);
// pay the invoice here before you continue...
const mintQuoteChecked = await wallet.checkMintQuoteBolt11(mintQuote.quote);
if (mintQuoteChecked.state === MintQuoteState.PAID) {
const proofs = await wallet.mintProofs(64, mintQuote.quote);
}
// store proofs in your app ..
import { Wallet } from '@cashu/cashu-ts';
const mintUrl = 'http://localhost:3338';
const wallet = new Wallet(mintUrl);
await wallet.loadMint(); // wallet is now ready to use
const invoice = 'lnbc......'; // Lightning invoice to pay
const meltQuote = await wallet.createMeltQuoteBolt11(invoice);
const amountToSend = meltQuote.amount + meltQuote.fee_reserve;
// Wallet.send performs coin selection and swaps the proofs with the mint
// if no appropriate amount can be selected offline. When selecting coins for a
// melt, we must include the mint and/or lightning fees to ensure there are
// sufficient funds to cover the invoice.
const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amountToSend, proofs, {
includeFees: true,
});
const meltResponse = await wallet.meltProofs(meltQuote, proofsToSend);
// store proofsToKeep and meltResponse.change in your app ..
import { getEncodedTokenV4 } from '@cashu/cashu-ts';
// we assume that `wallet` already minted `proofs`, as above
// or you fetched existing proofs from your app database
const proofs = [...]; // array of proofs
const { keep, send } = await wallet.send(32, proofs);
const token = getEncodedTokenV4({ mint: mintUrl, proofs: send });
console.log(token);
const wallet2 = new Wallet(mintUrl); // receiving wallet
await wallet2.loadMint(); // wallet2 is now ready to use
const receiveProofs = await wallet2.receive(token);
// store receiveProofs in your app ..
import { getEncodedTokenV4 } from '@cashu/cashu-ts';
// we assume that `wallet` already minted `proofs`, as above
// or you fetched existing proofs from your app database
const proofs = [...]; // array of proofs
const pubkey = '02...'; // Your public key
const { keep, send } = await wallet.ops.send(32, proofs).asP2PK({pubkey}).run();
const token = getEncodedTokenV4({ mint: mintUrl, proofs: send });
console.log(token);
const wallet2 = new Wallet(mintUrl); // receiving wallet
await wallet2.loadMint(); // wallet2 is now ready to use
const privkey = '5d...'; // private key for pubkey
const receiveProofs = await wallet2.receive(token, {privkey});
// store receiveProofs in your app ..
import { getDecodedToken } from '@cashu/cashu-ts';
try {
const decodedToken = getDecodedToken(token);
console.log(decodedToken); // { mint: "https://mint.0xchat.com", unit: "sat", proofs: [...] }
} catch (_) {
console.log('Invalid token');
}
BOLT12 enables reusable Lightning offers that can be paid multiple times, unlike BOLT11 invoices which are single-use. Key differences:
// Create reusable BOLT12 offer
const bolt12Quote = await wallet.createMintQuoteBolt12(bytesToHex(pubkey), {
amount: 1000, // Optional: omit to create an amountless offer
description: 'My reusable offer', // The mint must signal in their settings that offers with a description are supported
});
// Pay a BOLT12 offer
const meltQuote = await wallet.createMeltQuoteBolt12(offer, 1000000); // amount in msat
const { keep, send } = await wallet.send(meltQuote.amount + meltQuote.fee_reserve, proofs);
const { change } = await wallet.meltProofsBolt12(meltQuote, send);
// Mint from accumulated BOLT12 payments
const updatedQuote = await wallet.checkMintQuoteBolt12(bolt12Quote.quote);
const availableAmount = updatedQuote.amount_paid - updatedQuote.amount_issued;
if (availableAmount > 0) {
const newProofs = await wallet.mintProofsBolt12(
availableAmount,
updatedQuote,
bytesToHex(privateKey),
);
}
Cashu-TS offers a flexible WalletOps builder that makes it simple to construct transactions in a readable and intuitive way.
You can access WalletOps from inside a wallet instance using: wallet.ops or instantiate your own WalletOps instance.
Fluent, single-use builders for send, receive, mint and melt. If you don’t customize an output side, the wallet’s policy defaults apply.
const { keep, send } = await wallet.ops.send(5, myProofs).run();
send and keep.keep is omitted so the wallet may still attempt an offline exact match where possible. This avoids mint fees.const { keep, send } = await wallet.ops
.send(15, myProofs)
.asDeterministic(0, [4, 4]) // counter=0 => auto-reserve; split must include 2x 4's
.keepAsRandom() // change proofs must have random secrets
.run();
Note Passing
counter=0means "reserve counters automatically" using wallet CounterSource.
const { keep, send } = await wallet.ops
.send(10, myProofs)
.asP2PK({ pubkey, locktime: 1712345678 })
.includeFees(true) // sender covers receiver’s future spend fee
.run();
const { keep, send } = await wallet.ops
.send(20, myProofs)
.asFactory(makeOutputData, [4, 8, 8]) // makeOutputData: OutputDataFactory
.keepAsDeterministic() // deterministic change, auto-reserve
.keyset('0123456')
.onCountersReserved((info) => {
console.log('Reserved counters', info);
})
.run();
const mySendData: OutputData[] = [
/* amounts must sum to 15 */
];
const { keep, send } = await wallet.ops.send(15, myProofs).asCustom(mySendData).run();
Exact match only (throws on no exact match):
const { keep, send } = await wallet.ops
.send(7, myProofs)
.offlineExactOnly(/* requireDleq? */ false)
.includeFees(true) // optional; applied to the offline selection rules
.run();
Close match allowed (overspend permitted by wallet RGLI):
const { keep, send } = await wallet.ops
.send(7, myProofs)
.offlineCloseMatch(/* requireDleq? */ true) // only proofs with valid DLEQ
.run();
Important Offline modes cannot be combined with custom output types (
asXXXX/keepAsXXXX). The builder will throw:Offline selection cannot be combined with custom output types. Remove send/keep output configuration, or use an online swap.
const proofs = await wallet.ops.receive(token).run();
const proofs = await wallet.ops
.receive(token)
.asDeterministic() // counter=0 => auto-reserve
.requireDleq(true) // reject incoming proofs without DLEQ for the selected keyset
.keyset('0123456')
.onCountersReserved((c) => console.log('RX counters', c))
.run();
const proofs = await wallet.ops
.receive(token)
.asP2PK({ pubkey, locktime }) // NUT-11 options for new proofs
.privkey(['k1', 'k2', 'k3']) // sign incoming P2PK proofs
.proofsWeHave(myExistingProofs) // helps denomination selection
.run();
const proofsA = await wallet.ops
.receive(tokenA)
.asFactory(makeOutputData, [8, 4, 16]) // split must include these denoms
.run();
const proofsB = await wallet.ops
.receive(tokenB)
.asCustom(prebuiltRxOutputs) // amounts must sum to final received amount after fees
.run();
const newProofs = await wallet.ops
.mint(100, quote) // quote: string | MintQuoteResponse
.run();
const newProofs = await wallet.ops
.mint(250, quote)
.asDeterministic(0, [128, 64]) // counter=0 => auto-reserve, split must include denoms
.keyset('0123456')
.onCountersReserved((info) => console.log(info))
.run();
// Create a locked mint quote
const pubkey = '02...'; // Your public key
const quote = await wallet.createLockedMintQuote(64, pubkey);
// Sign and mint
const newProofs = await wallet.ops
.mint(50, quote)
.privkey('user-secret-key') // sign locked mint quote
.run();
// given a bolt11 meltQuote...
const { quote, change } = await wallet.ops.meltBolt11(meltQuote, myProofs).run();
meltQuote using myProofs// given a bolt12 meltQuote...
const { quote, change } = await wallet.ops
.meltBolt12(meltQuote, myProofs)
.asDeterministic() // counter=0 => auto-reserve
.onChangeOutputsCreated((blanks) => {
// Persist blanks and later call wallet.completeMelt(blanks)
})
.onCountersReserved((info) => console.log('Reserved', info))
.run();
Counter 0
asDeterministic(0) means "reserve counters automatically" using the wallet’s CounterSource. You’ll receive onCountersReserved when they’re atomically reserved.
For lifecycle management, see WalletEvents.
Two sides in send
send has send and keep branches.
If you only set send, the builder omits keep so the wallet may still do offline exact-match selection.
Offline modes vs custom outputs
offlineExactOnly / offlineCloseMatch work only with existing proofs.
They cannot honor new output types (p2pk/factory/custom/etc). The builder enforces this.
Keysets
.keyset(id) pins all fee lookups to that keyset. If you don’t specify it, the wallet uses its policy default keyset (either supplied at init or cheapest).
P2PK
You can pass P2PKOptions or build them fluently using the P2PKBuilder API.
Small helper that only shapes P2PKOptions, it does not create secrets.
new P2PKBuilder()
.addLockPubkey(k: string | string[]) // accepts 02|03 compressed, or x only (Nostr)
.addRefundPubkey(k: string | string[]) // requires lockUntil(...) to be set
.lockUntil(when: number | Date) // unix seconds, unix ms, or Date
.requireLockSignatures(n: number) // n of m for lock keys
.requireRefundSignatures(n: number) // n of m for refund keys
.toOptions(): P2PKOptions;
P2PKBuilder.fromOptions(opts: P2PKOptions): P2PKBuilder
Behaviour
Keys are normalised and de-duplicated, insertion order is preserved, total lock plus refund keys must be ≤ 10, refund keys will throw if no locktime is set.
Example usage:
import { P2PKBuilder } from '@cashu/cashu-ts';
const p2pk = new P2PKBuilder().addLockPubkey('02abc...').lockUntil(1_712_345_678).toOptions();
await wallet.ops.send(5, proofs).asP2PK(p2pk).run();
try {
const res = await wallet.ops.send(5, proofs).offlineExactOnly().run();
console.log('Sent:', res.send.length, 'Kept:', res.keep.length);
} catch (e) {
// e is a proper Error (WalletOps normalizes unknowns internally)
if ((e as Error).message.includes('Timeout')) {
// …
}
throw e;
}
Deterministic outputs use per-keyset counters. The wallet reserves them atomically and emits a single event you can use to persist the "next" value in your storage.
API at a glance:
wallet.counters.peekNext(id) – returns the current "next" for a keysetwallet.counters.advanceToAtLeast(id, n) – bump forward if behindwallet.on.countersReserved(cb) – subscribe to reservations (see WalletEvents for subscription patterns)** Optional:** - Depends on CounterSource:
These methods will throw if the CounterSource does not support them.
wallet.counters.snapshot() – inspect current overall statewallet.counters.setNext(id, n) – hard-set for migrations/tests// 1) Seed once at app start if you have previously saved "next" per keyset
const wallet = new Wallet(mintUrl, {
unit: 'sat',
bip39seed,
keysetId: preferredKeysetId, // e.g. '0111111'
counterInit: loadCountersFromDb(), // e.g. { '0111111': 128 }
});
await wallet.loadMint();
// Alternative to using counterInit for individual keyset allocation
await wallet.counters.advanceToAtLeast('0111111', 128);
// 2) Subscribe once, persist future reservations
wallet.on.countersReserved(({ keysetId, start, count, next }) => {
// next is start + count (i.e: next available)
saveNextToDb(keysetId, next); // do an atomic upsert per keysetId
});
// 3) Inspect current state, what will be reserved next
const nextCounter = await wallet.counters.peekNext('0111111'); // 128
// 4) After a restore or cross device sync, bump the cursor forward
const { lastCounterWithSignature } = await wallet.batchRestore();
if (lastCounterWithSignature != null) {
const next = lastCounterWithSignature + 1; // e.g. 137
await wallet.counters.advanceToAtLeast('0111111', next);
await saveNextToDb('0111111', next);
}
// 5) Parallel keysets without mutation
const wA = wallet; // bound to '0111111'
const wB = wallet.withKeyset('0122222'); // bound to '0122222', same CounterSource
await wB.counters.advanceToAtLeast('0122222', 10);
await wA.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
await wB.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
wA.keysetId; // '0111111'
wB.keysetId; // '0122222'
// 6) Switch wallet default keyset and bump counter
await wallet.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
wallet.keysetId; // '0111111'
wallet.bindKeyset('0133333'); // bound to '0133333', same CounterSource
wallet.keysetId; // '0133333'
await wallet.counters.advanceToAtLeast('0133333', 456);
// Counters persist per keyset, so rebinding does not reset the old one
await wallet.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
await wA.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
await wB.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
Note The wallet does not await your callback. If saveNextToDb (or similar) is async, handle errors to avoid unhandled rejections For more on lifecycle management, see WalletEvents
wallet.on exposes event subscriptions for counters, quotes, melts, and proof states. Each method returns a canceller function. You can bind an AbortSignal, set a timeout, or group cancellers and dispose them together.
Subscriptions:
wallet.on.countersReserved(cb, { signal }) – deterministic counter reservationswallet.on.meltBlanksCreated(cb, { signal }) – NUT-08 blanks before meltwallet.on.mintQuoteUpdates(ids, onUpdate, onErr, { signal }) – live mint quote updateswallet.on.meltQuoteUpdates(ids, onUpdate, onErr, { signal }) – live melt quote updateswallet.on.proofStateUpdates(proofs, onUpdate, onErr, { signal }) – push updateswallet.on.proofStatesStream(proofs, opts) – async iterator with bounded bufferNote: For the 'Updates' subscriptions, the first call auto-establishes a mint WebSocket and errors surface via the onErr callback.
One-shot helpers:
wallet.on.onceMintPaid(id, { signal, timeoutMs }) – resolve once quote paidwallet.on.onceMeltPaid(id, { signal, timeoutMs }) – resolve once melt paidwallet.on.onceAnyMintPaid(ids, { signal, timeoutMs }) – resolve when any paidGrouping:
wallet.on.group() – collect many cancellers, dispose all at onceSubscriptions should be cancelled when no longer needed to avoid leaks and keep your app tidy.
The simplest way to cancel a subscription is to call its cancel handle.
const cancelSub = wallet.on.countersReserved(({ keysetId, next }) => {
void saveNextToDb(keysetId, next).catch(console.error);
});
// later
cancelSub();
Subscriptions also accept an AbortSignal. Aborting stops the stream and cleans up.
// Create an abort controller
const ac = new AbortController();
// Setup subscriptions to use abort signal
wallet.on.countersReserved(
({ keysetId, next }) => {
void saveNextToDb(keysetId, next).catch(console.error);
},
{ signal: ac.signal }, // abort controller
);
// when done... trigger the abort signal
ac.abort();
// eg: via DOM events:
window.addEventListener('pagehide', () => ac.abort(), { once: true });
window.addEventListener('beforeunload', () => ac.abort(), { once: true });
The once* helpers are always cancelled automatically after resolution or rejection, as well as on timeout or abort:
try {
const paid = await wallet.on.onceMintPaid(quoteId, {
signal: ac.signal,
timeoutMs: 60_000,
});
console.log('Paid', paid.amount);
} catch (e) {
console.warn('Not paid in time or aborted', e);
}
Async iterator with buffer control:
import { CheckStateEnum } from '@cashu/cashu-ts';
const ac = new AbortController();
(async () => {
for await (const u of wallet.on.proofStatesStream(proofs, { signal: ac.signal })) {
if (u.state === CheckStateEnum.SPENT) {
console.log('Spent proof', u.proof.id);
}
}
})();
// later
ac.abort();
const cancelAll = wallet.on.group();
cancelAll.add(wallet.on.meltBlanksCreated((b) => cacheBlanks(b)));
cancelAll.add(wallet.on.mintQuoteUpdates(ids, onMint, onErr));
cancelAll();
// safe to call multiple times
WalletOps builders include per-operation hooks (onCountersReserved, onChangeOutputsCreated) that fire during a single transaction build.
WalletEvents provides global subscriptions (wallet.on.*) that can outlive a single builder call.
Use the builder hooks for transaction-local callbacks, and WalletEvents for app-wide subscriptions.
Contributions are very welcome.
If you want to contribute, please open an Issue or a PR.
If you open a PR, please do so from the development branch as the base branch.
Features and fixes should be implemented by branching off development. Hotfixes can be implemented by branching off a given tag. A new release can be created if at least one new feature or fix has been added to the development branch. If the release has breaking API changes, the major version must be incremented (X.0.0). If not, the release can increment the minor version (0.X.0). Patches and hotfixes increment the patch version (0.0.X). To create a new release, the following steps must be taken:
git checkout development && git pull Checkout and pull latest changes from developmentnpm version <major | minor | patch> create new release commit & taggit push && git push --tags push commit and taggit checkout main && git pull && git merge <tag> After creating a new version, merge the tag into main