feat(utils): findCashuPayload to locate tokens and payment requests in text - #967
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #967 +/- ##
==========================================
+ Coverage 95.92% 95.97% +0.04%
==========================================
Files 56 56
Lines 5961 5984 +23
Branches 1516 1521 +5
==========================================
+ Hits 5718 5743 +25
+ Misses 103 102 -1
+ Partials 140 139 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds a new public helper findCashuPayload(text) to locate the first embedded Cashu token (cashuA/cashuB) or payment request (creqA/creqb1/CREQB1) inside arbitrary text, validate it by decoding, and return the encoded payload verbatim for wallets to process.
Changes:
- Implement
findCashuPayload+ exportedCashuPayloadKindinsrc/utils/core.tswith a capped number of decode attempts. - Add
MAX_PAYLOAD_DECODE_ATTEMPTSlimit insrc/utils/limits.ts. - Add tests and documentation for token/payment-request detection in pasted text and URLs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/utils/core.test.ts | Adds test coverage for findCashuPayload behavior (wrappers, punctuation, ordering, cap behavior, and URL-path regression). |
| src/utils/limits.ts | Introduces MAX_PAYLOAD_DECODE_ATTEMPTS to cap decode validations during scanning. |
| src/utils/core.ts | Implements CashuPayloadKind, scanner regexes, and the findCashuPayload decode-to-validate search loop. |
| etc/cashu-ts.api.md | Updates API report to include the new exported type and function. |
| docs-src/usage/usage_index.md | Updates Helpers page description to include token detection. |
| docs-src/usage/helpers.md | Documents findCashuPayload and shows a basic usage example. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
robwoodgate
left a comment
There was a problem hiding this comment.
Adversarial pass with executed probes; details inline (this supersedes my earlier summary comment, now removed). Sequencing note: #986 is queued ahead of this PR and makes junk-suffixed candidates fail uniformly on both platforms. Previously they were returned with the junk included (v4/creqA), missed entirely (v3), or platform-dependent (Node Buffer vs browser atob). Please rebase once it merges.
…latforms (#986) Base64 and CBOR payload decoding now gives the same verdict on Node and in browsers: well-formed input decodes, malformed input throws `CTSError`. ## Why The two platform decoders (`Buffer`, `atob`) disagree on some edge cases, so the same string could decode on one platform and fail on the other. #967 validates scanned text candidates by decoding them, so verdicts must be uniform across platforms; this PR is sequenced ahead of it. ## Changes - `Bytes.fromBase64` normalizes (trim, whitespace strip, base64url mapping, padding) and validates in one place, then decodes the validated string, so both backends see identical input. - `decodeCBOR` requires a payload to consume the whole buffer. - `encodeBase64ToJson` delegates to `Bytes.fromBase64` directly; the redundant private pre-mapping helper is removed and the base64url output mapping has a single implementation. - Tests run a shared accept/reject corpus under both decoder backends. ## Impact `getDecodedToken`, `getDecodedTokenBinary` and `PaymentRequest.fromEncodedRequest` throw `CTSError` for malformed payloads on every platform. Line-wrapped, unpadded, and edge-whitespace input still decodes. Full suite and prtasks pass.
…ts in text Two-stage: prefix-anchored charset candidates, then decode-to-validate via handleTokens / PaymentRequest.fromEncodedRequest, first valid match by position wins. Each pattern is a literal prefix plus one character class and a failed candidate re-enters at matchIndex + 1 under a decode-attempt cap, so the scan stays linear on attacker-influenced input. Token and creqA charsets are base64url only. Including `+` and `/` let a token in a URL path run on into the following segment, and the decoders accept the trailing junk rather than reject it, so the payload came back corrupted; a regression test pins this.
Adds the section to the Helpers page and broadens the Helpers row in the usage index, which previously named mint URL normalization as the page's only content. The example uses the idioms the other pages already document (getDecodedToken with keyset IDs, decodePaymentRequest).
… docs The decode-attempt cap bounds how many candidates are tried but not the cost of each, so a hostile paste of millions of charset characters produced one enormous match for the base64/CBOR decoders. Scan quantifiers are now bounded by MAX_PAYLOAD_LENGTH (256 KiB, ~90x the largest token in the test tree). Also corrects two doc comments that drifted from the implementation: the scanner class is base64url only, not both alphabets, and the NUT-26 prefix is creqb1/CREQB1 rather than creqB.
Bound a single candidate at MAX_PAYLOAD_LENGTH, lowered to 1 MiB: V8 matches a bounded quantifier recursively, so a match of a few million characters overflows the stack inside exec, which sits outside the decode try/catch. The previous cap test passed with the bound removed; it now pads a v3 token either side of the limit so only length decides the outcome. Canonicalise case-insensitive matches to lowercase, so a mixed-case creqb1 paste no longer hands callers spec-invalid bech32m. Guard the argument at the public boundary, since exec would otherwise coerce a non-string and scan it. Docs: justify the base64url class from NUT-00 and the delimiters it stops at rather than from decoder leniency, record the other two causes of a null return, show getTokenMetadata alongside getDecodedToken since short keyset IDs only resolve against keysets the caller holds, and name payment requests in the usage index row. Hoist the duplicated token fixtures in the test file.
Now that base64 and CBOR decoding validate strictly, a candidate that carries a base64url suffix fails rather than decoding its valid prefix and returning the junk attached. Covers v4, v3 and creqA, since the three previously failed in three different ways.
4e9291d to
c4b50dd
Compare
robwoodgate
left a comment
There was a problem hiding this comment.
Awesome job, great utility, thanks @KvngMikey .
This will be v5 only, as it may change decoding experience on some platforms, throwing cases that previously passed etc.
no problem, as soon as V5 lands, i can make the downstream PRs to capture this. |
[5.0.0-rc.8](v5.0.0-rc.7...v5.0.0-rc.8) (2026-08-27) ### Features * **model:** expose NUT-06 urls, time and tos_url on MintInfo ([#1003](#1003)) ([832a71c](832a71c)) * **utils:** findCashuPayload to locate tokens and payment requests in text ([#967](#967)) ([d094499](d094499)) * **wallet:** add LockBuilder and asLocked as forward-compatible names ([#995](#995)) ([4d69fe7](4d69fe7)) * **wallet:** add swap preview serialize helpers and persistence docs ([#972](#972)) ([02f0bfe](02f0bfe)) ### Bug Fixes * **transport:** split request option precedence by option class ([#968](#968)) ([603553d](603553d)) * **utils:** make base64 and CBOR payload decoding consistent across platforms ([#986](#986)) ([59d36b1](59d36b1)) * **wallet:** keep large proofs in the close-match selection pool ([#1001](#1001)) ([a9ade37](a9ade37))
TL;DR
Adds
findCashuPayload(text), which locates the first cashu token or payment request inside arbitrary text and returns the encoded payload verbatim. Wallets can drop their hand-rolled URL-prefix tables and regexes.Fixes #849
Why
Wallets receive payloads buried in whatever the user pasted: chat messages, clipboard blobs,
bitcoin:URI parameters, wallet-site URLs.getDecodedTokenalready handles the clean cases (it stripscashu:,cashu://,web+cashu://), but nothing in the library locates a payload inside text that contains other content. Every wallet ends up maintaining its own curated table of known wallet URL prefixes plus a regex, and those tables rot as the ecosystem churns. Scanning for the payload itself subsumes every wrapper form, so there is no per-wallet knowledge to maintain.Changes
Two-stage, exactly as specced in #849:
cashuA/cashuB,creqA,creqb1), each a literal prefix plus a single character-class quantifier. No alternation, no nested quantifiers, so no catastrophic backtracking.creqb1(NUT-26) is matched case-insensitively because QR alphanumeric mode emits the uppercase form.handleTokensfor tokens,PaymentRequest.fromEncodedRequestfor requests. A regex alone false-positives; the decoder is the authority. First candidate that decodes, by position, wins.Notes for review:
[A-Za-z0-9=_-]), not base64. This is the one place the implementation departs from the issue text, which says "base64/base64url charset". Including+and/is actively wrong: a token in a URL path (example.com/cashuB…/more) has the trailing segment swallowed into the candidate, and base64 decoding ignores what follows the padding while CBOR ignores trailing bytes, so the candidate decodes successfully and the payload comes back ascashuB…/more. The decoders validate the prefix, not the boundary. The cashu specs are base64url throughout (NUT-00 tokens, NUT-18creqA), so nothing spec-compliant is lost. Regression test pins all three payload types. Happy to revisit if you want the wider charset back.matchIndex + 1, not match end, so a payload swallowed inside a failed greedy span is still found, thecashuB-inside-cashuAexample is a test case.MAX_PAYLOAD_DECODE_ATTEMPTS = 16inutils/limits.ts, following the house pattern there. Bounds the adversarial prefix-stuffed case to linear total work. The number is a suggestion and is a one-integer change if needed.handleTokensviaremovePrefix: keyset-free, and avoidsgetDecodedToken's 5.0keysetIdsrequirement, which the finder has no business needing.CashuPayloadKindis exported as a named alias for'token' | 'paymentRequest'so consumers can type against it. Structurally identical to the inline signature in the issue.CREQB1…comes back uppercase — test case).Docs: new section on the Helpers page; the usage-index Helpers row previously named mint URL normalization as the page's only content, so it is broadened.
Impact
Purely additive, one new function and one new exported type, no existing behavior touched. The API report diff is those two entries and nothing else.