Skip to content

feat!: nut-18 mint preferences (mp, sm) - #683

Merged
robwoodgate merged 17 commits into
cashubtc:mainfrom
d4rp4t:feat/nut18-nut26-mint-strict-flag
Jul 21, 2026
Merged

feat!: nut-18 mint preferences (mp, sm)#683
robwoodgate merged 17 commits into
cashubtc:mainfrom
d4rp4t:feat/nut18-nut26-mint-strict-flag

Conversation

@d4rp4t

@d4rp4t d4rp4t commented May 26, 2026

Copy link
Copy Markdown
Contributor

NUT: cashubtc/nuts#381

Summary

Aligns the NUT-18 / NUT-26 mint-preferences fields with the latest payment-request consolidation candidate.

  • mp (mint_preferred, TLV 0x09) — whether the mint list is advisory. Supersedes the earlier ms (mint_strict) flag with inverted polarity: a strict mint list is now the omitted default, and an advisory/preferred list is the explicit mp = true
  • sm (supported_method, TLV 0x0a, repeatable) — payment methods the payee accepts (e.g. bolt11, bolt12, onchain), each carrying an optional per-method fee (mf)

The per-method fee compensates the receiver for melting out: it applies only to payments from a mint outside m (or from any mint if m is not set), and the payer owes the lowest mf among the listed methods their mint supports. Payments from a listed mint carry no fee.

The requested amount is always net of input fees (NUT-18): the payer selects proofs such that the receiver can swap/melt them without dipping below the amount. This maps directly onto the existing includeFees(true) proof-selection machinery; the usage guide shows the wiring.

Motivation

The original branch implemented ms (mint_strict) from an earlier draft. The consolidation candidate renames the strictness flag to mp (mint_preferred) and inverts the default so the flag only needs to be set for the non-default (advisory) case — strict is implied by omission — removing the redundant "omitted == explicit default" encoding the ms form carried.

Later revisions promoted sm from a bare list of method-name strings to a list of {mn, mf?} objects, replaced the top-level non-preferred-mint fee (fr) with a net-of-input-fees flag (nf), folding all melt-out pricing into the per-method mf, and finally dropped nf entirely: the spec now makes the requested amount unconditionally net of input fees, so the flag carried no information. With nf gone, supported_method moved down to TLV tag 0x0a.

Changes

  • RawPaymentRequest: rename msmp; add sm?: RawSupportedMethod[] ({mn, mf?})
  • PaymentRequest: rename mintsStrictmintsPreferred; add supportedMethods (SupportedMethod[], {method, fee?: Amount}); plumb through toRawRequest, toEncodedCreqA, toEncodedCreqB, fromRawRequest, fromEncodedRequest
  • DecodedTLVPaymentRequest: rename mintsStrictmintsPreferred; add supportedMethods?: Array<{method, fee?: bigint}>
  • TLV codec: TLV 0x09 renamed mint_strictmint_preferred (default-if-absent flips 10); tag 0x0a (supported_method, repeatable) is a sub-TLV — sub-tag 0x01 method (string), 0x02 fee (u64, optional). Duplicate method/fee sub-tags are rejected, matching the nut10 sub-TLV
  • get isMintListStrict(): resolves the spec's default-to-strict semantic — undefined when no mint list is set, mintsPreferred !== true otherwise
  • feesFor(mint, mintMethods?) / amountToSend(mint, mintMethods?): price the applicable per-method fee — 0 for a mint in m, otherwise the lowest mf among the sm entries the mint supports (mintMethods). Pure fee calculators; they do not validate admissibility (strict-list / sm-method gating is the caller's concern). amountToSend throws on an amountless request
  • single_use is now tri-state optional (absent / false / true) instead of a boolean defaulting to false, so it round-trips the absent-vs-explicit-false distinction rather than always serializing single_use = 0. Breaking (v5): a decoded request that omits the flag now exposes singleUse: undefined (was false) — documented in migration-5.0.0.md
  • Both flags (mp, single_use) are defensively coerced to real booleans on decode, so an untyped CBOR value can't leak a non-boolean into the getter or be re-serialized verbatim
  • Breaking (v5): the PaymentRequest constructor now takes a single PaymentRequestOptions object whose keys mirror the class properties, replacing the positional form. The old signature had grown to 11 optional slots (three of them adjacent booleans), making call sites undefined-padded and swap-prone, and every spec revision appended another slot. A positional call fails to type-check; documented in migration-5.0.0.md
  • Public exports: PaymentRequestOptions, RawSupportedMethod, SupportedMethod
  • Usage guide (docs-src/usage/payment_requests.md) covers decoding, strictness, fee pricing and selecting proofs with includeFees(true) so the receiver nets the requested amount

Reviewer Notes

  • Wire identity preserved: mp and sm are only serialized when explicitly set, and single_use is omitted when absent — encoded requests round-trip canonically (minimal CBOR, no redundant default tags). The NUT-18 and NUT-26 spec vectors for the combined mp/sm payload (with a per-method fee on bolt12) are pinned byte-for-byte (creqA + creqB) and match the upstream test vectors
  • mintsPreferred reflects raw wire state (undefined when absent). The spec's "absent means strict when m is set" semantic is exposed via the isMintListStrict getter rather than mutated into the field on decode — keeps roundtrip clean and avoids gaining a tag the original didn't carry
  • Per-method fee uses Amount in the class, mirroring how amount is handled; raw/TLV layers stay numeric/bigint
  • canPayFrom-style admissibility helper was considered and deliberately skipped: the mint-list case is already a correct one-liner via isMintListStrict, and a useful sm-method check needs the sending mint's MintInfo (not self-contained on PaymentRequest), so it is left for when a caller actually needs to gate

@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.79518% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.48%. Comparing base (1c959da) to head (397e6d5).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/utils/tlv.ts 97.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #683      +/-   ##
==========================================
+ Coverage   95.44%   95.48%   +0.04%     
==========================================
  Files          55       55              
  Lines        5292     5363      +71     
  Branches     1309     1329      +20     
==========================================
+ Hits         5051     5121      +70     
  Misses        107      107              
- Partials      134      135       +1     
Flag Coverage Δ
integration 39.13% <4.81%> (-0.44%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robwoodgate robwoodgate added the nut hold This PR is held pending the merge of a new or updated NUT label May 27, 2026
@robwoodgate

Copy link
Copy Markdown
Collaborator

Thanks @d4rp4t - Please can you run npm run prtasks to update the api report and formatting.

@robwoodgate robwoodgate changed the title feat: mint strict flag in payment request feat: nut-18 mint preferences (ms, fr, sm) May 28, 2026
@robwoodgate
robwoodgate marked this pull request as ready for review May 28, 2026 15:42
@robwoodgate robwoodgate added this to the v5.0 milestone Jun 20, 2026
d4rp4t and others added 3 commits June 25, 2026 11:31
Spec moved from cashubtc/nuts#380 to cashubtc/nuts#381, adding fee_reserve (fr)
and supported_methods (sm) alongside the existing mint-strict flag. Adds an
isMintListStrict resolver for the spec default-to-true semantic.
…onfusion

An untyped falsy CBOR value (ms: 0 or ms: null) read as strict via the
isMintListStrict getter's `!== false` check while serializing to false over
TLV, diverging across creqA/creqB. Coerce to a real boolean at construction
so every representation agrees.
@robwoodgate
robwoodgate force-pushed the feat/nut18-nut26-mint-strict-flag branch from 5fda60d to e51c5a9 Compare June 25, 2026 10:31
…_use tri-state

Spec NUT-18/NUT-26 replaced `ms` (mint_strict) with `mp` (mint_preferred), inverting the
default: a strict mint list is the omitted default, an advisory list is the explicit flag.
Rename the field, TLV tag 0x09, and the `isMintListStrict` getter to match; strict now
resolves from `mp` absent or false.

Also make `single_use` tri-state optional (absent/false/true) like `mp`, defensively coerce
both flags to real booleans so an untyped CBOR value can't leak a non-boolean, and pin the
creqA/creqB spec vectors byte-for-byte.
@robwoodgate

Copy link
Copy Markdown
Collaborator

closing / reopening to force refresh

@github-project-automation github-project-automation Bot moved this from Backlog to Done in cashu-ts Jun 26, 2026
@robwoodgate robwoodgate reopened this Jun 26, 2026
@robwoodgate robwoodgate changed the title feat: nut-18 mint preferences (ms, fr, sm) feat: nut-18 mint preferences (mp, fr, sm) Jun 26, 2026
@robwoodgate robwoodgate added the experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental label Jul 1, 2026
Track the NUT-18/NUT-26 update: sm entries become {mn, mf?} objects
carrying an optional per-method fee that stacks additively with the
top-level fr (non-preferred-mint fee).

- RawSupportedMethod {mn, mf} wire type; model SupportedMethod {method,
  fee?: Amount} mirroring feeReserve's Amount treatment
- NUT-26 tag 0x0b promoted to sub-TLV (0x01 method, 0x02 fee u64) with
  encode/parseSupportedMethod; duplicate sub-tags rejected
- PaymentRequest.amountToSend(mint, method?) sums the applicable fr + mf
  fees; does not validate admissibility (caller's concern)
- update pinned creqA/creqB spec vectors; both byte-match the NUT vectors
@robwoodgate
robwoodgate force-pushed the feat/nut18-nut26-mint-strict-flag branch from 32ef298 to 7ecf99d Compare July 1, 2026 20:53
Split the fr + mf surcharge calc out of amountToSend into feesFor(mint,
method?), which never throws and returns 0 when nothing applies. Lets
callers price the fees on an amountless request (where amountToSend has
no base to add to) and add them to the payer-chosen amount. amountToSend
now delegates to feesFor and still throws on a missing amount, pointing
callers at feesFor.
Cover decoding, mint-list admissibility (isMintListStrict), fee-aware
amountToSend / feesFor (fr + mf stacking, amountless case), locked
requests via toP2PKOptions, creating/encoding, and the delivery payload.
Register in typedoc projectDocuments and link from the usage index.
Resolve migration-5.0.0.md add-add conflict by keeping both new sections
(main's P2PKOptions kind/data refactor and this branch's singleUse
tri-state note). Code files auto-merged: main's P2PKOptions/toP2PKOptions
rewrite is orthogonal to this branch's feesFor/sm additions. Typecheck
and full unit suite (1658 tests) green post-merge.
- qualify {@link PaymentRequest.feesFor} so api-extractor resolves it as a
  class member, not a missing package export (ae-unresolved-link warning)
- regenerate etc/cashu-ts.api.md for the new PaymentRequest surface
  (amountToSend, feesFor, SupportedMethod, RawSupportedMethod, sm retype)
Add malformed-payload tests for the new tag 0x0b codec: duplicate method
sub-tag, duplicate fee sub-tag, and a supported_method missing its method
field. Covers the reject-not-last-wins paths introduced with per-method
fees (tlv.ts:393/399/407), which had no coverage.
Track the latest NUT-18/NUT-26 candidate: the top-level fee (fr) is
dropped in favor of a net-of-input-fees flag (nf, TLV tag 0x0a as u8),
and the per-method fee (mf) now applies only when paying from a mint
outside the request's list, with the payer owing the lowest fee among
the listed methods their mint supports. feesFor/amountToSend now take
the mint's supported methods; spec vectors repinned.
…-strict-flag

# Conflicts:
#	test/wallet/paymentRequests.test.ts
@robwoodgate robwoodgate changed the title feat: nut-18 mint preferences (mp, fr, sm) feat: nut-18 mint preferences (mp, nf, sm) Jul 6, 2026
Replace the 11-slot positional constructor with a single
PaymentRequestOptions object whose keys mirror the class properties.
Adjacent same-typed slots (three booleans) and undefined padding made
positional calls unreadable and swap-prone, and every spec revision
appended another slot. Decode paths are unaffected; a v4-style call
fails to type-check. Documented in migration-5.0.0.md.
@robwoodgate robwoodgate changed the title feat: nut-18 mint preferences (mp, nf, sm) feat!: nut-18 mint preferences (mp, nf, sm) Jul 6, 2026
robwoodgate added a commit that referenced this pull request Jul 6, 2026
Fork PRs run with a read-only `GITHUB_TOKEN` regardless of the requested
permissions, so the sticky comment upsert always 403s and fails an
otherwise green run (seen on #683). Skip the comment step when the PR
head is not this repo; the job summary already carries the same report,
and the score gate itself is unaffected.
…f input fees

NUT-18 dropped the nf flag: the requested amount is always net of
input fees, so payers should select proofs with includeFees. The
supported_method TLV moves to tag 0x0a and the pinned spec vectors
are updated to match.
…-strict-flag

# Conflicts:
#	migration-5.0.0.md
@robwoodgate robwoodgate changed the title feat!: nut-18 mint preferences (mp, nf, sm) feat!: nut-18 mint preferences (mp, sm) Jul 8, 2026
…a or sm)

Per the amended spec, a request that sets an amount or supported
methods must carry a unit: mf and the melt-method check are
denominated in it. Encoding (creqA and creqB) and fee pricing now
throw on such requests; decoding stays lenient so foreign requests
can still be inspected. Docs now state the sm check is against the
mint's NUT-05 melt methods for the request unit.
@robwoodgate
robwoodgate force-pushed the feat/nut18-nut26-mint-strict-flag branch from d761336 to 4144898 Compare July 14, 2026 13:16
@robwoodgate robwoodgate removed the experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental label Jul 21, 2026
@robwoodgate
robwoodgate merged commit 65cb30e into cashubtc:main Jul 21, 2026
18 checks passed
@robwoodgate robwoodgate removed the nut hold This PR is held pending the merge of a new or updated NUT label Jul 21, 2026
robwoodgate added a commit that referenced this pull request Jul 21, 2026
> Stacked on #683 (based on its head branch); the diff shows that PR's
commits until it merges.
 Last #683 commit is 4144898

## Summary

Adds a fluent builder for authoring NUT-18 payment requests,
complementing the `PaymentRequestOptions` constructor from #683. The
builder handles the fiddly parts an options object cannot: transport tag
formats, NUT-10 lock serialization, mint URL normalization, and
cross-field validation.

```ts
const pr = PaymentRequest.builder()
  .amount(100, 'sat') // unit required with amount (NUT-18 MUST)
  .description('Coffee')
  .addMint('https://my.mint') // normalized + deduped
  .mintsPreferred()
  .addNostrTransport(nprofile) // NIP-17 tags applied
  .addHttpPostTransport('https://pay.example.com')
  .addSupportedMethod('bolt12', 5)
  .lock(new P2PKBuilder().addLockPubkey(pk).toOptions())
  .build();
```

## Design notes

- **Authoring-strict, wire-lenient.** Setters collect state in any
order; `build()` is the single validation point (`mp` without mints,
duplicate `sm` methods). The `PaymentRequest` class itself stays lenient
since it is also the decode type for foreign requests, whose round-trip
must be preserved. Same split as `P2PKOptions` (data) vs
`P2PKBuilder.toOptions()` (validated authoring).
- **`lock()` closes the receiver-side NUT-10 gap.** There was a parse
path (`toP2PKOptions()`) but no build path; receivers hand-serialized
NUT-11 tags. The new `p2pkOptionsToPRNut10()` serializes a `P2PKOptions`
into the request's `nut10` option, and the NUT-11 tag serialization
moves out of `OutputData.createSingleP2PKData` into a shared
`buildP2PKTags()` so authored requests and sender-side secrets cannot
drift. A round-trip test pins `lock()` against `toP2PKOptions()`.
`blindKeys` throws (P2BK blinding is per output at send time). For
NUT-10 kinds beyond P2PK/HTLC, `nut10()` passes a raw option through.
- **`amount(value, unit)`** takes the unit as a required parameter,
making "amount without unit" unrepresentable (NUT-18: `u` MUST be set
when `a` is set). `unit()` remains for amountless requests.
- **`addMint`** runs `normalizeUrl` (the same canonicalization `Mint`
applies) so `m` entries byte-match what senders compare against.
- **Colocation.** `PaymentRequestBuilder` lives in `PaymentRequest.ts`:
a separate file would make `static PaymentRequest.builder()` a circular
import.
- `RESERVED_P2PK_TAGS` folded into `P2PK_KNOWN_TAG_KEYS` (identical
contents, one set with both meanings documented); tag helpers moved to a
new "Lock Tag Serialization" section in NUT11.ts.

## Reviewer notes

- The `OutputData` refactor is behaviour-preserving: `buildP2PKTags` is
the same logic extracted verbatim (threshold gating, post-blinding key
layout, tag order), covered by the untouched OutputData tests.
- Usage guide updated (`docs-src/usage/payment_requests.md`); API report
regenerated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants