Skip to content
Merged
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

## Unreleased

**Note secrets now come off LUD-25's own derivation.** The draft specifies a
BIP-32 scheme under `m/139'` and the reference wallet implements it; this
wallet had been using `lnurlcash-kit`'s pre-spec HMAC scheme, which shipped
four days before that section existed. The same twelve words now restore the
same notes in any wallet that implements LUD-25, which was the entire point of
deriving them.

```
(d1, d2, d3, d4) = HMAC-SHA256(key = m/139'/0, msg = "<mint host>")[0..16]
k1[i] = m/139'/d1/d2/d3/d4/i'
```

- **Nothing already minted is lost.** `notecase restore` walks both ladders in
one pass and records which one found each note (`scheme` on the record:
`bip32`, or absent for everything written before this release, which is
exactly what those records are). Notes minted under the old scheme keep
working and come back on a restore; they simply move onto the new ladder the
first time they are rotated.
- **The legacy counter is kept, synced, and never advanced.** `counters` still
means what it always did - the pre-spec ladder's position - because it is
where a restore starts looking for those notes, and reinterpreting it as an
`m/139'` position would claim rungs on a ladder nothing has used. The new
ladder lives in `cashCounters` beside it. Both sync across devices, both
merge upwards only.
- Counter sync payloads are `v: 2` and carry both maps. A device that predates
this reads the one it knows and ignores the other, and it never mints on the
new ladder, so the two cannot collide. A `v: 1` payload arriving here reads
as no cash counters, which is the truth rather than a gap.
- Requires `lnurlcash-kit` 0.8.0.

**Said plainly in the README and `llms.txt`: the counter is half the backup.**
A mint must answer a private lookup for a burned note exactly as it answers
one for a note it never issued, so a walk cannot see a spent index; and since
every rotate burns the index below it, a wallet that has rotated more than the
gap would scan as empty without its counter. It is not secret, so it belongs
in an ordinary backup.

## 0.13.0 - 2026-09-04

- **Fixed: a mint that issues unsigned notes could cost you the note.**
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,37 @@ seed` shows them after the PIN. Every note secret this wallet makes comes
off them:

```
root = HMAC-SHA256(key = "lnurlcash-note-v1", msg = seed)
k1[i] = HMAC-SHA256(key = root, msg = "<mint host>:<i>")
(d1, d2, d3, d4) = HMAC-SHA256(key = m/139'/0, msg = "<mint host>")[0..16]
k1[i] = m/139'/d1/d2/d3/d4/i'
```

so a wallet that has lost everything but the words and the names of its
mints can ask each mint which of those secrets are still worth something.
That is what `notecase restore` does: `notecase init --restore`, add the
mints you used, then `notecase restore`, and the notes come back.
which is the scheme LUD-25 specifies, so the same twelve words restore the
same notes in any wallet that implements it. `d1..d4` are used exactly as
they fall out of the hash: BIP-32 reads any index at or above 2^31 as
hardened, so which of those four levels are hardened is decided by the
mint's own host name.

A wallet that has lost everything but the words and the names of its mints
can ask each mint which of those secrets are still worth something. That is
what `notecase restore` does: `notecase init --restore`, add the mints you
used, then `notecase restore`, and the notes come back. It walks the
pre-spec scheme this wallet used before LUD-25 had one as well, so notes
minted then come back too.

The index a note came from is kept on the record, and the next unused
index per mint is written to disk **in the same save that stages a fresh
secret, before its hash goes anywhere**. A crash between the two wastes an
index, which costs nothing. The other order would hand a mint a secret the
wallet could never find its way back to.

That counter is not an optimisation, it is half the backup. A mint must
answer a private lookup for a burned note exactly as it answers one for a
note it never issued, so a walk cannot see a spent index; and since every
rotate burns the index below it, a wallet that has rotated more than twenty
times would scan as empty without its counter. It is not secret - an index
reveals nothing without the seed - so it belongs in an ordinary backup, and
it only ever moves upwards.

One thing the words cannot do on their own: they will not find a note
somebody handed you that you have not rotated yet, because that secret
came off their seed - the wallet lists those and `notecase adopt` moves
Expand Down
20 changes: 15 additions & 5 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,22 @@ end, so the local resolver never learns which mint the wallet banks with.
Every note secret is derived, so twelve words plus the mint hosts are
enough to find the money:

root = HMAC-SHA256(key = "lnurlcash-note-v1", msg = 64-byte BIP39 seed)
k1[i] = HMAC-SHA256(key = root, msg = "<mint host>:<i>")
(d1, d2, d3, d4) = HMAC-SHA256(key = m/139'/0, msg = "<mint host>")[0..16]
read big-endian as 4 uint32
k1[i] = m/139'/d1/d2/d3/d4/i'

host is the exact lowercase host[:port]; i is decimal from 0, one counter
per mint. Restore walks i upward asking the mint about each secret and
stops after 20 consecutive unknowns.
LUD-25's own scheme. d1..d4 are RAW uint32 used exactly as they fall: BIP-32
reads >= 2^31 as hardened, so which levels are hardened depends on the host
name. Never mask the top bit, never harden all four - either derives a
different tree. host is the exact lowercase host[:port]; i is decimal from 0,
one counter per mint. Restore walks i upward and stops after 20 consecutive
unknowns, and walks the pre-spec HMAC scheme (key "lnurlcash-note-v1", msg
"<host>:<i>") alongside it for notes minted before LUD-25 specified one.

The counter is half the backup, not an optimisation: a mint answers a
private lookup for a burned note exactly as for one never issued, and a
rotate burns the index below, so a wallet that rotated more than the gap
scans as empty without it. Not secret; back it up; moves upwards only.

With the note store on (`sync`), the records themselves live on relays as
kind 30078, d = "lnurlcash-note:<note id>", NIP-44 to sha256(seed ||
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@scure/bip39": "^2.3.0",
"farrier-kit": "^1.1.3",
"keystore-kit": "^0.2.1",
"lnurlcash-kit": "^0.7.0",
"lnurlcash-kit": "^0.8.0",
"nostr-tools": "2.24.1",
"undici": "^8.10.0"
},
Expand Down
63 changes: 51 additions & 12 deletions src/notesync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,19 @@ export type NotePayload = {v: 1; note: SyncedNote}
// have each device overwriting the others' numbers on every push - which
// is the precise failure the counters exist to prevent. A `d` tag per
// device makes the merge a max() over records nobody else writes.
export type CounterPayload = {v: 1; device: string; counters: Record<string, number>; updatedAt: number}
// v2 adds `cashCounters`, the LUD-25 m/139' ladder, alongside the legacy
// hmac one. Both directions are safe across versions: a device that predates
// this reads `counters` and ignores the new field, and it never mints on the
// m/139' ladder either, so it cannot collide with one that does. A v1 payload
// arriving here simply carries no cash counters, which reads as 0 - correct,
// since its author never used that ladder.
export type CounterPayload = {
v: 1 | 2
device: string
counters: Record<string, number>
cashCounters?: Record<string, number>
updatedAt: number
}

const selfKey = (key: MintBackupKey): Uint8Array => nip44.getConversationKey(key.secret, key.pubkey)

Expand Down Expand Up @@ -98,15 +110,22 @@ export const decodeNote = (key: MintBackupKey, event: Event): SyncedNote | null
export const encodeCounters = (
key: MintBackupKey,
deviceId: string,
counters: Record<string, number>
counters: Record<string, number>,
cashCounters: Record<string, number> = {}
): Event =>
finalizeEvent(
{
kind: NOTE_SYNC_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', counterDTag(deviceId)]],
content: nip44.encrypt(
JSON.stringify({v: 1, device: deviceId, counters, updatedAt: Date.now()} satisfies CounterPayload),
JSON.stringify({
v: 2,
device: deviceId,
counters,
cashCounters,
updatedAt: Date.now()
} satisfies CounterPayload),
selfKey(key)
)
},
Expand All @@ -116,12 +135,23 @@ export const encodeCounters = (
export const decodeCounters = (key: MintBackupKey, event: Event): CounterPayload | null => {
try {
const parsed = JSON.parse(nip44.decrypt(event.content, selfKey(key))) as CounterPayload
if (parsed?.v !== 1 || typeof parsed.device !== 'string') return null
const counters: Record<string, number> = {}
for (const [host, value] of Object.entries(parsed.counters ?? {})) {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) counters[host] = value
if ((parsed?.v !== 1 && parsed?.v !== 2) || typeof parsed.device !== 'string') return null
const sane = (from: unknown): Record<string, number> => {
const out: Record<string, number> = {}
for (const [host, value] of Object.entries((from ?? {}) as Record<string, unknown>)) {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) out[host] = value
}
return out
}
return {
v: parsed.v,
device: parsed.device,
counters: sane(parsed.counters),
// A v1 device wrote no cash counters, and never minted on that ladder
// either, so an empty map here is the truth rather than a gap.
cashCounters: sane(parsed.cashCounters),
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0
}
return {v: 1, device: parsed.device, counters, updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0}
} catch {
return null
}
Expand Down Expand Up @@ -176,12 +206,21 @@ export const fetchStore = async (

// The highest index any device has claimed at each mint. Taken before
// minting, so two wallets on one seed do not derive the same secret twice.
export const mergeCounters = (payloads: CounterPayload[]): Record<string, number> => {
const merged: Record<string, number> = {}
// Upwards only, both ladders. A counter that went backwards would hand a
// second device an index the first has already minted at, and the note under
// it would collide with one that is already money.
export const mergeCounters = (
payloads: CounterPayload[]
): {counters: Record<string, number>; cashCounters: Record<string, number>} => {
const counters: Record<string, number> = {}
const cashCounters: Record<string, number> = {}
for (const payload of payloads) {
for (const [host, value] of Object.entries(payload.counters)) {
merged[host] = Math.max(merged[host] ?? 0, value)
counters[host] = Math.max(counters[host] ?? 0, value)
}
for (const [host, value] of Object.entries(payload.cashCounters ?? {})) {
cashCounters[host] = Math.max(cashCounters[host] ?? 0, value)
}
}
return merged
return {counters, cashCounters}
}
22 changes: 18 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// note can be reclaimed if the recipient never takes it
// spent burned at the mint; kept for history

import type {NoteScheme} from 'lnurlcash-kit'
import type {NwcConnection} from './nwcservice.ts'

export type NoteState = 'live' | 'staged' | 'ambiguous' | 'melting' | 'sent' | 'spent'
Expand All @@ -40,6 +41,11 @@ export type NoteRecord = {
// host in the seed's ladder. A note without one predates the seed (or
// came from somewhere else) and restore cannot find it again.
index?: number
// Which ladder that index belongs to. 'bip32' is LUD-25's own m/139'
// scheme, which is what this wallet mints under now. Absent means 'hmac',
// the kit's pre-spec scheme, because every record written before this
// field existed came off that one - so no stored wallet needs rewriting.
scheme?: NoteScheme
// Nostr provenance: the pubkey this note was gift-wrapped to, or came
// from. A note with either is never wrapped again.
sentTo?: string
Expand Down Expand Up @@ -193,11 +199,19 @@ export type WalletData = {
// seed cannot be turned back into words. Exactly as secret as the seed,
// in the same sealed store, and the one thing worth writing down.
mnemonic?: string
// Next unused derivation index per mint host. Bumped and persisted in
// the same write that stages a record, always BEFORE its hash goes on
// the wire: a crash there wastes an index, and the other order loses a
// note.
// Next unused derivation index per mint host on the LEGACY hmac ladder.
// Nothing mints under it any more, but the number is kept and still
// synced: it is where a restore starts looking for notes minted before
// LUD-25 specified a derivation, and moving it backwards would lose them.
counters?: Record<string, number>
// The same, for LUD-25's m/139' ladder - the one this wallet mints under.
// Deliberately a second map rather than a reinterpretation of the first:
// an existing wallet's counters count hmac indices, and reading them as
// m/139' indices would claim positions on a ladder nothing has used.
// Bumped and persisted in the same write that stages a record, always
// BEFORE its hash goes on the wire: a crash there wastes an index, and
// the other order loses a note.
cashCounters?: Record<string, number>
settings: {
defaultMintHost?: string
// A spending capability - the store holding this must stay encrypted.
Expand Down
Loading