Skip to main content

nula_core/nips/
nip15.rs

1//! [NIP-15] Nostr Marketplace.
2//!
3//! NIP-15 lets a `merchant` publish *stalls* (`kind: 30017`), *products*
4//! (`kind: 30018`) and *auctions* (`kind: 30020`) as addressable events whose
5//! `content` carries a JSON payload, and exchange *orders* / *payment
6//! requests* / *payment verifications* with a `customer` over encrypted direct
7//! messages (NIP-04 / NIP-17).
8//!
9//! # Relationship to upstream `rust-nostr`
10//!
11//! This module is a superset of the upstream implementation and corrects two
12//! spec deviations:
13//!
14//! - **Product `d` tag.** NIP-15 addresses a product by its *own* id, so the
15//!   `kind: 30018` event carries `["d", <product id>]`. Upstream emits the
16//!   *stall* id here; this module uses the product id per the spec.
17//! - **Order item field.** The spec names the order line item field
18//!   `product_id`; this module matches that (upstream uses `id`).
19//!
20//! It also adds [`AuctionData`] (typed `kind: 30020` support, absent
21//! upstream) and `from_event` parsers for every addressable type.
22//!
23//! [NIP-15]: https://github.com/nostr-protocol/nips/blob/master/15.md
24//!
25//! # Example
26//!
27//! ```
28//! use nula_core::nips::nip15::{ProductData, StallData};
29//! use nula_core::Keys;
30//!
31//! let keys = Keys::generate().unwrap();
32//!
33//! let stall = StallData::new("stall-1", "My Stall", "USD");
34//! let stall_event = stall.to_event_builder().unwrap().sign_with_keys(&keys).unwrap();
35//! assert_eq!(StallData::from_event(&stall_event).unwrap().id, "stall-1");
36//!
37//! let product = ProductData::new("prod-1", "stall-1", "Widget", "USD").price(9.99);
38//! let product_event = product.to_event_builder().unwrap().sign_with_keys(&keys).unwrap();
39//! // Addressable by the *product* id, per NIP-15.
40//! assert_eq!(product_event.tags.identifier(), Some("prod-1"));
41//! ```
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::event::{Alphabet, Event, EventBuilder, Kind, Tag};
47use crate::key::PublicKey;
48use crate::util::json::JsonUtil;
49
50/// A shipping zone offered by a stall.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct ShippingMethod {
53    /// Merchant-defined shipping zone id (echoed back in customer orders).
54    pub id: String,
55    /// Human-readable name of the shipping zone.
56    #[serde(skip_serializing_if = "Option::is_none", default)]
57    pub name: Option<String>,
58    /// Base cost for this zone, in the stall's currency.
59    pub cost: f64,
60    /// Regions covered by this zone.
61    #[serde(default)]
62    pub regions: Vec<String>,
63}
64
65impl ShippingMethod {
66    /// Create a shipping method with the given id and base cost.
67    #[must_use]
68    pub fn new<S>(id: S, cost: f64) -> Self
69    where
70        S: Into<String>,
71    {
72        Self {
73            id: id.into(),
74            name: None,
75            cost,
76            regions: Vec::new(),
77        }
78    }
79
80    /// Set the display name.
81    #[must_use]
82    pub fn name<S>(mut self, name: S) -> Self
83    where
84        S: Into<String>,
85    {
86        self.name = Some(name.into());
87        self
88    }
89
90    /// Set the covered regions.
91    #[must_use]
92    pub fn regions(mut self, regions: Vec<String>) -> Self {
93        self.regions = regions;
94        self
95    }
96
97    /// Project to the per-product [`ShippingCost`] that references this zone.
98    #[must_use]
99    pub fn to_shipping_cost(&self) -> ShippingCost {
100        ShippingCost {
101            id: self.id.clone(),
102            cost: self.cost,
103        }
104    }
105}
106
107/// A per-product surcharge for a given shipping zone.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct ShippingCost {
110    /// Id of the [`ShippingMethod`] this surcharge applies to.
111    pub id: String,
112    /// Extra cost added on top of the zone's base cost.
113    pub cost: f64,
114}
115
116/// Stall payload (`kind: 30017` content).
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct StallData {
119    /// Merchant-generated stall id (also the `d` tag).
120    pub id: String,
121    /// Stall name.
122    pub name: String,
123    /// Optional stall description.
124    #[serde(skip_serializing_if = "Option::is_none", default)]
125    pub description: Option<String>,
126    /// Currency used across the stall (ISO 4217 or `"BTC"` / `"SAT"`).
127    pub currency: String,
128    /// Shipping zones offered by the stall.
129    #[serde(default)]
130    pub shipping: Vec<ShippingMethod>,
131}
132
133impl StallData {
134    /// Create a stall with the mandatory fields.
135    #[must_use]
136    pub fn new<S>(id: S, name: S, currency: S) -> Self
137    where
138        S: Into<String>,
139    {
140        Self {
141            id: id.into(),
142            name: name.into(),
143            description: None,
144            currency: currency.into(),
145            shipping: Vec::new(),
146        }
147    }
148
149    /// Set the stall description.
150    #[must_use]
151    pub fn description<S>(mut self, description: S) -> Self
152    where
153        S: Into<String>,
154    {
155        self.description = Some(description.into());
156        self
157    }
158
159    /// Set the shipping zones.
160    #[must_use]
161    pub fn shipping(mut self, shipping: Vec<ShippingMethod>) -> Self {
162        self.shipping = shipping;
163        self
164    }
165
166    /// Build the `kind: 30017` [`EventBuilder`].
167    ///
168    /// # Errors
169    ///
170    /// Returns [`serde_json::Error`] if the payload cannot be serialized
171    /// (e.g. a shipping cost is `NaN`).
172    pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
173        let content = self.try_to_json()?;
174        Ok(EventBuilder::new(Kind::MARKETPLACE_STALL, content).tag(Tag::d(self.id.clone())))
175    }
176
177    /// Parse a [`StallData`] from a `kind: 30017` [`Event`].
178    ///
179    /// # Errors
180    ///
181    /// Returns [`MarketplaceError`] if the kind is wrong or the JSON content
182    /// is malformed.
183    pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
184        expect_kind(event, Kind::MARKETPLACE_STALL)?;
185        Ok(Self::from_json(&event.content)?)
186    }
187}
188
189/// Product payload (`kind: 30018` content).
190///
191/// `categories` is carried in `t` tags rather than the JSON body, so it is
192/// skipped during serialization and re-populated by [`ProductData::from_event`].
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct ProductData {
195    /// Merchant-generated product id (also the `d` tag).
196    pub id: String,
197    /// Id of the stall this product belongs to.
198    pub stall_id: String,
199    /// Product name.
200    pub name: String,
201    /// Optional product description.
202    #[serde(skip_serializing_if = "Option::is_none", default)]
203    pub description: Option<String>,
204    /// Optional image URLs.
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub images: Option<Vec<String>>,
207    /// Currency (matches the stall's currency).
208    pub currency: String,
209    /// Unit price.
210    pub price: f64,
211    /// Available quantity; `None` means unlimited (digital goods, services).
212    pub quantity: Option<u64>,
213    /// Optional `[name, value]` specification pairs.
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub specs: Option<Vec<Vec<String>>>,
216    /// Per-zone shipping surcharges.
217    #[serde(default)]
218    pub shipping: Vec<ShippingCost>,
219    /// Category hashtags (carried in `t` tags, not the JSON body).
220    #[serde(skip_serializing, default)]
221    pub categories: Option<Vec<String>>,
222}
223
224impl ProductData {
225    /// Create a product with the mandatory fields. Quantity defaults to `1`.
226    #[must_use]
227    pub fn new<S>(id: S, stall_id: S, name: S, currency: S) -> Self
228    where
229        S: Into<String>,
230    {
231        Self {
232            id: id.into(),
233            stall_id: stall_id.into(),
234            name: name.into(),
235            description: None,
236            images: None,
237            currency: currency.into(),
238            price: 0.0,
239            quantity: Some(1),
240            specs: None,
241            shipping: Vec::new(),
242            categories: None,
243        }
244    }
245
246    /// Set the description.
247    #[must_use]
248    pub fn description<S>(mut self, description: S) -> Self
249    where
250        S: Into<String>,
251    {
252        self.description = Some(description.into());
253        self
254    }
255
256    /// Set the image URLs.
257    #[must_use]
258    pub fn images(mut self, images: Vec<String>) -> Self {
259        self.images = Some(images);
260        self
261    }
262
263    /// Set the unit price.
264    #[must_use]
265    pub const fn price(mut self, price: f64) -> Self {
266        self.price = price;
267        self
268    }
269
270    /// Set the available quantity (`None` = unlimited).
271    #[must_use]
272    pub const fn quantity(mut self, quantity: Option<u64>) -> Self {
273        self.quantity = quantity;
274        self
275    }
276
277    /// Set the `[name, value]` specification pairs. Pairs that are not exactly
278    /// two elements are dropped.
279    #[must_use]
280    pub fn specs(mut self, specs: Vec<Vec<String>>) -> Self {
281        let valid: Vec<Vec<String>> = specs.into_iter().filter(|s| s.len() == 2).collect();
282        self.specs = Some(valid);
283        self
284    }
285
286    /// Set the per-zone shipping surcharges.
287    #[must_use]
288    pub fn shipping(mut self, shipping: Vec<ShippingCost>) -> Self {
289        self.shipping = shipping;
290        self
291    }
292
293    /// Set the category hashtags.
294    #[must_use]
295    pub fn categories(mut self, categories: Vec<String>) -> Self {
296        self.categories = Some(categories);
297        self
298    }
299
300    /// Build the `kind: 30018` [`EventBuilder`].
301    ///
302    /// The event is addressable by the **product** id (`["d", <id>]`) and
303    /// carries one `t` tag per category.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`serde_json::Error`] if the payload cannot be serialized.
308    pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
309        let content = self.try_to_json()?;
310        let mut builder =
311            EventBuilder::new(Kind::MARKETPLACE_PRODUCT, content).tag(Tag::d(self.id.clone()));
312        if let Some(categories) = &self.categories {
313            for category in categories {
314                builder = builder.tag(Tag::t(category));
315            }
316        }
317        Ok(builder)
318    }
319
320    /// Parse a [`ProductData`] from a `kind: 30018` [`Event`].
321    ///
322    /// Categories are read from the event's `t` tags (the JSON body never
323    /// carries them).
324    ///
325    /// # Errors
326    ///
327    /// Returns [`MarketplaceError`] if the kind is wrong or the JSON content
328    /// is malformed.
329    pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
330        expect_kind(event, Kind::MARKETPLACE_PRODUCT)?;
331        let mut product = Self::from_json(&event.content)?;
332        product.categories = collect_hashtags(event);
333        Ok(product)
334    }
335}
336
337/// Auction payload (`kind: 30020` content).
338///
339/// Auctions are structurally similar to fixed-price products but priced by
340/// bidding. Typed support for them is absent from upstream `rust-nostr`.
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct AuctionData {
343    /// Merchant-generated auction id (also the `d` tag).
344    pub id: String,
345    /// Id of the stall this auction belongs to.
346    pub stall_id: String,
347    /// Auction name.
348    pub name: String,
349    /// Optional description.
350    #[serde(skip_serializing_if = "Option::is_none", default)]
351    pub description: Option<String>,
352    /// Optional image URLs.
353    #[serde(skip_serializing_if = "Option::is_none", default)]
354    pub images: Option<Vec<String>>,
355    /// Starting bid, in the stall's currency.
356    pub starting_bid: u64,
357    /// Optional Unix start date; omit to schedule later.
358    #[serde(skip_serializing_if = "Option::is_none", default)]
359    pub start_date: Option<u64>,
360    /// Auction duration in seconds after `start_date`.
361    pub duration: u64,
362    /// Optional `[name, value]` specification pairs.
363    #[serde(skip_serializing_if = "Option::is_none", default)]
364    pub specs: Option<Vec<Vec<String>>>,
365    /// Per-zone shipping surcharges.
366    #[serde(default)]
367    pub shipping: Vec<ShippingCost>,
368}
369
370impl AuctionData {
371    /// Create an auction with the mandatory fields.
372    #[must_use]
373    pub fn new<S>(id: S, stall_id: S, name: S, starting_bid: u64, duration: u64) -> Self
374    where
375        S: Into<String>,
376    {
377        Self {
378            id: id.into(),
379            stall_id: stall_id.into(),
380            name: name.into(),
381            description: None,
382            images: None,
383            starting_bid,
384            start_date: None,
385            duration,
386            specs: None,
387            shipping: Vec::new(),
388        }
389    }
390
391    /// Build the `kind: 30020` [`EventBuilder`], addressable by the auction id.
392    ///
393    /// # Errors
394    ///
395    /// Returns [`serde_json::Error`] if the payload cannot be serialized.
396    pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
397        let content = self.try_to_json()?;
398        Ok(EventBuilder::new(Kind::MARKETPLACE_AUCTION, content).tag(Tag::d(self.id.clone())))
399    }
400
401    /// Parse an [`AuctionData`] from a `kind: 30020` [`Event`].
402    ///
403    /// # Errors
404    ///
405    /// Returns [`MarketplaceError`] if the kind is wrong or the JSON content
406    /// is malformed.
407    pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
408        expect_kind(event, Kind::MARKETPLACE_AUCTION)?;
409        Ok(Self::from_json(&event.content)?)
410    }
411}
412
413/// A single line item inside a [`CustomerOrder`].
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct CustomerOrderItem {
416    /// Id of the ordered product.
417    pub product_id: String,
418    /// Quantity ordered.
419    pub quantity: u64,
420}
421
422/// A customer's contact details attached to an order.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct CustomerContact {
425    /// Customer's Nostr public key.
426    #[serde(skip_serializing_if = "Option::is_none", default)]
427    pub nostr: Option<PublicKey>,
428    /// Customer's phone number.
429    #[serde(skip_serializing_if = "Option::is_none", default)]
430    pub phone: Option<String>,
431    /// Customer's email address.
432    #[serde(skip_serializing_if = "Option::is_none", default)]
433    pub email: Option<String>,
434}
435
436/// Customer order message (`type: 0`), sent to the merchant over an encrypted DM.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct CustomerOrder {
439    /// Customer-generated order id.
440    pub id: String,
441    /// Message discriminant; always `0`.
442    #[serde(rename = "type")]
443    pub message_type: u8,
444    /// Customer name.
445    #[serde(skip_serializing_if = "Option::is_none", default)]
446    pub name: Option<String>,
447    /// Shipping address (for physical goods).
448    #[serde(skip_serializing_if = "Option::is_none", default)]
449    pub address: Option<String>,
450    /// Free-form message to the merchant.
451    #[serde(skip_serializing_if = "Option::is_none", default)]
452    pub message: Option<String>,
453    /// Customer contact details.
454    pub contact: CustomerContact,
455    /// Ordered items.
456    pub items: Vec<CustomerOrderItem>,
457    /// Selected shipping zone id.
458    pub shipping_id: String,
459}
460
461impl CustomerOrder {
462    /// Create an order with the mandatory fields and `type: 0`.
463    #[must_use]
464    pub fn new<S>(
465        id: S,
466        contact: CustomerContact,
467        items: Vec<CustomerOrderItem>,
468        shipping_id: S,
469    ) -> Self
470    where
471        S: Into<String>,
472    {
473        Self {
474            id: id.into(),
475            message_type: 0,
476            name: None,
477            address: None,
478            message: None,
479            contact,
480            items,
481            shipping_id: shipping_id.into(),
482        }
483    }
484}
485
486/// A single payment option in a [`MerchantPaymentRequest`].
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct PaymentOption {
489    /// Payment type (`url`, `btc`, `ln`, `lnurl`, …).
490    #[serde(rename = "type")]
491    pub option_type: String,
492    /// Payment link (URL, lightning invoice, on-chain address, …).
493    pub link: String,
494}
495
496/// Merchant payment request message (`type: 1`).
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct MerchantPaymentRequest {
499    /// Order id this request answers.
500    pub id: String,
501    /// Message discriminant; always `1`.
502    #[serde(rename = "type")]
503    pub message_type: u8,
504    /// Optional message to the customer.
505    #[serde(skip_serializing_if = "Option::is_none", default)]
506    pub message: Option<String>,
507    /// Available payment options.
508    pub payment_options: Vec<PaymentOption>,
509}
510
511impl MerchantPaymentRequest {
512    /// Create a payment request with `type: 1`.
513    #[must_use]
514    pub fn new<S>(id: S, payment_options: Vec<PaymentOption>) -> Self
515    where
516        S: Into<String>,
517    {
518        Self {
519            id: id.into(),
520            message_type: 1,
521            message: None,
522            payment_options,
523        }
524    }
525}
526
527/// Merchant payment/shipping verification message (`type: 2`).
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct MerchantVerifyPayment {
530    /// Order id this verification answers.
531    pub id: String,
532    /// Message discriminant; always `2`.
533    #[serde(rename = "type")]
534    pub message_type: u8,
535    /// Optional message to the customer.
536    #[serde(skip_serializing_if = "Option::is_none", default)]
537    pub message: Option<String>,
538    /// Whether payment was received.
539    pub paid: bool,
540    /// Whether the order shipped.
541    pub shipped: bool,
542}
543
544impl MerchantVerifyPayment {
545    /// Create a verification message with `type: 2`.
546    #[must_use]
547    pub fn new<S>(id: S, paid: bool, shipped: bool) -> Self
548    where
549        S: Into<String>,
550    {
551        Self {
552            id: id.into(),
553            message_type: 2,
554            message: None,
555            paid,
556            shipped,
557        }
558    }
559}
560
561/// Errors raised when parsing a marketplace event.
562#[derive(Debug, Error)]
563#[non_exhaustive]
564#[allow(
565    variant_size_differences,
566    reason = "the serde_json::Error source is already boxed to an 8-byte pointer (the smallest sound representation), but still trips the heuristic against the small UnexpectedKind variant — mirrors nip18::RepostError"
567)]
568pub enum MarketplaceError {
569    /// The event's kind did not match the expected marketplace kind.
570    #[error("expected kind {expected}, got {got}")]
571    UnexpectedKind {
572        /// The expected marketplace kind.
573        expected: u16,
574        /// What the event actually advertised.
575        got: u16,
576    },
577    /// The event `content` was not valid JSON for the target payload.
578    ///
579    /// The [`serde_json::Error`] is boxed to keep the enum small (it is
580    /// markedly larger than the other variants).
581    #[error("invalid marketplace JSON content: {0}")]
582    InvalidContent(#[source] Box<serde_json::Error>),
583}
584
585impl From<serde_json::Error> for MarketplaceError {
586    fn from(value: serde_json::Error) -> Self {
587        Self::InvalidContent(Box::new(value))
588    }
589}
590
591fn expect_kind(event: &Event, expected: Kind) -> Result<(), MarketplaceError> {
592    if event.kind == expected {
593        Ok(())
594    } else {
595        Err(MarketplaceError::UnexpectedKind {
596            expected: expected.as_u16(),
597            got: event.kind.as_u16(),
598        })
599    }
600}
601
602fn collect_hashtags(event: &Event) -> Option<Vec<String>> {
603    let hashtags: Vec<String> = event
604        .tags
605        .find_letter(Alphabet::T)
606        .filter_map(Tag::content)
607        .map(str::to_owned)
608        .collect();
609    if hashtags.is_empty() {
610        None
611    } else {
612        Some(hashtags)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::Keys;
620
621    fn keys() -> Keys {
622        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
623    }
624
625    #[test]
626    fn stall_json_is_byte_compatible_with_upstream() {
627        let stall = StallData::new("123", "Test Stall", "USD")
628            .description("Test Description")
629            .shipping(vec![ShippingMethod::new("123", 5.0).name("default")]);
630        assert_eq!(
631            stall.try_to_json().unwrap(),
632            r#"{"id":"123","name":"Test Stall","description":"Test Description","currency":"USD","shipping":[{"id":"123","name":"default","cost":5.0,"regions":[]}]}"#
633        );
634    }
635
636    #[test]
637    fn stall_round_trip_through_event() {
638        let stall = StallData::new("s1", "Stall", "USD").shipping(vec![
639            ShippingMethod::new("z1", 2.5).regions(vec!["EU".to_owned()]),
640        ]);
641        let event = stall
642            .to_event_builder()
643            .unwrap()
644            .sign_with_keys(&keys())
645            .unwrap();
646        assert_eq!(event.kind, Kind::MARKETPLACE_STALL);
647        assert_eq!(event.tags.identifier(), Some("s1"));
648        assert_eq!(StallData::from_event(&event).unwrap(), stall);
649    }
650
651    #[test]
652    fn product_is_addressable_by_product_id_not_stall_id() {
653        // Regression against the upstream `stall_id` bug: NIP-15 addresses a
654        // product by its own id.
655        let product = ProductData::new("prod-9", "stall-1", "Widget", "USD").price(9.99);
656        let event = product
657            .to_event_builder()
658            .unwrap()
659            .sign_with_keys(&keys())
660            .unwrap();
661        assert_eq!(event.kind, Kind::MARKETPLACE_PRODUCT);
662        assert_eq!(event.tags.identifier(), Some("prod-9"));
663    }
664
665    #[test]
666    fn product_round_trip_with_categories_from_tags() {
667        let product = ProductData::new("p1", "s1", "Thing", "SAT")
668            .price(1000.0)
669            .quantity(Some(3))
670            .images(vec!["https://img.example/x.png".to_owned()])
671            .specs(vec![vec!["size".to_owned(), "M".to_owned()]])
672            .categories(vec!["electronics".to_owned(), "phones".to_owned()]);
673        let event = product
674            .to_event_builder()
675            .unwrap()
676            .sign_with_keys(&keys())
677            .unwrap();
678
679        // The content body must NOT carry categories.
680        assert!(!event.content.contains("categories"));
681        // Categories come back from the `t` tags.
682        let parsed = ProductData::from_event(&event).unwrap();
683        assert_eq!(parsed, product);
684    }
685
686    #[test]
687    fn product_unlimited_quantity_serializes_as_null() {
688        let product = ProductData::new("p1", "s1", "Service", "USD").quantity(None);
689        assert!(
690            product
691                .try_to_json()
692                .unwrap()
693                .contains(r#""quantity":null"#)
694        );
695    }
696
697    #[test]
698    fn auction_round_trip_through_event() {
699        let auction = AuctionData::new("a1", "s1", "Rare Item", 100, 86_400);
700        let event = auction
701            .to_event_builder()
702            .unwrap()
703            .sign_with_keys(&keys())
704            .unwrap();
705        assert_eq!(event.kind, Kind::MARKETPLACE_AUCTION);
706        assert_eq!(event.tags.identifier(), Some("a1"));
707        assert_eq!(AuctionData::from_event(&event).unwrap(), auction);
708    }
709
710    #[test]
711    fn wrong_kind_is_rejected() {
712        let event = EventBuilder::text_note("nope")
713            .sign_with_keys(&keys())
714            .unwrap();
715        assert!(matches!(
716            StallData::from_event(&event).unwrap_err(),
717            MarketplaceError::UnexpectedKind {
718                expected: 30_017,
719                got: 1
720            }
721        ));
722    }
723
724    #[test]
725    fn order_uses_product_id_field() {
726        let order = CustomerOrder::new(
727            "o1",
728            CustomerContact {
729                nostr: None,
730                phone: None,
731                email: Some("a@b.c".to_owned()),
732            },
733            vec![CustomerOrderItem {
734                product_id: "p1".to_owned(),
735                quantity: 2,
736            }],
737            "z1",
738        );
739        let json = order.try_to_json().unwrap();
740        assert!(json.contains(r#""type":0"#));
741        assert!(json.contains(r#""product_id":"p1""#));
742        assert_eq!(CustomerOrder::from_json(&json).unwrap(), order);
743    }
744
745    #[test]
746    fn payment_messages_carry_type_discriminants() {
747        let req = MerchantPaymentRequest::new(
748            "o1",
749            vec![PaymentOption {
750                option_type: "ln".to_owned(),
751                link: "lnbc...".to_owned(),
752            }],
753        );
754        assert!(req.try_to_json().unwrap().contains(r#""type":1"#));
755
756        let verify = MerchantVerifyPayment::new("o1", true, false);
757        assert!(verify.try_to_json().unwrap().contains(r#""type":2"#));
758    }
759}