lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit dbb9e054196896c97c5bb69de08e2f63bf012298
parent b56c5bd6caeee53c4cd9f5e99099e4215eee6294
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 08:32:16 +0000

event: add release-product trade mutation protocol

- add canonical trade mutation identities and JCS content hashing
- register trade proposal, decision, revision, and cancellation kinds
- expose trade mutation event codec builders and parsers
- replace active order operation vectors with trade mutation contracts

Diffstat:
Mbuild/nix/toolchains.nix | 16+---------------
Dcontracts/conformance/vectors/order/build_order_decision_draft.v1.json | 36------------------------------------
Dcontracts/conformance/vectors/order/build_order_request_draft.v1.json | 74--------------------------------------------------------------------------
Dcontracts/conformance/vectors/order/parse_listing_address.v1.json | 16----------------
Dcontracts/conformance/vectors/order/parse_order_decision.v1.json | 22----------------------
Dcontracts/conformance/vectors/order/parse_order_request.v1.json | 31-------------------------------
Acontracts/conformance/vectors/trade/build_mutation_event.v1.json | 36++++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/trade/canonicalize_mutation.v1.json | 28++++++++++++++++++++++++++++
Acontracts/conformance/vectors/trade/parse_listing_address.v1.json | 16++++++++++++++++
Acontracts/conformance/vectors/trade/parse_mutation_event.v1.json | 29+++++++++++++++++++++++++++++
Mcontracts/operations.toml | 128+++++++++++++++++++++++++++++++++----------------------------------------------
Mcrates/authority/src/authorization.rs | 41++++++++++++++++++++++-------------------
Mcrates/event/src/contract.rs | 222+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/event/src/event_head.rs | 22++++++++++++++--------
Mcrates/event/src/ids.rs | 26++++++++++++++++++++++++++
Mcrates/event/src/kinds.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/event/src/lib.rs | 1+
Acrates/event/src/trade.rs | 1220++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/lib.rs | 1+
Mcrates/event_codec/src/manifest.rs | 1+
Acrates/event_codec/src/trade/mod.rs | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mflake.lock | 6+++---
Mrust-toolchain.toml | 1+
Mtools/xtask/src/contract.rs | 162++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
24 files changed, 2106 insertions(+), 455 deletions(-)

diff --git a/build/nix/toolchains.nix b/build/nix/toolchains.nix @@ -1,20 +1,6 @@ { pkgs }: -let - toolchain = builtins.fromTOML (builtins.readFile ../../rust-toolchain.toml); - stableVersion = toolchain.toolchain.channel; - stableTargets = toolchain.toolchain.targets or [ ]; - stableExtensions = [ - "clippy" - "rust-analyzer" - "rust-src" - "rustfmt" - ]; -in { - stable = pkgs.rust-bin.stable.${stableVersion}.default.override { - extensions = stableExtensions; - targets = stableTargets; - }; + stable = pkgs.rust-bin.fromRustupToolchainFile ../../rust-toolchain.toml; coverage = pkgs.rust-bin.fromRustupToolchainFile ../../rust-toolchain-coverage.toml; } diff --git a/contracts/conformance/vectors/order/build_order_decision_draft.v1.json b/contracts/conformance/vectors/order/build_order_decision_draft.v1.json @@ -1,36 +0,0 @@ -{ - "suite": "order", - "contract_version": "0.1.0", - "vectors": [ - { - "id": "order_build_order_decision_draft_accept_minimal_001", - "kind": "order.build_order_decision_draft", - "input": { - "root_event_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "prev_event_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "payload": { - "order_id": "order-1", - "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", - "buyer_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "decision": { - "decision": "accepted", - "inventory_commitments": [ - { - "bin_id": "bin-1", - "bin_count": 2 - } - ] - } - } - }, - "expected": { - "wire_parts": { - "kind": 3423, - "content_type": "TradeOrderDecision", - "tags_shape": "order_order_decision_tags" - } - } - } - ] -} diff --git a/contracts/conformance/vectors/order/build_order_request_draft.v1.json b/contracts/conformance/vectors/order/build_order_request_draft.v1.json @@ -1,74 +0,0 @@ -{ - "suite": "order", - "contract_version": "0.1.0", - "vectors": [ - { - "id": "order_build_order_request_draft_minimal_001", - "kind": "order.build_order_request_draft", - "input": { - "listing_event": { - "id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "relays": "wss://relay.example.com" - }, - "payload": { - "order_id": "order-1", - "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", - "buyer_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "items": [ - { - "bin_id": "bin-1", - "bin_count": 2 - } - ], - "economics": { - "quote_id": "quote-1", - "quote_version": 1, - "pricing_basis": "listing_event", - "currency": "USD", - "items": [ - { - "bin_id": "bin-1", - "bin_count": 2, - "quantity_amount": "1", - "quantity_unit": "each", - "unit_price_amount": "5", - "unit_price_currency": "USD", - "line_subtotal": { - "amount": "10", - "currency": "USD" - } - } - ], - "discounts": [], - "adjustments": [], - "subtotal": { - "amount": "10", - "currency": "USD" - }, - "discount_total": { - "amount": "0", - "currency": "USD" - }, - "adjustment_total": { - "amount": "0", - "currency": "USD" - }, - "total": { - "amount": "10", - "currency": "USD" - } - } - } - }, - "expected": { - "wire_parts": { - "kind": 3422, - "content_type": "TradeOrderRequested", - "tags_shape": "order_order_request_tags", - "payload_shape": "order_order_request_with_economics" - } - } - } - ] -} diff --git a/contracts/conformance/vectors/order/parse_listing_address.v1.json b/contracts/conformance/vectors/order/parse_listing_address.v1.json @@ -1,16 +0,0 @@ -{ - "suite": "order", - "contract_version": "0.1.0", - "vectors": [ - { - "id": "order_parse_listing_address_minimal_001", - "kind": "order.parse_listing_address", - "input": { - "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" - }, - "expected": { - "address_shape": "trade_listing_address" - } - } - ] -} diff --git a/contracts/conformance/vectors/order/parse_order_decision.v1.json b/contracts/conformance/vectors/order/parse_order_decision.v1.json @@ -1,22 +0,0 @@ -{ - "suite": "order", - "contract_version": "0.1.0", - "vectors": [ - { - "id": "order_parse_order_decision_minimal_001", - "kind": "order.parse_order_decision", - "input": { - "event": { - "kind": 3423, - "author": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "content_type": "TradeOrderDecision", - "tags_shape": "order_order_decision_tags" - } - }, - "expected": { - "envelope_shape": "order_order_decision", - "payload_type": "RadrootsOrderDecision" - } - } - ] -} diff --git a/contracts/conformance/vectors/order/parse_order_request.v1.json b/contracts/conformance/vectors/order/parse_order_request.v1.json @@ -1,31 +0,0 @@ -{ - "suite": "order", - "contract_version": "0.1.0", - "vectors": [ - { - "id": "order_parse_order_request_minimal_001", - "kind": "order.parse_order_request", - "input": { - "event": { - "kind": 3422, - "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "content_type": "TradeOrderRequested", - "tags_shape": "order_order_request_tags", - "payload_shape": "order_order_request_with_economics" - } - }, - "expected": { - "envelope_shape": "order_order_request", - "payload_type": "RadrootsOrderRequest", - "required_payload_fields": [ - "order_id", - "listing_addr", - "buyer_pubkey", - "seller_pubkey", - "items", - "economics" - ] - } - } - ] -} diff --git a/contracts/conformance/vectors/trade/build_mutation_event.v1.json b/contracts/conformance/vectors/trade/build_mutation_event.v1.json @@ -0,0 +1,36 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_build_mutation_event_proposal_001", + "kind": "trade.build_mutation_event", + "input": { + "mutation_type": "proposal", + "contract_id": "radroots.trade.proposal.v1", + "trade_id": "11111111111111111111111111111111", + "parent_mutation_ids": [] + }, + "expected": { + "wire_parts": { + "kind": 3470, + "tags": [ + [ + "contract", + "radroots.trade.proposal.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content_shape": "RadrootsTradeMutationEnvelopeV1" + } + } + } + ] +} diff --git a/contracts/conformance/vectors/trade/canonicalize_mutation.v1.json b/contracts/conformance/vectors/trade/canonicalize_mutation.v1.json @@ -0,0 +1,28 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_canonicalize_proposal_minimal_001", + "kind": "trade.canonicalize_mutation", + "input": { + "contract_id": "radroots.trade.proposal.v1", + "schema_version": 1, + "trade_id": "11111111111111111111111111111111", + "root_mutation_id": null, + "buyer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "seller_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "farm_id": "farm-1", + "parent_mutation_ids": [], + "author_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "counterparty_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "body_shape": "proposal_complete_candidate" + }, + "expected": { + "candidate_id": "domain_separated_sha256_candidate_terms", + "mutation_id": "domain_separated_sha256_mutation_envelope", + "canonical_content": "rfc8785_jcs_without_duplicate_keys_or_float_fields" + } + } + ] +} diff --git a/contracts/conformance/vectors/trade/parse_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_listing_address.v1.json @@ -0,0 +1,16 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_parse_listing_address_minimal_001", + "kind": "trade.parse_listing_address", + "input": { + "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address_shape": "trade_listing_address" + } + } + ] +} diff --git a/contracts/conformance/vectors/trade/parse_mutation_event.v1.json b/contracts/conformance/vectors/trade/parse_mutation_event.v1.json @@ -0,0 +1,29 @@ +{ + "suite": "trade", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "trade_parse_mutation_event_proposal_001", + "kind": "trade.parse_mutation_event", + "input": { + "event": { + "kind": 3470, + "contract_tag": "radroots.trade.proposal.v1", + "trade_id_tag": "11111111111111111111111111111111", + "content_shape": "RadrootsTradeMutationEnvelopeV1" + } + }, + "expected": { + "envelope_shape": "RadrootsTradeMutationEnvelopeV1", + "rejects": [ + "kind_contract_mismatch", + "contract_tag_mismatch", + "trade_id_tag_mismatch", + "counterparty_tag_mismatch", + "parent_tags_mismatch", + "non_canonical_content" + ] + } + } + ] +} diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -8,7 +8,7 @@ domains = [ "profile", "farm", "listing", - "order", + "trade", "trade_validation", "social", "knowledge", @@ -33,21 +33,24 @@ public = [ "RadrootsFileMetadata", "RadrootsCalendarDateEvent", "RadrootsCalendarTimeEvent", - "RadrootsOrderEnvelope", - "RadrootsOrderEventType", - "RadrootsOrderItem", - "RadrootsOrderPricingBasis", - "RadrootsOrderEconomicLineKind", - "RadrootsOrderEconomicActor", - "RadrootsOrderEconomicEffect", - "RadrootsOrderEconomicItem", - "RadrootsOrderEconomicLine", - "RadrootsOrderEconomicTotals", - "RadrootsOrderEconomics", - "RadrootsOrderRequest", - "RadrootsOrderInventoryCommitment", - "RadrootsOrderDecisionOutcome", - "RadrootsOrderDecision", + "RadrootsTradeId", + "RadrootsTradeCandidateId", + "RadrootsTradeMutationId", + "RadrootsTradeMutationKindV1", + "RadrootsTradeMutationEnvelopeV1", + "RadrootsTradeMutationBodyV1", + "RadrootsTradeCandidateTermsV1", + "RadrootsTradeCandidateLineV1", + "RadrootsTradeLineTombstoneV1", + "RadrootsTradeEconomicsProfileV1", + "RadrootsTradeEconomicAdjustmentV1", + "RadrootsFulfillmentProfileV1", + "RadrootsTradeCancellationProfileV1", + "RadrootsTradePrivateTermsRefV1", + "RadrootsSellerReservationAssertionV1", + "RadrootsSellerReservationLineV1", + "RadrootsTradeDecisionV1", + "RadrootsTradeCanonicalMutationV1", "RadrootsWikiArticle", "RadrootsWikiRedirect", "RadrootsWikiMergeRequest", @@ -307,92 +310,69 @@ rust_types = ["radroots_event::calendar::RadrootsCalendarTimeEvent"] [operations.social_calendar_time_event_build_tags.conformance] vector = "contracts/conformance/vectors/social/mvp.v1.json" -[operations.order_build_order_request_draft] -domain = "order" -id = "order.build_order_request_draft" +[operations.trade_canonicalize_mutation] +domain = "trade" +id = "trade.canonicalize_mutation" stability = "beta" -inputs = ["RadrootsOrderRequest", "RadrootsEventPtr"] -outputs = ["RadrootsNip01EventWireParts"] +inputs = ["RadrootsTradeMutationEnvelopeV1"] +outputs = ["RadrootsTradeCanonicalMutationV1"] error_class = "encode_error" deterministic = true signing = "native" transport = "native" -[operations.order_build_order_request_draft.implementation] -rust_modules = ["crates/event_codec/src/order/encode.rs"] +[operations.trade_canonicalize_mutation.implementation] +rust_modules = ["crates/event/src/trade.rs"] rust_types = [ - "radroots_event::RadrootsEventPtr", - "radroots_event::order::RadrootsOrderRequest", + "radroots_event::trade::RadrootsTradeCanonicalMutationV1", + "radroots_event::trade::RadrootsTradeMutationEnvelopeV1", ] -[operations.order_build_order_request_draft.conformance] -vector = "contracts/conformance/vectors/order/build_order_request_draft.v1.json" +[operations.trade_canonicalize_mutation.conformance] +vector = "contracts/conformance/vectors/trade/canonicalize_mutation.v1.json" -[operations.order_build_order_decision_draft] -domain = "order" -id = "order.build_order_decision_draft" +[operations.trade_build_mutation_event] +domain = "trade" +id = "trade.build_mutation_event" stability = "beta" -inputs = ["root_event_id", "prev_event_id", "RadrootsOrderDecision"] +inputs = ["RadrootsTradeMutationEnvelopeV1"] outputs = ["RadrootsNip01EventWireParts"] error_class = "encode_error" deterministic = true signing = "native" transport = "native" -[operations.order_build_order_decision_draft.implementation] -rust_modules = ["crates/event_codec/src/order/encode.rs"] -rust_types = ["radroots_event::order::RadrootsOrderDecision"] - -[operations.order_build_order_decision_draft.conformance] -vector = "contracts/conformance/vectors/order/build_order_decision_draft.v1.json" - -[operations.order_parse_order_request] -domain = "order" -id = "order.parse_order_request" -stability = "beta" -inputs = ["RadrootsEventEnvelope"] -outputs = ["RadrootsOrderEnvelope", "RadrootsOrderRequest"] -error_class = "parse_error" -deterministic = true -signing = "native" -transport = "native" - -[operations.order_parse_order_request.implementation] -rust_modules = ["crates/event_codec/src/order/decode.rs"] -rust_types = [ - "radroots_event::RadrootsEventEnvelope", - "radroots_event::order::RadrootsOrderEnvelope", - "radroots_event::order::RadrootsOrderRequest", -] +[operations.trade_build_mutation_event.implementation] +rust_modules = ["crates/event_codec/src/trade/mod.rs"] +rust_types = ["radroots_event::trade::RadrootsTradeMutationEnvelopeV1"] -[operations.order_parse_order_request.conformance] -vector = "contracts/conformance/vectors/order/parse_order_request.v1.json" +[operations.trade_build_mutation_event.conformance] +vector = "contracts/conformance/vectors/trade/build_mutation_event.v1.json" -[operations.order_parse_order_decision] -domain = "order" -id = "order.parse_order_decision" +[operations.trade_parse_mutation_event] +domain = "trade" +id = "trade.parse_mutation_event" stability = "beta" inputs = ["RadrootsEventEnvelope"] -outputs = ["RadrootsOrderEnvelope", "RadrootsOrderDecision"] +outputs = ["RadrootsTradeMutationEnvelopeV1"] error_class = "parse_error" deterministic = true signing = "native" transport = "native" -[operations.order_parse_order_decision.implementation] -rust_modules = ["crates/event_codec/src/order/decode.rs"] +[operations.trade_parse_mutation_event.implementation] +rust_modules = ["crates/event_codec/src/trade/mod.rs"] rust_types = [ "radroots_event::RadrootsEventEnvelope", - "radroots_event::order::RadrootsOrderEnvelope", - "radroots_event::order::RadrootsOrderDecision", + "radroots_event::trade::RadrootsTradeMutationEnvelopeV1", ] -[operations.order_parse_order_decision.conformance] -vector = "contracts/conformance/vectors/order/parse_order_decision.v1.json" +[operations.trade_parse_mutation_event.conformance] +vector = "contracts/conformance/vectors/trade/parse_mutation_event.v1.json" -[operations.order_parse_listing_address] -domain = "order" -id = "order.parse_listing_address" +[operations.trade_parse_listing_address] +domain = "trade" +id = "trade.parse_listing_address" stability = "beta" inputs = ["listing_addr"] outputs = ["RadrootsListingAddress"] @@ -401,12 +381,12 @@ deterministic = true signing = "native" transport = "native" -[operations.order_parse_listing_address.implementation] +[operations.trade_parse_listing_address.implementation] rust_modules = ["crates/event/src/ids.rs"] rust_types = ["radroots_event::ids::RadrootsListingAddress"] -[operations.order_parse_listing_address.conformance] -vector = "contracts/conformance/vectors/order/parse_listing_address.v1.json" +[operations.trade_parse_listing_address.conformance] +vector = "contracts/conformance/vectors/trade/parse_listing_address.v1.json" [operations.trade_validation_validate_listing_event] domain = "trade_validation" diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -157,7 +157,7 @@ mod tests { use crate::RadrootsSignerError; use radroots_event::contract::{RadrootsActorRole, event_contract}; use radroots_event::ids::RadrootsPublicKey; - use radroots_event::kinds::{KIND_LISTING, KIND_ORDER_REQUEST, KIND_POST}; + use radroots_event::kinds::{KIND_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; fn hex_64(character: char) -> String { std::iter::repeat_n(character, 64).collect() @@ -325,9 +325,10 @@ mod tests { #[test] fn buyer_and_seller_contract_roles_match_current_contracts() { let listing = event_contract("radroots.listing.published.v1").expect("listing contract"); - let order_request = event_contract("radroots.order.request.v1").expect("order contract"); - let order_decision = - event_contract("radroots.order.decision.v1").expect("decision contract"); + let trade_proposal = + event_contract("radroots.trade.proposal.v1").expect("trade proposal contract"); + let trade_decision = + event_contract("radroots.trade.decision.v1").expect("trade decision contract"); let seller = seller_actor(hex_64('a').as_str()); let buyer = buyer_actor(hex_64('b').as_str()); @@ -337,15 +338,15 @@ mod tests { authorize_actor_for_contract(&buyer, listing), Err(RadrootsAuthorityError::ActorRoleUnsatisfied { .. }) )); - assert!(authorize_actor_for_contract(&buyer, order_request).is_ok()); + assert!(authorize_actor_for_contract(&buyer, trade_proposal).is_ok()); assert!(matches!( - authorize_actor_for_contract(&seller, order_request), + authorize_actor_for_contract(&seller, trade_proposal), Err(RadrootsAuthorityError::ActorRoleUnsatisfied { .. }) )); - assert_eq!(order_decision.author_role, RadrootsActorRole::Seller); - assert!(authorize_actor_for_contract(&seller, order_decision).is_ok()); + assert_eq!(trade_decision.author_role, RadrootsActorRole::Seller); + assert!(authorize_actor_for_contract(&seller, trade_decision).is_ok()); assert!(matches!( - authorize_actor_for_contract(&buyer, order_decision), + authorize_actor_for_contract(&buyer, trade_decision), Err(RadrootsAuthorityError::ActorRoleUnsatisfied { .. }) )); } @@ -546,25 +547,27 @@ mod tests { } #[test] - fn order_request_draft_requires_buyer_role() { + fn trade_proposal_draft_requires_buyer_role() { let pubkey = hex_64('a'); let draft = RadrootsEventDraft::new( - "radroots.order.request.v1", - KIND_ORDER_REQUEST, + "radroots.trade.proposal.v1", + KIND_TRADE_PROPOSAL, 1_700_000_000, vec![ - vec!["d".to_owned(), "order-1".to_owned()], - vec!["p".to_owned(), pubkey.clone()], vec![ - "a".to_owned(), - format!("30402:{}:listing-a", pubkey.as_str()), + "contract".to_owned(), + "radroots.trade.proposal.v1".to_owned(), + ], + vec![ + "d".to_owned(), + "11111111111111111111111111111111".to_owned(), ], - vec!["listing_event".to_owned(), hex_64('b')], + vec!["p".to_owned(), pubkey.clone()], ], - "{}", + r#"{"contract_id":"radroots.trade.proposal.v1"}"#, pubkey.as_str(), ) - .expect("order draft"); + .expect("trade proposal draft"); let buyer = buyer_actor(pubkey.as_str()); let seller = seller_actor(pubkey.as_str()); diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -87,6 +87,7 @@ pub enum RadrootsReducer { ProfileProjection, NostrRelayPolicyProjection, SocialProjection, + TradeProjection, TradeValidation, } @@ -426,13 +427,6 @@ const TAG_P_MANY: RadrootsTagContract = tag( RadrootsTagValueType::PublicKey, true, ); -const TAG_A_REQUIRED: RadrootsTagContract = tag( - "a", - RadrootsTagCardinality::RequiredOne, - RadrootsTagSemantic::ListingAddress, - RadrootsTagValueType::AddressableCoordinate, - true, -); const TAG_A_ADDRESS_REQUIRED: RadrootsTagContract = tag( "a", RadrootsTagCardinality::RequiredOne, @@ -461,13 +455,6 @@ const TAG_E_ROOT: RadrootsTagContract = tag( RadrootsTagValueType::EventId, true, ); -const TAG_E_PREVIOUS: RadrootsTagContract = tag( - "e", - RadrootsTagCardinality::RequiredOne, - RadrootsTagSemantic::PreviousEvent, - RadrootsTagValueType::EventId, - true, -); const TAG_E_SOURCE_VERSION: RadrootsTagContract = tag( "e", RadrootsTagCardinality::RequiredOne, @@ -566,13 +553,6 @@ const TAG_IMAGE: RadrootsTagContract = tag( RadrootsTagValueType::Url, false, ); -const TAG_LISTING_EVENT: RadrootsTagContract = tag( - "listing_event", - RadrootsTagCardinality::RequiredOne, - RadrootsTagSemantic::ListingSnapshot, - RadrootsTagValueType::EventId, - false, -); const TAG_SERVICE_OUTPUT: RadrootsTagContract = tag( "output", RadrootsTagCardinality::RequiredOne, @@ -679,15 +659,8 @@ const LISTING_TAGS: &[RadrootsTagContract] = &[ TAG_CATEGORY, TAG_IMAGE, ]; -const ORDER_REQUEST_TAGS: &[RadrootsTagContract] = - &[TAG_D, TAG_P_REQUIRED, TAG_A_REQUIRED, TAG_LISTING_EVENT]; -const CHAINED_ORDER_TAGS: &[RadrootsTagContract] = &[ - TAG_D, - TAG_P_REQUIRED, - TAG_A_REQUIRED, - TAG_E_ROOT, - TAG_E_PREVIOUS, -]; +const TRADE_MUTATION_TAGS: &[RadrootsTagContract] = + &[TAG_CONTRACT_REQUIRED, TAG_D, TAG_P_REQUIRED, TAG_E_MANY]; const TRADE_VALIDATION_RECEIPT_TAGS: &[RadrootsTagContract] = &[TAG_E_ROOT, TAG_A_OPTIONAL, TAG_SERVICE_OUTPUT]; const KNOWLEDGE_SOURCE_TAGS: &[RadrootsTagContract] = &[ @@ -736,8 +709,8 @@ const LISTING_REDUCERS: &[RadrootsReducer] = &[ RadrootsReducer::MarketProjection, RadrootsReducer::ListingInventoryAccounting, ]; -const ORDER_REDUCERS: &[RadrootsReducer] = &[ - RadrootsReducer::OrderProjection, +const TRADE_MUTATION_REDUCERS: &[RadrootsReducer] = &[ + RadrootsReducer::TradeProjection, RadrootsReducer::ListingInventoryAccounting, ]; const TRADE_VALIDATION_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::TradeValidation]; @@ -1569,28 +1542,44 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[ ["radroots.group.roles.v1"] ), kind_contract!( - KIND_ORDER_REQUEST, - "KIND_ORDER_REQUEST", - "Order Request", + KIND_TRADE_PROPOSAL, + "KIND_TRADE_PROPOSAL", + "Trade Proposal", + RadrootsEventClass::Regular, + RadrootsNostrStandard::Radroots, + ["radroots.trade.proposal.v1"] + ), + kind_contract!( + KIND_TRADE_DECISION, + "KIND_TRADE_DECISION", + "Trade Decision", + RadrootsEventClass::Regular, + RadrootsNostrStandard::Radroots, + ["radroots.trade.decision.v1"] + ), + kind_contract!( + KIND_TRADE_REVISION_PROPOSAL, + "KIND_TRADE_REVISION_PROPOSAL", + "Trade Revision Proposal", RadrootsEventClass::Regular, RadrootsNostrStandard::Radroots, - ["radroots.order.request.v1"] + ["radroots.trade.revision_proposal.v1"] ), kind_contract!( - KIND_ORDER_DECISION, - "KIND_ORDER_DECISION", - "Order Decision", + KIND_TRADE_REVISION_DECISION, + "KIND_TRADE_REVISION_DECISION", + "Trade Revision Decision", RadrootsEventClass::Regular, RadrootsNostrStandard::Radroots, - ["radroots.order.decision.v1"] + ["radroots.trade.revision_decision.v1"] ), kind_contract!( - KIND_ORDER_CANCELLATION, - "KIND_ORDER_CANCELLATION", - "Order Cancellation", + KIND_TRADE_CANCELLATION, + "KIND_TRADE_CANCELLATION", + "Trade Cancellation", RadrootsEventClass::Regular, RadrootsNostrStandard::Radroots, - ["radroots.order.cancellation.v1"] + ["radroots.trade.cancellation.v1"] ), kind_contract!( KIND_TRADE_VALIDATION_RECEIPT, @@ -2811,43 +2800,84 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ GROUP_REDUCERS ), event_contract!( - "radroots.order.request.v1", - KIND_ORDER_REQUEST, - "Order Request", - "RadrootsOrderRequest", + "radroots.trade.proposal.v1", + KIND_TRADE_PROPOSAL, + "Trade Proposal", + "RadrootsTradeMutationEnvelopeV1", RadrootsEventClass::Regular, RadrootsEventPrivacy::Public, RadrootsActorRole::Buyer, RadrootsContentSchema::JsonObject, - RadrootsEventDiscriminator::KindOnly, - ORDER_REQUEST_TAGS, - ORDER_REDUCERS + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "contract_id", + value: "radroots.trade.proposal.v1", + }, + TRADE_MUTATION_TAGS, + TRADE_MUTATION_REDUCERS ), event_contract!( - "radroots.order.decision.v1", - KIND_ORDER_DECISION, - "Order Decision", - "RadrootsOrderDecision", + "radroots.trade.decision.v1", + KIND_TRADE_DECISION, + "Trade Decision", + "RadrootsTradeMutationEnvelopeV1", RadrootsEventClass::Regular, RadrootsEventPrivacy::Public, RadrootsActorRole::Seller, RadrootsContentSchema::JsonObject, - RadrootsEventDiscriminator::KindOnly, - CHAINED_ORDER_TAGS, - ORDER_REDUCERS + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "contract_id", + value: "radroots.trade.decision.v1", + }, + TRADE_MUTATION_TAGS, + TRADE_MUTATION_REDUCERS ), event_contract!( - "radroots.order.cancellation.v1", - KIND_ORDER_CANCELLATION, - "Order Cancellation", - "RadrootsOrderCancellation", + "radroots.trade.revision_proposal.v1", + KIND_TRADE_REVISION_PROPOSAL, + "Trade Revision Proposal", + "RadrootsTradeMutationEnvelopeV1", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::JsonObject, + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "contract_id", + value: "radroots.trade.revision_proposal.v1", + }, + TRADE_MUTATION_TAGS, + TRADE_MUTATION_REDUCERS + ), + event_contract!( + "radroots.trade.revision_decision.v1", + KIND_TRADE_REVISION_DECISION, + "Trade Revision Decision", + "RadrootsTradeMutationEnvelopeV1", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::JsonObject, + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "contract_id", + value: "radroots.trade.revision_decision.v1", + }, + TRADE_MUTATION_TAGS, + TRADE_MUTATION_REDUCERS + ), + event_contract!( + "radroots.trade.cancellation.v1", + KIND_TRADE_CANCELLATION, + "Trade Cancellation", + "RadrootsTradeMutationEnvelopeV1", RadrootsEventClass::Regular, RadrootsEventPrivacy::Public, RadrootsActorRole::Buyer, RadrootsContentSchema::JsonObject, - RadrootsEventDiscriminator::KindOnly, - CHAINED_ORDER_TAGS, - ORDER_REDUCERS + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "contract_id", + value: "radroots.trade.cancellation.v1", + }, + TRADE_MUTATION_TAGS, + TRADE_MUTATION_REDUCERS ), event_contract!( "radroots.trade.validation_receipt.v1", @@ -2946,9 +2976,11 @@ pub fn kind_contract_family(contract: &RadrootsKindContract) -> Option<RadrootsC | KIND_FARM_CRDT_CHANGE => RadrootsContractFamily::Farm, KIND_LISTING => RadrootsContractFamily::Market, KIND_TRADE_VALIDATION_RECEIPT - | KIND_ORDER_REQUEST - | KIND_ORDER_DECISION - | KIND_ORDER_CANCELLATION => RadrootsContractFamily::Trade, + | KIND_TRADE_PROPOSAL + | KIND_TRADE_DECISION + | KIND_TRADE_REVISION_PROPOSAL + | KIND_TRADE_REVISION_DECISION + | KIND_TRADE_CANCELLATION => RadrootsContractFamily::Trade, KIND_WIKI_MERGE_REQUEST | KIND_WIKI_ARTICLE | KIND_WIKI_REDIRECT @@ -3037,6 +3069,7 @@ pub fn validate_event_contract_parts( } validate_content_shape_parts(content, contract)?; validate_contract_tags_parts(tags, contract)?; + validate_discriminator_parts(content, contract)?; validate_custom_knowledge_contract_parts(content, contract)?; Ok(()) } @@ -3094,7 +3127,7 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> { Some(RadrootsContractFamily::Profile) } else if id.starts_with("radroots.relay.") { Some(RadrootsContractFamily::Relay) - } else if id.starts_with("radroots.trade.") || id.starts_with("radroots.order.") { + } else if id.starts_with("radroots.trade.") { Some(RadrootsContractFamily::Trade) } else { None @@ -3340,6 +3373,30 @@ fn validate_custom_knowledge_contract_parts( } } +fn validate_discriminator_parts( + content: &str, + contract: &RadrootsEventContract, +) -> Result<(), RadrootsContractValidationError> { + let (field, value) = match &contract.discriminator { + RadrootsEventDiscriminator::ContentJsonFieldEquals { field, value } => (*field, *value), + RadrootsEventDiscriminator::EnvelopeType(value) => ("type", *value), + _ => return Ok(()), + }; + let object = parse_content_object(content, contract.id)?; + match object.get(field).and_then(|actual| actual.as_str()) { + Some(actual) if actual == value => Ok(()), + Some(_) => Err(RadrootsContractValidationError::ContentFieldMismatch { + contract_id: contract.id, + field, + expected: value.to_owned(), + }), + None => Err(RadrootsContractValidationError::MissingContentField { + contract_id: contract.id, + field, + }), + } +} + fn parse_content_object( content: &str, contract_id: &'static str, @@ -3668,16 +3725,16 @@ mod tests { } #[test] - fn order_request_listing_event_contract_is_event_id() { - let contract = event_contract("radroots.order.request.v1").expect("order request"); + fn trade_mutation_contract_requires_exact_contract_tag() { + let contract = event_contract("radroots.trade.proposal.v1").expect("trade proposal"); let tag = contract .tags .iter() - .find(|tag| tag.name == "listing_event") - .expect("listing event tag"); + .find(|tag| tag.name == "contract") + .expect("contract tag"); - assert_eq!(tag.semantic, RadrootsTagSemantic::ListingSnapshot); - assert_eq!(tag.value_type, RadrootsTagValueType::EventId); + assert_eq!(tag.semantic, RadrootsTagSemantic::Contract); + assert_eq!(tag.value_type, RadrootsTagValueType::ContractId); assert!(!tag.relay_indexed); } @@ -3800,10 +3857,7 @@ mod tests { "radroots.trade.test.v1", Some(RadrootsContractFamily::Trade), ), - ( - "radroots.order.test.v1", - Some(RadrootsContractFamily::Trade), - ), + ("radroots.order.test.v1", None), ("radroots.test.unknown.v1", None), ] { assert_eq!(contract_family_for_id(id), family, "{id}"); @@ -3819,7 +3873,7 @@ mod tests { (KIND_CALENDAR_EVENT_RSVP, RadrootsContractFamily::Calendar), (KIND_FARM_CRDT_CHANGE, RadrootsContractFamily::Farm), (KIND_LISTING, RadrootsContractFamily::Market), - (KIND_ORDER_CANCELLATION, RadrootsContractFamily::Trade), + (KIND_TRADE_CANCELLATION, RadrootsContractFamily::Trade), (KIND_KNOWLEDGE_CLAIM, RadrootsContractFamily::Knowledge), (KIND_JOB_FEEDBACK, RadrootsContractFamily::Job), (KIND_JOB_REQUEST_MIN, RadrootsContractFamily::Job), @@ -3965,17 +4019,17 @@ mod tests { #[test] fn supports_content_field_discriminators() { assert!(discriminator_matches( - &RadrootsEventDiscriminator::EnvelopeType("order_request"), + &RadrootsEventDiscriminator::EnvelopeType("proposal"), &[], - r#"{"domain":"radroots.order","type":"order_request"}"# + r#"{"domain":"radroots.trade","type":"proposal"}"# )); assert!(discriminator_matches( &RadrootsEventDiscriminator::ContentJsonFieldEquals { field: "domain", - value: "radroots.order" + value: "radroots.trade" }, &[], - r#"{"domain": "radroots.order", "type": "order_request"}"# + r#"{"domain": "radroots.trade", "type": "proposal"}"# )); } diff --git a/crates/event/src/event_head.rs b/crates/event/src/event_head.rs @@ -170,7 +170,7 @@ mod tests { use crate::RadrootsEventEnvelopeParts; use crate::contract::RadrootsContractMatchError; use crate::kinds::{ - KIND_FOLLOW, KIND_LIST_SET_GENERIC, KIND_ORDER_REQUEST, KIND_POST, KIND_PROFILE, + KIND_FOLLOW, KIND_LIST_SET_GENERIC, KIND_POST, KIND_PROFILE, KIND_TRADE_PROPOSAL, }; fn hex_64(character: char) -> String { @@ -413,21 +413,27 @@ mod tests { } #[test] - fn contract_bridge_keeps_order_events_out_of_head_selection() { - let order = event_with_content( - KIND_ORDER_REQUEST, + fn contract_bridge_keeps_trade_mutations_out_of_head_selection() { + let trade = event_with_content( + KIND_TRADE_PROPOSAL, &hex_64('4'), &hex_64('d'), 1, vec![ + vec![ + "contract".to_string(), + "radroots.trade.proposal.v1".to_string(), + ], vec!["p".to_string(), hex_64('e')], - vec!["a".to_string(), format!("30402:{}:listing-1", hex_64('f'))], - vec![TAG_D.to_string(), "order-1".to_string()], + vec![ + TAG_D.to_string(), + "11111111111111111111111111111111".to_string(), + ], ], - "{}", + r#"{"contract_id":"radroots.trade.proposal.v1"}"#, ); assert_eq!( - event_head_candidate_for_event(&order).expect("order contract"), + event_head_candidate_for_event(&trade).expect("trade contract"), RadrootsEventHeadCandidateResult::NotHeadSelected ); } diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs @@ -176,6 +176,9 @@ macro_rules! validated_string_id { validated_string_id!(RadrootsPublicKey, validate_hex_64); validated_string_id!(RadrootsEventId, validate_hex_64); validated_string_id!(RadrootsEventSignature, validate_hex_128); +validated_string_id!(RadrootsTradeId, validate_hex_32); +validated_string_id!(RadrootsTradeCandidateId, validate_hex_64); +validated_string_id!(RadrootsTradeMutationId, validate_hex_64); validated_string_id!(RadrootsDTag, validate_d_tag); validated_string_id!( RadrootsAddressableCoordinate, @@ -231,6 +234,10 @@ fn validate_hex_64(value: &str) -> Result<String, RadrootsIdParseError> { validate_hex(value, 64) } +fn validate_hex_32(value: &str) -> Result<String, RadrootsIdParseError> { + validate_hex(value, 32) +} + fn validate_hex_128(value: &str) -> Result<String, RadrootsIdParseError> { validate_hex(value, 128) } @@ -391,6 +398,10 @@ mod tests { core::iter::repeat_n(character, 64).collect() } + fn hex_32(character: char) -> String { + core::iter::repeat_n(character, 32).collect() + } + fn hex_128(character: char) -> String { core::iter::repeat_n(character, 128).collect() } @@ -451,6 +462,21 @@ mod tests { } #[test] + fn trade_semantic_ids_use_protocol_sized_hex() { + let trade_id = RadrootsTradeId::parse(hex_32('A')).expect("trade id"); + assert_eq!(trade_id.as_str(), hex_32('a')); + assert_eq!( + RadrootsTradeId::parse(hex_64('a')).unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 32, + actual: 64 + } + ); + assert_identifier_impls!(RadrootsTradeCandidateId, &hex_64('b')); + assert_identifier_impls!(RadrootsTradeMutationId, &hex_64('c')); + } + + #[test] fn d_tags_reject_empty_control_and_whitespace() { assert_eq!( RadrootsDTag::parse("").unwrap_err(), diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs @@ -94,6 +94,12 @@ pub const KIND_KNOWLEDGE_REVIEW: u32 = 3462; pub const KIND_KNOWLEDGE_FIELD_REPORT: u32 = 3463; pub const KIND_KNOWLEDGE_CHANGE_PROPOSAL: u32 = 3464; pub const KIND_CONTRIBUTION_ATTESTATION: u32 = 3465; +pub const KIND_TRADE_PROPOSAL: u32 = 3470; +pub const KIND_TRADE_DECISION: u32 = 3471; +pub const KIND_TRADE_REVISION_PROPOSAL: u32 = 3472; +pub const KIND_TRADE_REVISION_DECISION: u32 = 3473; +pub const KIND_TRADE_CANCELLATION: u32 = 3474; +pub const KIND_TRADE_SELLER_RESERVATION_ASSERTION: u32 = 3475; pub const KIND_KNOWLEDGE_SOURCE: u32 = 30450; pub const KIND_EVIDENCE_BOUNTY: u32 = 30451; @@ -159,13 +165,23 @@ pub const ORDER_EVENT_KINDS: [u32; 3] = [ KIND_ORDER_CANCELLATION, ]; +pub const TRADE_MUTATION_EVENT_KINDS: [u32; 5] = [ + KIND_TRADE_PROPOSAL, + KIND_TRADE_DECISION, + KIND_TRADE_REVISION_PROPOSAL, + KIND_TRADE_REVISION_DECISION, + KIND_TRADE_CANCELLATION, +]; + pub const TRADE_VALIDATION_EVENT_KINDS: [u32; 1] = [KIND_TRADE_VALIDATION_RECEIPT]; -pub const COMMERCIAL_EVENT_KINDS: [u32; 5] = [ +pub const COMMERCIAL_EVENT_KINDS: [u32; 7] = [ KIND_LISTING, - KIND_ORDER_REQUEST, - KIND_ORDER_DECISION, - KIND_ORDER_CANCELLATION, + KIND_TRADE_PROPOSAL, + KIND_TRADE_DECISION, + KIND_TRADE_REVISION_PROPOSAL, + KIND_TRADE_REVISION_DECISION, + KIND_TRADE_CANCELLATION, KIND_TRADE_VALIDATION_RECEIPT, ]; @@ -536,6 +552,18 @@ pub const fn is_order_event_kind(kind: u32) -> bool { } #[inline] +pub const fn is_trade_mutation_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_TRADE_PROPOSAL + | KIND_TRADE_DECISION + | KIND_TRADE_REVISION_PROPOSAL + | KIND_TRADE_REVISION_DECISION + | KIND_TRADE_CANCELLATION + ) +} + +#[inline] pub const fn is_trade_validation_receipt_kind(kind: u32) -> bool { kind == KIND_TRADE_VALIDATION_RECEIPT } @@ -547,7 +575,9 @@ pub const fn is_trade_validation_event_kind(kind: u32) -> bool { #[inline] pub const fn is_commercial_event_kind(kind: u32) -> bool { - is_listing_event_kind(kind) || is_order_event_kind(kind) || is_trade_validation_event_kind(kind) + is_listing_event_kind(kind) + || is_trade_mutation_event_kind(kind) + || is_trade_validation_event_kind(kind) } #[inline] @@ -891,6 +921,16 @@ mod tests { ] ); assert_eq!( + TRADE_MUTATION_EVENT_KINDS, + [ + KIND_TRADE_PROPOSAL, + KIND_TRADE_DECISION, + KIND_TRADE_REVISION_PROPOSAL, + KIND_TRADE_REVISION_DECISION, + KIND_TRADE_CANCELLATION, + ] + ); + assert_eq!( TRADE_VALIDATION_EVENT_KINDS, [KIND_TRADE_VALIDATION_RECEIPT] ); @@ -898,9 +938,11 @@ mod tests { COMMERCIAL_EVENT_KINDS, [ KIND_LISTING, - KIND_ORDER_REQUEST, - KIND_ORDER_DECISION, - KIND_ORDER_CANCELLATION, + KIND_TRADE_PROPOSAL, + KIND_TRADE_DECISION, + KIND_TRADE_REVISION_PROPOSAL, + KIND_TRADE_REVISION_DECISION, + KIND_TRADE_CANCELLATION, KIND_TRADE_VALIDATION_RECEIPT, ] ); @@ -920,6 +962,14 @@ mod tests { assert!(!is_order_event_kind(KIND_TRADE_VALIDATION_RECEIPT)); assert!(!is_order_event_kind(3431)); + assert!(is_trade_mutation_event_kind(KIND_TRADE_PROPOSAL)); + assert!(is_trade_mutation_event_kind(KIND_TRADE_DECISION)); + assert!(is_trade_mutation_event_kind(KIND_TRADE_REVISION_PROPOSAL)); + assert!(is_trade_mutation_event_kind(KIND_TRADE_REVISION_DECISION)); + assert!(is_trade_mutation_event_kind(KIND_TRADE_CANCELLATION)); + assert!(!is_trade_mutation_event_kind(KIND_ORDER_REQUEST)); + assert!(!is_trade_mutation_event_kind(KIND_TRADE_VALIDATION_RECEIPT)); + assert!(!is_trade_validation_service_request_kind(5321)); assert!(!is_trade_validation_service_request_kind(5322)); assert!(!is_trade_validation_service_result_kind(6321)); @@ -940,8 +990,9 @@ mod tests { assert!(!is_trade_validation_event_kind(3434)); assert!(is_commercial_event_kind(KIND_LISTING)); - assert!(is_commercial_event_kind(KIND_ORDER_REQUEST)); + assert!(is_commercial_event_kind(KIND_TRADE_PROPOSAL)); assert!(is_commercial_event_kind(KIND_TRADE_VALIDATION_RECEIPT)); + assert!(!is_commercial_event_kind(KIND_ORDER_REQUEST)); assert!(!is_commercial_event_kind(KIND_PROFILE)); assert!(!is_commercial_event_kind(30403)); assert!(!is_commercial_event_kind(5321)); diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -61,6 +61,7 @@ pub mod resource_cap; pub mod seal; pub mod social; pub mod tags; +pub mod trade; pub mod trade_validation; pub mod wire; diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs @@ -0,0 +1,1220 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{ + collections::BTreeMap, + format, + string::{String, ToString}, + vec::Vec, +}; +#[cfg(feature = "std")] +use std::{ + collections::BTreeMap, + string::{String, ToString}, + vec::Vec, +}; + +use core::fmt; + +use crate::ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsIdParseError, + RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId, + RadrootsTradeMutationId, +}; +use crate::kinds::{ + KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, + KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, +}; +#[cfg(feature = "serde")] +use serde::{ + Deserialize, Deserializer, Serialize, + de::{Error as _, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest, Sha256}; + +pub const RADROOTS_TRADE_SCHEMA_VERSION: u16 = 1; +pub const RADROOTS_TRADE_MUTATION_DOMAIN: &[u8] = b"radroots:trade-mutation:v1\0"; +pub const RADROOTS_TRADE_CANDIDATE_DOMAIN: &[u8] = b"radroots:trade-candidate:v1\0"; +pub const RADROOTS_TRADE_PROPOSAL_CONTRACT_ID: &str = "radroots.trade.proposal.v1"; +pub const RADROOTS_TRADE_DECISION_CONTRACT_ID: &str = "radroots.trade.decision.v1"; +pub const RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID: &str = + "radroots.trade.revision_proposal.v1"; +pub const RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID: &str = + "radroots.trade.revision_decision.v1"; +pub const RADROOTS_TRADE_CANCELLATION_CONTRACT_ID: &str = "radroots.trade.cancellation.v1"; +pub const RADROOTS_TRADE_SELLER_RESERVATION_ASSERTION_CONTRACT_ID: &str = + "radroots.trade.seller_reservation_assertion.v1"; +pub const RADROOTS_TRADE_MUTATION_CONTRACT_IDS: [&str; 5] = [ + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, + RADROOTS_TRADE_DECISION_CONTRACT_ID, + RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, + RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, + RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, +]; +pub const RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES: usize = 64 * 1024; +pub const RADROOTS_TRADE_MAX_ACTIVE_LINES: usize = 64; +pub const RADROOTS_TRADE_MAX_ADJUSTMENTS: usize = 128; +pub const RADROOTS_TRADE_MAX_PARENT_MUTATIONS: usize = 4; +pub const RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeMutationKindV1 { + Proposal, + Decision, + RevisionProposal, + RevisionDecision, + Cancellation, +} + +impl RadrootsTradeMutationKindV1 { + pub const fn contract_id(self) -> &'static str { + match self { + Self::Proposal => RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, + Self::Decision => RADROOTS_TRADE_DECISION_CONTRACT_ID, + Self::RevisionProposal => RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, + Self::RevisionDecision => RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, + Self::Cancellation => RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, + } + } + + pub const fn nostr_kind(self) -> u32 { + match self { + Self::Proposal => KIND_TRADE_PROPOSAL, + Self::Decision => KIND_TRADE_DECISION, + Self::RevisionProposal => KIND_TRADE_REVISION_PROPOSAL, + Self::RevisionDecision => KIND_TRADE_REVISION_DECISION, + Self::Cancellation => KIND_TRADE_CANCELLATION, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeMutationEnvelopeV1 { + pub mutation_id: Option<RadrootsTradeMutationId>, + pub contract_id: String, + pub schema_version: u16, + pub trade_id: RadrootsTradeId, + pub root_mutation_id: Option<RadrootsTradeMutationId>, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub farm_id: RadrootsDTag, + pub parent_mutation_ids: Vec<RadrootsTradeMutationId>, + pub author_pubkey: RadrootsPublicKey, + pub counterparty_pubkey: RadrootsPublicKey, + pub authored_at_unix_s: u64, + pub body: RadrootsTradeMutationBodyV1, +} + +impl RadrootsTradeMutationEnvelopeV1 { + pub fn mutation_kind(&self) -> RadrootsTradeMutationKindV1 { + self.body.mutation_kind() + } + + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + if self.schema_version != RADROOTS_TRADE_SCHEMA_VERSION { + return Err(RadrootsTradeProtocolError::InvalidSchemaVersion { + expected: RADROOTS_TRADE_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + let expected = self.mutation_kind().contract_id(); + if self.contract_id != expected { + return Err(RadrootsTradeProtocolError::ContractMismatch { + expected, + actual: self.contract_id.clone(), + }); + } + validate_parent_mutation_ids(self.mutation_id.as_ref(), &self.parent_mutation_ids)?; + match self.mutation_kind() { + RadrootsTradeMutationKindV1::Proposal => { + if self.root_mutation_id.is_some() || !self.parent_mutation_ids.is_empty() { + return Err(RadrootsTradeProtocolError::InvalidInitialParents); + } + } + _ => { + if self.root_mutation_id.is_none() || self.parent_mutation_ids.is_empty() { + return Err(RadrootsTradeProtocolError::MissingParentMutation); + } + } + } + self.body.validate() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "mutation_type") +)] +pub enum RadrootsTradeMutationBodyV1 { + Proposal { + candidate: RadrootsTradeCandidateTermsV1, + }, + Decision { + proposal_mutation_id: RadrootsTradeMutationId, + candidate_id: RadrootsTradeCandidateId, + decision: RadrootsTradeDecisionV1, + }, + RevisionProposal { + candidate: RadrootsTradeCandidateTermsV1, + }, + RevisionDecision { + proposal_mutation_id: RadrootsTradeMutationId, + candidate_id: RadrootsTradeCandidateId, + decision: RadrootsTradeDecisionV1, + }, + Cancellation { + target_candidate_id: Option<RadrootsTradeCandidateId>, + target_claim_mutation_id: Option<RadrootsTradeMutationId>, + reason: String, + }, +} + +impl RadrootsTradeMutationBodyV1 { + pub const fn mutation_kind(&self) -> RadrootsTradeMutationKindV1 { + match self { + Self::Proposal { .. } => RadrootsTradeMutationKindV1::Proposal, + Self::Decision { .. } => RadrootsTradeMutationKindV1::Decision, + Self::RevisionProposal { .. } => RadrootsTradeMutationKindV1::RevisionProposal, + Self::RevisionDecision { .. } => RadrootsTradeMutationKindV1::RevisionDecision, + Self::Cancellation { .. } => RadrootsTradeMutationKindV1::Cancellation, + } + } + + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + match self { + Self::Proposal { candidate } | Self::RevisionProposal { candidate } => { + candidate.validate() + } + Self::Decision { + proposal_mutation_id: _, + candidate_id: _, + decision, + } + | Self::RevisionDecision { + proposal_mutation_id: _, + candidate_id: _, + decision, + } => decision.validate(), + Self::Cancellation { + target_candidate_id, + target_claim_mutation_id, + reason, + } => { + if target_candidate_id.is_none() && target_claim_mutation_id.is_none() { + return Err(RadrootsTradeProtocolError::MissingCancellationTarget); + } + validate_non_empty(reason, "reason") + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeCandidateTermsV1 { + pub candidate_id: Option<RadrootsTradeCandidateId>, + pub schema_version: u16, + pub base_candidate_id: Option<RadrootsTradeCandidateId>, + pub supersession_intent: Option<String>, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, + pub farm_id: RadrootsDTag, + pub lines: Vec<RadrootsTradeCandidateLineV1>, + pub line_tombstones: Vec<RadrootsTradeLineTombstoneV1>, + pub economics: RadrootsTradeEconomicsProfileV1, + pub fulfillment: RadrootsFulfillmentProfileV1, + pub cancellation: RadrootsTradeCancellationProfileV1, + pub private_terms: Option<RadrootsTradePrivateTermsRefV1>, + pub proposal_expires_at_unix_s: u64, +} + +impl RadrootsTradeCandidateTermsV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + if self.schema_version != RADROOTS_TRADE_SCHEMA_VERSION { + return Err(RadrootsTradeProtocolError::InvalidSchemaVersion { + expected: RADROOTS_TRADE_SCHEMA_VERSION, + actual: self.schema_version, + }); + } + if self.lines.is_empty() { + return Err(RadrootsTradeProtocolError::MissingLines); + } + if self.lines.len() > RADROOTS_TRADE_MAX_ACTIVE_LINES { + return Err(RadrootsTradeProtocolError::TooManyLines { + max: RADROOTS_TRADE_MAX_ACTIVE_LINES, + actual: self.lines.len(), + }); + } + validate_sorted_unique_by(&self.lines, |line| line.line_id.as_str(), "lines")?; + validate_sorted_unique_by( + &self.line_tombstones, + |line| line.line_id.as_str(), + "line_tombstones", + )?; + for line in &self.lines { + line.validate()?; + } + for tombstone in &self.line_tombstones { + tombstone.validate()?; + } + self.economics.validate()?; + self.fulfillment.validate()?; + self.cancellation.validate()?; + if let Some(private_terms) = &self.private_terms { + private_terms.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeCandidateLineV1 { + pub line_id: RadrootsDTag, + pub listing_addr: RadrootsAddressableCoordinate, + pub listing_event_id: RadrootsEventId, + pub listing_snapshot_sha256: String, + pub product_id: String, + pub option_id: Option<String>, + pub bin_id: RadrootsInventoryBinId, + pub quantity_mantissa: String, + pub quantity_scale: u8, + pub unit_code: String, + pub unit_profile: String, + pub unit_price_mantissa: String, + pub currency_code: String, + pub line_subtotal_mantissa: String, + pub replaces_line_id: Option<RadrootsDTag>, +} + +impl RadrootsTradeCandidateLineV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_sha256_hex(&self.listing_snapshot_sha256, "listing_snapshot_sha256")?; + validate_non_empty(&self.product_id, "product_id")?; + if let Some(option_id) = &self.option_id { + validate_non_empty(option_id, "option_id")?; + } + validate_decimal_string(&self.quantity_mantissa, "quantity_mantissa")?; + validate_non_empty(&self.unit_code, "unit_code")?; + validate_non_empty(&self.unit_profile, "unit_profile")?; + validate_decimal_string(&self.unit_price_mantissa, "unit_price_mantissa")?; + validate_non_empty(&self.currency_code, "currency_code")?; + validate_decimal_string(&self.line_subtotal_mantissa, "line_subtotal_mantissa") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeLineTombstoneV1 { + pub line_id: RadrootsDTag, + pub reason: String, +} + +impl RadrootsTradeLineTombstoneV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.reason, "line_tombstone.reason") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeEconomicsProfileV1 { + pub profile_id: String, + pub currency_code: String, + pub currency_exponent: u8, + pub rounding_profile: String, + pub subtotal_mantissa: String, + pub discount_total_mantissa: String, + pub adjustment_total_mantissa: String, + pub total_mantissa: String, + pub adjustments: Vec<RadrootsTradeEconomicAdjustmentV1>, +} + +impl RadrootsTradeEconomicsProfileV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.profile_id, "economics.profile_id")?; + validate_non_empty(&self.currency_code, "economics.currency_code")?; + validate_non_empty(&self.rounding_profile, "economics.rounding_profile")?; + validate_decimal_string(&self.subtotal_mantissa, "economics.subtotal_mantissa")?; + validate_decimal_string( + &self.discount_total_mantissa, + "economics.discount_total_mantissa", + )?; + validate_decimal_string( + &self.adjustment_total_mantissa, + "economics.adjustment_total_mantissa", + )?; + validate_decimal_string(&self.total_mantissa, "economics.total_mantissa")?; + if self.adjustments.len() > RADROOTS_TRADE_MAX_ADJUSTMENTS { + return Err(RadrootsTradeProtocolError::TooManyAdjustments { + max: RADROOTS_TRADE_MAX_ADJUSTMENTS, + actual: self.adjustments.len(), + }); + } + validate_sorted_unique_by( + &self.adjustments, + |line| line.adjustment_id.as_str(), + "adjustments", + )?; + for adjustment in &self.adjustments { + adjustment.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeEconomicAdjustmentV1 { + pub adjustment_id: RadrootsDTag, + pub actor: String, + pub effect: String, + pub amount_mantissa: String, + pub reason: String, +} + +impl RadrootsTradeEconomicAdjustmentV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.actor, "adjustment.actor")?; + validate_non_empty(&self.effect, "adjustment.effect")?; + validate_decimal_string(&self.amount_mantissa, "adjustment.amount_mantissa")?; + validate_non_empty(&self.reason, "adjustment.reason") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsFulfillmentProfileV1 { + pub profile_id: String, + pub method: String, + pub starts_at_unix_s: u64, + pub ends_at_unix_s: u64, + pub timezone: String, + pub utc_offset_seconds: i32, + pub fold: u8, + pub location_class: String, + pub requires_private_terms: bool, +} + +impl RadrootsFulfillmentProfileV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.profile_id, "fulfillment.profile_id")?; + validate_non_empty(&self.method, "fulfillment.method")?; + validate_non_empty(&self.timezone, "fulfillment.timezone")?; + validate_non_empty(&self.location_class, "fulfillment.location_class")?; + if self.ends_at_unix_s <= self.starts_at_unix_s { + return Err(RadrootsTradeProtocolError::InvalidTimeRange); + } + if self.fold > 1 { + return Err(RadrootsTradeProtocolError::InvalidField("fulfillment.fold")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradeCancellationProfileV1 { + pub profile_id: String, + pub buyer_pre_agreement: bool, + pub post_agreement_cutoff_unix_s: Option<u64>, +} + +impl RadrootsTradeCancellationProfileV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.profile_id, "cancellation.profile_id") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsTradePrivateTermsRefV1 { + pub artifact_id: String, + pub schema_id: String, + pub ciphertext_commitment: String, + pub required_acknowledgement: bool, +} + +impl RadrootsTradePrivateTermsRefV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(&self.artifact_id, "private_terms.artifact_id")?; + validate_non_empty(&self.schema_id, "private_terms.schema_id")?; + validate_sha256_hex( + &self.ciphertext_commitment, + "private_terms.ciphertext_commitment", + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsSellerReservationAssertionV1 { + pub reservation_id: RadrootsDTag, + pub inventory_authority_id: RadrootsPublicKey, + pub inventory_epoch: u64, + pub candidate_id: RadrootsTradeCandidateId, + pub commitments: Vec<RadrootsSellerReservationLineV1>, + pub reservation_expires_at_unix_s: u64, + pub assertion_commitment: String, +} + +impl RadrootsSellerReservationAssertionV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + if self.commitments.is_empty() { + return Err(RadrootsTradeProtocolError::MissingReservationCommitments); + } + validate_sorted_unique_by( + &self.commitments, + |line| line.line_id.as_str(), + "reservation.commitments", + )?; + for commitment in &self.commitments { + commitment.validate()?; + } + validate_sha256_hex( + &self.assertion_commitment, + "reservation.assertion_commitment", + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RadrootsSellerReservationLineV1 { + pub line_id: RadrootsDTag, + pub bin_id: RadrootsInventoryBinId, + pub quantity_mantissa: String, + pub quantity_scale: u8, + pub unit_code: String, +} + +impl RadrootsSellerReservationLineV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + validate_decimal_string( + &self.quantity_mantissa, + "reservation.line.quantity_mantissa", + )?; + validate_non_empty(&self.unit_code, "reservation.line.unit_code") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))] +pub enum RadrootsTradeDecisionV1 { + Accepted { + reservation_assertion: Option<RadrootsSellerReservationAssertionV1>, + }, + Declined { + reason: String, + }, +} + +impl RadrootsTradeDecisionV1 { + pub fn validate(&self) -> Result<(), RadrootsTradeProtocolError> { + match self { + Self::Accepted { + reservation_assertion, + } => { + if let Some(assertion) = reservation_assertion { + assertion.validate()?; + } + Ok(()) + } + Self::Declined { reason } => validate_non_empty(reason, "decision.reason"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTradeCanonicalMutationV1 { + pub mutation_id: RadrootsTradeMutationId, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub content: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeProtocolError { + InvalidSchemaVersion { + expected: u16, + actual: u16, + }, + ContractMismatch { + expected: &'static str, + actual: String, + }, + InvalidInitialParents, + MissingParentMutation, + TooManyParents { + max: usize, + actual: usize, + }, + UnsortedParents, + DuplicateParent, + SelfParent, + MissingLines, + TooManyLines { + max: usize, + actual: usize, + }, + TooManyAdjustments { + max: usize, + actual: usize, + }, + DuplicateKey(String), + InvalidJson(String), + NonCanonicalJson, + UnsupportedNumber, + ContentTooLarge { + max: usize, + actual: usize, + }, + EmptyField(&'static str), + InvalidField(&'static str), + InvalidIdentifier { + field: &'static str, + error: RadrootsIdParseError, + }, + InvalidTimeRange, + MissingReservationCommitments, + MissingCancellationTarget, + CandidateIdMismatch { + declared: String, + computed: String, + }, + MutationIdMismatch { + declared: String, + computed: String, + }, +} + +impl fmt::Display for RadrootsTradeProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSchemaVersion { expected, actual } => { + write!(f, "trade schema version {actual} must be {expected}") + } + Self::ContractMismatch { expected, actual } => { + write!(f, "trade contract {actual} must be {expected}") + } + Self::InvalidInitialParents => { + write!(f, "initial trade proposal must not have parents") + } + Self::MissingParentMutation => write!(f, "trade mutation requires a parent mutation"), + Self::TooManyParents { max, actual } => { + write!(f, "trade mutation has {actual} parents; max is {max}") + } + Self::UnsortedParents => write!(f, "trade mutation parents must be sorted"), + Self::DuplicateParent => write!(f, "trade mutation parents must be unique"), + Self::SelfParent => write!(f, "trade mutation cannot reference itself as a parent"), + Self::MissingLines => write!(f, "trade candidate requires at least one active line"), + Self::TooManyLines { max, actual } => { + write!(f, "trade candidate has {actual} active lines; max is {max}") + } + Self::TooManyAdjustments { max, actual } => { + write!(f, "trade economics has {actual} adjustments; max is {max}") + } + Self::DuplicateKey(key) => write!(f, "trade canonical JSON has duplicate key {key}"), + Self::InvalidJson(error) => write!(f, "trade canonical JSON is invalid: {error}"), + Self::NonCanonicalJson => write!(f, "trade JSON is not canonical JCS"), + Self::UnsupportedNumber => write!(f, "trade canonical JSON number is unsupported"), + Self::ContentTooLarge { max, actual } => { + write!(f, "trade content is {actual} bytes; max is {max}") + } + Self::EmptyField(field) => write!(f, "trade field {field} cannot be empty"), + Self::InvalidField(field) => write!(f, "trade field {field} is invalid"), + Self::InvalidIdentifier { field, error } => { + write!(f, "trade field {field} is invalid: {error}") + } + Self::InvalidTimeRange => write!(f, "trade fulfillment time range is invalid"), + Self::MissingReservationCommitments => { + write!(f, "seller reservation assertion requires commitments") + } + Self::MissingCancellationTarget => write!(f, "trade cancellation requires a target"), + Self::CandidateIdMismatch { declared, computed } => write!( + f, + "trade candidate id mismatch: declared {declared}, computed {computed}" + ), + Self::MutationIdMismatch { declared, computed } => write!( + f, + "trade mutation id mismatch: declared {declared}, computed {computed}" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsTradeProtocolError {} + +#[cfg(feature = "serde")] +pub fn canonical_trade_candidate_id( + candidate: &RadrootsTradeCandidateTermsV1, +) -> Result<RadrootsTradeCandidateId, RadrootsTradeProtocolError> { + let mut value = serde_json::to_value(candidate) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + remove_object_field(&mut value, "candidate_id")?; + let canonical = canonical_jcs_value(&value)?; + digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical.as_bytes()) + .parse() + .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier { + field: "candidate_id", + error, + }) +} + +#[cfg(feature = "serde")] +pub fn canonical_trade_mutation_id( + envelope: &RadrootsTradeMutationEnvelopeV1, +) -> Result<RadrootsTradeMutationId, RadrootsTradeProtocolError> { + let mut value = serde_json::to_value(envelope) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + remove_object_field(&mut value, "mutation_id")?; + let canonical = canonical_jcs_value(&value)?; + digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical.as_bytes()) + .parse() + .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier { + field: "mutation_id", + error, + }) +} + +#[cfg(feature = "serde")] +pub fn canonical_trade_mutation_content( + mut envelope: RadrootsTradeMutationEnvelopeV1, +) -> Result<RadrootsTradeCanonicalMutationV1, RadrootsTradeProtocolError> { + finalize_body_candidate_ids(&mut envelope.body)?; + envelope.validate()?; + let mutation_id = canonical_trade_mutation_id(&envelope)?; + envelope.mutation_id = Some(mutation_id.clone()); + let value = serde_json::to_value(&envelope) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + let content = canonical_jcs_value(&value)?; + if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES { + return Err(RadrootsTradeProtocolError::ContentTooLarge { + max: RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES, + actual: content.len(), + }); + } + Ok(RadrootsTradeCanonicalMutationV1 { + mutation_id, + envelope, + content, + }) +} + +#[cfg(feature = "serde")] +pub fn trade_mutation_from_canonical_content( + content: &str, +) -> Result<RadrootsTradeMutationEnvelopeV1, RadrootsTradeProtocolError> { + if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES { + return Err(RadrootsTradeProtocolError::ContentTooLarge { + max: RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES, + actual: content.len(), + }); + } + let value = parse_json_without_duplicate_keys(content)?; + if canonical_jcs_value(&value)? != content { + return Err(RadrootsTradeProtocolError::NonCanonicalJson); + } + let envelope: RadrootsTradeMutationEnvelopeV1 = serde_json::from_value(value) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + envelope.validate()?; + verify_candidate_ids(&envelope.body)?; + if let Some(declared) = &envelope.mutation_id { + let computed = canonical_trade_mutation_id(&envelope)?; + if declared != &computed { + return Err(RadrootsTradeProtocolError::MutationIdMismatch { + declared: declared.to_string(), + computed: computed.to_string(), + }); + } + } + Ok(envelope) +} + +#[cfg(feature = "serde")] +pub fn canonical_jcs_from_str(content: &str) -> Result<String, RadrootsTradeProtocolError> { + let value = parse_json_without_duplicate_keys(content)?; + canonical_jcs_value(&value) +} + +#[cfg(feature = "serde")] +pub fn canonical_jcs_value(value: &Value) -> Result<String, RadrootsTradeProtocolError> { + let mut output = String::new(); + write_canonical_jcs(value, &mut output)?; + Ok(output) +} + +#[cfg(feature = "serde")] +fn parse_json_without_duplicate_keys(content: &str) -> Result<Value, RadrootsTradeProtocolError> { + let mut deserializer = serde_json::Deserializer::from_str(content); + let value = NoDuplicateJsonValue::deserialize(&mut deserializer).map_err(map_json_error)?; + deserializer.end().map_err(map_json_error)?; + Ok(value.0) +} + +#[cfg(feature = "serde")] +fn map_json_error(error: serde_json::Error) -> RadrootsTradeProtocolError { + let message = error.to_string(); + if let Some(key) = message.strip_prefix("duplicate key: ") { + let key = key.split(" at line ").next().unwrap_or(key).to_string(); + return RadrootsTradeProtocolError::DuplicateKey(key); + } + RadrootsTradeProtocolError::InvalidJson(message) +} + +#[cfg(feature = "serde")] +fn write_canonical_jcs( + value: &Value, + output: &mut String, +) -> Result<(), RadrootsTradeProtocolError> { + match value { + Value::Null => output.push_str("null"), + Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), + Value::Number(number) => output.push_str(&canonical_number(number)?), + Value::String(value) => { + let encoded = serde_json::to_string(value) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + output.push_str(&encoded); + } + Value::Array(values) => { + output.push('['); + for (index, item) in values.iter().enumerate() { + if index > 0 { + output.push(','); + } + write_canonical_jcs(item, output)?; + } + output.push(']'); + } + Value::Object(map) => { + output.push('{'); + let mut keys = map.keys().collect::<Vec<_>>(); + keys.sort(); + for (index, key) in keys.iter().enumerate() { + if index > 0 { + output.push(','); + } + let encoded = serde_json::to_string(key.as_str()) + .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + output.push_str(&encoded); + output.push(':'); + let value = + map.get(key.as_str()) + .ok_or(RadrootsTradeProtocolError::InvalidJson( + "missing key".to_string(), + ))?; + write_canonical_jcs(value, output)?; + } + output.push('}'); + } + } + Ok(()) +} + +fn canonical_number(number: &Number) -> Result<String, RadrootsTradeProtocolError> { + if number.is_i64() || number.is_u64() { + Ok(number.to_string()) + } else { + Err(RadrootsTradeProtocolError::UnsupportedNumber) + } +} + +fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +#[cfg(feature = "serde")] +fn remove_object_field( + value: &mut Value, + field: &'static str, +) -> Result<(), RadrootsTradeProtocolError> { + let Value::Object(map) = value else { + return Err(RadrootsTradeProtocolError::InvalidJson( + "root must be object".to_string(), + )); + }; + map.remove(field); + Ok(()) +} + +#[cfg(feature = "serde")] +fn finalize_body_candidate_ids( + body: &mut RadrootsTradeMutationBodyV1, +) -> Result<(), RadrootsTradeProtocolError> { + match body { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { + let candidate_id = canonical_trade_candidate_id(candidate)?; + candidate.candidate_id = Some(candidate_id); + } + _ => {} + } + Ok(()) +} + +#[cfg(feature = "serde")] +fn verify_candidate_ids( + body: &RadrootsTradeMutationBodyV1, +) -> Result<(), RadrootsTradeProtocolError> { + match body { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { + if let Some(declared) = &candidate.candidate_id { + let computed = canonical_trade_candidate_id(candidate)?; + if declared != &computed { + return Err(RadrootsTradeProtocolError::CandidateIdMismatch { + declared: declared.to_string(), + computed: computed.to_string(), + }); + } + } + } + _ => {} + } + Ok(()) +} + +fn validate_parent_mutation_ids( + mutation_id: Option<&RadrootsTradeMutationId>, + parents: &[RadrootsTradeMutationId], +) -> Result<(), RadrootsTradeProtocolError> { + if parents.len() > RADROOTS_TRADE_MAX_PARENT_MUTATIONS { + return Err(RadrootsTradeProtocolError::TooManyParents { + max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS, + actual: parents.len(), + }); + } + let mut previous: Option<&str> = None; + for parent in parents { + if Some(parent) == mutation_id { + return Err(RadrootsTradeProtocolError::SelfParent); + } + if let Some(previous) = previous { + match previous.cmp(parent.as_str()) { + core::cmp::Ordering::Greater => { + return Err(RadrootsTradeProtocolError::UnsortedParents); + } + core::cmp::Ordering::Equal => { + return Err(RadrootsTradeProtocolError::DuplicateParent); + } + core::cmp::Ordering::Less => {} + } + } + previous = Some(parent.as_str()); + } + Ok(()) +} + +fn validate_sorted_unique_by<T, F>( + items: &[T], + mut key: F, + field: &'static str, +) -> Result<(), RadrootsTradeProtocolError> +where + F: FnMut(&T) -> &str, +{ + let mut previous: Option<&str> = None; + for item in items { + let item_key = key(item); + validate_non_empty(item_key, field)?; + if let Some(previous) = previous { + if previous >= item_key { + return Err(RadrootsTradeProtocolError::InvalidField(field)); + } + } + previous = Some(item_key); + } + Ok(()) +} + +fn validate_non_empty(value: &str, field: &'static str) -> Result<(), RadrootsTradeProtocolError> { + if value.trim().is_empty() { + Err(RadrootsTradeProtocolError::EmptyField(field)) + } else { + Ok(()) + } +} + +fn validate_decimal_string( + value: &str, + field: &'static str, +) -> Result<(), RadrootsTradeProtocolError> { + validate_non_empty(value, field)?; + let mut chars = value.chars(); + if matches!(chars.clone().next(), Some('-')) { + chars.next(); + } + let mut saw_digit = false; + for character in chars { + match character { + '0'..='9' => saw_digit = true, + _ => return Err(RadrootsTradeProtocolError::InvalidField(field)), + } + } + if saw_digit { + Ok(()) + } else { + Err(RadrootsTradeProtocolError::InvalidField(field)) + } +} + +fn validate_sha256_hex(value: &str, field: &'static str) -> Result<(), RadrootsTradeProtocolError> { + RadrootsTradeMutationId::parse(value) + .map(|_| ()) + .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier { field, error }) +} + +#[cfg(feature = "serde")] +struct NoDuplicateJsonValue(Value); + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for NoDuplicateJsonValue { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(NoDuplicateJsonValueVisitor) + } +} + +#[cfg(feature = "serde")] +struct NoDuplicateJsonValueVisitor; + +#[cfg(feature = "serde")] +impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { + type Value = NoDuplicateJsonValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON value without duplicate object keys") + } + + fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::Bool(value))) + } + + fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::Number(value.into()))) + } + + fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::Number(value.into()))) + } + + fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + Err(E::custom("unsupported number")) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::String(value.to_string()))) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::String(value))) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::Null)) + } + + fn visit_unit<E>(self) -> Result<Self::Value, E> { + Ok(NoDuplicateJsonValue(Value::Null)) + } + + fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: Deserializer<'de>, + { + Deserialize::deserialize(deserializer) + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::<NoDuplicateJsonValue>()? { + values.push(value.0); + } + Ok(NoDuplicateJsonValue(Value::Array(values))) + } + + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + let mut values = BTreeMap::new(); + while let Some(key) = map.next_key::<String>()? { + if values.contains_key(&key) { + return Err(A::Error::custom(format!("duplicate key: {key}"))); + } + let value = map.next_value::<NoDuplicateJsonValue>()?; + values.insert(key, value.0); + } + let mut object = Map::new(); + for (key, value) in values { + object.insert(key, value); + } + Ok(NoDuplicateJsonValue(Value::Object(object))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() + } + + fn hex_32(character: char) -> String { + core::iter::repeat_n(character, 32).collect() + } + + fn pubkey(character: char) -> RadrootsPublicKey { + RadrootsPublicKey::parse(hex_64(character)).unwrap() + } + + fn event_id(character: char) -> RadrootsEventId { + RadrootsEventId::parse(hex_64(character)).unwrap() + } + + fn trade_id() -> RadrootsTradeId { + RadrootsTradeId::parse(hex_32('1')).unwrap() + } + + fn candidate() -> RadrootsTradeCandidateTermsV1 { + RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").unwrap(), + lines: vec![RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("line-1").unwrap(), + listing_addr: RadrootsAddressableCoordinate::parse(format!( + "30402:{}:listing-1", + hex_64('b') + )) + .unwrap(), + listing_event_id: event_id('c'), + listing_snapshot_sha256: hex_64('d'), + product_id: "carrots".to_string(), + option_id: None, + bin_id: RadrootsInventoryBinId::parse("bin-1").unwrap(), + quantity_mantissa: "2".to_string(), + quantity_scale: 0, + unit_code: "count".to_string(), + unit_profile: "mvp-count".to_string(), + unit_price_mantissa: "500".to_string(), + currency_code: "USD".to_string(), + line_subtotal_mantissa: "1000".to_string(), + replaces_line_id: None, + }], + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "mvp-fixed".to_string(), + currency_code: "USD".to_string(), + currency_exponent: 2, + rounding_profile: "half-even".to_string(), + subtotal_mantissa: "1000".to_string(), + discount_total_mantissa: "0".to_string(), + adjustment_total_mantissa: "0".to_string(), + total_mantissa: "1000".to_string(), + adjustments: Vec::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: "market-pickup".to_string(), + method: "pickup".to_string(), + starts_at_unix_s: 1_800_000_000, + ends_at_unix_s: 1_800_003_600, + timezone: "America/New_York".to_string(), + utc_offset_seconds: -18_000, + fold: 0, + location_class: "farmstand".to_string(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_string(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: 1_799_999_000, + } + } + + fn proposal() -> RadrootsTradeMutationEnvelopeV1 { + RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: trade_id(), + root_mutation_id: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").unwrap(), + parent_mutation_ids: Vec::new(), + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 1_799_000_000, + body: RadrootsTradeMutationBodyV1::Proposal { + candidate: candidate(), + }, + } + } + + #[test] + fn canonical_json_sorts_keys_and_rejects_duplicate_keys() { + assert_eq!( + canonical_jcs_from_str(r#"{"z":1,"a":["b",true,null]}"#).unwrap(), + r#"{"a":["b",true,null],"z":1}"# + ); + assert!(matches!( + canonical_jcs_from_str(r#"{"a":1,"a":2}"#), + Err(RadrootsTradeProtocolError::DuplicateKey(_)) + )); + } + + #[test] + fn canonical_trade_mutation_sets_candidate_and_mutation_ids() { + let canonical = canonical_trade_mutation_content(proposal()).unwrap(); + assert!(canonical.envelope.mutation_id.is_some()); + assert_eq!( + canonical.mutation_id, + canonical.envelope.mutation_id.clone().unwrap() + ); + let parsed = trade_mutation_from_canonical_content(&canonical.content).unwrap(); + assert_eq!(parsed.mutation_id, Some(canonical.mutation_id)); + } + + #[test] + fn canonical_trade_mutation_rejects_parent_ordering() { + let mut envelope = proposal(); + envelope.contract_id = RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID.to_string(); + envelope.root_mutation_id = Some(RadrootsTradeMutationId::parse(hex_64('a')).unwrap()); + envelope.parent_mutation_ids = vec![ + RadrootsTradeMutationId::parse(hex_64('b')).unwrap(), + RadrootsTradeMutationId::parse(hex_64('a')).unwrap(), + ]; + envelope.body = RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate(), + }; + assert!(matches!( + canonical_trade_mutation_content(envelope), + Err(RadrootsTradeProtocolError::UnsortedParents) + )); + } +} diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -52,6 +52,7 @@ pub mod list; pub mod list_set; pub mod listing; pub mod order; +pub mod trade; #[cfg(test)] mod test_fixtures; diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs @@ -393,6 +393,7 @@ fn reducer_label(value: RadrootsReducer) -> &'static str { RadrootsReducer::ProfileProjection => "profile_projection", RadrootsReducer::NostrRelayPolicyProjection => "nostr_relay_policy_projection", RadrootsReducer::SocialProjection => "social_projection", + RadrootsReducer::TradeProjection => "trade_projection", RadrootsReducer::TradeValidation => "trade_validation", } } diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs @@ -0,0 +1,357 @@ +#[cfg(all(not(feature = "std"), feature = "serde_json"))] +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; + +#[cfg(feature = "serde_json")] +use radroots_event::{ + RadrootsEventEnvelope, + ids::{RadrootsDTag, RadrootsTradeMutationId}, + kinds::is_trade_mutation_event_kind, + tags::{TAG_D, TAG_E}, + trade::{ + RadrootsTradeMutationEnvelopeV1, RadrootsTradeProtocolError, + canonical_trade_mutation_content, trade_mutation_from_canonical_content, + }, + wire::RadrootsNip01EventWireParts, +}; + +#[cfg(feature = "serde_json")] +use crate::error::EventEncodeError; + +#[cfg(feature = "serde_json")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTradeMutationParseError { + InvalidKind(u32), + MissingTag(&'static str), + InvalidTag(&'static str), + ContractTagMismatch, + TradeIdTagMismatch, + CounterpartyTagMismatch, + ParentTagsMismatch, + KindContractMismatch, + Canonical(RadrootsTradeProtocolError), +} + +#[cfg(feature = "serde_json")] +impl core::fmt::Display for RadrootsTradeMutationParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidKind(kind) => write!(f, "invalid trade mutation kind: {kind}"), + Self::MissingTag(tag) => write!(f, "missing trade mutation tag: {tag}"), + Self::InvalidTag(tag) => write!(f, "invalid trade mutation tag: {tag}"), + Self::ContractTagMismatch => write!(f, "trade mutation contract tag mismatch"), + Self::TradeIdTagMismatch => write!(f, "trade mutation trade id tag mismatch"), + Self::CounterpartyTagMismatch => write!(f, "trade mutation counterparty tag mismatch"), + Self::ParentTagsMismatch => write!(f, "trade mutation parent tags mismatch"), + Self::KindContractMismatch => write!(f, "trade mutation kind contract mismatch"), + Self::Canonical(error) => write!(f, "{error}"), + } + } +} + +#[cfg(all(feature = "std", feature = "serde_json"))] +impl std::error::Error for RadrootsTradeMutationParseError {} + +#[cfg(feature = "serde_json")] +impl From<RadrootsTradeProtocolError> for RadrootsTradeMutationParseError { + fn from(value: RadrootsTradeProtocolError) -> Self { + Self::Canonical(value) + } +} + +#[cfg(feature = "serde_json")] +pub fn trade_mutation_event_build( + envelope: RadrootsTradeMutationEnvelopeV1, +) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { + let canonical = canonical_trade_mutation_content(envelope) + .map_err(map_trade_protocol_error_to_encode_error)?; + let tags = trade_mutation_tags(&canonical.envelope)?; + Ok(RadrootsNip01EventWireParts { + kind: canonical.envelope.mutation_kind().nostr_kind(), + content: canonical.content, + tags, + }) +} + +#[cfg(feature = "serde_json")] +pub fn trade_mutation_from_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsTradeMutationEnvelopeV1, RadrootsTradeMutationParseError> { + if !is_trade_mutation_event_kind(event.kind_u32()) { + return Err(RadrootsTradeMutationParseError::InvalidKind( + event.kind_u32(), + )); + } + let envelope = trade_mutation_from_canonical_content(event.content())?; + if envelope.mutation_kind().nostr_kind() != event.kind_u32() { + return Err(RadrootsTradeMutationParseError::KindContractMismatch); + } + validate_trade_mutation_tags(&envelope, &event.tags_as_vec())?; + Ok(envelope) +} + +#[cfg(feature = "serde_json")] +pub fn trade_mutation_tags( + envelope: &RadrootsTradeMutationEnvelopeV1, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = Vec::with_capacity(3 + envelope.parent_mutation_ids.len()); + push_tag(&mut tags, "contract", envelope.contract_id.clone())?; + push_tag(&mut tags, TAG_D, envelope.trade_id.to_string())?; + push_tag(&mut tags, "p", envelope.counterparty_pubkey.to_string())?; + for parent in &envelope.parent_mutation_ids { + push_tag(&mut tags, TAG_E, parent.to_string())?; + } + Ok(tags) +} + +#[cfg(feature = "serde_json")] +fn validate_trade_mutation_tags( + envelope: &RadrootsTradeMutationEnvelopeV1, + tags: &[Vec<String>], +) -> Result<(), RadrootsTradeMutationParseError> { + let contract = required_tag_value(tags, "contract")?; + if contract != envelope.contract_id { + return Err(RadrootsTradeMutationParseError::ContractTagMismatch); + } + let trade_id = required_tag_value(tags, TAG_D)?; + if trade_id != envelope.trade_id.as_str() { + return Err(RadrootsTradeMutationParseError::TradeIdTagMismatch); + } + let counterparty = required_tag_value(tags, "p")?; + if counterparty != envelope.counterparty_pubkey.as_str() { + return Err(RadrootsTradeMutationParseError::CounterpartyTagMismatch); + } + let mut parents = Vec::new(); + for tag in tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some(TAG_E)) + { + let value = tag + .get(1) + .map(String::as_str) + .ok_or(RadrootsTradeMutationParseError::InvalidTag(TAG_E))?; + let parent = RadrootsTradeMutationId::parse(value) + .map_err(|_| RadrootsTradeMutationParseError::InvalidTag(TAG_E))?; + parents.push(parent); + } + if parents != envelope.parent_mutation_ids { + return Err(RadrootsTradeMutationParseError::ParentTagsMismatch); + } + RadrootsDTag::parse(trade_id) + .map_err(|_| RadrootsTradeMutationParseError::InvalidTag(TAG_D))?; + Ok(()) +} + +#[cfg(feature = "serde_json")] +fn required_tag_value<'a>( + tags: &'a [Vec<String>], + name: &'static str, +) -> Result<&'a str, RadrootsTradeMutationParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(String::as_str) == Some(name)) + .ok_or(RadrootsTradeMutationParseError::MissingTag(name))?; + let value = tag + .get(1) + .map(String::as_str) + .ok_or(RadrootsTradeMutationParseError::InvalidTag(name))?; + if value.trim().is_empty() { + return Err(RadrootsTradeMutationParseError::InvalidTag(name)); + } + Ok(value) +} + +#[cfg(feature = "serde_json")] +fn push_tag( + tags: &mut Vec<Vec<String>>, + name: &'static str, + value: String, +) -> Result<(), EventEncodeError> { + if value.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField(name)); + } + tags.push(vec![name.to_string(), value]); + Ok(()) +} + +#[cfg(feature = "serde_json")] +fn map_trade_protocol_error_to_encode_error(error: RadrootsTradeProtocolError) -> EventEncodeError { + match error { + RadrootsTradeProtocolError::EmptyField(field) => { + EventEncodeError::EmptyRequiredField(field) + } + RadrootsTradeProtocolError::ContractMismatch { .. } + | RadrootsTradeProtocolError::InvalidField(_) + | RadrootsTradeProtocolError::InvalidIdentifier { .. } + | RadrootsTradeProtocolError::InvalidInitialParents + | RadrootsTradeProtocolError::MissingParentMutation + | RadrootsTradeProtocolError::TooManyParents { .. } + | RadrootsTradeProtocolError::UnsortedParents + | RadrootsTradeProtocolError::DuplicateParent + | RadrootsTradeProtocolError::SelfParent + | RadrootsTradeProtocolError::MissingLines + | RadrootsTradeProtocolError::TooManyLines { .. } + | RadrootsTradeProtocolError::TooManyAdjustments { .. } + | RadrootsTradeProtocolError::UnsupportedNumber + | RadrootsTradeProtocolError::ContentTooLarge { .. } + | RadrootsTradeProtocolError::InvalidTimeRange + | RadrootsTradeProtocolError::MissingReservationCommitments + | RadrootsTradeProtocolError::MissingCancellationTarget + | RadrootsTradeProtocolError::CandidateIdMismatch { .. } + | RadrootsTradeProtocolError::MutationIdMismatch { .. } + | RadrootsTradeProtocolError::InvalidSchemaVersion { .. } => { + EventEncodeError::InvalidField("trade_mutation") + } + RadrootsTradeProtocolError::DuplicateKey(_) + | RadrootsTradeProtocolError::InvalidJson(_) + | RadrootsTradeProtocolError::NonCanonicalJson => EventEncodeError::Json, + } +} + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use super::*; + use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, + ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsPublicKey, RadrootsTradeId, + }, + trade::{ + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, + RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, + RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, + RadrootsTradeEconomicAdjustmentV1, RadrootsTradeEconomicsProfileV1, + RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, + }, + }; + + fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() + } + + fn hex_32(character: char) -> String { + core::iter::repeat_n(character, 32).collect() + } + + fn pubkey(character: char) -> RadrootsPublicKey { + RadrootsPublicKey::parse(hex_64(character)).unwrap() + } + + fn event_id(character: char) -> RadrootsEventId { + RadrootsEventId::parse(hex_64(character)).unwrap() + } + + fn proposal() -> RadrootsTradeMutationEnvelopeV1 { + RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: RadrootsTradeId::parse(hex_32('1')).unwrap(), + root_mutation_id: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").unwrap(), + parent_mutation_ids: Vec::new(), + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 1_799_000_000, + body: RadrootsTradeMutationBodyV1::Proposal { + candidate: candidate(), + }, + } + } + + fn candidate() -> RadrootsTradeCandidateTermsV1 { + RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: RadrootsDTag::parse("farm-1").unwrap(), + lines: vec![RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("line-1").unwrap(), + listing_addr: RadrootsAddressableCoordinate::parse(format!( + "30402:{}:listing-1", + hex_64('b') + )) + .unwrap(), + listing_event_id: event_id('c'), + listing_snapshot_sha256: hex_64('d'), + product_id: "carrots".to_string(), + option_id: None, + bin_id: RadrootsInventoryBinId::parse("bin-1").unwrap(), + quantity_mantissa: "2".to_string(), + quantity_scale: 0, + unit_code: "count".to_string(), + unit_profile: "mvp-count".to_string(), + unit_price_mantissa: "500".to_string(), + currency_code: "USD".to_string(), + line_subtotal_mantissa: "1000".to_string(), + replaces_line_id: None, + }], + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "mvp-fixed".to_string(), + currency_code: "USD".to_string(), + currency_exponent: 2, + rounding_profile: "half-even".to_string(), + subtotal_mantissa: "1000".to_string(), + discount_total_mantissa: "0".to_string(), + adjustment_total_mantissa: "0".to_string(), + total_mantissa: "1000".to_string(), + adjustments: Vec::<RadrootsTradeEconomicAdjustmentV1>::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: "market-pickup".to_string(), + method: "pickup".to_string(), + starts_at_unix_s: 1_800_000_000, + ends_at_unix_s: 1_800_003_600, + timezone: "America/New_York".to_string(), + utc_offset_seconds: -18_000, + fold: 0, + location_class: "farmstand".to_string(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_string(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: 1_799_999_000, + } + } + + #[test] + fn trade_mutation_event_build_roundtrips_canonical_content_and_tags() { + let parts = trade_mutation_event_build(proposal()).unwrap(); + assert_eq!(parts.kind, radroots_event::kinds::KIND_TRADE_PROPOSAL); + assert_eq!( + parts.tags[0], + vec![ + "contract".to_string(), + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_string() + ] + ); + let envelope = trade_mutation_from_event( + &RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: hex_64('e'), + author: hex_64('a'), + created_at: 1_799_000_000, + kind: parts.kind, + tags: parts.tags, + content: parts.content, + sig: core::iter::repeat_n('f', 128).collect(), + }) + .unwrap(), + ) + .unwrap(); + assert_eq!(envelope.contract_id, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID); + } +} diff --git a/flake.lock b/flake.lock @@ -80,11 +80,11 @@ ] }, "locked": { - "lastModified": 1773284828, - "narHash": "sha256-0qI+a9z7KpYCBbp4ENN32b2tf0VmC3MhgFw1KAroqxQ=", + "lastModified": 1784178955, + "narHash": "sha256-YtXHVFBzzqSidcEhPhfTcF2yzzu0p0yA7x07LOR+LaY=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "2b18fe48d9a8a4ff3850d56b67cfe72f2a589237", + "rev": "1785b85aeccca381caf8777133410f577b8f2f59", "type": "github" }, "original": { diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,3 +1,4 @@ [toolchain] channel = "1.97.0" +components = ["clippy", "rust-analyzer", "rust-src", "rustfmt"] targets = ["wasm32-unknown-unknown"] diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -718,90 +718,107 @@ const DVM_FEEDBACK_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const TRADE_ORDER_REQUESTED_WITNESSES: [EventBoundarySourceWitness; 5] = [ +const TRADE_PROPOSAL_WITNESSES: [EventBoundarySourceWitness; 3] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", - required_fragments: &["pub const KIND_ORDER_REQUEST: u32 = 3422;"], + required_fragments: &["pub const KIND_TRADE_PROPOSAL: u32 = 3470;"], }, EventBoundarySourceWitness { - relative_path: "crates/event/src/order.rs", + relative_path: "crates/event/src/trade.rs", required_fragments: &[ - "pub struct RadrootsOrderRequest", - "Self::OrderRequested => KIND_ORDER_REQUEST", + "pub const RADROOTS_TRADE_PROPOSAL_CONTRACT_ID: &str", + "Self::Proposal => KIND_TRADE_PROPOSAL", ], }, EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/encode.rs", - required_fragments: &["pub fn order_request_event_build"], - }, - EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/decode.rs", - required_fragments: &["pub fn order_request_from_event"], - }, - EventBoundarySourceWitness { - relative_path: "crates/trade/src/order.rs", + relative_path: "crates/event_codec/src/trade/mod.rs", required_fragments: &[ - "pub struct RadrootsOrderRequestRecord", - "pub fn reduce_order_events", + "pub fn trade_mutation_event_build", + "pub fn trade_mutation_from_event", ], }, ]; -const TRADE_ORDER_DECISION_WITNESSES: [EventBoundarySourceWitness; 5] = [ +const TRADE_DECISION_WITNESSES: [EventBoundarySourceWitness; 3] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", - required_fragments: &["pub const KIND_ORDER_DECISION: u32 = 3423;"], + required_fragments: &["pub const KIND_TRADE_DECISION: u32 = 3471;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/event/src/trade.rs", + required_fragments: &[ + "pub const RADROOTS_TRADE_DECISION_CONTRACT_ID: &str", + "Self::Decision => KIND_TRADE_DECISION", + ], }, EventBoundarySourceWitness { - relative_path: "crates/event/src/order.rs", + relative_path: "crates/event_codec/src/trade/mod.rs", required_fragments: &[ - "pub enum RadrootsOrderDecisionOutcome", - "pub struct RadrootsOrderDecision", - "Self::OrderDecision => KIND_ORDER_DECISION", + "pub fn trade_mutation_event_build", + "pub fn trade_mutation_from_event", ], }, +]; + +const TRADE_REVISION_PROPOSAL_WITNESSES: [EventBoundarySourceWitness; 3] = [ EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/encode.rs", - required_fragments: &["pub fn order_decision_event_build"], + relative_path: "crates/event/src/kinds.rs", + required_fragments: &["pub const KIND_TRADE_REVISION_PROPOSAL: u32 = 3472;"], }, EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/decode.rs", - required_fragments: &["pub fn order_decision_from_event"], + relative_path: "crates/event/src/trade.rs", + required_fragments: &[ + "pub const RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID: &str", + "Self::RevisionProposal => KIND_TRADE_REVISION_PROPOSAL", + ], }, EventBoundarySourceWitness { - relative_path: "crates/trade/src/order.rs", + relative_path: "crates/event_codec/src/trade/mod.rs", required_fragments: &[ - "pub struct RadrootsOrderDecisionRecord", - "pub fn reduce_order_events", + "pub fn trade_mutation_event_build", + "pub fn trade_mutation_from_event", ], }, ]; -const TRADE_ORDER_CANCELLED_WITNESSES: [EventBoundarySourceWitness; 5] = [ +const TRADE_REVISION_DECISION_WITNESSES: [EventBoundarySourceWitness; 3] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", - required_fragments: &["pub const KIND_ORDER_CANCELLATION: u32 = 3432;"], + required_fragments: &["pub const KIND_TRADE_REVISION_DECISION: u32 = 3473;"], + }, + EventBoundarySourceWitness { + relative_path: "crates/event/src/trade.rs", + required_fragments: &[ + "pub const RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID: &str", + "Self::RevisionDecision => KIND_TRADE_REVISION_DECISION", + ], }, EventBoundarySourceWitness { - relative_path: "crates/event/src/order.rs", + relative_path: "crates/event_codec/src/trade/mod.rs", required_fragments: &[ - "pub struct RadrootsOrderCancellation", - "Self::OrderCancelled => KIND_ORDER_CANCELLATION", + "pub fn trade_mutation_event_build", + "pub fn trade_mutation_from_event", ], }, +]; + +const TRADE_CANCELLATION_WITNESSES: [EventBoundarySourceWitness; 3] = [ EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/encode.rs", - required_fragments: &["pub fn order_cancellation_event_build"], + relative_path: "crates/event/src/kinds.rs", + required_fragments: &["pub const KIND_TRADE_CANCELLATION: u32 = 3474;"], }, EventBoundarySourceWitness { - relative_path: "crates/event_codec/src/order/decode.rs", - required_fragments: &["pub fn order_cancellation_from_event"], + relative_path: "crates/event/src/trade.rs", + required_fragments: &[ + "pub const RADROOTS_TRADE_CANCELLATION_CONTRACT_ID: &str", + "Self::Cancellation => KIND_TRADE_CANCELLATION", + ], }, EventBoundarySourceWitness { - relative_path: "crates/trade/src/order.rs", + relative_path: "crates/event_codec/src/trade/mod.rs", required_fragments: &[ - "pub struct RadrootsOrderCancellationRecord", - "pub fn reduce_order_events", + "pub fn trade_mutation_event_build", + "pub fn trade_mutation_from_event", ], }, ]; @@ -830,7 +847,7 @@ const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 39] = [ +const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [ EventBoundaryExpectation { domain: "profile", kind: "0", @@ -1182,38 +1199,55 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 39] = [ witnesses: &DVM_FEEDBACK_WITNESSES, }, EventBoundaryExpectation { - domain: "trade:order_requested", - kind: "3422", - radroots_type: "TradeOrderRequested", + domain: "trade:proposal", + kind: "3470", + radroots_type: "RadrootsTradeMutationEnvelopeV1", rpc_methods: &[ - "active CLI `order submit`", - "SDK encode/decode/validate", - "trade reducer", + "trade.get_trade", + "trade.list_trades", + "trade.submit_proposal", ], - witnesses: &TRADE_ORDER_REQUESTED_WITNESSES, + witnesses: &TRADE_PROPOSAL_WITNESSES, }, EventBoundaryExpectation { - domain: "trade:order_decision", - kind: "3423", - radroots_type: "TradeOrderDecision", + domain: "trade:decision", + kind: "3471", + radroots_type: "RadrootsTradeMutationEnvelopeV1", rpc_methods: &[ - "active CLI `order accept`", - "active CLI `order decline`", - "SDK encode/decode/validate", - "trade reducer", + "trade.decide_candidate", + "trade.get_trade", + "trade.list_trades", ], - witnesses: &TRADE_ORDER_DECISION_WITNESSES, + witnesses: &TRADE_DECISION_WITNESSES, }, EventBoundaryExpectation { - domain: "trade:order_cancelled", - kind: "3432", - radroots_type: "TradeOrderCancelled", + domain: "trade:revision_proposal", + kind: "3472", + radroots_type: "RadrootsTradeMutationEnvelopeV1", rpc_methods: &[ - "active CLI `order cancel`", - "SDK encode/decode/validate", - "trade reducer", + "trade.get_trade", + "trade.list_trades", + "trade.propose_revision", ], - witnesses: &TRADE_ORDER_CANCELLED_WITNESSES, + witnesses: &TRADE_REVISION_PROPOSAL_WITNESSES, + }, + EventBoundaryExpectation { + domain: "trade:revision_decision", + kind: "3473", + radroots_type: "RadrootsTradeMutationEnvelopeV1", + rpc_methods: &[ + "trade.decide_candidate", + "trade.get_trade", + "trade.list_trades", + ], + witnesses: &TRADE_REVISION_DECISION_WITNESSES, + }, + EventBoundaryExpectation { + domain: "trade:cancellation", + kind: "3474", + radroots_type: "RadrootsTradeMutationEnvelopeV1", + rpc_methods: &["trade.cancel_trade", "trade.get_trade", "trade.list_trades"], + witnesses: &TRADE_CANCELLATION_WITNESSES, }, EventBoundaryExpectation { domain: "trade:validation_receipt",