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:
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",