Class IssuanceWarrant
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:
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic enumThe four shapes a warrant can take. -
Method Summary
Modifier and TypeMethodDescriptionstatic booleancoversFaceValue(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 booleanverifyMerchant(@NonNull String issuerId, long saleTotalMinor, int faceDecimals, @NonNull String unit, @NonNull String saleNonce, @NonNull String signatureHex, long couponFaceMinor) Verifies amerchantwarrant offline, against the stall's own key.
-
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.faceDecimalsandunit— 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, hexsaleTotalMinor- the total being sold, in minor units, which every coupon from this sale is bounded byfaceDecimals- decimal places for the unitunit- the currencysaleNonce- 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 amerchantwarrant 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'sissuertagsaleTotalMinor- the total the warrant claims to authorisefaceDecimals- decimal placesunit- the currencysaleNonce- the per-sale nonce carried in the warrantsignatureHex- the stall's BIP-340 signature over the digestcouponFaceMinor- 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.
-