lnurlcash-conformance 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
4
  may add or tighten checks that a previously-passing mint now fails; pin an
5
5
  exact version if you gate CI on the grade.
6
6
 
7
+ ## 0.4.0 - 2026-08-26
8
+
9
+ **The mint-output naming check now reads the spelling LUD-25 actually
10
+ specifies.** The draft names the output with a LUD-12 `comment = hex(h)`,
11
+ advertised as a `commentAllowed` of at least 64; `mintToHash` is the
12
+ parameter form one mint shipped before the comment form was written. The
13
+ suite knew only the latter, so a mint naming notes exactly as the draft
14
+ describes graded as "not offered - a minted note's k1 is the invoice
15
+ preimage". That is the worst kind of wrong answer a conformance suite can
16
+ give: it tells a wallet author the safe mint is the unsafe one.
17
+
18
+ Both spellings are now read from the payRequest and the mint address
19
+ document, probed with a fresh hash each (a mint that binds output ids
20
+ uniquely would rightly refuse the second spelling for naming an output the
21
+ first just took), and reported as `named by comment` / `named by h`.
22
+
23
+ The two are deliberately not probed identically, because the draft's rules
24
+ for them are opposites:
25
+
26
+ - `h` is a parameter invented for naming, so a malformed one MUST be
27
+ refused before an invoice exists. Unchanged.
28
+ - `comment` is plain LUD-12 free text any wallet may send for unrelated
29
+ reasons, so a comment that is not a bare hash MUST fall back to crediting
30
+ `k1=P` rather than be refused, and the mint MUST NOT serve LUD-21 `verify`
31
+ on that fallback - there the preimage is not proof of payment, it is the
32
+ note. Both are now checked, the second as a failure.
33
+
34
+ Only `mintToHash` is expected in both documents. `commentAllowed` is a
35
+ payRequest field; the mint address document is a withdrawRequest, where a
36
+ LUD-12 comment has nowhere to go, so its absence there is no longer
37
+ reported as a disagreement.
38
+
39
+ **A rate-limited grade no longer accuses the mint.** Every mint quote
40
+ issues a real invoice on a real node, so a grader firing a dozen looks like
41
+ the abuse a limiter exists to stop - and probing two spellings nearly
42
+ doubled the count. HTTP 429 was indistinguishable from a spec refusal, so
43
+ the mint was reported as violating whatever check happened to be running
44
+ when the bucket ran dry. 429 is now waited out (honouring `Retry-After`)
45
+ and retried, and reported as an incomplete grade rather than a verdict if
46
+ it persists.
47
+
48
+ The mock mint gains `commentAllowed`, `commentRefusesMalformed` and
49
+ `verifyOnUnnamedMint` to cover all of this.
50
+
7
51
  ## 0.3.0 - 2026-08-24
8
52
 
9
53
  - Add an optional bound-mint settlement receipt for sealed signers. A
@@ -211,7 +211,30 @@ const DEFAULTS = {
211
211
  // 'payRequest,mintAddress' is the one that binds without confirming at
212
212
  // the moment money moves. Read only when mintToHash is on. Accepts an
213
213
  // array or a comma-separated string, so the CLI can pass one.
214
- mintToHashAdvertisedOn: undefined
214
+ mintToHashAdvertisedOn: undefined,
215
+
216
+ // The SAME capability in the spelling LUD-25 actually specifies:
217
+ // `comment = hex(sha256(secret))` (LUD-12), advertised as a
218
+ // `commentAllowed` of at least 64. A number advertises that many
219
+ // characters and reads `comment` as the output name; false says
220
+ // nothing and reads nothing. Independent of mintToHash - a mint may
221
+ // ship either, both, or neither.
222
+ //
223
+ // The two spellings are not symmetric, and that is the whole reason
224
+ // both exist here. `h` is a parameter invented for naming, so a
225
+ // malformed one MUST be refused before an invoice exists. `comment` is
226
+ // plain LUD-12 free text any wallet may send for unrelated reasons, so
227
+ // LUD-25 requires the opposite: fall back to crediting k1=P, never
228
+ // refuse - and MUST NOT serve verify on that fallback, where P is not
229
+ // proof of payment but the note itself.
230
+ commentAllowed: false,
231
+ // non-compliant, commentAllowed on: refuse a comment that is not a
232
+ // bare 32-byte hex hash instead of falling back, so an ordinary LUD-12
233
+ // wallet cannot pay this mint at all
234
+ commentRefusesMalformed: false,
235
+ // non-compliant, commentAllowed on: serve LUD-21 verify even on the
236
+ // no-comment fallback, where the preimage it hands out IS the note
237
+ verifyOnUnnamedMint: false
215
238
  }
216
239
 
217
240
  export const createMockMint = async (options = {}) => {
@@ -466,6 +489,11 @@ export const createMockMint = async (options = {}) => {
466
489
  // the option is on, so a mock started with no options answers
467
490
  // exactly what it always answered.
468
491
  ...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {}),
492
+ // LUD-25: "A mint payLink intending to support this SHOULD
493
+ // advertise a commentAllowed of at least 64". A payRequest field
494
+ // only - the mint address document is a withdrawRequest, where a
495
+ // LUD-12 comment has nowhere to go.
496
+ ...(opts.commentAllowed ? {commentAllowed: opts.commentAllowed} : {}),
469
497
  // A receipt verifier needs the signing key before payment. The
470
498
  // baseline mock remains byte-for-byte unchanged when receipts are
471
499
  // off; a real node-key signer can alternatively be recovered from
@@ -637,6 +665,36 @@ export const createMockMint = async (options = {}) => {
637
665
  }
638
666
  }
639
667
 
668
+ // The LUD-25 spelling. Read after `h` so a mock offering both lets
669
+ // an explicit comment name the output, and deliberately NOT an
670
+ // else-branch: a mint may ship either spelling or both.
671
+ let namedByComment = false
672
+ if (opts.commentAllowed) {
673
+ const sent = q.get('comment')
674
+ if (sent !== null && sent !== '') {
675
+ const wellFormed = /^[0-9a-f]{64}$/i.test(sent)
676
+ if (wellFormed) {
677
+ const h = sent.toLowerCase()
678
+ // Same collision rule the `h` spelling gets: an id already
679
+ // spoken for must never be minted over, whichever parameter
680
+ // named it.
681
+ if (outputIdInUse(h) && !opts.mintToHashAcceptsUsedH) {
682
+ return fail('Invalid or already spent k1.')
683
+ }
684
+ if (!opts.mintToHashIgnoresH) {
685
+ boundTo = h
686
+ namedByComment = true
687
+ }
688
+ } else if (opts.commentRefusesMalformed) {
689
+ // The non-compliant branch. LUD-25 says a comment that is not
690
+ // a bare hash MUST fall back, because plain LUD-12 comments
691
+ // are free text: refusing one turns an ordinary "thanks!"
692
+ // into a mint that cannot be paid.
693
+ return fail('Invalid comment.')
694
+ }
695
+ }
696
+ }
697
+
640
698
  const preimage = bytesToHex(randomBytes(32))
641
699
  const paymentHash = noteId(preimage)
642
700
  const pr = fakeInvoice(amount, preimage)
@@ -647,7 +705,14 @@ export const createMockMint = async (options = {}) => {
647
705
  }
648
706
  invoices.set(paymentHash, invoice)
649
707
  const body = {pr, disposable: false}
650
- if (opts.verify) body.verify = `${origin}/verify/${paymentHash}`
708
+ // LUD-25 gates verify on whether the note was named: in the
709
+ // no-comment fallback the preimage verify would hand out IS the
710
+ // note's entire bearer secret, so a mint offering the comment
711
+ // spelling must withhold verify from quotes that used it. A mock
712
+ // that never heard of the comment spelling keeps answering exactly
713
+ // as it always did.
714
+ const verifySafe = !opts.commentAllowed || namedByComment || opts.verifyOnUnnamedMint
715
+ if (opts.verify && verifySafe) body.verify = `${origin}/verify/${paymentHash}`
651
716
  // Appended last, and only when the quote really was bound, so a
652
717
  // mock that was never told about any of this answers byte for byte
653
718
  // what it always answered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Language-neutral conformance vectors, an adversarial mock mint, and a grader for LNURLcash (LUD-25) implementations",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
package/runner/index.mjs CHANGED
@@ -89,7 +89,17 @@ const isNpub = value => {
89
89
  }
90
90
  }
91
91
 
92
- const get = async (url, timeoutMs = 15_000) => {
92
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
93
+
94
+ // A mint may rate limit, and the good ones do: every mint quote issues a
95
+ // real invoice on a real node, so a grader firing a dozen of them looks
96
+ // exactly like the abuse the limiter exists to stop. An HTTP 429 is the
97
+ // grade failing to complete, NOT a verdict on the mint, so it is waited
98
+ // out (honouring Retry-After) and retried. Without this the mint is
99
+ // accused of whatever the check happened to be probing when the bucket
100
+ // ran dry - a conforming mint graded as broken, which is worse than no
101
+ // grade at all.
102
+ const get = async (url, timeoutMs = 15_000, retriesLeft = 2) => {
93
103
  const res = await fetch(url.toString(), {signal: AbortSignal.timeout(timeoutMs)})
94
104
  const text = await res.text()
95
105
  let body
@@ -98,6 +108,17 @@ const get = async (url, timeoutMs = 15_000) => {
98
108
  } catch {
99
109
  throw new Error(`response was not JSON: ${text.slice(0, 120)}`)
100
110
  }
111
+ if (res.status === 429) {
112
+ if (retriesLeft > 0) {
113
+ const after = Number(res.headers.get('retry-after'))
114
+ const waitMs = Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 10_000, 30_000)
115
+ await sleep(waitMs + 250)
116
+ return get(url, timeoutMs, retriesLeft - 1)
117
+ }
118
+ throw soft(
119
+ `the mint rate limited this grade (HTTP 429${body?.reason ? `: ${body.reason}` : ''}) - space the run out and grade again; nothing here is a verdict on the mint`
120
+ )
121
+ }
101
122
  return body
102
123
  }
103
124
 
@@ -449,119 +470,186 @@ export const gradeMint = async (payUrl, report) => {
449
470
  return detail
450
471
  })
451
472
 
452
- // Naming the note you are buying. Optional, outside LUD-25, and soft in
453
- // the direction that matters: a mint that says nothing anywhere and
454
- // ignores `h` mints exactly what the draft describes, which is every
455
- // mint today and not a defect. What is graded is a mint that CLAIMS the
473
+ // Naming the note you are buying. No longer outside LUD-25: the draft
474
+ // spells it `comment = hex(sha256(secret))` (Protecting a freshly minted
475
+ // note from a preimage race), advertised as a `commentAllowed` of at
476
+ // least 64 - enough for a hex-encoded 32-byte hash. `mintToHash` is the
477
+ // parameter form one mint shipped before the comment form was written.
478
+ // Both are read here: a suite that knows only one spelling reports a
479
+ // mint that names notes perfectly well in the other as not offering it
480
+ // at all, which is worse than silence - it tells a wallet author the
481
+ // safe mint is the unsafe one.
482
+ //
483
+ // Soft in the direction that matters: a mint that says nothing anywhere
484
+ // and names nothing mints exactly what the draft's fallback describes,
485
+ // which is not a defect. What is graded is a mint that CLAIMS the
456
486
  // capability, because a wallet then stops rotating on sight and trusts
457
487
  // the mint to bind the note.
458
488
  //
459
- // The claim lives in three places and they say different things. The
460
- // payRequest's `mintToHash: true` means "I accept an h on my pay
461
- // callback", and since every mint publishes a payRequest while the mint
462
- // address document is experimental, that is the one to decide from. The
463
- // mint address document repeats it, as corroboration. The pay
464
- // callback's own response echoes it when THAT quote was bound, which is
465
- // the one that matters at the moment money moves: the other two can be
466
- // cached or stale. Anything that is not exactly the boolean true is no.
467
- await report.check('accepts an output hash on the mint quote (mintToHash, optional)', async () => {
489
+ // The claim lives in three places. The payRequest is the one to decide
490
+ // from, since every mint publishes one while the mint address document
491
+ // is experimental; the mint address repeats it as corroboration; and the
492
+ // pay callback's own response echoes `mintToHash` when THAT quote was
493
+ // bound. LUD-25 defines no echo for the comment spelling, so a missing
494
+ // echo is only held against a mint claiming `mintToHash`.
495
+ //
496
+ // The two spellings are NOT symmetric, and that is why this cannot be
497
+ // one probe with two parameter names:
498
+ //
499
+ // `h` is a parameter invented for exactly this purpose, so a malformed
500
+ // one is a wallet error and MUST be refused before an invoice exists.
501
+ //
502
+ // `comment` is plain LUD-12 free text that any wallet may send for
503
+ // unrelated reasons, so LUD-25 requires the opposite (line 80 of the
504
+ // draft): a comment that is not a bare hex-encoded 32-byte hash MUST
505
+ // fall back to crediting the note as k1=P, never be refused - and on
506
+ // that fallback the mint MUST NOT serve LUD-21 verify, because there
507
+ // P is not proof of payment, it is the entire note.
508
+ await report.check('accepts a named output on the mint quote (LUD-25 comment / mintToHash, optional)', async () => {
468
509
  const amount = Math.max(pay.minSendable, 1000)
469
- const quoteAt = async (h, msat = amount) => {
510
+
511
+ // LUD-25: "A mint payLink intending to support this SHOULD advertise a
512
+ // commentAllowed of at least 64". Anything shorter cannot carry the
513
+ // hash at all, so it is not this capability whatever else it is.
514
+ const spellingsOf = source => {
515
+ const found = []
516
+ if (source?.mintToHash === true) found.push('h')
517
+ if (Number.isFinite(source?.commentAllowed) && source.commentAllowed >= 64) found.push('comment')
518
+ return found
519
+ }
520
+ const quoteAt = async (spelling, value, msat = amount) => {
470
521
  const url = new URL(pay.callback)
471
522
  url.searchParams.set('amount', String(msat))
472
- url.searchParams.set('h', h)
523
+ url.searchParams.set(spelling, value)
473
524
  return get(url)
474
525
  }
526
+ const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
527
+
528
+ const advertised = spellingsOf(pay)
529
+ const corroborated = spellingsOf(mintAddress)
530
+ const claimed = [...new Set([...advertised, ...corroborated])]
475
531
 
476
532
  // Asked of every mint, advertisement or not: a mint that echoes the
477
533
  // capability without publishing it is still claiming it, and a wallet
478
534
  // reading the echo would believe it. Nothing pays the invoice that
479
535
  // comes back, so this is read-only - an unpaid quote costs a mint an
480
536
  // invoice and nothing else.
481
- const secret = bytesToHex(randomBytes(32))
482
- const h = noteId(secret)
483
- const bound = await quoteAt(h)
484
- const advertised = pay.mintToHash === true
485
- const corroborated = mintAddress?.mintToHash === true
486
- const echoed = bound.mintToHash === true
487
-
488
- if (!advertised && !corroborated && !echoed) {
537
+ // A fresh secret per spelling, never one shared between them: a mint
538
+ // that accepts both and binds an output id uniquely - as it should,
539
+ // and as the repeat probe below asserts - would rightly refuse the
540
+ // second spelling for naming an output the first just took.
541
+ const probed = claimed.length > 0 ? claimed : ['h']
542
+ const named = Object.fromEntries(
543
+ probed.map(spelling => {
544
+ const secret = bytesToHex(randomBytes(32))
545
+ return [spelling, {secret, h: noteId(secret)}]
546
+ })
547
+ )
548
+ const bound = {}
549
+ for (const spelling of probed) bound[spelling] = await quoteAt(spelling, named[spelling].h)
550
+ const echoedIn = probed.filter(spelling => bound[spelling].mintToHash === true)
551
+
552
+ if (claimed.length === 0 && echoedIn.length === 0) {
553
+ const refused = probed.map(spelling => bound[spelling]).find(body => body.status === 'ERROR')
489
554
  throw soft(
490
- bound.status === 'ERROR'
491
- ? `not offered, and an h on the quote was refused outright: ${bound.reason}`
555
+ refused
556
+ ? `not offered, and a named output was refused outright: ${refused.reason}`
492
557
  : 'not offered - a minted note\'s k1 is the invoice preimage, which every routing node on the payment path learns, so a wallet must claim and rotate the instant it settles'
493
558
  )
494
559
  }
495
560
 
496
561
  const problems = []
497
562
  const claimedBy = [
498
- advertised && 'the payRequest',
499
- corroborated && 'the mint address',
500
- echoed && 'the quote itself'
563
+ advertised.length > 0 && `the payRequest (${advertised.join(', ')})`,
564
+ corroborated.length > 0 && `the mint address (${corroborated.join(', ')})`,
565
+ echoedIn.length > 0 && 'the quote itself'
501
566
  ].filter(Boolean)
502
567
 
503
- assert(
504
- bound.status !== 'ERROR',
505
- `claims mintToHash (${claimedBy.join(', ')}) and refused a well-formed h: ${bound.reason}`
506
- )
507
- assert(typeof bound.pr === 'string', 'no pr in the response')
508
- const invoiced = invoiceAmountMsat(bound.pr)
509
- if (invoiced !== null) {
510
- assert(invoiced === amount, `asked for ${amount} msat, invoiced ${invoiced} msat`)
568
+ for (const spelling of probed) {
569
+ const body = bound[spelling]
570
+ assert(
571
+ body.status !== 'ERROR',
572
+ `claims this capability (${claimedBy.join(', ')}) and refused a well-formed hash sent as ${spelt(spelling)}: ${body.reason}`
573
+ )
574
+ assert(typeof body.pr === 'string', `no pr in the response to ${spelt(spelling)}`)
575
+ const invoiced = invoiceAmountMsat(body.pr)
576
+ if (invoiced !== null) {
577
+ assert(invoiced === amount, `asked for ${amount} msat, invoiced ${invoiced} msat`)
578
+ }
511
579
  }
512
580
 
513
581
  // A quote is not a note. Crediting one before its invoice settles
514
582
  // would hand out money for nothing.
515
583
  const withdrawUrl = pay.withdrawLink ? fromLud17(pay.withdrawLink) : null
516
584
  if (withdrawUrl) {
517
- const probe = new URL(withdrawUrl)
518
- probe.searchParams.set('k1', secret)
519
- const early = await get(probe)
520
- assert(
521
- early.status === 'ERROR',
522
- 'the note exists before anything paid for it - a quote is not a note'
523
- )
585
+ for (const spelling of probed) {
586
+ const probe = new URL(withdrawUrl)
587
+ probe.searchParams.set('k1', named[spelling].secret)
588
+ const early = await get(probe)
589
+ assert(
590
+ early.status === 'ERROR',
591
+ 'the note exists before anything paid for it - a quote is not a note'
592
+ )
593
+ }
524
594
  }
525
595
 
526
- // A malformed h must be refused BEFORE an invoice exists. A wallet
527
- // that pays a quote the mint was always going to reject has bought
528
- // nothing, and the mint keeps the sats.
529
- //
530
596
  // Malformed means not 32 bytes of hex, in any casing. Upper case is a
531
597
  // spelling, not a defect, and it is deliberately not probed: a WALLET
532
598
  // MUST send lowercase and every client here does, a SERVICE SHOULD
533
599
  // normalise before comparing, and one that refuses upper case outright
534
600
  // is strict rather than wrong - the wallet learns before it pays.
535
- for (const [what, value] of [
601
+ const malformed = [
536
602
  ['not hex', 'z'.repeat(64)],
537
603
  ['a character short', '0'.repeat(63)],
538
604
  ['a character long', '0'.repeat(65)],
539
605
  ['empty', '']
540
- ]) {
541
- const body = await quoteAt(value)
542
- assert(
543
- body.status === 'ERROR' && !body.pr,
544
- `issued an invoice for an h that is ${what} - a wallet would pay for a quote this mint cannot honour`
545
- )
606
+ ]
607
+
608
+ for (const spelling of probed) {
609
+ for (const [what, value] of malformed) {
610
+ const body = await quoteAt(spelling, value)
611
+ if (spelling === 'h') {
612
+ // A wallet that pays a quote the mint was always going to reject
613
+ // has bought nothing, and the mint keeps the sats.
614
+ assert(
615
+ body.status === 'ERROR' && !body.pr,
616
+ `issued an invoice for an h that is ${what} - a wallet would pay for a quote this mint cannot honour`
617
+ )
618
+ continue
619
+ }
620
+ // The comment spelling, where the draft says the opposite.
621
+ assert(
622
+ body.status !== 'ERROR',
623
+ `refused a comment that is ${what} (${body.reason}) - LUD-25 requires the no-name fallback here, because plain LUD-12 comments are free text and any wallet may send one`
624
+ )
625
+ assert(
626
+ !body.verify,
627
+ `served a LUD-21 verify URL on a quote whose comment is ${what}, which the draft credits as k1=P - verify then hands the note itself to anyone holding the invoice, not merely proof that it was paid`
628
+ )
629
+ }
546
630
  }
547
631
 
548
- // The three claims must agree. None of these disagreements loses
549
- // anyone money on its own - a wallet reading a missing field as false
550
- // falls back to the preimage flow, which is safe - so each is named
551
- // rather than failed. Whether the mint really binds is the one thing
552
- // this check cannot see, because that needs a settlement: it is
553
- // graded separately, and failed rather than warned.
554
- if (!echoed) {
632
+ // The claims must agree. None of these disagreements loses anyone
633
+ // money on its own - a wallet reading a missing field as false falls
634
+ // back to the preimage flow, which is safe - so each is named rather
635
+ // than failed. Whether the mint really binds is the one thing this
636
+ // check cannot see, because that needs a settlement: it is graded
637
+ // separately, and failed rather than warned.
638
+ if (advertised.includes('h') && echoedIn.length === 0) {
555
639
  problems.push(
556
640
  'bound quotes carry no mintToHash in the response, so a wallet cannot confirm at the one moment it is worth confirming, and falls back to racing the preimage'
557
641
  )
558
642
  }
559
- if (echoed && !advertised) {
643
+ if (echoedIn.length > 0 && advertised.length === 0) {
560
644
  problems.push(
561
645
  'echoes mintToHash on a quote but does not advertise it on the payRequest, which is the endpoint every mint publishes and the one a wallet decides from'
562
646
  )
563
647
  }
564
- if (advertised && mintAddress && !corroborated) {
648
+ // Only the `mintToHash` spelling belongs in both documents. LUD-25 asks
649
+ // a mint *payLink* to advertise `commentAllowed`, and the mint address
650
+ // document is a withdrawRequest, where a LUD-12 comment has nowhere to
651
+ // go - its absence there is correct, not a disagreement.
652
+ if (advertised.includes('h') && mintAddress && !corroborated.includes('h')) {
565
653
  problems.push('the payRequest advertises mintToHash and the mint address document does not')
566
654
  }
567
655
 
@@ -573,15 +661,17 @@ export const gradeMint = async (payUrl, report) => {
573
661
  // rule the withdraw callback already enforces.
574
662
  const otherAmount = Math.min(pay.maxSendable, amount * 2)
575
663
  if (otherAmount !== amount) {
576
- const twice = await quoteAt(h, otherAmount)
577
- if (twice.status !== 'ERROR') {
578
- problems.push(
579
- 'issued a second quote against an output hash it had already bound - whichever payment settles first takes the id, and the other payer has bought nothing'
580
- )
664
+ for (const spelling of probed) {
665
+ const twice = await quoteAt(spelling, named[spelling].h, otherAmount)
666
+ if (twice.status !== 'ERROR') {
667
+ problems.push(
668
+ `issued a second quote against an output already named by ${spelt(spelling)} - whichever payment settles first takes the id, and the other payer has bought nothing`
669
+ )
670
+ }
581
671
  }
582
672
  }
583
673
  if (problems.length > 0) throw soft(problems.join('; '))
584
- return `claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and refused four malformed ones`
674
+ return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
585
675
  })
586
676
 
587
677
  // The payRequest, with the mint address the checks above fetched hung