Skip to content

feat(nut18): add PaymentRequestBuilder - #780

Merged
robwoodgate merged 2 commits into
mainfrom
feat/payment-request-builder
Jul 21, 2026
Merged

feat(nut18): add PaymentRequestBuilder#780
robwoodgate merged 2 commits into
mainfrom
feat/payment-request-builder

Conversation

@robwoodgate

@robwoodgate robwoodgate commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

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.

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.

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.52%. Comparing base (65cb30e) to head (9cb626a).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##             main     #780       +/-   ##
===========================================
+ Coverage   39.13%   95.52%   +56.38%     
===========================================
  Files          55       55               
  Lines        5358     5428       +70     
  Branches     1328     1345       +17     
===========================================
+ Hits         2097     5185     +3088     
+ Misses       2794      107     -2687     
+ Partials      467      136      -331     
Flag Coverage Δ
integration 38.66% <14.44%> (-0.47%) ⬇️

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 stacked hold PR is based on top of another PR, so should be merged in sequence. See comments for details. label Jul 13, 2026
@robwoodgate
robwoodgate force-pushed the feat/payment-request-builder branch from 221ca2a to e5dbfdf Compare July 14, 2026 13:17
@robwoodgate
robwoodgate marked this pull request as ready for review July 14, 2026 19:11
Fluent authoring API for NUT-18 payment requests: transport helpers
(nostr NIP tags, HTTP POST), mint URL normalization with dedupe, and
cross-field validation at build() (mp without mints, duplicate sm
methods, amount without unit).

lock() serializes a P2PKOptions into the request's nut10 option via the
new p2pkOptionsToPRNut10(); the NUT-11 tag serialization moves out of
OutputData into a shared buildP2PKTags() so authored requests and
sender-side secrets cannot drift. RESERVED_P2PK_TAGS is consolidated
into P2PK_KNOWN_TAG_KEYS.
…lidation

Renames the feesFor/amountToSend mintMethods param to meltMethods:
the sm check is against the mint's NUT-05 melt methods, and the old
name invited the mint-vs-melt mixup the spec wording now rules out.
PaymentRequestBuilder.build() rejects supported methods without a
unit and unit() rejects an empty string, so a request the encoder
would refuse cannot be authored via the builder.
@robwoodgate
robwoodgate force-pushed the feat/payment-request-builder branch from c864ea5 to 9cb626a Compare July 21, 2026 20:31
@robwoodgate
robwoodgate merged commit 49f6034 into main Jul 21, 2026
18 checks passed
@robwoodgate
robwoodgate deleted the feat/payment-request-builder branch July 21, 2026 20:37
@github-project-automation github-project-automation Bot moved this from Backlog to Done in cashu-ts Jul 21, 2026
robwoodgate added a commit that referenced this pull request Jul 21, 2026
> Stacked on #780 (targets its head branch, so the diff shows only this
PR's commits).
Retarget to `main` when #780 merges, last commit of 780 was
c864ea5

## Summary

Closes the gap between documenting the NUT-18 payer/receiver rules and
enforcing them. Until now the docs choreographed four separate steps
(mint-list check, melt-method check, `amountToSend`,
`includeFees(true)`); a wallet that skipped one could underpay the
receiver or violate a spec MUST silently.

**Payer side: `wallet.ops.sendToRequest(pr, proofs, amount?)`** builds a
send that enforces the request in one step:

- strict mint list (URL-normalized membership)
- unit rule (request unit must match the wallet)
- NUT-05 melt-method support for the request unit, resolved from the
wallet's own `MintInfo` (callers never pass method strings, so the
mint-vs-melt and unit-binding mistakes are unrepresentable)
- lowest applicable per-method fee (`mf`), waived for listed mints
- the request's `nut10` lock via `asP2PK`, throwing on kinds it cannot
express (an exotic lock would otherwise send unlocked)
- net-of-input-fees selection (`includeFees(true)`)

It returns the normal `SendBuilder`, so output types, keysets and
offline modes chain as usual. Amountless requests take the payer-chosen
amount as the third argument.

**Receiver side: `wallet.isPaymentRequestSatisfied(pr, proofs,
expectedAmount?)`** checks `sum(proofs) - inputFees >= amount + mf`
before a payment is treated as settled, with input fees from the
wallet's keysets and `mf` priced from its melt methods. Scope is the
amount only; DLEQ/lock validation stays separate.

**`PaymentRequest.includesMint(url)`** is the shared primitive:
mint-list membership compared after URL normalization (unparsable
foreign entries fall back to a raw comparison). The docs' previous
`pr.mints?.includes(myMint)` example had the normalization footgun this
removes.

## Design notes

- Both checks live on `Wallet`/`WalletOps` because that is where the
state lives (mint URL, unit, keyset fees, melt methods). An earlier
draft put the settlement check on `PaymentRequest` and needed three
wallet-state options passed in; review moved it.
- `sendToRequest` returns the builder rather than running it, so
`.includeFees(false)` after the fact remains possible: deliberate
opt-out, not a hole, and it keeps the builder contract uniform.
- The low-level pieces (`feesFor`, `amountToSend`, `isMintListStrict`)
are unchanged and remain the manual-control path; docs now lead with the
one-step call and keep the manual recipe below it.

## Tests

- `sendToRequest`: fee quadrants (listed/unlisted, with/without list),
URL-normalized strict-list matching, unit mismatch, unit-bound melt
rejection, amountless handling, lock honour/reject.
- `isPaymentRequestSatisfied`: net-of-fees formula against a 1000 ppk
keyset (the spec's dust scenario), `mf` inside/outside the mint list,
unit mismatch, amountless expectation.
- `includesMint`: case/trailing-slash normalization, unparsable entries,
absent list.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stacked hold PR is based on top of another PR, so should be merged in sequence. See comments for details.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant