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
16 changes: 8 additions & 8 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.1.1",
"lnurlcash-kit": "0.5.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
Expand All @@ -57,7 +57,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "0.1.1",
"lnurlcash-conformance": "0.4.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
Expand Down
6 changes: 6 additions & 0 deletions src/composables/useMoveFundsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ export const useMoveFundsPage = () => {
);
} else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence);
// a named target's claim material carries the note at the wallet's
// own secret - claimable the moment the melt settles, so it is
// tracked unverified rather than lost with the result object
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log(
'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
Expand Down
5 changes: 4 additions & 1 deletion src/composables/useReceiveLightningDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ export const useReceiveLightningDialog = (
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) {
// named mints claim through the wallet's own secret and may
// legitimately serve no verify URL; unnamed mints need one (the
// preimage it reveals IS the claim)
if (next.mode === 'unnamed' && !next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
Expand Down
14 changes: 12 additions & 2 deletions src/lnurlcash/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ lnurlcash-kit directly.

## FUND-CRITICAL INVARIANTS (non-negotiable)

- Rotate on every receive, and immediately after claiming a fresh mint.
- Rotate on every receive, and immediately after claiming a fresh mint
from an UNNAMED mint (one that keys the note by the payment preimage).
A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint
≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the
quote; that secret never rode an invoice, so the claim needs no rotate.
The claim poll itself is the placement proof: a mint that took the hash
but credited the preimage is caught there and rescued through the
quote's verify URL when one is served.
- A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or
Expand Down Expand Up @@ -61,4 +68,7 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS

`npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via test-utils.
Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts`
(`conformance-mockmint.d.ts` augments its stale published declarations
with the comment-naming flags). The mock's `commentAllowed` option models
the named-mint generation; default options model the old one.
30 changes: 30 additions & 0 deletions src/lnurlcash/conformance-mockmint.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// The published lnurlcash-conformance 0.4.0 hand-written declarations
// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the
// comment-naming flags are implemented and documented in the CHANGELOG
// ("The mock mint gains commentAllowed, commentRefusesMalformed and
// verifyOnUnnamedMint") but never made it into the type surface. This
// augmentation restores them so tests can drive the comment-named mint
// mode without `as any`. Report upstream; drop this file once a release
// ships the declarations.
//
// Semantics (from mock-mint/index.mjs):
// - commentAllowed: false (default) means the pay callback ignores the
// `comment` parameter entirely. A number advertises that many
// characters on the payRequest and reads `comment` on the callback: a
// bare 64-hex sha256 binds the minted note to the wallet's secret
// (k1 = secret, preimage opens nothing); anything else falls back to
// preimage keying and verify is withheld for that quote.
// - commentRefusesMalformed: non-compliant; refuses a non-hash comment
// instead of falling back.
// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the
// no-comment fallback, where the preimage it hands out IS the note.

declare module 'lnurlcash-conformance/mock-mint' {
interface MockMintOptions {
commentAllowed?: number | false
commentRefusesMalformed?: boolean
verifyOnUnnamedMint?: boolean
}
}

export {}
108 changes: 98 additions & 10 deletions src/lnurlcash/mintContract.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on:
// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 /
// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows
// rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the
// typed field read undefined forever), and a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an
// HTTPS default anywhere in the receive path.
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
// the typed field read undefined forever); a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
// mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability
// (commentAllowed >= 64 or mintToHash): a new-generation mint
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
// see the byte-identical request this wallet always sent.

import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit'
import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit'

import {claimMintedNote, prepareMint} from './ops'
import {mintAddressCacheInfo} from './trustedMints'
Expand Down Expand Up @@ -77,6 +83,20 @@ const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof
return impl
}

// fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire
const capturingFetch = (
routes: ReadonlyArray<readonly [string, unknown]>,
seen: string[],
): typeof fetch => {
const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
seen.push(url)
return fixtureFetch(routes)(input, init)
}
return impl
}

describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
Expand Down Expand Up @@ -116,9 +136,10 @@ describe('mint-address wire contract', () => {

describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
const m = await mint({testHooks: true})
// conformance 0.4.0 defaults to plain https links; lnurlw:// (the form
// lnurl-mint emits under LUD-17) is an explicit option
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'})
const prepared = await prepareMint(`[email protected]:${m.port}`, 21_000)
// published conformance 0.1.1 emits lnurlw:// by default - NOT https
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)

// and the link is fully usable: settle the invoice, claim the note
Expand Down Expand Up @@ -163,3 +184,70 @@ describe('withdraw-link forms', () => {
)
})
})

describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({
tag: 'payRequest',
callback: 'https://mint.example/pay',
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
metadata: '[]',
...extra,
})
const routesFor = (payRequest: Record<string, unknown>) =>
[
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
] as const

const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'))
if (!hit) throw new Error('the quote request never hit the wire')
return new URL(hit)
}

it('sends no comment/h to a mint that never advertised output naming', async () => {
const seen: string[] = []
const prepared = await prepareMint('[email protected]', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
})
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// an old mint sees the byte-identical request this wallet always sent
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
})

it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => {
const seen: string[] = []
const prepared = await prepareMint('[email protected]', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen)
// both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment')
const h = url.searchParams.get('h')
expect(comment).toMatch(/^[0-9a-f]{64}$/)
expect(h).toBe(comment)
expect(comment).toBe(hashK1(noteSecret))
})

it('names the output when only the mintToHash spelling is advertised', async () => {
const seen: string[] = []
const prepared = await prepareMint('[email protected]', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
})
})
Loading
Loading