Class IssuanceWarrant

java.lang.Object
xyz.tcheeric.cashu.voucher.domain.IssuanceWarrant

public final class IssuanceWarrant extends Object
What, outside the issuing service, authorised an issuance.

Why this exists

A voucher carries exactly one signature and it belongs to the issuing service. The stall named in issuer signs nothing, so a coupon asserts "the service says stall X issued this" and carries no evidence that stall X did. Whoever holds the service key can mint any face value in any stall's name and the result is byte-for-byte indistinguishable from a genuine coupon.

The obvious remedy — have the stall countersign — authenticates the debtor and leaves the amount to whoever holds the signing key. That is the wrong half. The harm is not that a coupon names the wrong stall, it is that an unbacked claim exists against a stall that was never paid. So a warrant binds a digest that covers the face value to evidence from outside the issuing service.

A warrant attests a SALE, not a voucher

This is the design decision worth understanding before reading the digest.

One Sell action can mint several coupons: a requested quantity, and each of those auto-splitting again when a single coupon would be too large to receive. The split is a server-side decision based on token size, so the device cannot predict how many coupons there will be, and none of their voucher_ids exist at the moment the merchant signs. A per-voucher digest therefore cannot be produced by the device at all without a second round trip over a count it does not know.

So the digest covers the sale total, and every coupon minted from that sale carries the same warrant. Verification is a ceiling test: this coupon's face value must not exceed the warranted total.

What that costs, stated plainly. A verifier can check that a sale of this size was authorised by the stall. It cannot check, from the warrant alone, that this particular coupon was part of that sale rather than an extra one minted alongside it. Relating a coupon to its sale relies on the server's split arithmetic, which is inside the trust boundary this scheme exists to reach outside of. The amount is still bound, and the amount is what the attack turns on, so this is a real but bounded weakening rather than a hole.

The ceiling is also why splits keep working

A coupon that is later split keeps the signed bytes it was issued with, so it keeps its warrant, and its face value only ever goes down. A ceiling test stays true across every split without anyone re-signing anything — which matters because the stall is not present at split time and could not re-sign if it were asked to.

See Also:
  • invalid reference
    VoucherTags#ISSUANCE_WARRANT
  • Nested Class Summary

    Nested Classes
    Modifier and Type
    Class
    Description
    static enum 
    The four shapes a warrant can take.
  • Method Summary

    Modifier and Type
    Method
    Description
    static boolean
    coversFaceValue(long saleTotalMinor, long couponFaceMinor)
    Whether a warranted sale total covers a coupon's face value.
    static byte[]
    saleDigest(@NonNull String issuerId, long saleTotalMinor, int faceDecimals, @NonNull String unit, @NonNull String saleNonce)
    The digest a warrant signs: one sale, by one stall, for one total.
    static boolean
    verifyMerchant(@NonNull String issuerId, long saleTotalMinor, int faceDecimals, @NonNull String unit, @NonNull String saleNonce, @NonNull String signatureHex, long couponFaceMinor)
    Verifies a merchant warrant offline, against the stall's own key.

    Methods inherited from class java.lang.Object

    clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait
  • Method Details

    • saleDigest

      public static byte[] saleDigest(@NonNull @NonNull String issuerId, long saleTotalMinor, int faceDecimals, @NonNull @NonNull String unit, @NonNull @NonNull String saleNonce)
      The digest a warrant signs: one sale, by one stall, for one total.

      Every component is here for a reason:

      • issuerId — which stall is on the hook. Without it a warrant is transferable between stalls.
      • saleTotalMinor — the field the attack turns on. A warrant that does not cover the amount authenticates the debtor and leaves the sum to the attacker, which is the whole reason a plain countersignature was rejected.
      • faceDecimals and unit — 2500 is not a number, it is EUR 25.00 or XAF 2500. Omitting these makes amounts comparable across currencies that are not comparable.
      • saleNonce — a device-chosen nonce making two identical sales distinct. Without it a merchant selling EUR 25.00 twice produces one digest twice, and a warrant from the first sale verifies against coupons from the second.

      Note what is absent: voucher_id. See the class comment.

      Parameters:
      issuerId - the stall's Nostr pubkey, hex
      saleTotalMinor - the total being sold, in minor units, which every coupon from this sale is bounded by
      faceDecimals - decimal places for the unit
      unit - the currency
      saleNonce - a per-sale nonce chosen by the signing device
      Returns:
      the 32-byte digest to sign
    • verifyMerchant

      public static boolean verifyMerchant(@NonNull @NonNull String issuerId, long saleTotalMinor, int faceDecimals, @NonNull @NonNull String unit, @NonNull @NonNull String saleNonce, @NonNull @NonNull String signatureHex, long couponFaceMinor)
      Verifies a merchant warrant offline, against the stall's own key.

      Two checks, and the second is the one people forget. The signature must verify, AND the coupon's face value must not exceed the warranted sale total. A signature that verifies over a EUR 25.00 sale says nothing about a EUR 5,000.00 coupon that happens to carry it.

      Parameters:
      issuerId - the stall's pubkey, hex, from the voucher's issuer tag
      saleTotalMinor - the total the warrant claims to authorise
      faceDecimals - decimal places
      unit - the currency
      saleNonce - the per-sale nonce carried in the warrant
      signatureHex - the stall's BIP-340 signature over the digest
      couponFaceMinor - THIS coupon's face value, which must fit inside the sale
      Returns:
      true only if the signature verifies and the coupon fits under the ceiling
    • coversFaceValue

      public static boolean coversFaceValue(long saleTotalMinor, long couponFaceMinor)
      Whether a warranted sale total covers a coupon's face value.

      This is a ceiling, so the test is <= and the direction matters. A coupon worth less than the sale is the ordinary case: sales split into several coupons, and coupons shrink further when partly spent. A coupon worth MORE than the sale it claims to come from is the forgery this whole scheme exists to refuse.

      Negative values are refused rather than clamped. A negative face value is not a small coupon, it is a coupon whose encoding we do not understand.