commit a68142d0090f24ced89d3ff9ac6fd994f8650885
parent 93879c2a8f9696fc6453267ea427d4a9e9f63ffc
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 23:38:39 +0000
event-store: separate verified admission and visibility
- centralize typed admission over signature-verified NIP-01 events
- preserve durable raw events and deterministic protocol head selection
- expose valid-stream reads, visibility state, and cursor compare-and-swap
- reject ephemeral outbox persistence and retain typed transport outcomes
Diffstat:
34 files changed, 5670 insertions(+), 6494 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,6 +9,32 @@ publish policy both pass for the same source revision.
### Changed
+- Trusted event-contract admission now has one signature-verified entry point.
+ Profile, root Post, Reply, Comment, DeletionRequest, and FoodAvailability
+ retain typed admitted values; other registered events require full contract
+ shape validation, while unsupported matching and invalid shapes remain
+ distinct failures.
+- Event-store ingest now verifies before durable admission, retains every
+ verified durable candidate for registry-independent raw-head reduction, and
+ separates immutable valid-stream replay from current visibility. Explicit
+ raw, valid, raw-head, visibility, and visible-head APIs replace ambiguous
+ projection/head reads; projection cursors require an expected version and a
+ monotonic prior-sequence compare-and-swap. Verified ephemeral events receive
+ an explicit not-persisted outcome and allocate no raw sequence, tags,
+ observations, or heads.
+- Nostr fetch-ingest receipts now distinguish admitted, unsupported, invalid,
+ malformed, inserted, duplicate, and ephemeral not-persisted events, carry
+ stable admission codes when classification occurs, and name valid-stream
+ eligibility directly. Local event-store failures now abort fetch ingest as
+ operational errors instead of being reported as malformed relay input.
+- Generic outbox APIs now reject every NIP-16 ephemeral event before durable
+ queue persistence. Live-only events, including NIP-42 relay-auth and NIP-98
+ HTTP-auth signatures, remain owned by their transport exchanges. Externally
+ supplied SQLite pools now validate their backing mode and configure every
+ connection before migration or writes.
+- Retired trade order-workflow and product-projection source files that were no
+ longer compiled or exported have been removed. Current FoodAvailability
+ projection ownership remains with the event store.
- Blossom blob URLs now validate complete raw Unicode text before URL parsing
and exact raw ASCII DNS label grammar before returning a typed value. Unicode
control/format text, implicit IDNA conversion, empty labels, underscores,
@@ -91,6 +117,10 @@ publish policy both pass for the same source revision.
### Added
+- A fixed signed central-admission corpus executes every admitted variant,
+ Update/PhotoUpdate/Ask root classification, Post-to-Reply promotion,
+ Operational Listing fallback from Food exclusion, generic NIP-99 exclusion,
+ unsupported kinds, malformed registered shapes, and ambiguous Food markers.
- Generic protocol builders can now finalize into an opaque checked external
signing request. The request preserves the standard unsigned-event JSON wire
shape while preventing raw mutation or unchecked reconstruction, and it
diff --git a/Cargo.lock b/Cargo.lock
@@ -4359,7 +4359,7 @@ dependencies = [
"hex",
"nostr",
"radroots_event",
- "radroots_nostr",
+ "radroots_event_codec",
"radroots_transport",
"serde",
"serde_json",
diff --git a/contracts/conformance/vectors/event/verified_admission.v1.json b/contracts/conformance/vectors/event/verified_admission.v1.json
@@ -0,0 +1,334 @@
+{
+ "suite": "verified_event_admission",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "event_admit_verified_profile_001",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f",
+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
+ "created_at": 1800000100,
+ "kind": 0,
+ "tags": [],
+ "content": "{\"display_name\":\"Moss Street Farm\",\"bot\":false,\"website\":\"https://mossstreet.example\",\"picture\":42}",
+ "sig": "e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721"
+ }
+ },
+ "expected": {
+ "variant": "profile",
+ "contract_id": "radroots.profile.metadata.v1",
+ "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_update_002",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781632860,
+ "kind": 1,
+ "tags": [],
+ "content": "The first strawberries are ready.",
+ "sig": "dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.update.v1",
+ "event_id": "fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_photo_update_003",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781635400,
+ "kind": 1,
+ "tags": [
+ [
+ "imeta",
+ "url https://cdn.example/harvest.webp",
+ "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "m image/webp",
+ "dim 1200x900",
+ "size 12345",
+ "alt Harvest"
+ ]
+ ],
+ "content": "Harvest https://cdn.example/harvest.webp",
+ "sig": "2f0863959b972f639d028c65d9ca0c2b62d5ad57530ad4c810e67941d1d50ab249488f570b9905095db2df18440aac399907dda5ca0e8289c49e625ce8b0b28b"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.photo_update.v1",
+ "event_id": "f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_ask_004",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781635220,
+ "kind": 1,
+ "tags": [
+ ["t", " RADROOTS-ASK "],
+ ["imeta", "url https://cdn.example/leaf.webp", "x malformed"]
+ ],
+ "content": "Question https://cdn.example/leaf.webp",
+ "sig": "538636b2d163d1a392f4c3fced234ba6af5c9b3f1fc66e3bdbbe374287cf4e62adec6369056a3f096be1b268451c2fb25040ae8e0d67188284c2da18832c20d1"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.ask.v1",
+ "event_id": "5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac"
+ }
+ },
+ {
+ "id": "event_admit_verified_post_to_reply_005",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781000001,
+ "kind": 1,
+ "tags": [
+ [
+ "e",
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ "",
+ "root"
+ ],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ ],
+ "content": "Direct reply",
+ "sig": "16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd"
+ }
+ },
+ "expected": {
+ "variant": "reply",
+ "contract_id": "radroots.social.reply.v1",
+ "event_id": "2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896"
+ }
+ },
+ {
+ "id": "event_admit_verified_comment_006",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "3e424094d13c2870de9e63f295e54ffc68caf00caa125021236a8011d611c6a1",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1800000200,
+ "kind": 1111,
+ "tags": [
+ ["E", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://victoria.example", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"],
+ ["K", "30402"],
+ ["P", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://victoria.example"],
+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://victoria.example", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"],
+ ["k", "30402"],
+ ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://victoria.example"]
+ ],
+ "content": "Are these carrots available Saturday?",
+ "sig": "bdec382660fb50d0c3beb8f57b7f0a3b89cea7469b02b5e92e476550bd61f2d3ce7cdcec538d2a8ad8ab5c19807717af798c36a2e05a4b949b628b4993b9ebd1"
+ }
+ },
+ "expected": {
+ "variant": "comment",
+ "contract_id": "radroots.social.comment.v1",
+ "event_id": "3e424094d13c2870de9e63f295e54ffc68caf00caa125021236a8011d611c6a1"
+ }
+ },
+ {
+ "id": "event_admit_verified_deletion_request_007",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "00d55f2b604090c5eb56a9e445de1e1c31fc86690fd2f368e5a7b7a8ea37a08f",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 50,
+ "kind": 5,
+ "tags": [
+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
+ ],
+ "content": "",
+ "sig": "58cfd1dc2401701c5121367820b0fbac7e5a05f184bd781a8918731a1ed4a8f2e50dee2260ff1b8c4b9c1ac7767e376d9860aabf5fb46dcf460b0cdac215ad3c"
+ }
+ },
+ "expected": {
+ "variant": "deletion_request",
+ "contract_id": "radroots.social.deletion_request.v1",
+ "event_id": "00d55f2b604090c5eb56a9e445de1e1c31fc86690fd2f368e5a7b7a8ea37a08f"
+ }
+ },
+ {
+ "id": "event_admit_verified_food_availability_008",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["d", "nantes-carrots"],
+ ["title", "Nantes Carrots"],
+ ["summary", "Fresh bunches"],
+ ["published_at", "1700000000"],
+ ["location", "Central Saanich, BC"],
+ ["price", "3", "CAD"],
+ ["radroots:price_unit", "lb"],
+ ["status", "active"],
+ ["t", "vegetables"],
+ ["g", "c28hr"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a"
+ }
+ },
+ "expected": {
+ "variant": "food_availability",
+ "contract_id": "radroots.food.availability.v1",
+ "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7"
+ }
+ },
+ {
+ "id": "event_admit_verified_operational_fallback_009",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000000,
+ "kind": 30402,
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"],
+ ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "carrot-nantes"],
+ ["title", "Nantes Carrots"],
+ ["category", "produce"],
+ ["summary", "Fresh bunches harvested in Saanich"],
+ ["published_at", "1700000000"],
+ ["radroots:primary_bin", "bunch"],
+ ["radroots:bin", "bunch", "1", "each"],
+ ["radroots:price", "bunch", "4", "CAD", "1", "each"],
+ ["price", "4", "CAD"],
+ ["inventory", "24"],
+ ["status", "active"],
+ ["delivery", "pickup"],
+ ["location", "Saanich Peninsula", "Victoria", "BC", "CA"],
+ ["g", "c28hr"]
+ ],
+ "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich",
+ "sig": "ef3413c4ac4be3f748fcb51b3e5b9b03c590f5f814dbd6ab3f2f2c26dbc87eb1347cefbe97abacce60f0c4530848695e766d3a950350c72089813b3318a3f944"
+ }
+ },
+ "expected": {
+ "variant": "contract_validated",
+ "contract_id": "radroots.operational_listing.published.v1",
+ "event_id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f"
+ }
+ },
+ {
+ "id": "event_admit_verified_unsupported_kind_010",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958",
+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
+ "created_at": 1800000104,
+ "kind": 65535,
+ "tags": [],
+ "content": "maximum-kind",
+ "sig": "d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_match",
+ "error_code": "unsupported_kind",
+ "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958"
+ }
+ },
+ {
+ "id": "event_admit_verified_generic_nip99_excluded_011",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["d", "generic-offer"],
+ ["title", "Generic Offer"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e533df401a4c6ddfd7dcfd2b3525e9fb8938748dcdc9492aa617ec50d966554511853704929b88b7fe791f3a36659f341b20245182574dd5e5353fa80f57d441"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_match",
+ "error_code": "unsupported_shape",
+ "event_id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d"
+ }
+ },
+ {
+ "id": "event_admit_verified_operational_invalid_shape_012",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["radroots:bin"],
+ ["delivery"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1fc38e6183061bb64f2337941b96a6cc75067b85aa46b4780bd395f62961b54569872c51090bec9f97185add686e3e6e11a1aa0c593d63266c433a614f2d90af"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_validation",
+ "error_code": "missing_tag",
+ "event_id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40"
+ }
+ },
+ {
+ "id": "event_admit_verified_ambiguous_food_markers_013",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["radroots:price_unit"],
+ ["radroots:price"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1da60ee3d831adae2aeb61df60c2e14f7168b696cf50a9362f326119d5c4d5a03dd8d6da1c9b10a29c3dd4ffbbfbe76404d758ee149bb56b13e2fe14eece385f"
+ }
+ },
+ "expected": {
+ "error_variant": "food_availability",
+ "error_code": "food_profile_ambiguous",
+ "event_id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866"
+ }
+ }
+ ]
+}
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -20,6 +20,31 @@ contract package.
- `domains.<domain>.<subdomain>.<action>`
- standard actions: `publish`, `get`, `list`, `validate`, `encode`, `decode`
+## General verified admission rule
+
+`event.admit_verified` is the single trusted contract-admission boundary for a
+`RadrootsSignatureVerifiedEvent`. It never accepts a bare envelope or performs
+signature verification implicitly. Profile, root Post, Reply, Comment,
+DeletionRequest, and focused FoodAvailability candidates retain their exact
+typed admitted values. All other registered candidates must pass complete
+registry shape validation before returning `ContractValidated`.
+
+Kind `1` routing is ordered: root Post admission runs first, and only its exact
+thread-excluded candidate may proceed to NIP-10 Reply admission. A thread-shaped
+event that fails Reply admission is invalid rather than a generic root Post.
+Kind `30402` routing partitions raw marker names before validation. Focused Food
+is admitted through its typed profile; an excluded Operational Listing may
+fall back to complete registry validation; marker-free generic NIP-99 remains
+unsupported; mixed focused and operational markers remain an explicit
+ambiguity error.
+
+Unsupported kind/shape matching and contract/profile validation failures are
+distinct `RadrootsEventAdmissionError` variants with stable codes. Successful
+admission proves only the verified envelope and the selected product or
+registry contract. It does not sign, publish, upload media, select a NIP-01
+head, authorize a deletion, evaluate suppression, mutate storage, or establish
+reference existence or relay availability.
+
## Calendar boundary rule
Calendar kinds `31922` through `31925` expose separate authored,
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -49,6 +49,9 @@ public = [
"RadrootsIdVerifiedEvent",
"RadrootsSignatureVerifiedEvent",
"RadrootsNip01VerificationError",
+ "RadrootsContractValidatedEvent",
+ "RadrootsAdmittedEvent",
+ "RadrootsEventAdmissionError",
"RadrootsEventHeadCoordinate",
"RadrootsEventHeadCandidate",
"RadrootsCurrentEventHead",
@@ -470,6 +473,33 @@ rust_types = [
[operations.event_verify_nip01.conformance]
vector = "contracts/conformance/vectors/profile/verified_event.v1.json"
+[operations.event_admit_verified]
+domain = "event"
+id = "event.admit_verified"
+stability = "beta"
+inputs = ["RadrootsSignatureVerifiedEvent"]
+outputs = ["RadrootsAdmittedEvent"]
+error_class = "admission_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.event_admit_verified.implementation]
+rust_modules = [
+ "crates/event_codec/src/admission.rs",
+ "crates/event_codec/src/verification.rs",
+]
+rust_types = [
+ "radroots_event_codec::admission::RadrootsAdmittedEvent",
+ "radroots_event_codec::admission::RadrootsEventAdmissionError",
+ "radroots_event_codec::verification::RadrootsContractValidatedEvent",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+]
+
+[operations.event_admit_verified.conformance]
+vector = "contracts/conformance/vectors/event/verified_admission.v1.json"
+case_kinds = ["event.admit_verified.valid", "event.admit_verified.invalid"]
+
[operations.event_select_head]
domain = "event"
id = "event.select_head"
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -264,3 +264,47 @@ semver_impacts = [
"add_conformance_vector",
]
summary = "Add a pure deterministic NIP-09 evaluator that returns canonical suppression decisions and evidence for same-author event and inclusive address targets while preserving immutable events, kind-5 immunity, advisory-kind irrelevance, and storage ownership boundaries."
+
+[[changes]]
+id = "central-verified-event-admission"
+classification = "feature"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_function",
+ "add_enum_variant",
+ "add_conformance_vector",
+]
+summary = "Add one signature-verified event admission operation that preserves typed product admissions, applies complete registry validation to generic contracts, and distinguishes unsupported matching, invalid shapes, Post-to-Reply routing, and kind-30402 profile exclusions."
+
+[[changes]]
+id = "event-store-validity-visibility-split"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_function",
+ "add_exported_field",
+ "add_enum_variant",
+ "remove_exported_type",
+ "remove_exported_field",
+ "remove_exported_function",
+ "change_exported_field_type",
+ "change_exported_function_signature",
+ "change_exported_algorithm_behavior",
+]
+summary = "Replace projection-eligible event-store APIs with signature-verified durable raw storage, typed ephemeral non-persistence, stable valid-stream eligibility, registry-independent NIP-01 heads keyed by protocol-opaque address identifiers, exact visible heads without stale fallback, and versioned monotonic cursor compare-and-swap."
+
+[[changes]]
+id = "nostr-fetch-admission-receipts"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_field",
+ "remove_exported_field",
+ "change_exported_algorithm_behavior",
+]
+summary = "Replace serialized fetch-receipt verification/projection fields with admission status, stable admission code, valid-stream eligibility, and distinct invalid and ephemeral non-persistence outcomes and counts; propagate local event-store failures instead of classifying them as malformed relay events."
+
+[[changes]]
+id = "outbox-ephemeral-event-policy"
+classification = "breaking"
+semver_impacts = ["add_enum_variant", "change_exported_algorithm_behavior"]
+summary = "Reject every NIP-16 ephemeral event from all generic durable-outbox entry points, keep transient events inside their owning live transport exchanges, and validate and configure every externally supplied SQLite pool connection before migration or writes."
diff --git a/crates/event/src/event_head.rs b/crates/event/src/event_head.rs
@@ -8,7 +8,7 @@ use crate::contract::{
};
use crate::ids::{RadrootsDTag, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey};
use crate::tags::TAG_D;
-use crate::{RadrootsEventEnvelope, RadrootsEventTag};
+use crate::{RadrootsEventEnvelope, RadrootsEventKindClass, RadrootsEventTag};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RadrootsEventHeadCoordinate {
@@ -19,7 +19,7 @@ pub enum RadrootsEventHeadCoordinate {
Addressable {
kind: u32,
pubkey: RadrootsPublicKey,
- d_tag: RadrootsDTag,
+ d_tag: String,
},
}
@@ -104,7 +104,7 @@ pub fn event_head_candidate_for_class(
RadrootsEventHeadCoordinate::Addressable {
kind: event.kind_u32(),
pubkey,
- d_tag,
+ d_tag: d_tag.into_string(),
}
};
RadrootsEventHeadCandidateResult::Candidate(RadrootsEventHeadCandidate {
@@ -116,6 +116,38 @@ pub fn event_head_candidate_for_class(
}
}
+/// Derives the raw NIP-01 head candidate from the numeric event-kind class.
+///
+/// This deliberately does not identify or validate a Radroots product
+/// contract. Raw replacement ordering must include every signature-verified
+/// replaceable or addressable event, including unsupported product shapes.
+pub fn event_head_candidate_for_nip01_event(
+ event: &RadrootsEventEnvelope,
+) -> RadrootsEventHeadCandidateResult {
+ let coordinate = match event.kind_class() {
+ RadrootsEventKindClass::Regular => {
+ return RadrootsEventHeadCandidateResult::NotHeadSelected;
+ }
+ RadrootsEventKindClass::Ephemeral => {
+ return RadrootsEventHeadCandidateResult::NotPersisted;
+ }
+ RadrootsEventKindClass::Replaceable => RadrootsEventHeadCoordinate::Replaceable {
+ kind: event.kind_u32(),
+ pubkey: event.author().clone(),
+ },
+ RadrootsEventKindClass::Addressable => RadrootsEventHeadCoordinate::Addressable {
+ kind: event.kind_u32(),
+ pubkey: event.author().clone(),
+ d_tag: String::from(first_tag_value(event.tag_slices(), TAG_D).unwrap_or("")),
+ },
+ };
+ RadrootsEventHeadCandidateResult::Candidate(RadrootsEventHeadCandidate {
+ coordinate,
+ event_id: event.id().clone(),
+ created_at: event.created_at_u64(),
+ })
+}
+
pub fn event_head_candidate_for_contract(
event: &RadrootsEventEnvelope,
contract: &RadrootsEventContract,
@@ -282,7 +314,7 @@ mod tests {
RadrootsEventHeadCoordinate::Addressable {
kind: 30023,
pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(),
- d_tag: RadrootsDTag::parse("article-1").unwrap()
+ d_tag: "article-1".to_owned()
}
);
}
@@ -372,6 +404,98 @@ mod tests {
}
#[test]
+ fn raw_nip01_bridge_uses_numeric_kind_classes_without_contract_identification() {
+ let replaceable = event(19_999, &hex_64('1'), &hex_64('a'), 1, Vec::new());
+ let replaceable = expect_candidate(event_head_candidate_for_nip01_event(&replaceable));
+ assert_eq!(
+ replaceable.coordinate,
+ RadrootsEventHeadCoordinate::Replaceable {
+ kind: 19_999,
+ pubkey: RadrootsPublicKey::parse(hex_64('a')).unwrap(),
+ }
+ );
+
+ let addressable = event(
+ 39_999,
+ &hex_64('2'),
+ &hex_64('b'),
+ 2,
+ vec![vec![TAG_D.to_string(), "unsupported".to_string()]],
+ );
+ let addressable = expect_candidate(event_head_candidate_for_nip01_event(&addressable));
+ assert_eq!(
+ addressable.coordinate,
+ RadrootsEventHeadCoordinate::Addressable {
+ kind: 39_999,
+ pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(),
+ d_tag: "unsupported".to_owned(),
+ }
+ );
+
+ let regular = event(40_000, &hex_64('3'), &hex_64('c'), 3, Vec::new());
+ assert_eq!(
+ event_head_candidate_for_nip01_event(®ular),
+ RadrootsEventHeadCandidateResult::NotHeadSelected
+ );
+ }
+
+ #[test]
+ fn raw_nip01_addressable_coordinates_treat_d_as_opaque_protocol_data() {
+ for (tags, expected) in [
+ (Vec::new(), ""),
+ (vec![vec![TAG_D.to_owned(), String::new()]], ""),
+ (
+ vec![
+ vec![TAG_D.to_owned()],
+ vec![TAG_D.to_owned(), "ignored".to_owned()],
+ ],
+ "",
+ ),
+ (
+ vec![vec![TAG_D.to_owned(), "not a product d".to_owned()]],
+ "not a product d",
+ ),
+ (
+ vec![vec![TAG_D.to_owned(), "line\nbreak".to_owned()]],
+ "line\nbreak",
+ ),
+ (
+ vec![
+ vec![TAG_D.to_owned(), "first value".to_owned()],
+ vec![TAG_D.to_owned(), "second-value".to_owned()],
+ ],
+ "first value",
+ ),
+ ] {
+ let event = event(39_999, &hex_64('2'), &hex_64('b'), 2, tags);
+ let candidate = expect_candidate(event_head_candidate_for_nip01_event(&event));
+ assert_eq!(
+ candidate.coordinate,
+ RadrootsEventHeadCoordinate::Addressable {
+ kind: 39_999,
+ pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(),
+ d_tag: expected.to_owned(),
+ }
+ );
+ }
+ }
+
+ #[test]
+ fn product_addressable_coordinates_retain_strict_d_validation() {
+ for tags in [
+ Vec::new(),
+ vec![vec![TAG_D.to_owned(), String::new()]],
+ vec![vec![TAG_D.to_owned(), "not a product d".to_owned()]],
+ ] {
+ let event = event(30_023, &hex_64('2'), &hex_64('b'), 2, tags);
+ assert!(matches!(
+ event_head_candidate_for_class(&event, RadrootsEventClass::Addressable),
+ RadrootsEventHeadCandidateResult::Malformed(_)
+ ));
+ }
+ }
+
+ #[test]
fn contract_bridge_uses_addressable_event_classes() {
let event = event(
KIND_LIST_SET_GENERIC,
@@ -386,7 +510,7 @@ mod tests {
RadrootsEventHeadCoordinate::Addressable {
kind: KIND_LIST_SET_GENERIC,
pubkey: RadrootsPublicKey::parse(hex_64('b')).unwrap(),
- d_tag: RadrootsDTag::parse("member_of.farms").unwrap()
+ d_tag: "member_of.farms".to_owned()
}
);
}
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -30,6 +30,18 @@ is independent of the optional `knowledge` decoder. The `nostr` feature exposes
`RadrootsSignatureVerifiedEvent` and rejects event kinds above `u16::MAX`
instead of truncating them.
+With `serde_json`, `admission::admit_verified_event` is the central contract
+boundary over an already verified event. It preserves typed admitted Profile,
+root Post, Reply, Comment, DeletionRequest, and FoodAvailability values, while
+other registered events must pass complete registry shape validation and
+return `ContractValidated`. Kind `1` is tested as a root Post before the exact
+thread-excluded candidate may be promoted to Reply. Kind `30402` is partitioned
+as focused Food, Operational Listing, generic NIP-99, or ambiguous before any
+profile validation; a valid excluded Operational Listing falls back to the
+registry, while generic and mixed-marker candidates remain distinct failures.
+The operation never accepts an unverified envelope and does not sign, publish,
+select event heads, evaluate deletion effects, or mutate storage.
+
The post codec exposes deterministic authored wire builders and a separate
verified-event projection. Update emits no profile tags, PhotoUpdate emits
strict ordered NIP-92 `imeta`, and Ask emits one exact `t=radroots-ask` marker
diff --git a/crates/event_codec/src/admission.rs b/crates/event_codec/src/admission.rs
@@ -0,0 +1,528 @@
+#[cfg(not(feature = "std"))]
+use alloc::boxed::Box;
+
+use core::fmt;
+
+use radroots_event::{
+ RadrootsEventEnvelope,
+ contract::{
+ RadrootsContractMatchError, RadrootsContractValidationError, RadrootsEventContract,
+ },
+ kinds::{
+ KIND_CLASSIFIED_LISTING, KIND_COMMENT, KIND_DELETION_REQUEST, KIND_POST, KIND_PROFILE,
+ },
+};
+
+use crate::{
+ comment::admission::{
+ RadrootsAdmittedNip22CommentEvent, RadrootsNip22CommentAdmissionError,
+ admit_verified_nip22_comment_event,
+ },
+ deletion::admission::{
+ RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09DeletionAdmissionError,
+ admit_verified_nip09_deletion_request_event,
+ },
+ food_availability::admission::{
+ RadrootsAdmittedFoodAvailabilityEvent, RadrootsFoodAvailabilityAdmissionError,
+ RadrootsFoodAvailabilityAdmissionOutcome, admit_verified_food_availability_event,
+ },
+ post::admission::{
+ RadrootsAdmittedRootPostEvent, RadrootsPostAdmissionError, RadrootsPostAdmissionOutcome,
+ admit_verified_post_event,
+ },
+ profile::admission::{
+ RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError, admit_verified_profile_event,
+ },
+ reply::admission::{
+ RadrootsAdmittedNip10ReplyEvent, RadrootsNip10ReplyAdmissionError,
+ admit_thread_excluded_post_candidate,
+ },
+ verification::{
+ RadrootsContractValidatedEvent, RadrootsSignatureVerifiedEvent, validate_event_contract,
+ },
+};
+
+/// A verified event admitted through its exact typed profile or full registry shape.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq)]
+pub enum RadrootsAdmittedEvent {
+ Profile(RadrootsAdmittedProfileEvent),
+ RootPost(RadrootsAdmittedRootPostEvent),
+ Reply(RadrootsAdmittedNip10ReplyEvent),
+ Comment(Box<RadrootsAdmittedNip22CommentEvent>),
+ DeletionRequest(RadrootsAdmittedNip09DeletionRequestEvent),
+ FoodAvailability(Box<RadrootsAdmittedFoodAvailabilityEvent>),
+ ContractValidated(RadrootsContractValidatedEvent),
+}
+
+impl RadrootsAdmittedEvent {
+ pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent {
+ match self {
+ Self::Profile(event) => event.verified_event(),
+ Self::RootPost(event) => event.verified_event(),
+ Self::Reply(event) => event.verified_event(),
+ Self::Comment(event) => event.verified_event(),
+ Self::DeletionRequest(event) => event.verified_event(),
+ Self::FoodAvailability(event) => event.verified_event(),
+ Self::ContractValidated(event) => event.verified_event(),
+ }
+ }
+
+ pub fn event(&self) -> &RadrootsEventEnvelope {
+ self.verified_event().event()
+ }
+
+ pub fn contract(&self) -> &'static RadrootsEventContract {
+ match self {
+ Self::Profile(event) => event.contract(),
+ Self::RootPost(event) => event.contract(),
+ Self::Reply(event) => event.contract(),
+ Self::Comment(event) => event.contract(),
+ Self::DeletionRequest(event) => event.contract(),
+ Self::FoodAvailability(event) => event.contract(),
+ Self::ContractValidated(event) => event.contract(),
+ }
+ }
+
+ pub fn contract_id(&self) -> &'static str {
+ self.contract().id
+ }
+
+ pub fn into_verified_event(self) -> RadrootsSignatureVerifiedEvent {
+ match self {
+ Self::Profile(event) => event.into_parts().0,
+ Self::RootPost(event) => event.into_parts().0,
+ Self::Reply(event) => event.into_parts().0,
+ Self::Comment(event) => event.into_parts().0,
+ Self::DeletionRequest(event) => event.into_parts().0,
+ Self::FoodAvailability(event) => event.into_parts().0,
+ Self::ContractValidated(event) => event.into_verified_event(),
+ }
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsEventAdmissionError {
+ ContractMatch(RadrootsContractMatchError),
+ ContractValidation(RadrootsContractValidationError),
+ Profile(RadrootsProfileAdmissionError),
+ Post(RadrootsPostAdmissionError),
+ Reply(RadrootsNip10ReplyAdmissionError),
+ Comment(RadrootsNip22CommentAdmissionError),
+ DeletionRequest(RadrootsNip09DeletionAdmissionError),
+ FoodAvailability(RadrootsFoodAvailabilityAdmissionError),
+}
+
+impl RadrootsEventAdmissionError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::ContractMatch(RadrootsContractMatchError::UnsupportedKind(_)) => {
+ "unsupported_kind"
+ }
+ Self::ContractMatch(RadrootsContractMatchError::UnsupportedShape(_)) => {
+ "unsupported_shape"
+ }
+ Self::ContractMatch(RadrootsContractMatchError::AmbiguousShape(_)) => "ambiguous_shape",
+ Self::ContractValidation(error) => error.code(),
+ Self::Profile(error) => error.code(),
+ Self::Post(error) => error.code(),
+ Self::Reply(error) => error.code(),
+ Self::Comment(error) => error.code(),
+ Self::DeletionRequest(error) => error.code(),
+ Self::FoodAvailability(error) => error.code(),
+ }
+ }
+}
+
+impl fmt::Display for RadrootsEventAdmissionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::ContractMatch(RadrootsContractMatchError::UnsupportedKind(kind)) => {
+ write!(formatter, "event kind {kind} has no registered contract")
+ }
+ Self::ContractMatch(RadrootsContractMatchError::UnsupportedShape(kind)) => {
+ write!(
+ formatter,
+ "event kind {kind} has no supported contract shape"
+ )
+ }
+ Self::ContractMatch(RadrootsContractMatchError::AmbiguousShape(kind)) => {
+ write!(
+ formatter,
+ "event kind {kind} matches multiple contract shapes"
+ )
+ }
+ Self::ContractValidation(error) => {
+ write!(
+ formatter,
+ "event contract validation failed with code {}",
+ error.code()
+ )
+ }
+ Self::Profile(error) => write!(formatter, "{error}"),
+ Self::Post(error) => write!(formatter, "{error}"),
+ Self::Reply(error) => write!(formatter, "{error}"),
+ Self::Comment(error) => write!(formatter, "{error}"),
+ Self::DeletionRequest(error) => write!(formatter, "{error}"),
+ Self::FoodAvailability(error) => write!(formatter, "{error}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsEventAdmissionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Profile(error) => Some(error),
+ Self::Post(error) => Some(error),
+ Self::Reply(error) => Some(error),
+ Self::Comment(error) => Some(error),
+ Self::DeletionRequest(error) => Some(error),
+ Self::FoodAvailability(error) => Some(error),
+ Self::ContractMatch(_) | Self::ContractValidation(_) => None,
+ }
+ }
+}
+
+/// Admits an already verified event through the exact typed or registry boundary.
+pub fn admit_verified_event(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ match event.event().kind_u32() {
+ KIND_PROFILE => admit_profile(event),
+ KIND_POST => admit_post_or_reply(event),
+ KIND_COMMENT => admit_verified_nip22_comment_event(event)
+ .map(|event| RadrootsAdmittedEvent::Comment(Box::new(event)))
+ .map_err(RadrootsEventAdmissionError::Comment),
+ KIND_DELETION_REQUEST => admit_verified_nip09_deletion_request_event(event)
+ .map(RadrootsAdmittedEvent::DeletionRequest)
+ .map_err(RadrootsEventAdmissionError::DeletionRequest),
+ KIND_CLASSIFIED_LISTING => admit_food_or_registry(event),
+ _ => admit_registry_contract(event),
+ }
+}
+
+fn admit_profile(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ admit_verified_profile_event(event)
+ .map(RadrootsAdmittedEvent::Profile)
+ .map_err(RadrootsEventAdmissionError::Profile)
+}
+
+fn admit_post_or_reply(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ match admit_verified_post_event(event).map_err(RadrootsEventAdmissionError::Post)? {
+ RadrootsPostAdmissionOutcome::Root(event) => Ok(RadrootsAdmittedEvent::RootPost(event)),
+ RadrootsPostAdmissionOutcome::ThreadExcluded(candidate) => {
+ admit_thread_excluded_post_candidate(candidate)
+ .map(RadrootsAdmittedEvent::Reply)
+ .map_err(RadrootsEventAdmissionError::Reply)
+ }
+ }
+}
+
+fn admit_food_or_registry(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ match admit_verified_food_availability_event(event)
+ .map_err(RadrootsEventAdmissionError::FoodAvailability)?
+ {
+ RadrootsFoodAvailabilityAdmissionOutcome::Admitted(event) => {
+ Ok(RadrootsAdmittedEvent::FoodAvailability(event))
+ }
+ RadrootsFoodAvailabilityAdmissionOutcome::Excluded(candidate) => {
+ admit_registry_contract(candidate.into_parts().0)
+ }
+ }
+}
+
+fn admit_registry_contract(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ validate_event_contract(event)
+ .map(RadrootsAdmittedEvent::ContractValidated)
+ .map_err(map_contract_validation)
+}
+
+fn map_contract_validation(error: RadrootsContractValidationError) -> RadrootsEventAdmissionError {
+ match error {
+ RadrootsContractValidationError::ContractMatch { error } => {
+ RadrootsEventAdmissionError::ContractMatch(error)
+ }
+ error => RadrootsEventAdmissionError::ContractValidation(error),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_event::contract::{RadrootsEventDiscriminator, all_event_contracts};
+
+ #[test]
+ fn covers_the_exact_admission_only_registry_inventory() {
+ let mut actual = all_event_contracts()
+ .iter()
+ .filter(|contract| {
+ matches!(
+ contract.discriminator,
+ RadrootsEventDiscriminator::AdmissionOnly
+ )
+ })
+ .map(|contract| contract.id);
+
+ for expected in [
+ "radroots.social.update.v1",
+ "radroots.social.photo_update.v1",
+ "radroots.social.ask.v1",
+ "radroots.social.reply.v1",
+ "radroots.social.deletion_request.v1",
+ "radroots.social.comment.v1",
+ "radroots.food.availability.v1",
+ ] {
+ assert_eq!(actual.next(), Some(expected));
+ }
+ assert_eq!(actual.next(), None);
+ }
+
+ #[test]
+ fn contract_match_error_codes_are_stable_and_distinct() {
+ for (error, code) in [
+ (
+ RadrootsContractMatchError::UnsupportedKind(65_535),
+ "unsupported_kind",
+ ),
+ (
+ RadrootsContractMatchError::UnsupportedShape(KIND_CLASSIFIED_LISTING),
+ "unsupported_shape",
+ ),
+ (
+ RadrootsContractMatchError::AmbiguousShape(KIND_CLASSIFIED_LISTING),
+ "ambiguous_shape",
+ ),
+ ] {
+ let error = RadrootsEventAdmissionError::ContractMatch(error);
+ assert_eq!(error.code(), code);
+ assert!(!error.to_string().is_empty());
+ }
+ }
+
+ #[cfg(feature = "nostr")]
+ mod signed {
+ use super::*;
+ use crate::{
+ test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX, verification::verify_nip01_event,
+ };
+ use nostr::secp256k1::Message;
+ use nostr::{Keys, SECP256K1};
+ use radroots_event::{
+ RadrootsEventEnvelopeParts, kinds::KIND_FOLLOW, wire::compute_canonical_nip01_event_id,
+ };
+
+ #[test]
+ fn routes_every_typed_profile_and_preserves_the_verified_envelope() {
+ let profile = admitted(100, KIND_PROFILE, vec![], "{}");
+ assert!(matches!(&profile, RadrootsAdmittedEvent::Profile(_)));
+ assert_admitted(profile, "radroots.profile.metadata.v1");
+
+ let post = admitted(101, KIND_POST, vec![], "Harvest update");
+ assert!(matches!(&post, RadrootsAdmittedEvent::RootPost(_)));
+ assert_admitted(post, "radroots.social.update.v1");
+
+ let reply = admitted(
+ 102,
+ KIND_POST,
+ vec![vec![
+ "e".into(),
+ "a".repeat(64),
+ String::new(),
+ "root".into(),
+ ]],
+ "Reply",
+ );
+ assert!(matches!(&reply, RadrootsAdmittedEvent::Reply(_)));
+ assert_admitted(reply, "radroots.social.reply.v1");
+
+ let comment = admitted(103, KIND_COMMENT, comment_tags(), "Comment");
+ assert!(matches!(&comment, RadrootsAdmittedEvent::Comment(_)));
+ assert_admitted(comment, "radroots.social.comment.v1");
+
+ let deletion = admitted(
+ 104,
+ KIND_DELETION_REQUEST,
+ vec![vec!["e".into(), "a".repeat(64)]],
+ "Superseded",
+ );
+ assert!(matches!(
+ &deletion,
+ RadrootsAdmittedEvent::DeletionRequest(_)
+ ));
+ assert_admitted(deletion, "radroots.social.deletion_request.v1");
+
+ let food = admitted(
+ 200,
+ KIND_CLASSIFIED_LISTING,
+ food_tags(),
+ "Carrots available this week.",
+ );
+ assert!(matches!(&food, RadrootsAdmittedEvent::FoodAvailability(_)));
+ assert_admitted(food, "radroots.food.availability.v1");
+ }
+
+ #[test]
+ fn generic_fallback_and_invalid_outcomes_remain_distinct() {
+ let generic = admitted(300, KIND_FOLLOW, vec![], "{}");
+ assert!(matches!(
+ &generic,
+ RadrootsAdmittedEvent::ContractValidated(_)
+ ));
+ assert_admitted(generic, "radroots.social.follow_list.v1");
+
+ let unsupported = admit(301, u32::from(u16::MAX), vec![], "unsupported")
+ .expect_err("unregistered kind must remain unsupported");
+ assert!(matches!(
+ unsupported,
+ RadrootsEventAdmissionError::ContractMatch(
+ RadrootsContractMatchError::UnsupportedKind(_)
+ )
+ ));
+
+ let unsupported_listing =
+ admit(302, KIND_CLASSIFIED_LISTING, vec![], "generic listing")
+ .expect_err("generic NIP-99 shape must remain unsupported");
+ assert!(matches!(
+ unsupported_listing,
+ RadrootsEventAdmissionError::ContractMatch(
+ RadrootsContractMatchError::UnsupportedShape(KIND_CLASSIFIED_LISTING)
+ )
+ ));
+
+ let tolerant_profile = admitted(
+ 303,
+ KIND_PROFILE,
+ vec![vec!["p".into(), "invalid".into()]],
+ "{}",
+ );
+ assert!(matches!(
+ &tolerant_profile,
+ RadrootsAdmittedEvent::Profile(_)
+ ));
+ assert_admitted(tolerant_profile, "radroots.profile.metadata.v1");
+
+ let invalid_profile = admit(306, KIND_PROFILE, vec![], "not JSON")
+ .expect_err("invalid Profile metadata must fail at the typed boundary");
+ assert!(matches!(
+ invalid_profile,
+ RadrootsEventAdmissionError::Profile(_)
+ ));
+
+ let invalid_reply = admit(
+ 304,
+ KIND_POST,
+ vec![vec![
+ "e".into(),
+ "invalid".into(),
+ String::new(),
+ "root".into(),
+ ]],
+ "Reply",
+ )
+ .expect_err("thread candidate must fail at the Reply boundary");
+ assert!(matches!(
+ invalid_reply,
+ RadrootsEventAdmissionError::Reply(_)
+ ));
+
+ let mut mixed_tags = food_tags();
+ mixed_tags.push(vec!["radroots:bin".into(), "bin-1".into()]);
+ let mixed = admit(305, KIND_CLASSIFIED_LISTING, mixed_tags, "Mixed listing")
+ .expect_err("mixed classified-listing markers must fail typed admission");
+ assert!(matches!(
+ &mixed,
+ RadrootsEventAdmissionError::FoodAvailability(_)
+ ));
+ assert_eq!(mixed.code(), "food_profile_ambiguous");
+ }
+
+ fn admitted(
+ created_at: u64,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> RadrootsAdmittedEvent {
+ admit(created_at, kind, tags, content).expect("event must be admitted")
+ }
+
+ fn admit(
+ created_at: u64,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError> {
+ let verified = verify_nip01_event(signed_event(created_at, kind, tags, content))
+ .expect("fixed signed event must verify");
+ admit_verified_event(verified)
+ }
+
+ fn assert_admitted(event: RadrootsAdmittedEvent, expected_contract_id: &str) {
+ let expected_event = event.event().clone();
+ assert_eq!(event.contract_id(), expected_contract_id);
+ assert_eq!(event.verified_event().event(), &expected_event);
+ assert_eq!(event.into_verified_event().into_event(), expected_event);
+ }
+
+ fn signed_event(
+ created_at: u64,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> RadrootsEventEnvelope {
+ let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX)
+ .expect("fixed fixture secret key must parse");
+ let author = keys.public_key().to_string();
+ let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content)
+ .expect("canonical event id");
+ let nostr_id = nostr::EventId::from_hex(id.as_str()).expect("Nostr event id");
+ let message = Message::from_digest(nostr_id.to_bytes());
+ let signature = SECP256K1.sign_schnorr_no_aux_rand(&message, keys.key_pair(SECP256K1));
+
+ RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: id.into_string(),
+ author,
+ created_at,
+ kind,
+ tags,
+ content: content.into(),
+ sig: signature.to_string(),
+ })
+ .expect("valid signed event envelope")
+ }
+
+ fn comment_tags() -> Vec<Vec<String>> {
+ vec![
+ vec!["E".into(), "a".repeat(64), String::new(), "b".repeat(64)],
+ vec!["K".into(), KIND_CLASSIFIED_LISTING.to_string()],
+ vec!["P".into(), "b".repeat(64)],
+ vec!["e".into(), "a".repeat(64), String::new(), "b".repeat(64)],
+ vec!["k".into(), KIND_CLASSIFIED_LISTING.to_string()],
+ vec!["p".into(), "b".repeat(64)],
+ ]
+ }
+
+ fn food_tags() -> Vec<Vec<String>> {
+ vec![
+ vec!["d".into(), "nantes-carrots".into()],
+ vec!["title".into(), "Nantes Carrots".into()],
+ vec!["summary".into(), "Fresh bunches".into()],
+ vec!["published_at".into(), "100".into()],
+ vec!["location".into(), "Central Saanich, BC".into()],
+ vec!["price".into(), "3".into(), "CAD".into()],
+ vec!["radroots:price_unit".into(), "lb".into()],
+ vec!["radroots:quantity".into(), "24".into(), "lb".into()],
+ vec!["status".into(), "active".into()],
+ ]
+ }
+ }
+}
diff --git a/crates/event_codec/src/knowledge/verification.rs b/crates/event_codec/src/knowledge/verification.rs
@@ -4,10 +4,7 @@ use alloc::string::{String, ToString};
use core::fmt;
use radroots_event::RadrootsEventEnvelope;
-use radroots_event::contract::{
- RadrootsContractValidationError, RadrootsEventContract,
- validate_event_contract as validate_radroots_event_contract,
-};
+use radroots_event::contract::RadrootsContractValidationError;
use radroots_event::knowledge::{
RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal,
RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation,
@@ -24,34 +21,8 @@ use crate::knowledge::decode::{
wiki_redirect_from_event,
};
use crate::parsed::RadrootsParsedEvent;
-use crate::verification::{
- RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event,
-};
-
-/// A NIP-01 verified event whose Radroots contract shape has been validated.
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsContractValidatedEvent {
- event: RadrootsEventEnvelope,
- contract: &'static RadrootsEventContract,
-}
-
-impl RadrootsContractValidatedEvent {
- pub fn event(&self) -> &RadrootsEventEnvelope {
- &self.event
- }
-
- pub fn contract(&self) -> &'static RadrootsEventContract {
- self.contract
- }
-
- pub fn contract_id(&self) -> &'static str {
- self.contract.id
- }
-
- pub fn into_event(self) -> RadrootsEventEnvelope {
- self.event
- }
-}
+pub use crate::verification::{RadrootsContractValidatedEvent, validate_event_contract};
+use crate::verification::{RadrootsNip01VerificationError, verify_nip01_event};
#[derive(Debug)]
pub enum RadrootsDecodeError {
@@ -146,53 +117,47 @@ impl RadrootsDecodedEvent {
}
}
-pub fn validate_event_contract(
- event: RadrootsSignatureVerifiedEvent,
-) -> Result<RadrootsContractValidatedEvent, RadrootsContractValidationError> {
- let event = event.into_event();
- let contract = validate_radroots_event_contract(&event)?;
- Ok(RadrootsContractValidatedEvent { event, contract })
-}
-
pub fn decode_validated_event(
event: RadrootsContractValidatedEvent,
) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> {
- match event.contract.id {
+ let contract_id = event.contract_id();
+ let event = event.into_event();
+ match contract_id {
"radroots.wiki.article.v1" => Ok(RadrootsDecodedEvent::WikiArticle(
- wiki_article_from_event(event.event)?,
+ wiki_article_from_event(event)?,
)),
"radroots.wiki.redirect.v1" => Ok(RadrootsDecodedEvent::WikiRedirect(
- wiki_redirect_from_event(event.event)?,
+ wiki_redirect_from_event(event)?,
)),
"radroots.wiki.merge_request.v1" => Ok(RadrootsDecodedEvent::WikiMergeRequest(
- wiki_merge_request_from_event(event.event)?,
+ wiki_merge_request_from_event(event)?,
)),
"radroots.knowledge.source.v1" => Ok(RadrootsDecodedEvent::KnowledgeSource(
- knowledge_source_from_event(event.event)?,
+ knowledge_source_from_event(event)?,
)),
"radroots.knowledge.claim.v1" => Ok(RadrootsDecodedEvent::KnowledgeClaim(
- knowledge_claim_from_event(event.event)?,
+ knowledge_claim_from_event(event)?,
)),
"radroots.knowledge.relation.v1" => Ok(RadrootsDecodedEvent::KnowledgeRelation(
- knowledge_relation_from_event(event.event)?,
+ knowledge_relation_from_event(event)?,
)),
"radroots.knowledge.review.v1" => Ok(RadrootsDecodedEvent::KnowledgeReview(
- knowledge_review_from_event(event.event)?,
+ knowledge_review_from_event(event)?,
)),
"radroots.knowledge.field_report.v1" => Ok(RadrootsDecodedEvent::KnowledgeFieldReport(
- knowledge_field_report_from_event(event.event)?,
+ knowledge_field_report_from_event(event)?,
)),
"radroots.knowledge.evidence_bounty.v1" => Ok(RadrootsDecodedEvent::EvidenceBounty(
- evidence_bounty_from_event(event.event)?,
+ evidence_bounty_from_event(event)?,
)),
"radroots.knowledge.change_proposal.v1" => {
Ok(RadrootsDecodedEvent::KnowledgeChangeProposal(
- knowledge_change_proposal_from_event(event.event)?,
+ knowledge_change_proposal_from_event(event)?,
))
}
"radroots.knowledge.contribution_attestation.v1" => {
Ok(RadrootsDecodedEvent::ContributionAttestation(
- contribution_attestation_from_event(event.event)?,
+ contribution_attestation_from_event(event)?,
))
}
contract_id => Err(RadrootsDecodeError::UnsupportedContract {
diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs
@@ -23,6 +23,8 @@ pub mod tag_builders;
pub mod verification;
pub mod wire;
+#[cfg(feature = "serde_json")]
+pub mod admission;
pub mod app_data;
pub mod article;
pub mod calendar;
@@ -69,12 +71,13 @@ pub use manifest::{
contract_manifest_sha256, knowledge_contract_manifest,
};
pub use tag_builders::RadrootsEventTagBuilder;
-#[cfg(feature = "knowledge")]
pub use verification::{
- RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent,
- decode_validated_event, validate_event_contract, verify_and_decode_radroots_event,
+ RadrootsContractValidatedEvent, RadrootsIdVerifiedEvent, RadrootsNip01VerificationError,
+ RadrootsSignatureVerifiedEvent, validate_event_contract, verify_event_id,
+ verify_event_signature, verify_nip01_event,
};
+#[cfg(feature = "knowledge")]
pub use verification::{
- RadrootsIdVerifiedEvent, RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent,
- verify_event_id, verify_event_signature, verify_nip01_event,
+ RadrootsDecodeError, RadrootsDecodedEvent, decode_validated_event,
+ verify_and_decode_radroots_event,
};
diff --git a/crates/event_codec/src/profile/admission.rs b/crates/event_codec/src/profile/admission.rs
@@ -1,6 +1,10 @@
use core::fmt;
-use radroots_event::{RadrootsEventEnvelope, kinds::KIND_PROFILE};
+use radroots_event::{
+ RadrootsEventEnvelope,
+ contract::{RadrootsEventContract, event_contract},
+ kinds::KIND_PROFILE,
+};
use crate::profile::inbound::{
RadrootsInboundProfileMetadata, RadrootsProfileMetadataParseError,
@@ -30,6 +34,11 @@ impl RadrootsAdmittedProfileEvent {
&self.metadata
}
+ pub fn contract(&self) -> &'static RadrootsEventContract {
+ event_contract("radroots.profile.metadata.v1")
+ .expect("Profile metadata contract is registry-owned")
+ }
+
pub fn into_parts(
self,
) -> (
diff --git a/crates/event_codec/src/verification.rs b/crates/event_codec/src/verification.rs
@@ -6,13 +6,17 @@ use core::fmt;
use core::str::FromStr;
use radroots_event::RadrootsEventEnvelope;
+use radroots_event::contract::{
+ RadrootsContractValidationError, RadrootsEventContract,
+ validate_event_contract as validate_radroots_event_contract,
+};
use radroots_event::ids::RadrootsEventId;
use radroots_event::wire::compute_canonical_nip01_event_id;
#[cfg(feature = "knowledge")]
pub use crate::knowledge::verification::{
- RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent,
- decode_validated_event, validate_event_contract, verify_and_decode_radroots_event,
+ RadrootsDecodeError, RadrootsDecodedEvent, decode_validated_event,
+ verify_and_decode_radroots_event,
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -45,6 +49,39 @@ impl RadrootsSignatureVerifiedEvent {
}
}
+/// A NIP-01 verified event whose registry-selected contract shape is valid.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsContractValidatedEvent {
+ verified_event: RadrootsSignatureVerifiedEvent,
+ contract: &'static RadrootsEventContract,
+}
+
+impl RadrootsContractValidatedEvent {
+ pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent {
+ &self.verified_event
+ }
+
+ pub fn event(&self) -> &RadrootsEventEnvelope {
+ self.verified_event.event()
+ }
+
+ pub fn contract(&self) -> &'static RadrootsEventContract {
+ self.contract
+ }
+
+ pub fn contract_id(&self) -> &'static str {
+ self.contract.id
+ }
+
+ pub fn into_verified_event(self) -> RadrootsSignatureVerifiedEvent {
+ self.verified_event
+ }
+
+ pub fn into_event(self) -> RadrootsEventEnvelope {
+ self.verified_event.into_event()
+ }
+}
+
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsNip01VerificationError {
@@ -94,6 +131,11 @@ impl std::error::Error for RadrootsNip01VerificationError {}
pub fn verify_event_id(
event: RadrootsEventEnvelope,
) -> Result<RadrootsIdVerifiedEvent, RadrootsNip01VerificationError> {
+ u16::try_from(event.kind_u32()).map_err(|_| {
+ RadrootsNip01VerificationError::KindOutOfRange {
+ kind: event.kind_u32(),
+ }
+ })?;
RadrootsEventId::parse(event.id_str())
.map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?;
let expected = compute_canonical_nip01_event_id(
@@ -140,6 +182,17 @@ pub fn verify_nip01_event(
verify_event_signature(verify_event_id(event)?)
}
+/// Applies full registry contract-shape validation to an already verified event.
+pub fn validate_event_contract(
+ event: RadrootsSignatureVerifiedEvent,
+) -> Result<RadrootsContractValidatedEvent, RadrootsContractValidationError> {
+ let contract = validate_radroots_event_contract(event.event())?;
+ Ok(RadrootsContractValidatedEvent {
+ verified_event: event,
+ contract,
+ })
+}
+
#[cfg(feature = "nostr")]
fn raw_event_from_radroots(
event: &RadrootsEventEnvelope,
@@ -244,6 +297,36 @@ mod tests {
}
}
+ #[test]
+ fn id_verification_rejects_an_out_of_range_kind_before_hashing() {
+ let original = signed_max_kind_event();
+ let kind = u32::from(u16::MAX) + 1;
+ let id = compute_canonical_nip01_event_id(
+ original.author_str(),
+ original.created_at_u64(),
+ kind,
+ &original.tags_as_vec(),
+ original.content(),
+ )
+ .expect("canonical hash remains mechanically computable")
+ .into_string();
+ let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id,
+ author: original.author_str().to_owned(),
+ created_at: original.created_at_u64(),
+ kind,
+ tags: original.tags_as_vec(),
+ content: original.content().to_owned(),
+ sig: original.sig_str().to_owned(),
+ })
+ .expect("base envelope permits the wider internal kind representation");
+
+ assert_eq!(
+ verify_event_id(event),
+ Err(RadrootsNip01VerificationError::KindOutOfRange { kind })
+ );
+ }
+
fn signed_max_kind_event() -> RadrootsEventEnvelope {
RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
id: "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958"
diff --git a/crates/event_codec/tests/fixtures/verified_admission.v1.json b/crates/event_codec/tests/fixtures/verified_admission.v1.json
@@ -0,0 +1,334 @@
+{
+ "suite": "verified_event_admission",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "event_admit_verified_profile_001",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f",
+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
+ "created_at": 1800000100,
+ "kind": 0,
+ "tags": [],
+ "content": "{\"display_name\":\"Moss Street Farm\",\"bot\":false,\"website\":\"https://mossstreet.example\",\"picture\":42}",
+ "sig": "e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721"
+ }
+ },
+ "expected": {
+ "variant": "profile",
+ "contract_id": "radroots.profile.metadata.v1",
+ "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_update_002",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781632860,
+ "kind": 1,
+ "tags": [],
+ "content": "The first strawberries are ready.",
+ "sig": "dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.update.v1",
+ "event_id": "fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_photo_update_003",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781635400,
+ "kind": 1,
+ "tags": [
+ [
+ "imeta",
+ "url https://cdn.example/harvest.webp",
+ "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "m image/webp",
+ "dim 1200x900",
+ "size 12345",
+ "alt Harvest"
+ ]
+ ],
+ "content": "Harvest https://cdn.example/harvest.webp",
+ "sig": "2f0863959b972f639d028c65d9ca0c2b62d5ad57530ad4c810e67941d1d50ab249488f570b9905095db2df18440aac399907dda5ca0e8289c49e625ce8b0b28b"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.photo_update.v1",
+ "event_id": "f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029"
+ }
+ },
+ {
+ "id": "event_admit_verified_root_ask_004",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781635220,
+ "kind": 1,
+ "tags": [
+ ["t", " RADROOTS-ASK "],
+ ["imeta", "url https://cdn.example/leaf.webp", "x malformed"]
+ ],
+ "content": "Question https://cdn.example/leaf.webp",
+ "sig": "538636b2d163d1a392f4c3fced234ba6af5c9b3f1fc66e3bdbbe374287cf4e62adec6369056a3f096be1b268451c2fb25040ae8e0d67188284c2da18832c20d1"
+ }
+ },
+ "expected": {
+ "variant": "root_post",
+ "contract_id": "radroots.social.ask.v1",
+ "event_id": "5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac"
+ }
+ },
+ {
+ "id": "event_admit_verified_post_to_reply_005",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1781000001,
+ "kind": 1,
+ "tags": [
+ [
+ "e",
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ "",
+ "root"
+ ],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ ],
+ "content": "Direct reply",
+ "sig": "16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd"
+ }
+ },
+ "expected": {
+ "variant": "reply",
+ "contract_id": "radroots.social.reply.v1",
+ "event_id": "2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896"
+ }
+ },
+ {
+ "id": "event_admit_verified_comment_006",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "3e424094d13c2870de9e63f295e54ffc68caf00caa125021236a8011d611c6a1",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1800000200,
+ "kind": 1111,
+ "tags": [
+ ["E", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://victoria.example", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"],
+ ["K", "30402"],
+ ["P", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://victoria.example"],
+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "wss://victoria.example", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"],
+ ["k", "30402"],
+ ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", "wss://victoria.example"]
+ ],
+ "content": "Are these carrots available Saturday?",
+ "sig": "bdec382660fb50d0c3beb8f57b7f0a3b89cea7469b02b5e92e476550bd61f2d3ce7cdcec538d2a8ad8ab5c19807717af798c36a2e05a4b949b628b4993b9ebd1"
+ }
+ },
+ "expected": {
+ "variant": "comment",
+ "contract_id": "radroots.social.comment.v1",
+ "event_id": "3e424094d13c2870de9e63f295e54ffc68caf00caa125021236a8011d611c6a1"
+ }
+ },
+ {
+ "id": "event_admit_verified_deletion_request_007",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "00d55f2b604090c5eb56a9e445de1e1c31fc86690fd2f368e5a7b7a8ea37a08f",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 50,
+ "kind": 5,
+ "tags": [
+ ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
+ ],
+ "content": "",
+ "sig": "58cfd1dc2401701c5121367820b0fbac7e5a05f184bd781a8918731a1ed4a8f2e50dee2260ff1b8c4b9c1ac7767e376d9860aabf5fb46dcf460b0cdac215ad3c"
+ }
+ },
+ "expected": {
+ "variant": "deletion_request",
+ "contract_id": "radroots.social.deletion_request.v1",
+ "event_id": "00d55f2b604090c5eb56a9e445de1e1c31fc86690fd2f368e5a7b7a8ea37a08f"
+ }
+ },
+ {
+ "id": "event_admit_verified_food_availability_008",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["d", "nantes-carrots"],
+ ["title", "Nantes Carrots"],
+ ["summary", "Fresh bunches"],
+ ["published_at", "1700000000"],
+ ["location", "Central Saanich, BC"],
+ ["price", "3", "CAD"],
+ ["radroots:price_unit", "lb"],
+ ["status", "active"],
+ ["t", "vegetables"],
+ ["g", "c28hr"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a"
+ }
+ },
+ "expected": {
+ "variant": "food_availability",
+ "contract_id": "radroots.food.availability.v1",
+ "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7"
+ }
+ },
+ {
+ "id": "event_admit_verified_operational_fallback_009",
+ "kind": "event.admit_verified.valid",
+ "input": {
+ "event": {
+ "id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f",
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "created_at": 1700000000,
+ "kind": 30402,
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAg"],
+ ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"],
+ ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"],
+ ["key", "carrot-nantes"],
+ ["title", "Nantes Carrots"],
+ ["category", "produce"],
+ ["summary", "Fresh bunches harvested in Saanich"],
+ ["published_at", "1700000000"],
+ ["radroots:primary_bin", "bunch"],
+ ["radroots:bin", "bunch", "1", "each"],
+ ["radroots:price", "bunch", "4", "CAD", "1", "each"],
+ ["price", "4", "CAD"],
+ ["inventory", "24"],
+ ["status", "active"],
+ ["delivery", "pickup"],
+ ["location", "Saanich Peninsula", "Victoria", "BC", "CA"],
+ ["g", "c28hr"]
+ ],
+ "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich",
+ "sig": "ef3413c4ac4be3f748fcb51b3e5b9b03c590f5f814dbd6ab3f2f2c26dbc87eb1347cefbe97abacce60f0c4530848695e766d3a950350c72089813b3318a3f944"
+ }
+ },
+ "expected": {
+ "variant": "contract_validated",
+ "contract_id": "radroots.operational_listing.published.v1",
+ "event_id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f"
+ }
+ },
+ {
+ "id": "event_admit_verified_unsupported_kind_010",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958",
+ "pubkey": "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
+ "created_at": 1800000104,
+ "kind": 65535,
+ "tags": [],
+ "content": "maximum-kind",
+ "sig": "d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_match",
+ "error_code": "unsupported_kind",
+ "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958"
+ }
+ },
+ {
+ "id": "event_admit_verified_generic_nip99_excluded_011",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["d", "generic-offer"],
+ ["title", "Generic Offer"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "e533df401a4c6ddfd7dcfd2b3525e9fb8938748dcdc9492aa617ec50d966554511853704929b88b7fe791f3a36659f341b20245182574dd5e5353fa80f57d441"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_match",
+ "error_code": "unsupported_shape",
+ "event_id": "0d65719b6e73a64f55d95c38ba46e25e222a893d4ec0cdfe83747b1269118d3d"
+ }
+ },
+ {
+ "id": "event_admit_verified_operational_invalid_shape_012",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["radroots:bin"],
+ ["delivery"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1fc38e6183061bb64f2337941b96a6cc75067b85aa46b4780bd395f62961b54569872c51090bec9f97185add686e3e6e11a1aa0c593d63266c433a614f2d90af"
+ }
+ },
+ "expected": {
+ "error_variant": "contract_validation",
+ "error_code": "missing_tag",
+ "event_id": "c9e41f7d91b036e21fdce11ab88a5eda6cc19c93875f160f7632b1a844a59d40"
+ }
+ },
+ {
+ "id": "event_admit_verified_ambiguous_food_markers_013",
+ "kind": "event.admit_verified.invalid",
+ "input": {
+ "event": {
+ "id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866",
+ "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "created_at": 1700000060,
+ "kind": 30402,
+ "tags": [
+ ["radroots:price_unit"],
+ ["radroots:price"]
+ ],
+ "content": "Carrots available this week.",
+ "sig": "1da60ee3d831adae2aeb61df60c2e14f7168b696cf50a9362f326119d5c4d5a03dd8d6da1c9b10a29c3dd4ffbbfbe76404d758ee149bb56b13e2fe14eece385f"
+ }
+ },
+ "expected": {
+ "error_variant": "food_availability",
+ "error_code": "food_profile_ambiguous",
+ "event_id": "1e40dd34180c85871cc1a7369290876e004d56890ebca9a619f4c1f61e46d866"
+ }
+ }
+ ]
+}
diff --git a/crates/event_codec/tests/verified_admission_conformance.rs b/crates/event_codec/tests/verified_admission_conformance.rs
@@ -0,0 +1,165 @@
+#![cfg(all(feature = "serde_json", feature = "nostr"))]
+
+use std::{borrow::Cow, fs, path::Path};
+
+use radroots_event::{RadrootsEventEnvelope, RadrootsNip01EventWire};
+use radroots_event_codec::{
+ admission::{RadrootsAdmittedEvent, RadrootsEventAdmissionError, admit_verified_event},
+ verification::verify_nip01_event,
+};
+use serde::Deserialize;
+use serde_json::Value;
+
+const PACKAGED_VECTORS: &str = include_str!("fixtures/verified_admission.v1.json");
+const WORKSPACE_VECTOR_PATH: &str =
+ "../../contracts/conformance/vectors/event/verified_admission.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Suite {
+ suite: String,
+ contract_version: String,
+ vectors: Vec<Vector>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Vector {
+ id: String,
+ kind: String,
+ input: Value,
+ expected: Value,
+}
+
+#[test]
+fn fixed_signed_vectors_execute_the_complete_verified_admission_boundary() {
+ let vectors = conformance_vectors();
+ let suite: Suite =
+ serde_json::from_str(&vectors).expect("verified admission vectors must parse");
+ assert_eq!(suite.suite, "verified_event_admission");
+ assert_eq!(suite.contract_version, "1.0.0");
+ assert_eq!(suite.vectors.len(), 13);
+
+ for vector in &suite.vectors {
+ execute(vector);
+ }
+}
+
+fn conformance_vectors() -> Cow<'static, str> {
+ let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
+ match fs::read_to_string(&workspace_path) {
+ Ok(canonical) => {
+ assert_eq!(
+ canonical,
+ PACKAGED_VECTORS,
+ "packaged verified admission vectors must match {}",
+ workspace_path.display()
+ );
+ Cow::Owned(canonical)
+ }
+ Err(error)
+ if error.kind() == std::io::ErrorKind::NotFound
+ && !Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join(WORKSPACE_CONTRACT_MARKER_PATH)
+ .is_file() =>
+ {
+ Cow::Borrowed(PACKAGED_VECTORS)
+ }
+ Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
+ }
+}
+
+fn execute(vector: &Vector) {
+ let envelope = event_envelope(&vector.input["event"]);
+ let event_id = envelope.id_str().to_owned();
+ let verified = verify_nip01_event(envelope)
+ .unwrap_or_else(|error| panic!("{} fixture is not NIP-01 verified: {error}", vector.id));
+ let result = admit_verified_event(verified);
+
+ match vector.kind.as_str() {
+ "event.admit_verified.valid" => {
+ let admitted = result.unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ assert_eq!(
+ admitted_variant(&admitted),
+ expected_str(vector, "variant"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ admitted.contract_id(),
+ expected_str(vector, "contract_id"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ admitted.event().id_str(),
+ expected_str(vector, "event_id"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(admitted.into_verified_event().event().id_str(), event_id);
+ }
+ "event.admit_verified.invalid" => {
+ let error = match result {
+ Err(error) => error,
+ Ok(_) => panic!("{} unexpectedly admitted", vector.id),
+ };
+ assert_eq!(
+ admission_error_variant(&error),
+ expected_str(vector, "error_variant"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ error.code(),
+ expected_str(vector, "error_code"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(event_id, expected_str(vector, "event_id"), "{}", vector.id);
+ }
+ kind => panic!("{} uses unsupported vector kind {kind}", vector.id),
+ }
+}
+
+fn admitted_variant(admitted: &RadrootsAdmittedEvent) -> &'static str {
+ match admitted {
+ RadrootsAdmittedEvent::Profile(_) => "profile",
+ RadrootsAdmittedEvent::RootPost(_) => "root_post",
+ RadrootsAdmittedEvent::Reply(_) => "reply",
+ RadrootsAdmittedEvent::Comment(_) => "comment",
+ RadrootsAdmittedEvent::DeletionRequest(_) => "deletion_request",
+ RadrootsAdmittedEvent::FoodAvailability(_) => "food_availability",
+ RadrootsAdmittedEvent::ContractValidated(_) => "contract_validated",
+ _ => panic!("conformance runner must be updated for a new admission variant"),
+ }
+}
+
+fn admission_error_variant(error: &RadrootsEventAdmissionError) -> &'static str {
+ match error {
+ RadrootsEventAdmissionError::ContractMatch(_) => "contract_match",
+ RadrootsEventAdmissionError::ContractValidation(_) => "contract_validation",
+ RadrootsEventAdmissionError::Profile(_) => "profile",
+ RadrootsEventAdmissionError::Post(_) => "post",
+ RadrootsEventAdmissionError::Reply(_) => "reply",
+ RadrootsEventAdmissionError::Comment(_) => "comment",
+ RadrootsEventAdmissionError::DeletionRequest(_) => "deletion_request",
+ RadrootsEventAdmissionError::FoodAvailability(_) => "food_availability",
+ _ => panic!("conformance runner must be updated for a new admission error variant"),
+ }
+}
+
+fn event_envelope(value: &Value) -> RadrootsEventEnvelope {
+ let raw_json = serde_json::to_string(value).expect("serialize fixed event");
+ RadrootsNip01EventWire::parse_json(&raw_json)
+ .expect("fixed signed event wire")
+ .into_envelope()
+ .expect("fixed signed event envelope")
+}
+
+fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
+ vector.expected[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("{}.expected.{field} must be a string", vector.id))
+}
diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml
@@ -21,9 +21,10 @@ radroots_event = { workspace = true, default-features = false, features = [
"std",
"serde",
] }
-radroots_nostr = { workspace = true, default-features = false, features = [
+radroots_event_codec = { workspace = true, default-features = false, features = [
+ "nostr",
+ "serde_json",
"std",
- "events",
] }
radroots_transport = { workspace = true, default-features = false }
hex = { workspace = true }
diff --git a/crates/event_store/README b/crates/event_store/README
@@ -1,3 +1,75 @@
# radroots_event_store
-SQLx-backed canonical event-envelope storage for Rad Roots protocol events.
+SQLx-backed canonical event-envelope storage for Radroots protocol events.
+
+## Ingest boundary
+
+`RadrootsEventIngest::from_signed_event` and `from_raw_json` verify the NIP-01
+identifier and signature before constructing an ingest value. The store applies
+`radroots_event_codec::admission::admit_verified_event` only to that verified
+typestate. A failed signature never enters trusted contract validation or the
+durable raw-event sequence.
+
+The ingest receipt reports admission status and its stable failure code
+separately from `valid_stream_eligible` and the raw-head decision. Admission may
+be `Admitted`, `Unsupported`, or `Invalid`; unsupported registry matching is
+not interchangeable with a registered contract or typed profile that failed
+validation. The failure code is present when that ingest performed the durable
+classification. A duplicate returns the immutable stored status and eligibility
+without pretending that the baseline schema persisted the original diagnostic
+code.
+
+## Read surfaces
+
+The public read APIs name their authority explicitly:
+
+- `raw_event`, `raw_events_after`, and `raw_events_by_tag` expose
+ signature-verified stored envelopes, including unsupported or invalid product
+ contracts.
+- `valid_event`, `valid_stream_after`, `valid_stream_by_tag`, and
+ `valid_stream_by_contract_and_tag` expose admitted durable events. Eligibility
+ is recorded at ingest and independent of arrival order or head selection, so
+ both old and new valid revisions remain replayable through the store API.
+- `raw_event_head` exposes the NIP-01 replaceable/addressable winner selected
+ from every verified durable candidate before product admission. An
+ addressable raw coordinate uses the first `d` tag value verbatim as an opaque
+ protocol identifier; a missing `d` tag or missing first value becomes the
+ empty identifier. Product-contract admission may impose stricter `d` rules
+ independently.
+- `event_visibility`, `visible_event`, and `visible_event_head` expose current
+ product visibility. A visible head is the exact raw head only when that event
+ is admitted; an unsupported or invalid winning raw head yields no older
+ fallback. Non-head durable revisions report `NotCurrent`.
+
+Verified ephemeral events are classified but never written to the raw sequence,
+tags, observations, or heads. Their ingest receipt carries
+`RadrootsEventPersistence::NotPersisted`; repeated delivery remains live-only
+and is never reported as a durable duplicate.
+
+Deletion suppression is not stored by this baseline schema. A later additive
+migration extends the same visibility boundary; deletion evaluation does not
+rewrite raw envelopes through the event-store API.
+
+The current event-store API is append-only, but the byte-pinned `0001` schema
+and the exposed SQLx pool do not prevent direct SQL mutation. Database-enforced
+raw-event immutability belongs to the later additive migration.
+
+`open_pool` inspects the opened main database rather than trusting URL text,
+validates the declared backing mode, rejects multi-connection in-memory pools,
+and configures every file-pool connection with foreign-key enforcement and the
+required busy timeout before migrations or writes.
+
+## Projection cursors
+
+`projection_cursor` requires the caller's expected projection version.
+`compare_and_swap_projection_cursor` requires an expected prior sequence for an
+existing cursor, rejects version mismatch and sequence regression, and reports
+conflicting writers. Callers must reset or rebuild derived state after a
+version mismatch. Persisted source-generation identity belongs to the later
+additive migration; the byte-pinned `0001_event_store` migration is not
+rewritten and no generation is fabricated here.
+
+The historical SQLite column name `projection_eligible` remains an internal
+`0001` compatibility detail. Public APIs and models use
+`valid_stream_eligible`; the old mixed projection/head meaning is not part of
+the current contract.
diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs
@@ -1,8 +1,7 @@
-use radroots_event::contract::RadrootsContractMatchError;
use radroots_event::draft::RadrootsSignedEventError;
-use radroots_event::event_head::RadrootsEventHeadMalformed;
use radroots_event::ids::RadrootsIdParseError;
use radroots_event::wire::RadrootsEventWireError;
+use radroots_event_codec::verification::RadrootsNip01VerificationError;
use radroots_transport::RadrootsTransportError;
#[derive(Debug, thiserror::Error)]
@@ -11,16 +10,14 @@ pub enum RadrootsEventStoreError {
Sqlx(#[from] sqlx::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
- #[error("contract match error: {0:?}")]
- ContractMatch(RadrootsContractMatchError),
- #[error("event-head malformed: {0:?}")]
- EventHeadMalformed(RadrootsEventHeadMalformed),
#[error("identifier parse error: {0}")]
IdParse(#[from] RadrootsIdParseError),
#[error("event wire error: {0}")]
EventWire(#[from] RadrootsEventWireError),
#[error("signed event error: {0}")]
SignedEvent(#[from] RadrootsSignedEventError),
+ #[error("NIP-01 verification error: {0}")]
+ Nip01Verification(#[from] RadrootsNip01VerificationError),
#[error("transport contract error: {0}")]
Transport(RadrootsTransportError),
#[error("stored event `{0}` was not found")]
@@ -33,8 +30,59 @@ pub enum RadrootsEventStoreError {
ContractListTooLarge { max: usize, actual: usize },
#[error("event-store query limit {actual} is outside {min}..={max}")]
QueryLimitOutOfRange { min: u32, max: u32, actual: u32 },
+ #[error(
+ "an in-memory event-store pool must have exactly one connection, configured maximum was {actual}"
+ )]
+ UnsafeInMemoryPoolConnectionCount { actual: u32 },
+ #[error(
+ "event-store pool backing mismatch: file_backed={file_backed}, configured filename `{filename}`"
+ )]
+ SqlitePoolBackingMismatch { file_backed: bool, filename: String },
#[error("invalid stored enum value `{value}` for {field}")]
InvalidStoredEnum { field: &'static str, value: String },
+ #[error("invalid stored boolean value `{value}` for {field}; expected 0 or 1")]
+ InvalidStoredBoolean { field: &'static str, value: i64 },
+ #[error("stored raw event `{event_id}` is not signature verified: `{status}`")]
+ StoredRawEventNotVerified { event_id: String, status: String },
+ #[error(
+ "stored raw event `{event_id}` uses pre-admission status `{contract_status}` and must be reconciled"
+ )]
+ StoredRawEventRequiresReconciliation {
+ event_id: String,
+ contract_status: String,
+ },
+ #[error("stored raw event `{event_id}` is missing its numeric NIP-01 event class")]
+ StoredRawEventMissingClass { event_id: String },
+ #[error("stored raw event `{event_id}` has an inconsistent admission classification")]
+ StoredRawEventClassificationInconsistent { event_id: String },
+ #[error("stored event `{event_id}` does not have a raw event-head coordinate")]
+ StoredHeadCoordinateUnavailable { event_id: String },
+ #[error("stored raw event head referencing `{event_id}` is inconsistent with its event")]
+ StoredHeadInconsistent { event_id: String },
+ #[error("projection `{projection_id}` version mismatch: expected {expected}, stored {actual}")]
+ ProjectionVersionMismatch {
+ projection_id: String,
+ expected: u32,
+ actual: u32,
+ },
+ #[error(
+ "projection `{projection_id}` cursor compare-and-swap conflict: expected prior sequence {expected:?}, stored {actual:?}"
+ )]
+ ProjectionCursorConflict {
+ projection_id: String,
+ expected: Option<i64>,
+ actual: Option<i64>,
+ },
+ #[error(
+ "projection `{projection_id}` cursor cannot move backward from {current} to {proposed}"
+ )]
+ ProjectionCursorRegression {
+ projection_id: String,
+ current: i64,
+ proposed: i64,
+ },
+ #[error("projection `{projection_id}` cursor sequence cannot be negative: {value}")]
+ InvalidProjectionCursor { projection_id: String, value: i64 },
#[error(
"stored transport observation fingerprint `{endpoint_fingerprint}` does not match `{transport_kind}` endpoint `{endpoint_uri}` for event `{event_id}`"
)]
@@ -44,6 +92,15 @@ pub enum RadrootsEventStoreError {
endpoint_uri: String,
endpoint_fingerprint: String,
},
+ #[error(
+ "stored transport observation for event `{event_id}` has invalid times/count: first={first_observed_at_ms}, last={last_observed_at_ms}, count={observation_count}"
+ )]
+ InvalidStoredTransportObservation {
+ event_id: String,
+ first_observed_at_ms: i64,
+ last_observed_at_ms: i64,
+ observation_count: i64,
+ },
#[error("integer value `{value}` is outside {field} range")]
IntegerRange { field: &'static str, value: i64 },
#[error("unsigned integer value `{value}` is outside {field} range")]
diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs
@@ -16,14 +16,15 @@ pub use error::RadrootsEventStoreError;
pub use migrations::{EVENT_STORE_MIGRATION_DOWN, EVENT_STORE_MIGRATION_UP};
#[cfg(feature = "sqlite")]
pub use model::{
- RadrootsEventContractStatus, RadrootsEventHeadStoreDecision, RadrootsEventIngest,
- RadrootsEventIngestReceipt, RadrootsEventStoreStatusSummary, RadrootsEventVerificationStatus,
- RadrootsProjectionCursor, RadrootsStoredEvent, RadrootsStoredEventHead, RadrootsStoredEventTag,
- RadrootsStoredSellerReservation, RadrootsStoredSellerReservationLine,
- RadrootsStoredTradeMissingParent, RadrootsStoredTradeMutation,
- RadrootsStoredTradeMutationParent, RadrootsStoredTradeTransportEnvelope,
- RadrootsTradeProjectionCheckpoint, RadrootsTransportObservation,
- RadrootsTransportObservationType, StoredEventClass,
+ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventIngestReceipt,
+ RadrootsEventPersistence, RadrootsEventStoreStatusSummary, RadrootsEventVisibility,
+ RadrootsProjectionCursor, RadrootsRawHeadDecision, RadrootsStoredEventTag,
+ RadrootsStoredRawEvent, RadrootsStoredRawEventHead, RadrootsStoredSellerReservation,
+ RadrootsStoredSellerReservationLine, RadrootsStoredTradeMissingParent,
+ RadrootsStoredTradeMutation, RadrootsStoredTradeMutationParent,
+ RadrootsStoredTradeTransportEnvelope, RadrootsStoredValidEvent, RadrootsStoredVisibleEvent,
+ RadrootsStoredVisibleEventHead, RadrootsTradeProjectionCheckpoint,
+ RadrootsTransportObservation, RadrootsTransportObservationType, StoredEventClass,
};
#[cfg(feature = "sqlite")]
pub use store::{
diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs
@@ -1,8 +1,5 @@
use crate::RadrootsEventStoreError;
-use radroots_event::RadrootsEventEnvelope;
-use radroots_event::contract::{
- RadrootsContractMatchError, RadrootsEventClass, RadrootsTagSemantic, RadrootsTagValueType,
-};
+use radroots_event::contract::{RadrootsTagSemantic, RadrootsTagValueType};
use radroots_event::draft::RadrootsSignedEvent;
use radroots_event::event_head::RadrootsEventHeadDecision;
use radroots_event::ids::{
@@ -11,80 +8,33 @@ use radroots_event::ids::{
};
use radroots_event::trade::RadrootsTradeMutationKindV1;
use radroots_event::wire::RadrootsNip01EventWire;
+use radroots_event::{RadrootsEventEnvelope, RadrootsEventKind, RadrootsEventKindClass};
+use radroots_event_codec::verification::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
use radroots_transport::{
RadrootsTransportKind, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum RadrootsEventVerificationStatus {
- NotChecked,
- IdVerified,
- Verified,
- IdMismatch,
- SignatureInvalid,
- MalformedEnvelope,
+pub enum RadrootsEventAdmissionStatus {
+ Admitted,
+ Unsupported,
+ Invalid,
}
-impl RadrootsEventVerificationStatus {
+impl RadrootsEventAdmissionStatus {
pub fn as_str(self) -> &'static str {
match self {
- Self::NotChecked => "not_checked",
- Self::IdVerified => "id_verified",
- Self::Verified => "verified",
- Self::IdMismatch => "id_mismatch",
- Self::SignatureInvalid => "signature_invalid",
- Self::MalformedEnvelope => "malformed_envelope",
+ Self::Admitted => "admitted",
+ Self::Unsupported => "unsupported",
+ Self::Invalid => "invalid",
}
}
pub fn parse(value: &str) -> Result<Self, RadrootsEventStoreError> {
match value {
- "not_checked" => Ok(Self::NotChecked),
- "id_verified" => Ok(Self::IdVerified),
- "verified" => Ok(Self::Verified),
- "id_mismatch" => Ok(Self::IdMismatch),
- "signature_invalid" => Ok(Self::SignatureInvalid),
- "malformed_envelope" => Ok(Self::MalformedEnvelope),
- _ => Err(RadrootsEventStoreError::InvalidStoredEnum {
- field: "verification_status",
- value: value.to_owned(),
- }),
- }
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsEventContractStatus {
- Supported,
- UnsupportedKind(u32),
- UnsupportedShape(u32),
- AmbiguousShape(u32),
-}
-
-impl RadrootsEventContractStatus {
- pub fn as_str(&self) -> &'static str {
- match self {
- Self::Supported => "supported",
- Self::UnsupportedKind(_) => "unsupported_kind",
- Self::UnsupportedShape(_) => "unsupported_shape",
- Self::AmbiguousShape(_) => "ambiguous_shape",
- }
- }
-
- pub fn from_match_error(error: RadrootsContractMatchError) -> Self {
- match error {
- RadrootsContractMatchError::UnsupportedKind(kind) => Self::UnsupportedKind(kind),
- RadrootsContractMatchError::UnsupportedShape(kind) => Self::UnsupportedShape(kind),
- RadrootsContractMatchError::AmbiguousShape(kind) => Self::AmbiguousShape(kind),
- }
- }
-
- pub fn parse(value: &str, kind: u32) -> Result<Self, RadrootsEventStoreError> {
- match value {
- "supported" => Ok(Self::Supported),
- "unsupported_kind" => Ok(Self::UnsupportedKind(kind)),
- "unsupported_shape" => Ok(Self::UnsupportedShape(kind)),
- "ambiguous_shape" => Ok(Self::AmbiguousShape(kind)),
+ "admitted" => Ok(Self::Admitted),
+ "unsupported" => Ok(Self::Unsupported),
+ "invalid" => Ok(Self::Invalid),
_ => Err(RadrootsEventStoreError::InvalidStoredEnum {
field: "contract_status",
value: value.to_owned(),
@@ -111,12 +61,12 @@ impl StoredEventClass {
}
}
- pub fn from_event_class(value: RadrootsEventClass) -> Self {
+ pub fn from_event_kind_class(value: RadrootsEventKindClass) -> Self {
match value {
- RadrootsEventClass::Regular => Self::Regular,
- RadrootsEventClass::Replaceable => Self::Replaceable,
- RadrootsEventClass::Addressable => Self::Addressable,
- RadrootsEventClass::Ephemeral => Self::Ephemeral,
+ RadrootsEventKindClass::Regular => Self::Regular,
+ RadrootsEventKindClass::Replaceable => Self::Replaceable,
+ RadrootsEventKindClass::Ephemeral => Self::Ephemeral,
+ RadrootsEventKindClass::Addressable => Self::Addressable,
}
}
@@ -222,18 +172,30 @@ impl RadrootsTransportObservation {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsEventIngest {
- pub signed_event: RadrootsSignedEvent,
- pub observed_at_ms: i64,
- pub transport_observation: Option<RadrootsTransportObservation>,
+ verified_event: RadrootsSignatureVerifiedEvent,
+ raw_json: String,
+ observed_at_ms: i64,
+ transport_observation: Option<RadrootsTransportObservation>,
}
impl RadrootsEventIngest {
- pub fn new(signed_event: RadrootsSignedEvent, observed_at_ms: i64) -> Self {
- Self {
- signed_event,
+ #[cfg(test)]
+ pub(crate) fn new(signed_event: RadrootsSignedEvent, observed_at_ms: i64) -> Self {
+ Self::from_signed_event(signed_event, observed_at_ms)
+ .expect("test event must have a valid NIP-01 signature")
+ }
+
+ pub fn from_signed_event(
+ signed_event: RadrootsSignedEvent,
+ observed_at_ms: i64,
+ ) -> Result<Self, RadrootsEventStoreError> {
+ let verified_event = verify_nip01_event(signed_event.envelope().clone())?;
+ Ok(Self {
+ verified_event,
+ raw_json: signed_event.raw_json().to_owned(),
observed_at_ms,
transport_observation: None,
- }
+ })
}
pub fn from_raw_json(
@@ -243,7 +205,7 @@ impl RadrootsEventIngest {
let raw_json = raw_json.into();
let wire = RadrootsNip01EventWire::parse_json(raw_json.as_str())?;
let signed_event = RadrootsSignedEvent::from_wire_verified_id(wire, raw_json)?;
- Ok(Self::new(signed_event, observed_at_ms))
+ Self::from_signed_event(signed_event, observed_at_ms)
}
pub fn with_observation(mut self, observation: RadrootsTransportObservation) -> Self {
@@ -252,28 +214,38 @@ impl RadrootsEventIngest {
}
pub fn event(&self) -> &RadrootsEventEnvelope {
- self.signed_event.envelope()
+ self.verified_event.event()
+ }
+
+ pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent {
+ &self.verified_event
}
pub fn raw_json(&self) -> &str {
- self.signed_event.raw_json()
+ self.raw_json.as_str()
+ }
+
+ pub fn observed_at_ms(&self) -> i64 {
+ self.observed_at_ms
+ }
+
+ pub fn transport_observation(&self) -> Option<&RadrootsTransportObservation> {
+ self.transport_observation.as_ref()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsEventHeadStoreDecision {
+pub enum RadrootsRawHeadDecision {
Applied,
NotHeadSelected,
NotPersisted,
- NotProjectionEligible,
SkippedDuplicate,
SkippedOlder,
SkippedSameTimestampHigherEventId,
- Malformed,
- Unsupported,
+ MalformedCoordinate,
}
-impl RadrootsEventHeadStoreDecision {
+impl RadrootsRawHeadDecision {
pub fn from_protocol(value: &RadrootsEventHeadDecision) -> Self {
match value {
RadrootsEventHeadDecision::Applied(_) => Self::Applied,
@@ -282,34 +254,57 @@ impl RadrootsEventHeadStoreDecision {
RadrootsEventHeadDecision::SkippedSameTimestampHigherEventId => {
Self::SkippedSameTimestampHigherEventId
}
- RadrootsEventHeadDecision::CoordinateMismatch => Self::Malformed,
+ RadrootsEventHeadDecision::CoordinateMismatch => Self::MalformedCoordinate,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsEventPersistence {
+ Inserted { seq: i64 },
+ Duplicate { seq: i64 },
+ NotPersisted,
+}
+
+impl RadrootsEventPersistence {
+ pub const fn sequence(&self) -> Option<i64> {
+ match self {
+ Self::Inserted { seq } | Self::Duplicate { seq } => Some(*seq),
+ Self::NotPersisted => None,
}
}
+
+ pub const fn is_inserted(&self) -> bool {
+ matches!(self, Self::Inserted { .. })
+ }
+
+ pub const fn is_duplicate(&self) -> bool {
+ matches!(self, Self::Duplicate { .. })
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsEventIngestReceipt {
- pub seq: i64,
+ pub persistence: RadrootsEventPersistence,
pub event_id: String,
- pub inserted: bool,
- pub verification_status: RadrootsEventVerificationStatus,
- pub contract_status: RadrootsEventContractStatus,
+ pub admission_status: RadrootsEventAdmissionStatus,
+ pub admission_code: Option<String>,
pub contract_id: Option<String>,
- pub projection_eligible: bool,
- pub head_decision: RadrootsEventHeadStoreDecision,
+ pub valid_stream_eligible: bool,
+ pub raw_head_decision: RadrootsRawHeadDecision,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsEventStoreStatusSummary {
pub total_events: i64,
- pub projection_eligible_events: i64,
+ pub valid_stream_events: i64,
pub transport_observations: i64,
pub last_event_seq: Option<i64>,
pub last_event_updated_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsStoredEvent {
+pub struct RadrootsStoredRawEvent {
pub seq: i64,
pub event_id: String,
pub pubkey: String,
@@ -319,16 +314,68 @@ pub struct RadrootsStoredEvent {
pub content: String,
pub sig: String,
pub raw_json: String,
- pub verification_status: RadrootsEventVerificationStatus,
- pub contract_status: RadrootsEventContractStatus,
+ pub admission_status: RadrootsEventAdmissionStatus,
pub contract_id: Option<String>,
- pub event_class: Option<StoredEventClass>,
- pub projection_eligible: bool,
+ pub event_class: StoredEventClass,
+ pub valid_stream_eligible: bool,
pub inserted_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsStoredValidEvent {
+ raw_event: RadrootsStoredRawEvent,
+}
+
+impl RadrootsStoredValidEvent {
+ pub(crate) fn try_from_raw(
+ raw_event: RadrootsStoredRawEvent,
+ ) -> Result<Self, RadrootsEventStoreError> {
+ let expected_class =
+ StoredEventClass::from_event_kind_class(RadrootsEventKind::new(raw_event.kind).class());
+ if raw_event.admission_status != RadrootsEventAdmissionStatus::Admitted
+ || raw_event.event_class != expected_class
+ || raw_event.event_class == StoredEventClass::Ephemeral
+ || !raw_event.valid_stream_eligible
+ {
+ return Err(
+ RadrootsEventStoreError::StoredRawEventClassificationInconsistent {
+ event_id: raw_event.event_id,
+ },
+ );
+ }
+ Ok(Self { raw_event })
+ }
+
+ pub fn raw_event(&self) -> &RadrootsStoredRawEvent {
+ &self.raw_event
+ }
+
+ pub fn into_raw_event(self) -> RadrootsStoredRawEvent {
+ self.raw_event
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsStoredVisibleEvent {
+ valid_event: RadrootsStoredValidEvent,
+}
+
+impl RadrootsStoredVisibleEvent {
+ pub(crate) fn new(valid_event: RadrootsStoredValidEvent) -> Self {
+ Self { valid_event }
+ }
+
+ pub fn valid_event(&self) -> &RadrootsStoredValidEvent {
+ &self.valid_event
+ }
+
+ pub fn into_valid_event(self) -> RadrootsStoredValidEvent {
+ self.valid_event
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsStoredEventTag {
pub event_id: String,
pub tag_index: u32,
@@ -341,7 +388,7 @@ pub struct RadrootsStoredEventTag {
}
#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsStoredEventHead {
+pub struct RadrootsStoredRawEventHead {
pub coordinate_type: StoredEventClass,
pub kind: u32,
pub pubkey: String,
@@ -352,6 +399,37 @@ pub struct RadrootsStoredEventHead {
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsStoredVisibleEventHead {
+ raw_head: RadrootsStoredRawEventHead,
+ event: RadrootsStoredVisibleEvent,
+}
+
+impl RadrootsStoredVisibleEventHead {
+ pub(crate) fn new(
+ raw_head: RadrootsStoredRawEventHead,
+ event: RadrootsStoredVisibleEvent,
+ ) -> Self {
+ Self { raw_head, event }
+ }
+
+ pub fn raw_head(&self) -> &RadrootsStoredRawEventHead {
+ &self.raw_head
+ }
+
+ pub fn event(&self) -> &RadrootsStoredVisibleEvent {
+ &self.event
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsEventVisibility {
+ Visible,
+ NotAdmitted,
+ NotCurrent { raw_head_event_id: String },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsProjectionCursor {
pub projection_id: String,
pub projection_version: u32,
@@ -539,63 +617,30 @@ mod tests {
use radroots_event::event_head::{
RadrootsCurrentEventHead, RadrootsEventHeadCoordinate, RadrootsEventHeadDecision,
};
- use radroots_event::ids::{RadrootsDTag, RadrootsEventId, RadrootsPublicKey};
+ use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey};
#[test]
- fn contract_status_event_class_and_observation_values_roundtrip() {
- assert_eq!(
- RadrootsEventContractStatus::from_match_error(
- RadrootsContractMatchError::UnsupportedKind(7)
- ),
- RadrootsEventContractStatus::UnsupportedKind(7)
- );
- assert_eq!(
- RadrootsEventContractStatus::from_match_error(
- RadrootsContractMatchError::UnsupportedShape(8)
- ),
- RadrootsEventContractStatus::UnsupportedShape(8)
- );
- assert_eq!(
- RadrootsEventContractStatus::from_match_error(
- RadrootsContractMatchError::AmbiguousShape(9)
- ),
- RadrootsEventContractStatus::AmbiguousShape(9)
- );
-
+ fn admission_status_event_class_and_observation_values_roundtrip() {
for (status, expected) in [
- (RadrootsEventContractStatus::Supported, "supported"),
- (
- RadrootsEventContractStatus::UnsupportedKind(1),
- "unsupported_kind",
- ),
- (
- RadrootsEventContractStatus::UnsupportedShape(2),
- "unsupported_shape",
- ),
- (
- RadrootsEventContractStatus::AmbiguousShape(3),
- "ambiguous_shape",
- ),
+ (RadrootsEventAdmissionStatus::Admitted, "admitted"),
+ (RadrootsEventAdmissionStatus::Unsupported, "unsupported"),
+ (RadrootsEventAdmissionStatus::Invalid, "invalid"),
] {
assert_eq!(status.as_str(), expected);
assert_eq!(
- RadrootsEventContractStatus::parse(expected, 99).expect("status"),
- match status {
- RadrootsEventContractStatus::Supported =>
- RadrootsEventContractStatus::Supported,
- RadrootsEventContractStatus::UnsupportedKind(_) => {
- RadrootsEventContractStatus::UnsupportedKind(99)
- }
- RadrootsEventContractStatus::UnsupportedShape(_) => {
- RadrootsEventContractStatus::UnsupportedShape(99)
- }
- RadrootsEventContractStatus::AmbiguousShape(_) => {
- RadrootsEventContractStatus::AmbiguousShape(99)
- }
- }
+ RadrootsEventAdmissionStatus::parse(expected).expect("status"),
+ status
);
}
- assert!(RadrootsEventContractStatus::parse("bad", 1).is_err());
+ for legacy in [
+ "supported",
+ "unsupported_kind",
+ "unsupported_shape",
+ "ambiguous_shape",
+ ] {
+ assert!(RadrootsEventAdmissionStatus::parse(legacy).is_err());
+ }
+ assert!(RadrootsEventAdmissionStatus::parse("bad").is_err());
for class in [
StoredEventClass::Regular,
@@ -609,23 +654,36 @@ mod tests {
);
}
assert_eq!(
- StoredEventClass::from_event_class(RadrootsEventClass::Regular),
+ StoredEventClass::from_event_kind_class(RadrootsEventKindClass::Regular),
StoredEventClass::Regular
);
assert_eq!(
- StoredEventClass::from_event_class(RadrootsEventClass::Replaceable),
+ StoredEventClass::from_event_kind_class(RadrootsEventKindClass::Replaceable),
StoredEventClass::Replaceable
);
assert_eq!(
- StoredEventClass::from_event_class(RadrootsEventClass::Addressable),
+ StoredEventClass::from_event_kind_class(RadrootsEventKindClass::Addressable),
StoredEventClass::Addressable
);
assert_eq!(
- StoredEventClass::from_event_class(RadrootsEventClass::Ephemeral),
+ StoredEventClass::from_event_kind_class(RadrootsEventKindClass::Ephemeral),
StoredEventClass::Ephemeral
);
assert!(StoredEventClass::parse("bad").is_err());
+ let inserted = RadrootsEventPersistence::Inserted { seq: 7 };
+ assert_eq!(inserted.sequence(), Some(7));
+ assert!(inserted.is_inserted());
+ assert!(!inserted.is_duplicate());
+ let duplicate = RadrootsEventPersistence::Duplicate { seq: 7 };
+ assert_eq!(duplicate.sequence(), Some(7));
+ assert!(!duplicate.is_inserted());
+ assert!(duplicate.is_duplicate());
+ let not_persisted = RadrootsEventPersistence::NotPersisted;
+ assert_eq!(not_persisted.sequence(), None);
+ assert!(!not_persisted.is_inserted());
+ assert!(!not_persisted.is_duplicate());
+
for observation_type in [
RadrootsTransportObservationType::Fetch,
RadrootsTransportObservationType::Subscription,
@@ -678,7 +736,7 @@ mod tests {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.expect("pubkey"),
- d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"),
+ d_tag: "opaque d value".to_owned(),
};
let current = RadrootsCurrentEventHead {
coordinate,
@@ -690,32 +748,26 @@ mod tests {
};
assert_eq!(
- RadrootsEventHeadStoreDecision::from_protocol(&RadrootsEventHeadDecision::Applied(
- current
- )),
- RadrootsEventHeadStoreDecision::Applied
+ RadrootsRawHeadDecision::from_protocol(&RadrootsEventHeadDecision::Applied(current)),
+ RadrootsRawHeadDecision::Applied
);
assert_eq!(
- RadrootsEventHeadStoreDecision::from_protocol(
- &RadrootsEventHeadDecision::SkippedDuplicate
- ),
- RadrootsEventHeadStoreDecision::SkippedDuplicate
+ RadrootsRawHeadDecision::from_protocol(&RadrootsEventHeadDecision::SkippedDuplicate),
+ RadrootsRawHeadDecision::SkippedDuplicate
);
assert_eq!(
- RadrootsEventHeadStoreDecision::from_protocol(&RadrootsEventHeadDecision::SkippedOlder),
- RadrootsEventHeadStoreDecision::SkippedOlder
+ RadrootsRawHeadDecision::from_protocol(&RadrootsEventHeadDecision::SkippedOlder),
+ RadrootsRawHeadDecision::SkippedOlder
);
assert_eq!(
- RadrootsEventHeadStoreDecision::from_protocol(
+ RadrootsRawHeadDecision::from_protocol(
&RadrootsEventHeadDecision::SkippedSameTimestampHigherEventId
),
- RadrootsEventHeadStoreDecision::SkippedSameTimestampHigherEventId
+ RadrootsRawHeadDecision::SkippedSameTimestampHigherEventId
);
assert_eq!(
- RadrootsEventHeadStoreDecision::from_protocol(
- &RadrootsEventHeadDecision::CoordinateMismatch
- ),
- RadrootsEventHeadStoreDecision::Malformed
+ RadrootsRawHeadDecision::from_protocol(&RadrootsEventHeadDecision::CoordinateMismatch),
+ RadrootsRawHeadDecision::MalformedCoordinate
);
for (semantic, expected) in [
diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs
@@ -1,26 +1,25 @@
use crate::RadrootsEventStoreError;
use crate::migrations::{EVENT_STORE_MIGRATION_DOWN, EVENT_STORE_MIGRATION_UP};
use crate::model::{
- RadrootsEventContractStatus, RadrootsEventHeadStoreDecision, RadrootsEventIngest,
- RadrootsEventIngestReceipt, RadrootsEventStoreStatusSummary, RadrootsEventVerificationStatus,
- RadrootsProjectionCursor, RadrootsStoredEvent, RadrootsStoredEventHead, RadrootsStoredEventTag,
- RadrootsStoredSellerReservation, RadrootsStoredSellerReservationLine,
- RadrootsStoredTradeMissingParent, RadrootsStoredTradeMutation,
- RadrootsStoredTradeMutationParent, RadrootsStoredTradeTransportEnvelope,
- RadrootsTradeProjectionCheckpoint, RadrootsTransportObservation,
- RadrootsTransportObservationType, StoredEventClass, tag_semantic_name, tag_value_type_name,
-};
-use radroots_event::RadrootsEventEnvelope;
-use radroots_event::contract::{
- RadrootsEventClass, RadrootsEventContract, identify_event_contract,
+ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventIngestReceipt,
+ RadrootsEventPersistence, RadrootsEventStoreStatusSummary, RadrootsEventVisibility,
+ RadrootsProjectionCursor, RadrootsRawHeadDecision, RadrootsStoredEventTag,
+ RadrootsStoredRawEvent, RadrootsStoredRawEventHead, RadrootsStoredSellerReservation,
+ RadrootsStoredSellerReservationLine, RadrootsStoredTradeMissingParent,
+ RadrootsStoredTradeMutation, RadrootsStoredTradeMutationParent,
+ RadrootsStoredTradeTransportEnvelope, RadrootsStoredValidEvent, RadrootsStoredVisibleEvent,
+ RadrootsStoredVisibleEventHead, RadrootsTradeProjectionCheckpoint,
+ RadrootsTransportObservation, RadrootsTransportObservationType, StoredEventClass,
+ tag_semantic_name, tag_value_type_name,
};
+use radroots_event::contract::{RadrootsContractMatchError, RadrootsEventContract};
use radroots_event::event_head::{
RadrootsCurrentEventHead, RadrootsEventHeadCandidate, RadrootsEventHeadCandidateResult,
- RadrootsEventHeadCoordinate, RadrootsEventHeadDecision, event_head_candidate_for_contract,
+ RadrootsEventHeadCoordinate, RadrootsEventHeadDecision, event_head_candidate_for_nip01_event,
select_event_head,
};
use radroots_event::ids::{
- RadrootsDTag, RadrootsEventId, RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId,
+ RadrootsDTag, RadrootsEventId, RadrootsTradeCandidateId, RadrootsTradeId,
RadrootsTradeMutationId,
};
use radroots_event::trade::{
@@ -28,15 +27,19 @@ use radroots_event::trade::{
RadrootsTradeDecisionV1, RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1,
RadrootsTradeMutationKindV1, trade_mutation_from_canonical_content,
};
-use radroots_nostr::prelude::{RadrootsNostrEventVerification, radroots_nostr_verify_event};
+use radroots_event::{RadrootsEventEnvelope, RadrootsEventKind, RadrootsEventKindClass};
+use radroots_event_codec::admission::{
+ RadrootsAdmittedEvent, RadrootsEventAdmissionError, admit_verified_event,
+};
use radroots_transport::{
RadrootsTransportKind, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri,
};
use sha2::{Digest, Sha256};
-use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
+use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use std::path::Path;
use std::str::FromStr;
+use std::time::Duration;
pub const RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX: u32 = 1_000;
pub const RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX: usize = 16;
@@ -53,7 +56,7 @@ impl RadrootsEventStore {
.max_connections(1)
.connect_with(options)
.await?;
- configure_connection(&pool, false).await?;
+ configure_pool(&pool, false).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -66,7 +69,7 @@ impl RadrootsEventStore {
.max_connections(1)
.connect_with(options)
.await?;
- configure_connection(&pool, true).await?;
+ configure_pool(&pool, true).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -75,7 +78,7 @@ impl RadrootsEventStore {
pool: SqlitePool,
file_backed: bool,
) -> Result<Self, RadrootsEventStoreError> {
- configure_connection(&pool, file_backed).await?;
+ configure_pool(&pool, file_backed).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -103,23 +106,35 @@ impl RadrootsEventStore {
pub async fn status_summary(
&self,
) -> Result<RadrootsEventStoreStatusSummary, RadrootsEventStoreError> {
- let row = sqlx::query(
- "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN projection_eligible = 1 THEN 1 ELSE 0 END), 0) AS projection_eligible_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes",
+ let mut tx = self.pool.begin().await?;
+ let inconsistent_event_id: Option<String> = sqlx::query_scalar(
+ "SELECT event_id FROM event_envelopes WHERE contract_status NOT IN ('supported', 'unsupported_kind', 'unsupported_shape', 'ambiguous_shape') AND (verification_status != 'verified' OR contract_status NOT IN ('admitted', 'unsupported', 'invalid') OR kind < 0 OR kind > 65535 OR kind BETWEEN 20000 AND 29999 OR event_class IS NULL OR event_class != CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END OR projection_eligible NOT IN (0, 1) OR projection_eligible != CASE WHEN contract_status = 'admitted' THEN 1 ELSE 0 END OR (contract_status = 'admitted') != (contract_id IS NOT NULL)) LIMIT 1",
)
- .fetch_one(&self.pool)
+ .fetch_optional(&mut *tx)
.await?;
- let transport_observations = query_i64(
- &self.pool,
- "SELECT COUNT(*) FROM event_transport_observation",
+ if let Some(event_id) = inconsistent_event_id {
+ return Err(
+ RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id },
+ );
+ }
+ let row = sqlx::query(
+ "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN verification_status = 'verified' AND contract_status = 'admitted' AND contract_id IS NOT NULL AND projection_eligible = 1 AND kind BETWEEN 0 AND 65535 AND NOT (kind BETWEEN 20000 AND 29999) AND event_class = CASE WHEN kind = 0 OR kind = 3 OR kind BETWEEN 10000 AND 19999 THEN 'replaceable' WHEN kind BETWEEN 30000 AND 39999 THEN 'addressable' ELSE 'regular' END THEN 1 ELSE 0 END), 0) AS valid_stream_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes",
)
+ .fetch_one(&mut *tx)
.await?;
- Ok(RadrootsEventStoreStatusSummary {
+ let transport_observations: i64 =
+ sqlx::query_scalar("SELECT COUNT(*) FROM event_transport_observation")
+ .fetch_one(&mut *tx)
+ .await?;
+ let summary = RadrootsEventStoreStatusSummary {
total_events: row.try_get("total_events")?,
- projection_eligible_events: row.try_get("projection_eligible_events")?,
+ valid_stream_events: row.try_get("valid_stream_events")?,
transport_observations,
last_event_seq: row.try_get("last_event_seq")?,
last_event_updated_at_ms: row.try_get("last_event_updated_at_ms")?,
- })
+ };
+ tx.commit().await?;
+ Ok(summary)
}
pub async fn ingest_event(
@@ -140,17 +155,30 @@ impl RadrootsEventStore {
ingest_event_in_transaction(tx, ingest).await
}
- pub async fn get_event(
+ pub async fn raw_event(
&self,
event_id: &str,
- ) -> Result<Option<RadrootsStoredEvent>, RadrootsEventStoreError> {
+ ) -> Result<Option<RadrootsStoredRawEvent>, RadrootsEventStoreError> {
let row = sqlx::query(
"SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE event_id = ?",
)
.bind(event_id)
.fetch_optional(&self.pool)
.await?;
- row.map(stored_event_from_row).transpose()
+ row.map(stored_raw_event_from_row).transpose()
+ }
+
+ pub async fn valid_event(
+ &self,
+ event_id: &str,
+ ) -> Result<Option<RadrootsStoredValidEvent>, RadrootsEventStoreError> {
+ let Some(raw_event) = self.raw_event(event_id).await? else {
+ return Ok(None);
+ };
+ if !raw_event.valid_stream_eligible {
+ return Ok(None);
+ }
+ Ok(Some(RadrootsStoredValidEvent::try_from_raw(raw_event)?))
}
pub async fn tags_for_event(
@@ -201,41 +229,75 @@ impl RadrootsEventStore {
.collect()
}
- pub async fn event_head(
+ pub async fn raw_event_head(
&self,
coordinate: &RadrootsEventHeadCoordinate,
- ) -> Result<Option<RadrootsStoredEventHead>, RadrootsEventStoreError> {
- let row = match coordinate {
- RadrootsEventHeadCoordinate::Replaceable { kind, pubkey } => {
- sqlx::query(
- "SELECT coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms FROM event_envelope_head WHERE coordinate_type = 'replaceable' AND kind = ? AND pubkey = ? AND d_tag IS NULL",
- )
- .bind(i64::from(*kind))
- .bind(pubkey.as_str())
- .fetch_optional(&self.pool)
- .await?
- }
- RadrootsEventHeadCoordinate::Addressable {
- kind,
- pubkey,
- d_tag,
- } => {
- sqlx::query(
- "SELECT coordinate_type, kind, pubkey, d_tag, event_id, created_at, updated_at_ms FROM event_envelope_head WHERE coordinate_type = 'addressable' AND kind = ? AND pubkey = ? AND d_tag = ?",
- )
- .bind(i64::from(*kind))
- .bind(pubkey.as_str())
- .bind(d_tag.as_str())
- .fetch_optional(&self.pool)
- .await?
- }
+ ) -> Result<Option<RadrootsStoredRawEventHead>, RadrootsEventStoreError> {
+ let mut tx = self.pool.begin().await?;
+ let snapshot = raw_head_snapshot_in_transaction(&mut tx, coordinate).await?;
+ tx.commit().await?;
+ Ok(snapshot.map(|snapshot| snapshot.raw_head))
+ }
+
+ pub async fn event_visibility(
+ &self,
+ event_id: &str,
+ ) -> Result<Option<RadrootsEventVisibility>, RadrootsEventStoreError> {
+ let mut tx = self.pool.begin().await?;
+ let Some(snapshot) = visible_event_snapshot(&mut tx, event_id).await? else {
+ tx.commit().await?;
+ return Ok(None);
};
- row.map(stored_head_from_row).transpose()
+ let visibility = visibility_from_snapshot(&snapshot);
+ tx.commit().await?;
+ Ok(Some(visibility?))
+ }
+
+ pub async fn visible_event(
+ &self,
+ event_id: &str,
+ ) -> Result<Option<RadrootsStoredVisibleEvent>, RadrootsEventStoreError> {
+ let mut tx = self.pool.begin().await?;
+ let Some(snapshot) = visible_event_snapshot(&mut tx, event_id).await? else {
+ tx.commit().await?;
+ return Ok(None);
+ };
+ if visibility_from_snapshot(&snapshot)? != RadrootsEventVisibility::Visible {
+ tx.commit().await?;
+ return Ok(None);
+ }
+ let valid_event = RadrootsStoredValidEvent::try_from_raw(snapshot.raw_event)?;
+ tx.commit().await?;
+ Ok(Some(RadrootsStoredVisibleEvent::new(valid_event)))
+ }
+
+ pub async fn visible_event_head(
+ &self,
+ coordinate: &RadrootsEventHeadCoordinate,
+ ) -> Result<Option<RadrootsStoredVisibleEventHead>, RadrootsEventStoreError> {
+ let mut tx = self.pool.begin().await?;
+ let Some(snapshot) = raw_head_snapshot_in_transaction(&mut tx, coordinate).await? else {
+ tx.commit().await?;
+ return Ok(None);
+ };
+ let RawHeadSnapshot {
+ raw_head,
+ raw_event,
+ } = snapshot;
+ if raw_event.admission_status != RadrootsEventAdmissionStatus::Admitted {
+ tx.commit().await?;
+ return Ok(None);
+ }
+ let valid_event = RadrootsStoredValidEvent::try_from_raw(raw_event)?;
+ let event = RadrootsStoredVisibleEvent::new(valid_event);
+ tx.commit().await?;
+ Ok(Some(RadrootsStoredVisibleEventHead::new(raw_head, event)))
}
- pub async fn get_projection_cursor(
+ pub async fn projection_cursor(
&self,
projection_id: &str,
+ expected_projection_version: u32,
) -> Result<Option<RadrootsProjectionCursor>, RadrootsEventStoreError> {
let row = sqlx::query(
"SELECT projection_id, projection_version, last_event_seq, updated_at_ms FROM projection_cursor WHERE projection_id = ?",
@@ -243,70 +305,174 @@ impl RadrootsEventStore {
.bind(projection_id)
.fetch_optional(&self.pool)
.await?;
- row.map(projection_cursor_from_row).transpose()
+ let cursor = row.map(projection_cursor_from_row).transpose()?;
+ if let Some(cursor) = cursor.as_ref()
+ && cursor.projection_version != expected_projection_version
+ {
+ return Err(RadrootsEventStoreError::ProjectionVersionMismatch {
+ projection_id: projection_id.to_owned(),
+ expected: expected_projection_version,
+ actual: cursor.projection_version,
+ });
+ }
+ Ok(cursor)
}
- pub async fn update_projection_cursor(
+ pub async fn compare_and_swap_projection_cursor(
&self,
cursor: &RadrootsProjectionCursor,
+ expected_prior_sequence: Option<i64>,
) -> Result<(), RadrootsEventStoreError> {
- sqlx::query(
- "INSERT INTO projection_cursor(projection_id, projection_version, last_event_seq, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(projection_id) DO UPDATE SET projection_version = excluded.projection_version, last_event_seq = excluded.last_event_seq, updated_at_ms = excluded.updated_at_ms",
+ if cursor.last_event_seq < 0 {
+ return Err(RadrootsEventStoreError::InvalidProjectionCursor {
+ projection_id: cursor.projection_id.clone(),
+ value: cursor.last_event_seq,
+ });
+ }
+ match expected_prior_sequence {
+ None => {
+ let inserted = sqlx::query(
+ "INSERT OR IGNORE INTO projection_cursor(projection_id, projection_version, last_event_seq, updated_at_ms) VALUES (?, ?, ?, ?)",
+ )
+ .bind(cursor.projection_id.as_str())
+ .bind(i64::from(cursor.projection_version))
+ .bind(cursor.last_event_seq)
+ .bind(cursor.updated_at_ms)
+ .execute(&self.pool)
+ .await?;
+ if inserted.rows_affected() == 1 {
+ return Ok(());
+ }
+ }
+ Some(expected) => {
+ if cursor.last_event_seq < expected {
+ return Err(RadrootsEventStoreError::ProjectionCursorRegression {
+ projection_id: cursor.projection_id.clone(),
+ current: expected,
+ proposed: cursor.last_event_seq,
+ });
+ }
+ let updated = sqlx::query(
+ "UPDATE projection_cursor SET last_event_seq = ?, updated_at_ms = ? WHERE projection_id = ? AND projection_version = ? AND last_event_seq = ?",
+ )
+ .bind(cursor.last_event_seq)
+ .bind(cursor.updated_at_ms)
+ .bind(cursor.projection_id.as_str())
+ .bind(i64::from(cursor.projection_version))
+ .bind(expected)
+ .execute(&self.pool)
+ .await?;
+ if updated.rows_affected() == 1 {
+ return Ok(());
+ }
+ }
+ }
+
+ let actual = projection_cursor_unchecked(&self.pool, cursor.projection_id.as_str()).await?;
+ if let Some(actual) = actual.as_ref() {
+ if actual.projection_version != cursor.projection_version {
+ return Err(RadrootsEventStoreError::ProjectionVersionMismatch {
+ projection_id: cursor.projection_id.clone(),
+ expected: cursor.projection_version,
+ actual: actual.projection_version,
+ });
+ }
+ if cursor.last_event_seq < actual.last_event_seq {
+ return Err(RadrootsEventStoreError::ProjectionCursorRegression {
+ projection_id: cursor.projection_id.clone(),
+ current: actual.last_event_seq,
+ proposed: cursor.last_event_seq,
+ });
+ }
+ }
+ Err(RadrootsEventStoreError::ProjectionCursorConflict {
+ projection_id: cursor.projection_id.clone(),
+ expected: expected_prior_sequence,
+ actual: actual.map(|cursor| cursor.last_event_seq),
+ })
+ }
+
+ pub async fn valid_stream_after(
+ &self,
+ after_sequence: i64,
+ limit: u32,
+ ) -> Result<Vec<RadrootsStoredValidEvent>, RadrootsEventStoreError> {
+ validate_event_query_limit(limit)?;
+ let rows = sqlx::query(
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE verification_status = 'verified' AND contract_status = 'admitted' AND projection_eligible = 1 AND seq > ? ORDER BY seq ASC LIMIT ?",
)
- .bind(cursor.projection_id.as_str())
- .bind(i64::from(cursor.projection_version))
- .bind(cursor.last_event_seq)
- .bind(cursor.updated_at_ms)
- .execute(&self.pool)
+ .bind(after_sequence)
+ .bind(i64::from(limit))
+ .fetch_all(&self.pool)
.await?;
- Ok(())
+ rows.into_iter()
+ .map(stored_raw_event_from_row)
+ .map(|event| event.and_then(RadrootsStoredValidEvent::try_from_raw))
+ .collect()
}
- pub async fn events_since_cursor(
+ pub async fn raw_events_after(
&self,
- projection_id: &str,
+ after_sequence: i64,
+ limit: u32,
+ ) -> Result<Vec<RadrootsStoredRawEvent>, RadrootsEventStoreError> {
+ validate_event_query_limit(limit)?;
+ let rows = sqlx::query(
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE seq > ? ORDER BY seq ASC LIMIT ?",
+ )
+ .bind(after_sequence)
+ .bind(i64::from(limit))
+ .fetch_all(&self.pool)
+ .await?;
+ rows.into_iter().map(stored_raw_event_from_row).collect()
+ }
+
+ pub async fn raw_events_by_tag(
+ &self,
+ tag_name: &str,
+ tag_value: &str,
limit: u32,
- ) -> Result<Vec<RadrootsStoredEvent>, RadrootsEventStoreError> {
- let cursor = self.get_projection_cursor(projection_id).await?;
- let last_event_seq = cursor
- .as_ref()
- .map(|cursor| cursor.last_event_seq)
- .unwrap_or(0);
+ ) -> Result<Vec<RadrootsStoredRawEvent>, RadrootsEventStoreError> {
+ validate_tag_query(tag_name, limit)?;
let rows = sqlx::query(
- "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE projection_eligible = 1 AND seq > ? ORDER BY seq ASC LIMIT ?",
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes AS event WHERE EXISTS (SELECT 1 FROM event_envelope_tags AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?",
)
- .bind(last_event_seq)
+ .bind(tag_name)
+ .bind(tag_value)
.bind(i64::from(limit))
.fetch_all(&self.pool)
.await?;
- rows.into_iter().map(stored_event_from_row).collect()
+ rows.into_iter().map(stored_raw_event_from_row).collect()
}
- pub async fn events_by_tag(
+ pub async fn valid_stream_by_tag(
&self,
tag_name: &str,
tag_value: &str,
limit: u32,
- ) -> Result<Vec<RadrootsStoredEvent>, RadrootsEventStoreError> {
+ ) -> Result<Vec<RadrootsStoredValidEvent>, RadrootsEventStoreError> {
validate_tag_query(tag_name, limit)?;
let rows = sqlx::query(
- "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes AS event WHERE projection_eligible = 1 AND EXISTS (SELECT 1 FROM event_envelope_tags AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?",
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes AS event WHERE verification_status = 'verified' AND contract_status = 'admitted' AND projection_eligible = 1 AND EXISTS (SELECT 1 FROM event_envelope_tags AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?",
)
.bind(tag_name)
.bind(tag_value)
.bind(i64::from(limit))
.fetch_all(&self.pool)
.await?;
- rows.into_iter().map(stored_event_from_row).collect()
+ rows.into_iter()
+ .map(stored_raw_event_from_row)
+ .map(|event| event.and_then(RadrootsStoredValidEvent::try_from_raw))
+ .collect()
}
- pub async fn events_by_contract_and_tag<S>(
+ pub async fn valid_stream_by_contract_and_tag<S>(
&self,
contract_ids: &[S],
tag_name: &str,
tag_value: &str,
limit: u32,
- ) -> Result<Vec<RadrootsStoredEvent>, RadrootsEventStoreError>
+ ) -> Result<Vec<RadrootsStoredValidEvent>, RadrootsEventStoreError>
where
S: AsRef<str>,
{
@@ -315,7 +481,7 @@ impl RadrootsEventStore {
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
- "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes AS event WHERE projection_eligible = 1 AND contract_id IN ({placeholders}) AND EXISTS (SELECT 1 FROM event_envelope_tags AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?"
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes AS event WHERE verification_status = 'verified' AND contract_status = 'admitted' AND projection_eligible = 1 AND contract_id IN ({placeholders}) AND EXISTS (SELECT 1 FROM event_envelope_tags AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?"
);
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
for contract_id in contract_ids {
@@ -327,7 +493,10 @@ impl RadrootsEventStore {
.bind(i64::from(limit))
.fetch_all(&self.pool)
.await?;
- rows.into_iter().map(stored_event_from_row).collect()
+ rows.into_iter()
+ .map(stored_raw_event_from_row)
+ .map(|event| event.and_then(RadrootsStoredValidEvent::try_from_raw))
+ .collect()
}
pub async fn get_trade_mutation(
@@ -488,45 +657,118 @@ pub struct RadrootsTransportObservationRow {
pub redacted_message: Option<String>,
}
-struct EventClassification {
- contract_status: RadrootsEventContractStatus,
+struct EventAdmission {
+ status: RadrootsEventAdmissionStatus,
+ code: Option<String>,
contract: Option<&'static RadrootsEventContract>,
}
-impl EventClassification {
- fn base_projection_eligible(&self, verification: RadrootsEventVerificationStatus) -> bool {
- verification == RadrootsEventVerificationStatus::Verified
- && self
- .contract
- .map(|contract| contract.class != RadrootsEventClass::Ephemeral)
- .unwrap_or(false)
+impl EventAdmission {
+ fn from_result(result: &Result<RadrootsAdmittedEvent, RadrootsEventAdmissionError>) -> Self {
+ match result {
+ Ok(event) => Self {
+ status: RadrootsEventAdmissionStatus::Admitted,
+ code: None,
+ contract: Some(event.contract()),
+ },
+ Err(error) => {
+ let status = if matches!(
+ error,
+ RadrootsEventAdmissionError::ContractMatch(
+ RadrootsContractMatchError::UnsupportedKind(_)
+ | RadrootsContractMatchError::UnsupportedShape(_)
+ )
+ ) {
+ RadrootsEventAdmissionStatus::Unsupported
+ } else {
+ RadrootsEventAdmissionStatus::Invalid
+ };
+ Self {
+ status,
+ code: Some(error.code().to_owned()),
+ contract: None,
+ }
+ }
+ }
+ }
+
+ fn valid_stream_eligible(&self, kind_class: RadrootsEventKindClass) -> bool {
+ self.status == RadrootsEventAdmissionStatus::Admitted
+ && kind_class != RadrootsEventKindClass::Ephemeral
}
}
struct AppliedHead {
- decision: RadrootsEventHeadStoreDecision,
- projection_eligible: bool,
+ decision: RadrootsRawHeadDecision,
}
struct InsertRawEventResult {
inserted: bool,
seq: i64,
+ admission_status: RadrootsEventAdmissionStatus,
+ contract_id: Option<String>,
+ valid_stream_eligible: bool,
+}
+
+struct RawHeadSnapshot {
+ raw_head: RadrootsStoredRawEventHead,
+ raw_event: RadrootsStoredRawEvent,
}
-async fn configure_connection(
+struct VisibleEventSnapshot {
+ raw_event: RadrootsStoredRawEvent,
+ raw_head_event_id: Option<String>,
+}
+
+async fn configure_pool(
pool: &SqlitePool,
file_backed: bool,
) -> Result<(), RadrootsEventStoreError> {
- sqlx::query("PRAGMA foreign_keys = ON")
- .execute(pool)
- .await?;
- sqlx::query("PRAGMA busy_timeout = 5000")
- .execute(pool)
- .await?;
+ let max_connections = pool.options().get_max_connections();
+ let existing_options = pool.connect_options();
+ let main_filename: String =
+ sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main'")
+ .fetch_one(pool)
+ .await?;
+ let database_is_memory = main_filename.is_empty();
+ if file_backed == database_is_memory {
+ return Err(RadrootsEventStoreError::SqlitePoolBackingMismatch {
+ file_backed,
+ filename: main_filename,
+ });
+ }
+ if !file_backed && max_connections != 1 {
+ return Err(RadrootsEventStoreError::UnsafeInMemoryPoolConnectionCount {
+ actual: max_connections,
+ });
+ }
+
+ let mut connect_options = existing_options
+ .as_ref()
+ .clone()
+ .foreign_keys(true)
+ .busy_timeout(Duration::from_millis(5_000));
if file_backed {
- sqlx::query("PRAGMA journal_mode = WAL")
- .execute(pool)
+ connect_options = connect_options.journal_mode(SqliteJournalMode::Wal);
+ }
+ pool.set_connect_options(connect_options);
+
+ let mut connections = Vec::with_capacity(max_connections as usize);
+ for _ in 0..max_connections {
+ connections.push(pool.acquire().await?);
+ }
+ for connection in &mut connections {
+ sqlx::query("PRAGMA foreign_keys = ON")
+ .execute(&mut **connection)
+ .await?;
+ sqlx::query("PRAGMA busy_timeout = 5000")
+ .execute(&mut **connection)
.await?;
+ if file_backed {
+ sqlx::query("PRAGMA journal_mode = WAL")
+ .execute(&mut **connection)
+ .await?;
+ }
}
Ok(())
}
@@ -562,26 +804,6 @@ async fn query_string(
Ok(row.try_get(0)?)
}
-fn validate_event_identity(event: &RadrootsEventEnvelope) -> Result<(), RadrootsEventStoreError> {
- RadrootsEventId::parse(event.id_str())?;
- RadrootsPublicKey::parse(event.author_str())?;
- Ok(())
-}
-
-fn classify_event(event: &RadrootsEventEnvelope) -> EventClassification {
- let tags = event.tags_as_vec();
- match identify_event_contract(event.kind_u32(), &tags, event.content()) {
- Ok(contract) => EventClassification {
- contract_status: RadrootsEventContractStatus::Supported,
- contract: Some(contract),
- },
- Err(error) => EventClassification {
- contract_status: RadrootsEventContractStatus::from_match_error(error),
- contract: None,
- },
- }
-}
-
fn is_trade_mutation_contract_id(contract_id: &str) -> bool {
RADROOTS_TRADE_MUTATION_CONTRACT_IDS.contains(&contract_id)
}
@@ -589,9 +811,8 @@ fn is_trade_mutation_contract_id(contract_id: &str) -> bool {
async fn store_trade_mutation_event(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
ingest: &RadrootsEventIngest,
- contract: &RadrootsEventContract,
event_seq: i64,
-) -> Result<bool, RadrootsEventStoreError> {
+) -> Result<(), RadrootsEventStoreError> {
let event = ingest.event();
let payload_sha256 = sha256_hex(event.content().as_bytes());
let parsed = match trade_mutation_from_canonical_content(event.content()) {
@@ -603,10 +824,10 @@ async fn store_trade_mutation_event(
None,
Some(event.id_str()),
format!("{error}").as_str(),
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
- return Ok(false);
+ return Ok(());
}
};
let Some(mutation_id) = parsed.mutation_id.clone() else {
@@ -616,10 +837,10 @@ async fn store_trade_mutation_event(
None,
Some(event.id_str()),
"canonical trade mutation content is missing mutation_id",
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
- return Ok(false);
+ return Ok(());
};
if parsed.author_pubkey.as_str() != event.author_str() {
insert_trade_quarantine(
@@ -628,10 +849,10 @@ async fn store_trade_mutation_event(
Some(mutation_id.as_str()),
Some(event.id_str()),
"trade mutation author_pubkey does not match transport event pubkey",
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
- return Ok(false);
+ return Ok(());
}
let mutation_kind = parsed.mutation_kind();
let candidate_id = candidate_id_for_mutation(&parsed);
@@ -659,7 +880,7 @@ async fn store_trade_mutation_event(
.bind(payload_sha256.as_str())
.bind(event_seq)
.bind(event.id_str())
- .bind(ingest.observed_at_ms)
+ .bind(ingest.observed_at_ms())
.execute(&mut **tx)
.await?;
insert_trade_mutation_parents(tx, &mutation_id, &parsed.parent_mutation_ids).await?;
@@ -670,7 +891,7 @@ async fn store_trade_mutation_event(
&parsed,
&mutation_id,
&payload_sha256,
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
insert_missing_parent_records(
@@ -678,7 +899,7 @@ async fn store_trade_mutation_event(
&parsed,
&mutation_id,
event.id_str(),
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
delete_resolved_missing_parent_records(tx, &mutation_id).await?;
@@ -688,12 +909,11 @@ async fn store_trade_mutation_event(
&parsed,
&mutation_id,
reservation,
- ingest.observed_at_ms,
+ ingest.observed_at_ms(),
)
.await?;
}
- let _ = contract;
- Ok(true)
+ Ok(())
}
async fn insert_trade_quarantine(
@@ -943,116 +1163,82 @@ fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
-fn verify_event(event: &RadrootsEventEnvelope) -> RadrootsEventVerificationStatus {
- verification_status_from_nostr(radroots_nostr_verify_event(event))
-}
-
-fn verification_status_from_nostr(
- verification: RadrootsNostrEventVerification,
-) -> RadrootsEventVerificationStatus {
- match verification {
- RadrootsNostrEventVerification::Verified => RadrootsEventVerificationStatus::Verified,
- RadrootsNostrEventVerification::IdVerified => RadrootsEventVerificationStatus::IdVerified,
- RadrootsNostrEventVerification::IdMismatch => RadrootsEventVerificationStatus::IdMismatch,
- RadrootsNostrEventVerification::SignatureInvalid => {
- RadrootsEventVerificationStatus::SignatureInvalid
- }
- RadrootsNostrEventVerification::MalformedEnvelope => {
- RadrootsEventVerificationStatus::MalformedEnvelope
- }
- }
-}
-
async fn ingest_event_in_transaction(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
ingest: RadrootsEventIngest,
) -> Result<RadrootsEventIngestReceipt, RadrootsEventStoreError> {
let event = ingest.event();
- validate_event_identity(event)?;
- let verification_status = verify_event(event);
- let classification = classify_event(event);
+ let admission_result = admit_verified_event(ingest.verified_event().clone());
+ let admission = EventAdmission::from_result(&admission_result);
+ let kind_class = event.kind_class();
+ let valid_stream_eligible = admission.valid_stream_eligible(kind_class);
+ if kind_class == RadrootsEventKindClass::Ephemeral {
+ return Ok(RadrootsEventIngestReceipt {
+ persistence: RadrootsEventPersistence::NotPersisted,
+ event_id: event.id_str().to_owned(),
+ admission_status: admission.status,
+ admission_code: admission.code,
+ contract_id: admission.contract.map(|contract| contract.id.to_owned()),
+ valid_stream_eligible: false,
+ raw_head_decision: RadrootsRawHeadDecision::NotPersisted,
+ });
+ }
let tags = event.tags_as_vec();
let tags_json = serde_json::to_string(&tags)?;
let event_id = event.id_str().to_owned();
let insert = insert_raw_event(
tx,
&ingest,
- &classification,
- verification_status,
+ &admission,
+ valid_stream_eligible,
ingest.raw_json(),
tags_json.as_str(),
)
.await?;
let inserted = insert.inserted;
- let mut head_decision = RadrootsEventHeadStoreDecision::Unsupported;
- let mut projection_eligible = classification.base_projection_eligible(verification_status);
-
if inserted {
- insert_tags(tx, event, classification.contract).await?;
- if let Some(contract) = classification.contract {
- if projection_eligible {
- if is_trade_mutation_contract_id(contract.id) {
- projection_eligible =
- store_trade_mutation_event(tx, &ingest, contract, insert.seq).await?;
- head_decision = if projection_eligible {
- RadrootsEventHeadStoreDecision::NotHeadSelected
- } else {
- RadrootsEventHeadStoreDecision::Malformed
- };
- } else {
- let head = apply_event_head(tx, event, contract, ingest.observed_at_ms).await?;
- projection_eligible = head.projection_eligible;
- head_decision = head.decision;
- }
- sqlx::query(
- "UPDATE event_envelopes SET projection_eligible = ?, updated_at_ms = ? WHERE event_id = ?",
- )
- .bind(bool_i64(projection_eligible))
- .bind(ingest.observed_at_ms)
- .bind(event_id.as_str())
- .execute(&mut **tx)
- .await?;
- } else {
- head_decision = RadrootsEventHeadStoreDecision::NotProjectionEligible;
- }
+ insert_tags(tx, event, admission.contract).await?;
+ if let Some(contract) = admission.contract
+ && insert.valid_stream_eligible
+ && is_trade_mutation_contract_id(contract.id)
+ {
+ store_trade_mutation_event(tx, &ingest, insert.seq).await?;
}
- } else if classification.contract.is_some() {
- head_decision = RadrootsEventHeadStoreDecision::SkippedDuplicate;
- projection_eligible = false;
}
+ let raw_head_decision = apply_raw_event_head(tx, event, ingest.observed_at_ms())
+ .await?
+ .decision;
- if let Some(observation) = ingest.transport_observation.as_ref() {
+ if let Some(observation) = ingest.transport_observation() {
upsert_observation(tx, event_id.as_str(), observation).await?;
}
Ok(RadrootsEventIngestReceipt {
- seq: insert.seq,
+ persistence: if inserted {
+ RadrootsEventPersistence::Inserted { seq: insert.seq }
+ } else {
+ RadrootsEventPersistence::Duplicate { seq: insert.seq }
+ },
event_id,
- inserted,
- verification_status,
- contract_status: classification.contract_status,
- contract_id: classification
- .contract
- .map(|contract| contract.id.to_owned()),
- projection_eligible,
- head_decision,
+ admission_status: insert.admission_status,
+ admission_code: inserted.then_some(admission.code).flatten(),
+ contract_id: insert.contract_id,
+ valid_stream_eligible: insert.valid_stream_eligible,
+ raw_head_decision,
})
}
async fn insert_raw_event(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
ingest: &RadrootsEventIngest,
- classification: &EventClassification,
- verification_status: RadrootsEventVerificationStatus,
+ admission: &EventAdmission,
+ valid_stream_eligible: bool,
raw_json: &str,
tags_json: &str,
) -> Result<InsertRawEventResult, RadrootsEventStoreError> {
let event = ingest.event();
- let contract_id = classification.contract.map(|contract| contract.id);
- let event_class = classification
- .contract
- .map(|contract| StoredEventClass::from_event_class(contract.class).as_str());
- let projection_eligible = classification.base_projection_eligible(verification_status);
+ let contract_id = admission.contract.map(|contract| contract.id);
+ let event_class = StoredEventClass::from_event_kind_class(event.kind_class()).as_str();
let result = sqlx::query(
"INSERT OR IGNORE INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
@@ -1064,18 +1250,49 @@ async fn insert_raw_event(
.bind(event.content())
.bind(event.sig_str())
.bind(raw_json)
- .bind(verification_status.as_str())
- .bind(classification.contract_status.as_str())
+ .bind("verified")
+ .bind(admission.status.as_str())
.bind(contract_id)
.bind(event_class)
- .bind(bool_i64(projection_eligible))
- .bind(ingest.observed_at_ms)
- .bind(ingest.observed_at_ms)
+ .bind(bool_i64(valid_stream_eligible))
+ .bind(ingest.observed_at_ms())
+ .bind(ingest.observed_at_ms())
.execute(&mut **tx)
.await?;
let inserted = result.rows_affected() > 0;
let seq = event_seq(tx, event.id_str()).await?;
- Ok(InsertRawEventResult { inserted, seq })
+ if inserted {
+ return Ok(InsertRawEventResult {
+ inserted: true,
+ seq,
+ admission_status: admission.status,
+ contract_id: contract_id.map(str::to_owned),
+ valid_stream_eligible,
+ });
+ }
+
+ let existing = stored_raw_event_row_in_transaction(tx, event.id_str()).await?;
+ let stored = stored_raw_event_from_row(existing)?;
+ Ok(InsertRawEventResult {
+ inserted: false,
+ seq: stored.seq,
+ admission_status: stored.admission_status,
+ contract_id: stored.contract_id,
+ valid_stream_eligible: stored.valid_stream_eligible,
+ })
+}
+
+async fn stored_raw_event_row_in_transaction(
+ tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
+ event_id: &str,
+) -> Result<sqlx::sqlite::SqliteRow, RadrootsEventStoreError> {
+ sqlx::query(
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(event_id)
+ .fetch_one(&mut **tx)
+ .await
+ .map_err(Into::into)
}
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -1152,30 +1369,26 @@ async fn upsert_observation(
Ok(())
}
-async fn apply_event_head(
+async fn apply_raw_event_head(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
event: &RadrootsEventEnvelope,
- contract: &RadrootsEventContract,
updated_at_ms: i64,
) -> Result<AppliedHead, RadrootsEventStoreError> {
- let candidate = match event_head_candidate_for_contract(event, contract) {
+ let candidate = match event_head_candidate_for_nip01_event(event) {
RadrootsEventHeadCandidateResult::Candidate(candidate) => candidate,
RadrootsEventHeadCandidateResult::NotHeadSelected => {
return Ok(AppliedHead {
- decision: RadrootsEventHeadStoreDecision::NotHeadSelected,
- projection_eligible: true,
+ decision: RadrootsRawHeadDecision::NotHeadSelected,
});
}
RadrootsEventHeadCandidateResult::NotPersisted => {
return Ok(AppliedHead {
- decision: RadrootsEventHeadStoreDecision::NotPersisted,
- projection_eligible: false,
+ decision: RadrootsRawHeadDecision::NotPersisted,
});
}
RadrootsEventHeadCandidateResult::Malformed(_) => {
return Ok(AppliedHead {
- decision: RadrootsEventHeadStoreDecision::Malformed,
- projection_eligible: false,
+ decision: RadrootsRawHeadDecision::MalformedCoordinate,
});
}
};
@@ -1184,10 +1397,8 @@ async fn apply_event_head(
if let RadrootsEventHeadDecision::Applied(head) = &protocol_decision {
upsert_head(tx, &candidate, head, updated_at_ms).await?;
}
- let projection_eligible = matches!(protocol_decision, RadrootsEventHeadDecision::Applied(_));
Ok(AppliedHead {
- decision: RadrootsEventHeadStoreDecision::from_protocol(&protocol_decision),
- projection_eligible,
+ decision: RadrootsRawHeadDecision::from_protocol(&protocol_decision),
})
}
@@ -1195,41 +1406,16 @@ async fn current_event_head(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
coordinate: &RadrootsEventHeadCoordinate,
) -> Result<Option<RadrootsCurrentEventHead>, RadrootsEventStoreError> {
- let row = match coordinate {
- RadrootsEventHeadCoordinate::Replaceable { kind, pubkey } => {
- sqlx::query(
- "SELECT event_id, created_at FROM event_envelope_head WHERE coordinate_type = 'replaceable' AND kind = ? AND pubkey = ? AND d_tag IS NULL",
- )
- .bind(i64::from(*kind))
- .bind(pubkey.as_str())
- .fetch_optional(&mut **tx)
- .await?
- }
- RadrootsEventHeadCoordinate::Addressable {
- kind,
- pubkey,
- d_tag,
- } => {
- sqlx::query(
- "SELECT event_id, created_at FROM event_envelope_head WHERE coordinate_type = 'addressable' AND kind = ? AND pubkey = ? AND d_tag = ?",
- )
- .bind(i64::from(*kind))
- .bind(pubkey.as_str())
- .bind(d_tag.as_str())
- .fetch_optional(&mut **tx)
- .await?
- }
- };
- row.map(|row| {
- let event_id: String = row.try_get("event_id")?;
- let created_at: i64 = row.try_get("created_at")?;
- Ok(RadrootsCurrentEventHead {
- coordinate: coordinate.clone(),
- event_id: RadrootsEventId::parse(event_id)?,
- created_at: u64_from_i64("created_at", created_at)?,
+ let snapshot = raw_head_snapshot_in_transaction(tx, coordinate).await?;
+ snapshot
+ .map(|snapshot| {
+ Ok(RadrootsCurrentEventHead {
+ coordinate: coordinate.clone(),
+ event_id: RadrootsEventId::parse(snapshot.raw_head.event_id)?,
+ created_at: snapshot.raw_head.created_at,
+ })
})
- })
- .transpose()
+ .transpose()
}
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -1289,23 +1475,67 @@ async fn upsert_head(
}
#[cfg_attr(coverage_nightly, coverage(off))]
-fn stored_event_from_row(
+fn stored_raw_event_from_row(
row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsStoredEvent, RadrootsEventStoreError> {
+) -> Result<RadrootsStoredRawEvent, RadrootsEventStoreError> {
let kind = u32_from_i64("kind", row.try_get("kind")?)?;
let created_at = u64_from_i64("created_at", row.try_get("created_at")?)?;
- let verification_status =
- RadrootsEventVerificationStatus::parse(row.try_get("verification_status")?)?;
- let contract_status =
- RadrootsEventContractStatus::parse(row.try_get("contract_status")?, kind)?;
+ let event_id: String = row.try_get("event_id")?;
+ let verification_status: String = row.try_get("verification_status")?;
+ if verification_status != "verified" {
+ return Err(RadrootsEventStoreError::StoredRawEventNotVerified {
+ event_id,
+ status: verification_status,
+ });
+ }
+ let contract_status: String = row.try_get("contract_status")?;
+ if is_legacy_contract_status(contract_status.as_str()) {
+ return Err(
+ RadrootsEventStoreError::StoredRawEventRequiresReconciliation {
+ event_id,
+ contract_status,
+ },
+ );
+ }
+ let admission_status = RadrootsEventAdmissionStatus::parse(contract_status.as_str())?;
let event_class = row
.try_get::<Option<String>, _>("event_class")?
- .map(|value| StoredEventClass::parse(value.as_str()))
- .transpose()?;
- let projection_eligible = row.try_get::<i64, _>("projection_eligible")? != 0;
- Ok(RadrootsStoredEvent {
+ .ok_or_else(|| RadrootsEventStoreError::StoredRawEventMissingClass {
+ event_id: event_id.clone(),
+ })
+ .and_then(|value| StoredEventClass::parse(value.as_str()))?;
+ let projection_eligible: i64 = row.try_get("projection_eligible")?;
+ let valid_stream_eligible = match projection_eligible {
+ 0 => false,
+ 1 => true,
+ _ => {
+ return Err(
+ RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id },
+ );
+ }
+ };
+ let contract_id: Option<String> = row.try_get("contract_id")?;
+ if kind > u32::from(u16::MAX) {
+ return Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id });
+ }
+ let expected_class =
+ StoredEventClass::from_event_kind_class(RadrootsEventKind::new(kind).class());
+ if expected_class == StoredEventClass::Ephemeral {
+ return Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id });
+ }
+ let expected_eligible = admission_status == RadrootsEventAdmissionStatus::Admitted
+ && expected_class != StoredEventClass::Ephemeral;
+ let contract_id_is_consistent =
+ (admission_status == RadrootsEventAdmissionStatus::Admitted) == contract_id.is_some();
+ if event_class != expected_class
+ || valid_stream_eligible != expected_eligible
+ || !contract_id_is_consistent
+ {
+ return Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { event_id });
+ }
+ Ok(RadrootsStoredRawEvent {
seq: row.try_get("seq")?,
- event_id: row.try_get("event_id")?,
+ event_id,
pubkey: row.try_get("pubkey")?,
created_at,
kind,
@@ -1313,11 +1543,10 @@ fn stored_event_from_row(
content: row.try_get("content")?,
sig: row.try_get("sig")?,
raw_json: row.try_get("raw_json")?,
- verification_status,
- contract_status,
- contract_id: row.try_get("contract_id")?,
+ admission_status,
+ contract_id,
event_class,
- projection_eligible,
+ valid_stream_eligible,
inserted_at_ms: row.try_get("inserted_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
@@ -1335,22 +1564,24 @@ fn stored_tag_from_row(
tag_json: row.try_get("tag_json")?,
contract_semantic: row.try_get("contract_semantic")?,
contract_value_type: row.try_get("contract_value_type")?,
- relay_indexed: row.try_get::<i64, _>("relay_indexed")? != 0,
+ relay_indexed: bool_from_i64("relay_indexed", row.try_get("relay_indexed")?)?,
})
}
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn stored_head_from_row(
- row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsStoredEventHead, RadrootsEventStoreError> {
- Ok(RadrootsStoredEventHead {
- coordinate_type: StoredEventClass::parse(row.try_get("coordinate_type")?)?,
- kind: u32_from_i64("kind", row.try_get("kind")?)?,
- pubkey: row.try_get("pubkey")?,
- d_tag: row.try_get("d_tag")?,
- event_id: row.try_get("event_id")?,
- created_at: u64_from_i64("created_at", row.try_get("created_at")?)?,
- updated_at_ms: row.try_get("updated_at_ms")?,
+fn stored_raw_head_from_joined_row(
+ row: &sqlx::sqlite::SqliteRow,
+) -> Result<RadrootsStoredRawEventHead, RadrootsEventStoreError> {
+ Ok(RadrootsStoredRawEventHead {
+ coordinate_type: StoredEventClass::parse(
+ row.try_get::<String, _>("raw_head_coordinate_type")?
+ .as_str(),
+ )?,
+ kind: u32_from_i64("kind", row.try_get("raw_head_kind")?)?,
+ pubkey: row.try_get("raw_head_pubkey")?,
+ d_tag: row.try_get("raw_head_d_tag")?,
+ event_id: row.try_get("raw_head_event_id")?,
+ created_at: u64_from_i64("created_at", row.try_get("raw_head_created_at")?)?,
+ updated_at_ms: row.try_get("raw_head_updated_at_ms")?,
})
}
@@ -1358,48 +1589,265 @@ fn stored_head_from_row(
fn projection_cursor_from_row(
row: sqlx::sqlite::SqliteRow,
) -> Result<RadrootsProjectionCursor, RadrootsEventStoreError> {
+ let projection_id: String = row.try_get("projection_id")?;
+ let last_event_seq: i64 = row.try_get("last_event_seq")?;
+ if last_event_seq < 0 {
+ return Err(RadrootsEventStoreError::InvalidProjectionCursor {
+ projection_id,
+ value: last_event_seq,
+ });
+ }
Ok(RadrootsProjectionCursor {
- projection_id: row.try_get("projection_id")?,
+ projection_id,
projection_version: u32_from_i64("projection_version", row.try_get("projection_version")?)?,
- last_event_seq: row.try_get("last_event_seq")?,
+ last_event_seq,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn trade_mutation_from_row(
- row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsStoredTradeMutation, RadrootsEventStoreError> {
- Ok(RadrootsStoredTradeMutation {
- mutation_id: parse_id(row.try_get::<String, _>("mutation_id")?)?,
- trade_id: parse_id(row.try_get::<String, _>("trade_id")?)?,
- root_mutation_id: parse_optional_id(row.try_get("root_mutation_id")?)?,
- contract_id: row.try_get("contract_id")?,
- mutation_kind: parse_trade_mutation_kind(
- row.try_get::<String, _>("mutation_kind")?.as_str(),
- )?,
- schema_version: u16_from_i64("schema_version", row.try_get("schema_version")?)?,
- candidate_id: parse_optional_id(row.try_get("candidate_id")?)?,
- proposal_mutation_id: parse_optional_id(row.try_get("proposal_mutation_id")?)?,
- target_claim_mutation_id: parse_optional_id(row.try_get("target_claim_mutation_id")?)?,
- author_pubkey: parse_id(row.try_get::<String, _>("author_pubkey")?)?,
- counterparty_pubkey: parse_id(row.try_get::<String, _>("counterparty_pubkey")?)?,
- buyer_pubkey: parse_id(row.try_get::<String, _>("buyer_pubkey")?)?,
- seller_pubkey: parse_id(row.try_get::<String, _>("seller_pubkey")?)?,
- farm_id: parse_id(row.try_get::<String, _>("farm_id")?)?,
- authored_at_unix_s: u64_from_i64("authored_at_unix_s", row.try_get("authored_at_unix_s")?)?,
- canonical_payload_bytes: row.try_get("canonical_payload_bytes")?,
- payload_sha256: row.try_get("payload_sha256")?,
- first_event_seq: row.try_get("first_event_seq")?,
- first_transport_event_id: parse_id(row.try_get::<String, _>("first_transport_event_id")?)?,
- inserted_at_ms: row.try_get("inserted_at_ms")?,
- })
+async fn projection_cursor_unchecked(
+ pool: &SqlitePool,
+ projection_id: &str,
+) -> Result<Option<RadrootsProjectionCursor>, RadrootsEventStoreError> {
+ let row = sqlx::query(
+ "SELECT projection_id, projection_version, last_event_seq, updated_at_ms FROM projection_cursor WHERE projection_id = ?",
+ )
+ .bind(projection_id)
+ .fetch_optional(pool)
+ .await?;
+ row.map(projection_cursor_from_row).transpose()
}
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn trade_mutation_parent_from_row(
- row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsStoredTradeMutationParent, RadrootsEventStoreError> {
+async fn raw_head_snapshot_in_transaction(
+ tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
+ coordinate: &RadrootsEventHeadCoordinate,
+) -> Result<Option<RawHeadSnapshot>, RadrootsEventStoreError> {
+ let row = match coordinate {
+ RadrootsEventHeadCoordinate::Replaceable { kind, pubkey } => {
+ sqlx::query(
+ "SELECT event.seq, event.event_id, event.pubkey, event.created_at, event.kind, event.tags_json, event.content, event.sig, event.raw_json, event.verification_status, event.contract_status, event.contract_id, event.event_class, event.projection_eligible, event.inserted_at_ms, event.updated_at_ms, head.coordinate_type AS raw_head_coordinate_type, head.kind AS raw_head_kind, head.pubkey AS raw_head_pubkey, head.d_tag AS raw_head_d_tag, head.event_id AS raw_head_event_id, head.created_at AS raw_head_created_at, head.updated_at_ms AS raw_head_updated_at_ms FROM event_envelope_head AS head LEFT JOIN event_envelopes AS event ON event.event_id = head.event_id WHERE head.coordinate_type = 'replaceable' AND head.kind = ? AND head.pubkey = ? AND head.d_tag IS NULL",
+ )
+ .bind(i64::from(*kind))
+ .bind(pubkey.as_str())
+ .fetch_optional(&mut **tx)
+ .await?
+ }
+ RadrootsEventHeadCoordinate::Addressable {
+ kind,
+ pubkey,
+ d_tag,
+ } => {
+ sqlx::query(
+ "SELECT event.seq, event.event_id, event.pubkey, event.created_at, event.kind, event.tags_json, event.content, event.sig, event.raw_json, event.verification_status, event.contract_status, event.contract_id, event.event_class, event.projection_eligible, event.inserted_at_ms, event.updated_at_ms, head.coordinate_type AS raw_head_coordinate_type, head.kind AS raw_head_kind, head.pubkey AS raw_head_pubkey, head.d_tag AS raw_head_d_tag, head.event_id AS raw_head_event_id, head.created_at AS raw_head_created_at, head.updated_at_ms AS raw_head_updated_at_ms FROM event_envelope_head AS head LEFT JOIN event_envelopes AS event ON event.event_id = head.event_id WHERE head.coordinate_type = 'addressable' AND head.kind = ? AND head.pubkey = ? AND head.d_tag = ?",
+ )
+ .bind(i64::from(*kind))
+ .bind(pubkey.as_str())
+ .bind(d_tag.as_str())
+ .fetch_optional(&mut **tx)
+ .await?
+ }
+ };
+ row.map(|row| {
+ let raw_head = stored_raw_head_from_joined_row(&row)?;
+ if row.try_get::<Option<String>, _>("event_id")?.is_none() {
+ return Err(RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id,
+ });
+ }
+ let raw_event = stored_raw_event_from_row(row)?;
+ validate_raw_head_snapshot(coordinate, &raw_head, &raw_event)?;
+ Ok(RawHeadSnapshot {
+ raw_head,
+ raw_event,
+ })
+ })
+ .transpose()
+}
+
+async fn visible_event_snapshot(
+ tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
+ event_id: &str,
+) -> Result<Option<VisibleEventSnapshot>, RadrootsEventStoreError> {
+ let row = sqlx::query(
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(event_id)
+ .fetch_optional(&mut **tx)
+ .await?;
+ let Some(row) = row else {
+ return Ok(None);
+ };
+ let raw_event = stored_raw_event_from_row(row)?;
+ let raw_head_event_id = match raw_event.event_class {
+ StoredEventClass::Regular | StoredEventClass::Ephemeral => None,
+ StoredEventClass::Replaceable | StoredEventClass::Addressable => {
+ let coordinate = raw_head_coordinate_for_stored_event(&raw_event)?;
+ raw_head_snapshot_in_transaction(tx, &coordinate)
+ .await?
+ .map(|snapshot| snapshot.raw_head.event_id)
+ }
+ };
+ Ok(Some(VisibleEventSnapshot {
+ raw_event,
+ raw_head_event_id,
+ }))
+}
+
+fn raw_head_coordinate_for_stored_event(
+ event: &RadrootsStoredRawEvent,
+) -> Result<RadrootsEventHeadCoordinate, RadrootsEventStoreError> {
+ let inconsistent = || RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: event.event_id.clone(),
+ };
+ let pubkey = radroots_event::ids::RadrootsPublicKey::parse(event.pubkey.clone())
+ .map_err(|_| inconsistent())?;
+ match event.event_class {
+ StoredEventClass::Replaceable => Ok(RadrootsEventHeadCoordinate::Replaceable {
+ kind: event.kind,
+ pubkey,
+ }),
+ StoredEventClass::Addressable => {
+ let tags: Vec<Vec<String>> =
+ serde_json::from_str(event.tags_json.as_str()).map_err(|_| inconsistent())?;
+ let d_tag = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("d"))
+ .and_then(|tag| tag.get(1))
+ .cloned()
+ .unwrap_or_default();
+ Ok(RadrootsEventHeadCoordinate::Addressable {
+ kind: event.kind,
+ pubkey,
+ d_tag,
+ })
+ }
+ StoredEventClass::Regular | StoredEventClass::Ephemeral => Err(inconsistent()),
+ }
+}
+
+fn validate_raw_head_snapshot(
+ requested_coordinate: &RadrootsEventHeadCoordinate,
+ raw_head: &RadrootsStoredRawEventHead,
+ raw_event: &RadrootsStoredRawEvent,
+) -> Result<(), RadrootsEventStoreError> {
+ let expected_coordinate = raw_head_coordinate_for_stored_event(raw_event)?;
+ let stored_coordinate = match raw_head.coordinate_type {
+ StoredEventClass::Replaceable if raw_head.d_tag.is_none() => {
+ RadrootsEventHeadCoordinate::Replaceable {
+ kind: raw_head.kind,
+ pubkey: radroots_event::ids::RadrootsPublicKey::parse(raw_head.pubkey.clone())
+ .map_err(|_| RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id.clone(),
+ })?,
+ }
+ }
+ StoredEventClass::Addressable => RadrootsEventHeadCoordinate::Addressable {
+ kind: raw_head.kind,
+ pubkey: radroots_event::ids::RadrootsPublicKey::parse(raw_head.pubkey.clone())
+ .map_err(|_| RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id.clone(),
+ })?,
+ d_tag: raw_head.d_tag.clone().ok_or_else(|| {
+ RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id.clone(),
+ }
+ })?,
+ },
+ _ => {
+ return Err(RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id.clone(),
+ });
+ }
+ };
+ if &stored_coordinate != requested_coordinate
+ || stored_coordinate != expected_coordinate
+ || raw_head.event_id != raw_event.event_id
+ || raw_head.created_at != raw_event.created_at
+ {
+ return Err(RadrootsEventStoreError::StoredHeadInconsistent {
+ event_id: raw_head.event_id.clone(),
+ });
+ }
+ Ok(())
+}
+
+fn visibility_from_snapshot(
+ snapshot: &VisibleEventSnapshot,
+) -> Result<RadrootsEventVisibility, RadrootsEventStoreError> {
+ let event = &snapshot.raw_event;
+ match event.event_class {
+ StoredEventClass::Ephemeral => Err(
+ RadrootsEventStoreError::StoredRawEventClassificationInconsistent {
+ event_id: event.event_id.clone(),
+ },
+ ),
+ StoredEventClass::Regular
+ if event.admission_status != RadrootsEventAdmissionStatus::Admitted =>
+ {
+ Ok(RadrootsEventVisibility::NotAdmitted)
+ }
+ StoredEventClass::Regular => Ok(RadrootsEventVisibility::Visible),
+ StoredEventClass::Replaceable | StoredEventClass::Addressable => {
+ if event.admission_status != RadrootsEventAdmissionStatus::Admitted {
+ return Ok(RadrootsEventVisibility::NotAdmitted);
+ }
+ let raw_head_event_id = snapshot.raw_head_event_id.as_ref().ok_or_else(|| {
+ RadrootsEventStoreError::StoredHeadCoordinateUnavailable {
+ event_id: event.event_id.clone(),
+ }
+ })?;
+ if raw_head_event_id == &event.event_id {
+ Ok(RadrootsEventVisibility::Visible)
+ } else {
+ Ok(RadrootsEventVisibility::NotCurrent {
+ raw_head_event_id: raw_head_event_id.clone(),
+ })
+ }
+ }
+ }
+}
+
+fn is_legacy_contract_status(value: &str) -> bool {
+ matches!(
+ value,
+ "supported" | "unsupported_kind" | "unsupported_shape" | "ambiguous_shape"
+ )
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_mutation_from_row(
+ row: sqlx::sqlite::SqliteRow,
+) -> Result<RadrootsStoredTradeMutation, RadrootsEventStoreError> {
+ Ok(RadrootsStoredTradeMutation {
+ mutation_id: parse_id(row.try_get::<String, _>("mutation_id")?)?,
+ trade_id: parse_id(row.try_get::<String, _>("trade_id")?)?,
+ root_mutation_id: parse_optional_id(row.try_get("root_mutation_id")?)?,
+ contract_id: row.try_get("contract_id")?,
+ mutation_kind: parse_trade_mutation_kind(
+ row.try_get::<String, _>("mutation_kind")?.as_str(),
+ )?,
+ schema_version: u16_from_i64("schema_version", row.try_get("schema_version")?)?,
+ candidate_id: parse_optional_id(row.try_get("candidate_id")?)?,
+ proposal_mutation_id: parse_optional_id(row.try_get("proposal_mutation_id")?)?,
+ target_claim_mutation_id: parse_optional_id(row.try_get("target_claim_mutation_id")?)?,
+ author_pubkey: parse_id(row.try_get::<String, _>("author_pubkey")?)?,
+ counterparty_pubkey: parse_id(row.try_get::<String, _>("counterparty_pubkey")?)?,
+ buyer_pubkey: parse_id(row.try_get::<String, _>("buyer_pubkey")?)?,
+ seller_pubkey: parse_id(row.try_get::<String, _>("seller_pubkey")?)?,
+ farm_id: parse_id(row.try_get::<String, _>("farm_id")?)?,
+ authored_at_unix_s: u64_from_i64("authored_at_unix_s", row.try_get("authored_at_unix_s")?)?,
+ canonical_payload_bytes: row.try_get("canonical_payload_bytes")?,
+ payload_sha256: row.try_get("payload_sha256")?,
+ first_event_seq: row.try_get("first_event_seq")?,
+ first_transport_event_id: parse_id(row.try_get::<String, _>("first_transport_event_id")?)?,
+ inserted_at_ms: row.try_get("inserted_at_ms")?,
+ })
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_mutation_parent_from_row(
+ row: sqlx::sqlite::SqliteRow,
+) -> Result<RadrootsStoredTradeMutationParent, RadrootsEventStoreError> {
Ok(RadrootsStoredTradeMutationParent {
mutation_id: parse_id(row.try_get::<String, _>("mutation_id")?)?,
parent_mutation_id: parse_id(row.try_get::<String, _>("parent_mutation_id")?)?,
@@ -1526,6 +1974,17 @@ fn transport_observation_from_row(
},
);
}
+ let first_observed_at_ms = row.try_get("first_observed_at_ms")?;
+ let last_observed_at_ms = row.try_get("last_observed_at_ms")?;
+ let observation_count = row.try_get("observation_count")?;
+ if observation_count <= 0 || first_observed_at_ms > last_observed_at_ms {
+ return Err(RadrootsEventStoreError::InvalidStoredTransportObservation {
+ event_id,
+ first_observed_at_ms,
+ last_observed_at_ms,
+ observation_count,
+ });
+ }
Ok(RadrootsTransportObservationRow {
event_id,
transport_kind,
@@ -1534,9 +1993,9 @@ fn transport_observation_from_row(
observation_type: RadrootsTransportObservationType::parse(
row.try_get("observation_type")?,
)?,
- first_observed_at_ms: row.try_get("first_observed_at_ms")?,
- last_observed_at_ms: row.try_get("last_observed_at_ms")?,
- observation_count: row.try_get("observation_count")?,
+ first_observed_at_ms,
+ last_observed_at_ms,
+ observation_count,
redacted_message: row.try_get("redacted_message")?,
})
}
@@ -1577,6 +2036,14 @@ fn bool_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
+fn bool_from_i64(field: &'static str, value: i64) -> Result<bool, RadrootsEventStoreError> {
+ match value {
+ 0 => Ok(false),
+ 1 => Ok(true),
+ _ => Err(RadrootsEventStoreError::InvalidStoredBoolean { field, value }),
+ }
+}
+
fn parse_id<T>(value: String) -> Result<T, RadrootsEventStoreError>
where
T: TryFrom<String, Error = radroots_event::ids::RadrootsIdParseError>,
@@ -1591,10 +2058,7 @@ where
value.map(parse_id).transpose()
}
-fn validate_tag_query(tag_name: &str, limit: u32) -> Result<(), RadrootsEventStoreError> {
- if tag_name.is_empty() {
- return Err(RadrootsEventStoreError::EmptyTagName);
- }
+fn validate_event_query_limit(limit: u32) -> Result<(), RadrootsEventStoreError> {
if !(1..=RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX).contains(&limit) {
return Err(RadrootsEventStoreError::QueryLimitOutOfRange {
min: 1,
@@ -1605,6 +2069,13 @@ fn validate_tag_query(tag_name: &str, limit: u32) -> Result<(), RadrootsEventSto
Ok(())
}
+fn validate_tag_query(tag_name: &str, limit: u32) -> Result<(), RadrootsEventStoreError> {
+ if tag_name.is_empty() {
+ return Err(RadrootsEventStoreError::EmptyTagName);
+ }
+ validate_event_query_limit(limit)
+}
+
fn validate_contract_tag_query<S>(
contract_ids: &[S],
tag_name: &str,
@@ -1626,24 +2097,24 @@ where
}
fn validate_trade_query_limit(limit: u32) -> Result<(), RadrootsEventStoreError> {
- if !(1..=RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX).contains(&limit) {
- return Err(RadrootsEventStoreError::QueryLimitOutOfRange {
- min: 1,
- max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- actual: limit,
- });
- }
- Ok(())
+ validate_event_query_limit(limit)
}
#[cfg(test)]
mod tests {
use super::*;
- use nostr::EventBuilder;
+ use nostr::{
+ EventBuilder, Keys as RadrootsNostrKeys, Kind as RadrootsNostrKind,
+ SecretKey as RadrootsNostrSecretKey, Tag as RadrootsNostrTag,
+ TagKind as RadrootsNostrTagKind, Timestamp as RadrootsNostrTimestamp,
+ };
use radroots_event::draft::RadrootsSignedEvent;
- use radroots_event::event_head::event_head_candidate_for_event;
- use radroots_event::ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId};
- use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT, KIND_POST, KIND_PROFILE};
+ use radroots_event::ids::{
+ RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsPublicKey,
+ };
+ use radroots_event::kinds::{
+ KIND_CLASSIFIED_LISTING, KIND_GEOCHAT, KIND_POST, KIND_PROFILE, KIND_RELAY_AUTH,
+ };
use radroots_event::trade::{
RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1,
@@ -1655,10 +2126,6 @@ mod tests {
canonical_trade_mutation_content,
};
use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id};
- use radroots_nostr::prelude::{
- RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrTag,
- RadrootsNostrTagKind, RadrootsNostrTimestamp,
- };
const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
"10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
@@ -1950,7 +2417,7 @@ mod tests {
fn head_coordinate_for_event(event: &RadrootsSignedEvent) -> RadrootsEventHeadCoordinate {
let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_event(event.envelope()).expect("head candidate")
+ event_head_candidate_for_nip01_event(event.envelope())
else {
panic!("event should select a head");
};
@@ -1964,6 +2431,16 @@ mod tests {
}
}
+ async fn assert_raw_head_inconsistent(
+ store: &RadrootsEventStore,
+ coordinate: &RadrootsEventHeadCoordinate,
+ ) {
+ assert!(matches!(
+ store.raw_event_head(coordinate).await,
+ Err(RadrootsEventStoreError::StoredHeadInconsistent { .. })
+ ));
+ }
+
async fn explain_query_plan(store: &RadrootsEventStore, sql: &str, bind: &str) -> String {
let rows = sqlx::query(sqlx::AssertSqlSafe(sql.to_owned()))
.bind(bind)
@@ -1976,48 +2453,6 @@ mod tests {
.join("\n")
}
- #[test]
- fn verification_status_values_round_trip() {
- for status in [
- RadrootsEventVerificationStatus::NotChecked,
- RadrootsEventVerificationStatus::IdVerified,
- RadrootsEventVerificationStatus::Verified,
- RadrootsEventVerificationStatus::IdMismatch,
- RadrootsEventVerificationStatus::SignatureInvalid,
- RadrootsEventVerificationStatus::MalformedEnvelope,
- ] {
- assert_eq!(
- RadrootsEventVerificationStatus::parse(status.as_str()).expect("status"),
- status
- );
- }
- assert!(RadrootsEventVerificationStatus::parse("invalid").is_err());
- }
-
- #[test]
- fn verification_status_mapper_covers_all_nostr_results() {
- assert_eq!(
- verification_status_from_nostr(RadrootsNostrEventVerification::Verified),
- RadrootsEventVerificationStatus::Verified
- );
- assert_eq!(
- verification_status_from_nostr(RadrootsNostrEventVerification::IdVerified),
- RadrootsEventVerificationStatus::IdVerified
- );
- assert_eq!(
- verification_status_from_nostr(RadrootsNostrEventVerification::IdMismatch),
- RadrootsEventVerificationStatus::IdMismatch
- );
- assert_eq!(
- verification_status_from_nostr(RadrootsNostrEventVerification::SignatureInvalid),
- RadrootsEventVerificationStatus::SignatureInvalid
- );
- assert_eq!(
- verification_status_from_nostr(RadrootsNostrEventVerification::MalformedEnvelope),
- RadrootsEventVerificationStatus::MalformedEnvelope
- );
- }
-
#[tokio::test]
async fn constructor_enforces_sqlite_pragmas() {
let store = RadrootsEventStore::open_memory().await.expect("open");
@@ -2034,12 +2469,108 @@ mod tests {
}
#[tokio::test]
+ async fn open_pool_configures_every_file_connection_and_rejects_multi_connection_memory() {
+ let memory_options = SqliteConnectOptions::from_str("sqlite::memory:")
+ .expect("memory options")
+ .foreign_keys(false);
+ let memory_pool = SqlitePoolOptions::new()
+ .max_connections(2)
+ .connect_with(memory_options)
+ .await
+ .expect("memory pool");
+ assert!(matches!(
+ RadrootsEventStore::open_pool(memory_pool, false).await,
+ Err(RadrootsEventStoreError::UnsafeInMemoryPoolConnectionCount { actual: 2 })
+ ));
+ let mislabeled_memory_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(
+ SqliteConnectOptions::from_str("sqlite::memory:")
+ .expect("memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mislabeled memory pool");
+ assert!(matches!(
+ RadrootsEventStore::open_pool(mislabeled_memory_pool, true).await,
+ Err(RadrootsEventStoreError::SqlitePoolBackingMismatch {
+ file_backed: true,
+ ..
+ })
+ ));
+ for memory_url in ["sqlite://?mode=memory", "sqlite://named?mode=memory"] {
+ let mode_memory_pool = SqlitePoolOptions::new()
+ .max_connections(2)
+ .connect_with(
+ SqliteConnectOptions::from_str(memory_url)
+ .expect("mode-memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mode-memory pool");
+ assert!(matches!(
+ RadrootsEventStore::open_pool(mode_memory_pool, false).await,
+ Err(RadrootsEventStoreError::UnsafeInMemoryPoolConnectionCount { actual: 2 })
+ ));
+
+ let mislabeled_mode_memory_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(
+ SqliteConnectOptions::from_str(memory_url)
+ .expect("mode-memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mislabeled mode-memory pool");
+ assert!(matches!(
+ RadrootsEventStore::open_pool(mislabeled_mode_memory_pool, true).await,
+ Err(RadrootsEventStoreError::SqlitePoolBackingMismatch {
+ file_backed: true,
+ ..
+ })
+ ));
+ }
+
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let path = tempdir.path().join("multi.sqlite");
+ let file_options = SqliteConnectOptions::new()
+ .filename(&path)
+ .create_if_missing(true)
+ .foreign_keys(false);
+ let file_pool = SqlitePoolOptions::new()
+ .max_connections(3)
+ .connect_with(file_options)
+ .await
+ .expect("file pool");
+ let store = RadrootsEventStore::open_pool(file_pool, true)
+ .await
+ .expect("store");
+ let mut connections = Vec::new();
+ for _ in 0..3 {
+ connections.push(store.pool().acquire().await.expect("connection"));
+ }
+ for connection in &mut connections {
+ let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
+ .fetch_one(&mut **connection)
+ .await
+ .expect("foreign keys");
+ assert_eq!(foreign_keys, 1);
+ let orphan = sqlx::query(
+ "INSERT INTO event_envelope_tags(event_id, tag_index, tag_name, tag_value, tag_json, contract_semantic, contract_value_type, relay_indexed) VALUES ('missing', 0, 'd', 'value', '[\"d\",\"value\"]', NULL, NULL, 0)",
+ )
+ .execute(&mut **connection)
+ .await;
+ assert!(orphan.is_err());
+ }
+ }
+
+ #[tokio::test]
async fn status_summary_counts_events_projections_and_transport_observations() {
let store = RadrootsEventStore::open_memory().await.expect("open");
let empty = store.status_summary().await.expect("empty status");
assert_eq!(empty.total_events, 0);
- assert_eq!(empty.projection_eligible_events, 0);
+ assert_eq!(empty.valid_stream_events, 0);
assert_eq!(empty.transport_observations, 0);
assert_eq!(empty.last_event_seq, None);
assert_eq!(empty.last_event_updated_at_ms, None);
@@ -2068,13 +2599,97 @@ mod tests {
let status = store.status_summary().await.expect("status");
assert_eq!(status.total_events, 1);
- assert_eq!(status.projection_eligible_events, 1);
+ assert_eq!(status.valid_stream_events, 1);
assert_eq!(status.transport_observations, 1);
assert_eq!(status.last_event_seq, Some(1));
assert_eq!(status.last_event_updated_at_ms, Some(1_000));
}
#[tokio::test]
+ async fn new_format_corruption_fails_closed_in_raw_valid_and_status_reads() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let event = signed_event(KIND_POST, 9, Vec::new(), "corruption target");
+ store
+ .ingest_event(RadrootsEventIngest::new(event.clone(), 900))
+ .await
+ .expect("ingest");
+
+ sqlx::query("UPDATE event_envelopes SET projection_eligible = 2 WHERE event_id = ?")
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt eligibility");
+ assert!(matches!(
+ store.raw_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+ assert!(matches!(
+ store.valid_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+ assert!(matches!(
+ store.status_summary().await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+
+ sqlx::query(
+ "UPDATE event_envelopes SET projection_eligible = 1, verification_status = 'signature_invalid' WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt verification");
+ assert!(matches!(
+ store.raw_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventNotVerified { .. })
+ ));
+ assert!(matches!(
+ store.status_summary().await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+
+ sqlx::query(
+ "UPDATE event_envelopes SET verification_status = 'verified', contract_id = NULL WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt contract id");
+ assert!(matches!(
+ store.raw_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+
+ sqlx::query(
+ "UPDATE event_envelopes SET contract_id = 'radroots.social.post.v1', event_class = 'replaceable' WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt class");
+ assert!(matches!(
+ store.raw_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+
+ sqlx::query(
+ "UPDATE event_envelopes SET event_class = 'regular', kind = 20001, projection_eligible = 0 WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("persist impossible ephemeral");
+ assert!(matches!(
+ store.raw_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+ assert!(matches!(
+ store.status_summary().await,
+ Err(RadrootsEventStoreError::StoredRawEventClassificationInconsistent { .. })
+ ));
+ }
+
+ #[tokio::test]
async fn file_store_reopens_existing_schema() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("event_store.sqlite");
@@ -2183,31 +2798,31 @@ mod tests {
.expect("first ingest");
let second = store.ingest_event(ingest).await.expect("second ingest");
let stored = store
- .get_event(event.id_str())
+ .raw_event(event.id_str())
.await
.expect("get")
.expect("stored");
- assert!(first.inserted);
- assert!(!second.inserted);
- assert_eq!(first.seq, second.seq);
+ assert!(first.persistence.is_inserted());
+ assert!(second.persistence.is_duplicate());
+ assert_eq!(first.persistence.sequence(), second.persistence.sequence());
+ assert_eq!(first.persistence.sequence(), Some(stored.seq));
assert_eq!(
- second.head_decision,
- RadrootsEventHeadStoreDecision::SkippedDuplicate
+ second.raw_head_decision,
+ RadrootsRawHeadDecision::NotHeadSelected
);
assert_eq!(
- first.verification_status,
- RadrootsEventVerificationStatus::Verified
+ first.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
);
- assert_eq!(stored.seq, first.seq);
assert_eq!(stored.raw_json, event.raw_json());
assert_eq!(stored.content, "hello");
assert_eq!(stored.tags_json, "[[\"t\",\"soil\"]]");
assert_eq!(
- stored.contract_status,
- RadrootsEventContractStatus::Supported
+ stored.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
);
- assert!(stored.projection_eligible);
+ assert!(stored.valid_stream_eligible);
assert_eq!(
store
.tags_for_event(event.id_str())
@@ -2219,18 +2834,164 @@ mod tests {
}
#[tokio::test]
- async fn trade_mutation_ingest_stores_semantic_rows_missing_parents_and_reservations() {
+ async fn duplicate_uses_persisted_classification_and_preserves_first_raw_bytes() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
- let decision =
- canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
- let decision_event = signed_trade_mutation(&decision);
+ let first_event = signed_event(
+ KIND_POST,
+ 11,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "same event",
+ );
+ let second_event = signed_event(
+ KIND_POST,
+ 11,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "same event",
+ );
+ assert_eq!(first_event.id_str(), second_event.id_str());
+ assert_ne!(first_event.sig_str(), second_event.sig_str());
+ assert_ne!(first_event.raw_json(), second_event.raw_json());
- let decision_receipt = store
- .ingest_event(RadrootsEventIngest::new(decision_event.clone(), 2_000))
+ store
+ .ingest_event(RadrootsEventIngest::new(first_event.clone(), 1_100))
+ .await
+ .expect("first ingest");
+ sqlx::query(
+ "UPDATE event_envelopes SET contract_status = 'unsupported', contract_id = NULL, projection_eligible = 0 WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("persist alternate classification");
+ let before: (String, String, String, i64) = sqlx::query_as(
+ "SELECT sig, raw_json, tags_json, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .fetch_one(store.pool())
+ .await
+ .expect("before");
+
+ let receipt = store
+ .ingest_event(RadrootsEventIngest::new(second_event, 1_200))
+ .await
+ .expect("duplicate ingest");
+ let after: (String, String, String, i64) = sqlx::query_as(
+ "SELECT sig, raw_json, tags_json, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .fetch_one(store.pool())
+ .await
+ .expect("after");
+
+ assert!(receipt.persistence.is_duplicate());
+ assert_eq!(
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
+ );
+ assert_eq!(receipt.admission_code, None);
+ assert_eq!(receipt.contract_id, None);
+ assert!(!receipt.valid_stream_eligible);
+ assert_eq!(
+ receipt.raw_head_decision,
+ RadrootsRawHeadDecision::NotHeadSelected
+ );
+ assert_eq!(after, before);
+ assert_eq!(after.0, first_event.sig_str());
+ assert_eq!(after.1, first_event.raw_json());
+ assert_eq!(
+ store
+ .raw_event(first_event.id_str())
+ .await
+ .expect("raw event")
+ .expect("stored")
+ .admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
+ );
+ assert!(
+ store
+ .valid_event(first_event.id_str())
+ .await
+ .expect("valid event")
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
+ async fn legacy_duplicate_requires_reconciliation_without_mutating_any_raw_data() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let first_event = signed_event(KIND_POST, 12, Vec::new(), "legacy");
+ let second_event = signed_event(KIND_POST, 12, Vec::new(), "legacy");
+ assert_eq!(first_event.id_str(), second_event.id_str());
+ assert_ne!(first_event.sig_str(), second_event.sig_str());
+
+ store
+ .ingest_event(RadrootsEventIngest::new(first_event.clone(), 1_300))
+ .await
+ .expect("first ingest");
+ sqlx::query(
+ "UPDATE event_envelopes SET contract_status = 'supported', event_class = NULL WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("legacy row");
+ let before: (String, String, String, String, Option<String>, i64) = sqlx::query_as(
+ "SELECT sig, raw_json, tags_json, contract_status, event_class, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .fetch_one(store.pool())
+ .await
+ .expect("before");
+
+ let error = store
+ .ingest_event(RadrootsEventIngest::new(second_event, 1_400))
+ .await
+ .expect_err("legacy duplicate");
+ let after: (String, String, String, String, Option<String>, i64) = sqlx::query_as(
+ "SELECT sig, raw_json, tags_json, contract_status, event_class, updated_at_ms FROM event_envelopes WHERE event_id = ?",
+ )
+ .bind(first_event.id_str())
+ .fetch_one(store.pool())
+ .await
+ .expect("after");
+
+ assert!(matches!(
+ error,
+ RadrootsEventStoreError::StoredRawEventRequiresReconciliation { .. }
+ ));
+ assert_eq!(after, before);
+ assert_eq!(after.0, first_event.sig_str());
+ assert_eq!(after.1, first_event.raw_json());
+ assert!(matches!(
+ store.raw_event(first_event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventRequiresReconciliation { .. })
+ ));
+ assert!(matches!(
+ store.valid_event(first_event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventRequiresReconciliation { .. })
+ ));
+ assert!(matches!(
+ store.event_visibility(first_event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredRawEventRequiresReconciliation { .. })
+ ));
+ let status = store.status_summary().await.expect("legacy status");
+ assert_eq!(status.total_events, 1);
+ assert_eq!(status.valid_stream_events, 0);
+ }
+
+ #[tokio::test]
+ async fn trade_mutation_ingest_stores_semantic_rows_missing_parents_and_reservations() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+ let decision =
+ canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
+ let decision_event = signed_trade_mutation(&decision);
+
+ let decision_receipt = store
+ .ingest_event(RadrootsEventIngest::new(decision_event.clone(), 2_000))
.await
.expect("decision ingest");
- assert!(decision_receipt.projection_eligible);
+ assert!(decision_receipt.valid_stream_eligible);
let stored_decision = store
.get_trade_mutation(&decision.mutation_id)
@@ -2326,7 +3087,7 @@ mod tests {
.await
.expect("transactional ingest");
tx.commit().await.expect("commit");
- assert!(receipt.projection_eligible);
+ assert!(receipt.valid_stream_eligible);
assert!(matches!(
store.trade_mutations_for_trade(&trade_id(), 0).await,
@@ -2364,7 +3125,7 @@ mod tests {
}
#[tokio::test]
- async fn malformed_trade_mutations_are_quarantined_and_not_projected() {
+ async fn contract_admitted_but_malformed_trade_mutations_are_quarantined() {
let store = RadrootsEventStore::open_memory().await.expect("store");
let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
@@ -2373,14 +3134,27 @@ mod tests {
format!("{} ", proposal.content),
&fixture_keys(),
);
+ let malformed_id = malformed.id_str().to_owned();
let malformed_receipt = store
.ingest_event(RadrootsEventIngest::new(malformed, 2_400))
.await
.expect("malformed ingest");
- assert!(!malformed_receipt.projection_eligible);
assert_eq!(
- malformed_receipt.head_decision,
- RadrootsEventHeadStoreDecision::Malformed
+ malformed_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert!(malformed_receipt.valid_stream_eligible);
+ assert_eq!(
+ malformed_receipt.raw_head_decision,
+ RadrootsRawHeadDecision::NotHeadSelected
+ );
+ assert!(store.raw_event(&malformed_id).await.expect("raw").is_some());
+ assert!(
+ store
+ .valid_event(&malformed_id)
+ .await
+ .expect("valid")
+ .is_some()
);
let mut missing_id_value: serde_json::Value =
@@ -2392,19 +3166,43 @@ mod tests {
let missing_id_content = canonical_jcs_value(&missing_id_value).expect("canonical json");
let missing_id =
signed_trade_content_with_keys(&proposal, missing_id_content, &fixture_keys());
+ let missing_id_event_id = missing_id.id_str().to_owned();
let missing_id_receipt = store
.ingest_event(RadrootsEventIngest::new(missing_id, 2_500))
.await
.expect("missing id ingest");
- assert!(!missing_id_receipt.projection_eligible);
+ assert_eq!(
+ missing_id_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert!(missing_id_receipt.valid_stream_eligible);
+ assert!(
+ store
+ .valid_event(&missing_id_event_id)
+ .await
+ .expect("valid")
+ .is_some()
+ );
let mismatched_author =
signed_trade_content_with_keys(&proposal, proposal.content.clone(), &alternate_keys());
+ let mismatched_author_id = mismatched_author.id_str().to_owned();
let mismatched_author_receipt = store
.ingest_event(RadrootsEventIngest::new(mismatched_author, 2_600))
.await
.expect("mismatched author ingest");
- assert!(!mismatched_author_receipt.projection_eligible);
+ assert_eq!(
+ mismatched_author_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert!(mismatched_author_receipt.valid_stream_eligible);
+ assert!(
+ store
+ .valid_event(&mismatched_author_id)
+ .await
+ .expect("valid")
+ .is_some()
+ );
let quarantined: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM trade_projection_quarantine")
@@ -2412,6 +3210,11 @@ mod tests {
.await
.expect("quarantine count");
assert_eq!(quarantined, 3);
+ let projected: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM trade_mutation")
+ .fetch_one(store.pool())
+ .await
+ .expect("projected count");
+ assert_eq!(projected, 0);
}
#[test]
@@ -2589,29 +3392,40 @@ mod tests {
.await
.expect("ingest");
let stored = store
- .get_event(event.id_str())
+ .raw_event(event.id_str())
.await
.expect("get")
.expect("stored");
assert_eq!(
- receipt.contract_status,
- RadrootsEventContractStatus::UnsupportedKind(999)
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
);
assert_eq!(
- stored.verification_status,
- RadrootsEventVerificationStatus::Verified
+ stored.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
+ );
+ assert!(!stored.valid_stream_eligible);
+ assert!(
+ store
+ .valid_event(event.id_str())
+ .await
+ .expect("valid event")
+ .is_none()
);
- assert!(!stored.projection_eligible);
let duplicate = store
.ingest_event(RadrootsEventIngest::new(event, 2_100))
.await
.expect("duplicate");
- assert!(!duplicate.inserted);
+ assert!(duplicate.persistence.is_duplicate());
+ assert_eq!(
+ duplicate.raw_head_decision,
+ RadrootsRawHeadDecision::NotHeadSelected
+ );
assert_eq!(
- duplicate.head_decision,
- RadrootsEventHeadStoreDecision::Unsupported
+ duplicate.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
);
}
@@ -2646,7 +3460,7 @@ mod tests {
assert!(matches!(error, RadrootsEventStoreError::EventWire(_)));
assert!(
store
- .events_since_cursor("social", 10)
+ .valid_stream_after(0, 10)
.await
.expect("events")
.is_empty()
@@ -2654,131 +3468,537 @@ mod tests {
}
#[tokio::test]
- async fn signature_invalid_events_are_stored_but_not_projected() {
+ async fn signature_invalid_events_are_rejected_before_storage() {
let store = RadrootsEventStore::open_memory().await.expect("open");
let event = tamper_signature(&signed_event(KIND_POST, 13, Vec::new(), "hello"));
- let receipt = store
- .ingest_event(RadrootsEventIngest::new(event.clone(), 2_200))
+ let error = RadrootsEventIngest::from_signed_event(event.clone(), 2_200)
+ .expect_err("invalid signature");
+
+ assert!(matches!(
+ error,
+ RadrootsEventStoreError::Nip01Verification(
+ radroots_event_codec::verification::RadrootsNip01VerificationError::SignatureInvalid
+ )
+ ));
+ assert!(
+ store
+ .raw_event(event.id_str())
+ .await
+ .expect("raw event")
+ .is_none()
+ );
+ assert!(
+ store
+ .raw_events_after(0, 10)
+ .await
+ .expect("events")
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn out_of_range_kind_events_are_rejected_before_storage() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let event = synthetic_signed_event(u32::from(u16::MAX) + 1, 13, Vec::new(), "hello");
+
+ let error = RadrootsEventIngest::from_signed_event(event.clone(), 2_250)
+ .expect_err("kind out of range");
+
+ assert!(matches!(
+ error,
+ RadrootsEventStoreError::Nip01Verification(
+ radroots_event_codec::verification::RadrootsNip01VerificationError::KindOutOfRange {
+ ..
+ }
+ )
+ ));
+ assert!(
+ store
+ .raw_event(event.id_str())
+ .await
+ .expect("raw event")
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
+ async fn ephemeral_admission_outcomes_are_never_persisted() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let admitted = signed_event(KIND_GEOCHAT, 15, Vec::new(), "hello");
+ let unsupported = signed_event(29_999, 16, Vec::new(), "unsupported");
+ let invalid = signed_event(KIND_RELAY_AUTH, 17, Vec::new(), "not-json");
+ let observation = RadrootsTransportObservation::new(
+ RadrootsTransportKind::Nostr,
+ "wss://relay.example.test",
+ RadrootsTransportObservationType::Subscription,
+ 2_260,
+ )
+ .expect("observation");
+
+ let admitted_receipt = store
+ .ingest_event(
+ RadrootsEventIngest::new(admitted.clone(), 2_260).with_observation(observation),
+ )
.await
.expect("ingest");
- let stored = store
- .get_event(event.id_str())
+ let unsupported_receipt = store
+ .ingest_event(RadrootsEventIngest::new(unsupported.clone(), 2_261))
.await
- .expect("get")
- .expect("stored");
+ .expect("unsupported");
+ let invalid_receipt = store
+ .ingest_event(RadrootsEventIngest::new(invalid.clone(), 2_262))
+ .await
+ .expect("invalid");
assert_eq!(
- receipt.verification_status,
- RadrootsEventVerificationStatus::SignatureInvalid
+ admitted_receipt.persistence,
+ RadrootsEventPersistence::NotPersisted
+ );
+ assert_eq!(
+ admitted_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert_eq!(admitted_receipt.admission_code, None);
+ assert_eq!(
+ unsupported_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
+ );
+ assert_eq!(
+ unsupported_receipt.admission_code.as_deref(),
+ Some("unsupported_kind")
);
assert_eq!(
- stored.verification_status,
- RadrootsEventVerificationStatus::SignatureInvalid
+ invalid_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Invalid
);
- assert!(!stored.projection_eligible);
+ assert!(invalid_receipt.admission_code.is_some());
+ for receipt in [&admitted_receipt, &unsupported_receipt, &invalid_receipt] {
+ assert_eq!(receipt.persistence, RadrootsEventPersistence::NotPersisted);
+ assert!(!receipt.valid_stream_eligible);
+ assert_eq!(
+ receipt.raw_head_decision,
+ RadrootsRawHeadDecision::NotPersisted
+ );
+ }
+ for event in [&admitted, &unsupported, &invalid] {
+ assert!(
+ store
+ .raw_event(event.id_str())
+ .await
+ .expect("raw event")
+ .is_none()
+ );
+ assert!(
+ store
+ .valid_event(event.id_str())
+ .await
+ .expect("valid event")
+ .is_none()
+ );
+ assert_eq!(
+ store
+ .event_visibility(event.id_str())
+ .await
+ .expect("visibility"),
+ None
+ );
+ assert!(
+ store
+ .tags_for_event(event.id_str())
+ .await
+ .expect("tags")
+ .is_empty()
+ );
+ }
assert!(
store
- .events_since_cursor("social", 10)
+ .observations_for_event(admitted.id_str())
.await
- .expect("events")
+ .expect("observations")
.is_empty()
);
+ let status = store.status_summary().await.expect("status");
+ assert_eq!(status.total_events, 0);
+ assert_eq!(status.valid_stream_events, 0);
+ assert_eq!(status.transport_observations, 0);
+ assert_eq!(
+ admitted_receipt.raw_head_decision,
+ RadrootsRawHeadDecision::NotPersisted
+ );
}
#[tokio::test]
- async fn malformed_envelope_events_are_stored_but_not_projected() {
+ async fn event_head_helper_maps_not_persisted_candidates() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = synthetic_signed_event(u32::from(u16::MAX) + 1, 13, Vec::new(), "hello");
+ let event = signed_event(KIND_GEOCHAT, 17, Vec::new(), "hello");
+ let mut tx = store.pool.begin().await.expect("tx");
+
+ let head = apply_raw_event_head(&mut tx, event.envelope(), 2_280)
+ .await
+ .expect("head");
+
+ assert_eq!(head.decision, RadrootsRawHeadDecision::NotPersisted);
+ }
+
+ #[tokio::test]
+ async fn marker_free_classified_listings_are_unsupported() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let event = signed_event(KIND_CLASSIFIED_LISTING, 16, Vec::new(), "{}");
let receipt = store
- .ingest_event(RadrootsEventIngest::new(event.clone(), 2_250))
+ .ingest_event(RadrootsEventIngest::new(event.clone(), 2_270))
.await
.expect("ingest");
let stored = store
- .get_event(event.id_str())
+ .raw_event(event.id_str())
.await
.expect("get")
.expect("stored");
assert_eq!(
- receipt.verification_status,
- RadrootsEventVerificationStatus::MalformedEnvelope
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
);
+ assert_eq!(receipt.raw_head_decision, RadrootsRawHeadDecision::Applied);
+ assert!(!receipt.valid_stream_eligible);
+ assert!(!stored.valid_stream_eligible);
+ let coordinate = head_coordinate_for_event(&event);
+ assert!(matches!(
+ &coordinate,
+ RadrootsEventHeadCoordinate::Addressable { d_tag, .. } if d_tag.is_empty()
+ ));
assert_eq!(
- stored.verification_status,
- RadrootsEventVerificationStatus::MalformedEnvelope
+ store
+ .raw_event_head(&coordinate)
+ .await
+ .expect("raw head")
+ .expect("stored raw head")
+ .event_id,
+ event.id_str()
+ );
+ assert_eq!(
+ store
+ .event_visibility(event.id_str())
+ .await
+ .expect("visibility"),
+ Some(RadrootsEventVisibility::NotAdmitted)
+ );
+ assert!(
+ store
+ .visible_event_head(&coordinate)
+ .await
+ .expect("visible head")
+ .is_none()
);
- assert!(!stored.projection_eligible);
}
#[tokio::test]
- async fn ephemeral_events_are_not_persisted_as_heads() {
+ async fn ambiguous_classified_listing_shape_is_invalid_but_still_updates_the_raw_head() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = signed_event(KIND_GEOCHAT, 15, Vec::new(), "hello");
+ let event = signed_event(
+ KIND_CLASSIFIED_LISTING,
+ 17,
+ vec![
+ vec!["d".to_owned(), "mixed-listing".to_owned()],
+ vec!["radroots:primary_bin".to_owned(), "bin-1".to_owned()],
+ vec!["radroots:price_unit".to_owned(), "kg".to_owned()],
+ ],
+ "{}",
+ );
+ let coordinate = head_coordinate_for_event(&event);
let receipt = store
- .ingest_event(RadrootsEventIngest::new(event.clone(), 2_260))
+ .ingest_event(RadrootsEventIngest::new(event.clone(), 2_275))
.await
.expect("ingest");
- let stored = store
- .get_event(event.id_str())
- .await
- .expect("get")
- .expect("stored");
assert_eq!(
- receipt.contract_status,
- RadrootsEventContractStatus::Supported
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Invalid
);
assert_eq!(
- receipt.head_decision,
- RadrootsEventHeadStoreDecision::NotProjectionEligible
+ receipt.admission_code.as_deref(),
+ Some("food_profile_ambiguous")
+ );
+ assert!(!receipt.valid_stream_eligible);
+ assert_eq!(receipt.raw_head_decision, RadrootsRawHeadDecision::Applied);
+ assert!(
+ store
+ .raw_event(event.id_str())
+ .await
+ .expect("raw event")
+ .is_some()
+ );
+ assert!(
+ store
+ .valid_event(event.id_str())
+ .await
+ .expect("valid event")
+ .is_none()
+ );
+ assert_eq!(
+ store
+ .raw_event_head(&coordinate)
+ .await
+ .expect("raw head")
+ .expect("head")
+ .event_id,
+ event.id_str()
+ );
+ assert_eq!(
+ store
+ .event_visibility(event.id_str())
+ .await
+ .expect("visibility"),
+ Some(RadrootsEventVisibility::NotAdmitted)
+ );
+ assert!(
+ store
+ .visible_event_head(&coordinate)
+ .await
+ .expect("visible head")
+ .is_none()
);
- assert!(!receipt.projection_eligible);
- assert!(!stored.projection_eligible);
}
#[tokio::test]
- async fn event_head_helper_maps_not_persisted_candidates() {
+ async fn raw_addressable_heads_use_the_first_opaque_d_value_or_empty() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = signed_event(KIND_GEOCHAT, 17, Vec::new(), "hello");
- let classification = classify_event(event.envelope());
- let contract = classification.contract.expect("contract");
- let mut tx = store.pool.begin().await.expect("tx");
+ let missing = signed_event(39_990, 30, Vec::new(), "missing");
+ let missing_value = signed_event(
+ 39_990,
+ 31,
+ vec![
+ vec!["d".to_owned()],
+ vec!["d".to_owned(), "ignored".to_owned()],
+ ],
+ "missing value",
+ );
+ let opaque = signed_event(
+ 39_991,
+ 32,
+ vec![vec!["d".to_owned(), " opaque/value ".to_owned()]],
+ "opaque",
+ );
+ let control = signed_event(
+ 39_992,
+ 33,
+ vec![vec!["d".to_owned(), "line\nbreak".to_owned()]],
+ "control",
+ );
+
+ for event in [&missing, &missing_value, &opaque, &control] {
+ let receipt = store
+ .ingest_event(RadrootsEventIngest::new(event.clone(), 3_000))
+ .await
+ .expect("ingest");
+ assert_eq!(
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Unsupported
+ );
+ assert_eq!(receipt.raw_head_decision, RadrootsRawHeadDecision::Applied);
+ }
+
+ let missing_coordinate = head_coordinate_for_event(&missing);
+ assert_eq!(
+ missing_coordinate,
+ head_coordinate_for_event(&missing_value)
+ );
+ assert!(matches!(
+ &missing_coordinate,
+ RadrootsEventHeadCoordinate::Addressable { d_tag, .. } if d_tag.is_empty()
+ ));
+ assert_eq!(
+ store
+ .raw_event_head(&missing_coordinate)
+ .await
+ .expect("missing raw head")
+ .expect("missing head")
+ .event_id,
+ missing_value.id_str()
+ );
+ for (event, expected_d) in [(&opaque, " opaque/value "), (&control, "line\nbreak")] {
+ let coordinate = head_coordinate_for_event(event);
+ assert!(matches!(
+ &coordinate,
+ RadrootsEventHeadCoordinate::Addressable { d_tag, .. } if d_tag == expected_d
+ ));
+ let head = store
+ .raw_event_head(&coordinate)
+ .await
+ .expect("raw head")
+ .expect("head");
+ assert_eq!(head.event_id, event.id_str());
+ assert_eq!(head.d_tag.as_deref(), Some(expected_d));
+ }
+ let missing_tags = store
+ .tags_for_event(missing_value.id_str())
+ .await
+ .expect("tags");
+ assert_eq!(missing_tags[0].tag_name, "d");
+ assert_eq!(missing_tags[0].tag_value, None);
+ }
+
+ #[tokio::test]
+ async fn raw_and_visible_head_reads_reject_every_head_event_mismatch() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let fixture_pubkey =
+ RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("fixture pubkey");
+
+ let kind_event = signed_event(10_001, 40, Vec::new(), "kind");
+ store
+ .ingest_event(RadrootsEventIngest::new(kind_event.clone(), 3_100))
+ .await
+ .expect("kind ingest");
+ sqlx::query("UPDATE event_envelope_head SET kind = 10002 WHERE event_id = ?")
+ .bind(kind_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt kind");
+ assert_raw_head_inconsistent(
+ &store,
+ &RadrootsEventHeadCoordinate::Replaceable {
+ kind: 10_002,
+ pubkey: fixture_pubkey.clone(),
+ },
+ )
+ .await;
+
+ let author_event = signed_event(10_003, 41, Vec::new(), "author");
+ store
+ .ingest_event(RadrootsEventIngest::new(author_event.clone(), 3_101))
+ .await
+ .expect("author ingest");
+ let other_pubkey = alternate_keys().public_key().to_hex();
+ sqlx::query("UPDATE event_envelope_head SET pubkey = ? WHERE event_id = ?")
+ .bind(other_pubkey.as_str())
+ .bind(author_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt author");
+ assert_raw_head_inconsistent(
+ &store,
+ &RadrootsEventHeadCoordinate::Replaceable {
+ kind: 10_003,
+ pubkey: RadrootsPublicKey::parse(other_pubkey).expect("other pubkey"),
+ },
+ )
+ .await;
- let head = apply_event_head(&mut tx, event.envelope(), contract, 2_280)
+ let class_event = signed_event(10_004, 42, Vec::new(), "class");
+ store
+ .ingest_event(RadrootsEventIngest::new(class_event.clone(), 3_102))
+ .await
+ .expect("class ingest");
+ sqlx::query(
+ "UPDATE event_envelope_head SET coordinate_type = 'addressable', d_tag = 'wrong-class' WHERE event_id = ?",
+ )
+ .bind(class_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt class");
+ assert_raw_head_inconsistent(
+ &store,
+ &RadrootsEventHeadCoordinate::Addressable {
+ kind: 10_004,
+ pubkey: fixture_pubkey.clone(),
+ d_tag: "wrong-class".to_owned(),
+ },
+ )
+ .await;
+
+ let d_event = signed_event(
+ 39_980,
+ 43,
+ vec![vec!["d".to_owned(), "actual".to_owned()]],
+ "d",
+ );
+ store
+ .ingest_event(RadrootsEventIngest::new(d_event.clone(), 3_103))
.await
- .expect("head");
-
- assert_eq!(head.decision, RadrootsEventHeadStoreDecision::NotPersisted);
- assert!(!head.projection_eligible);
- }
+ .expect("d ingest");
+ sqlx::query("UPDATE event_envelope_head SET d_tag = 'wrong-d' WHERE event_id = ?")
+ .bind(d_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt d");
+ assert_raw_head_inconsistent(
+ &store,
+ &RadrootsEventHeadCoordinate::Addressable {
+ kind: 39_980,
+ pubkey: fixture_pubkey.clone(),
+ d_tag: "wrong-d".to_owned(),
+ },
+ )
+ .await;
- #[tokio::test]
- async fn marker_free_classified_listings_are_unsupported() {
- let store = RadrootsEventStore::open_memory().await.expect("open");
- let event = signed_event(KIND_CLASSIFIED_LISTING, 16, Vec::new(), "{}");
+ let created_event = signed_event(10_005, 44, Vec::new(), "created");
+ let created_coordinate = head_coordinate_for_event(&created_event);
+ store
+ .ingest_event(RadrootsEventIngest::new(created_event.clone(), 3_104))
+ .await
+ .expect("created ingest");
+ sqlx::query(
+ "UPDATE event_envelope_head SET created_at = created_at + 1 WHERE event_id = ?",
+ )
+ .bind(created_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt created at");
+ assert_raw_head_inconsistent(&store, &created_coordinate).await;
+ assert!(matches!(
+ store.event_visibility(created_event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredHeadInconsistent { .. })
+ ));
+ assert!(matches!(
+ store.visible_event(created_event.id_str()).await,
+ Err(RadrootsEventStoreError::StoredHeadInconsistent { .. })
+ ));
+ assert!(matches!(
+ store.visible_event_head(&created_coordinate).await,
+ Err(RadrootsEventStoreError::StoredHeadInconsistent { .. })
+ ));
- let receipt = store
- .ingest_event(RadrootsEventIngest::new(event.clone(), 2_270))
+ let reference_event = signed_event(10_006, 45, Vec::new(), "reference");
+ let reference_coordinate = head_coordinate_for_event(&reference_event);
+ let unrelated_event = signed_event(998, 46, Vec::new(), "unrelated");
+ store
+ .ingest_event(RadrootsEventIngest::new(reference_event.clone(), 3_105))
.await
- .expect("ingest");
- let stored = store
- .get_event(event.id_str())
+ .expect("reference ingest");
+ store
+ .ingest_event(RadrootsEventIngest::new(unrelated_event.clone(), 3_106))
.await
- .expect("get")
- .expect("stored");
+ .expect("unrelated ingest");
+ sqlx::query("UPDATE event_envelope_head SET event_id = ? WHERE event_id = ?")
+ .bind(unrelated_event.id_str())
+ .bind(reference_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt reference");
+ assert_raw_head_inconsistent(&store, &reference_coordinate).await;
- assert_eq!(
- receipt.contract_status,
- RadrootsEventContractStatus::UnsupportedShape(KIND_CLASSIFIED_LISTING)
- );
- assert_eq!(
- receipt.head_decision,
- RadrootsEventHeadStoreDecision::Unsupported
- );
- assert!(!receipt.projection_eligible);
- assert!(!stored.projection_eligible);
+ let missing_event = signed_event(10_007, 47, Vec::new(), "missing reference");
+ let missing_coordinate = head_coordinate_for_event(&missing_event);
+ store
+ .ingest_event(RadrootsEventIngest::new(missing_event.clone(), 3_107))
+ .await
+ .expect("missing ingest");
+ sqlx::query("PRAGMA foreign_keys = OFF")
+ .execute(store.pool())
+ .await
+ .expect("disable foreign keys");
+ sqlx::query("UPDATE event_envelope_head SET event_id = ? WHERE event_id = ?")
+ .bind(event_id('f'))
+ .bind(missing_event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("remove reference");
+ sqlx::query("PRAGMA foreign_keys = ON")
+ .execute(store.pool())
+ .await
+ .expect("enable foreign keys");
+ assert_raw_head_inconsistent(&store, &missing_coordinate).await;
}
#[tokio::test]
@@ -2804,12 +4024,12 @@ mod tests {
let raw_json = tampered_content_raw_json(&invalid, "{\"tampered\":true}");
let error = RadrootsEventIngest::from_raw_json(raw_json, 2_400).expect_err("id mismatch");
let head = store
- .event_head(&coordinate)
+ .raw_event_head(&coordinate)
.await
.expect("head")
.expect("stored head");
- assert_eq!(first.head_decision, RadrootsEventHeadStoreDecision::Applied);
+ assert_eq!(first.raw_head_decision, RadrootsRawHeadDecision::Applied);
assert!(matches!(error, RadrootsEventStoreError::EventWire(_)));
assert_eq!(head.event_id, original.id_str());
}
@@ -2835,48 +4055,40 @@ mod tests {
"{}",
));
- let receipt = store
- .ingest_event(RadrootsEventIngest::new(invalid.clone(), 2_600))
- .await
- .expect("invalid");
+ let error = RadrootsEventIngest::from_signed_event(invalid.clone(), 2_600)
+ .expect_err("invalid signature");
let head = store
- .event_head(&coordinate)
+ .raw_event_head(&coordinate)
.await
.expect("head")
.expect("stored head");
- assert_eq!(
- receipt.verification_status,
- RadrootsEventVerificationStatus::SignatureInvalid
- );
- assert_eq!(
- receipt.head_decision,
- RadrootsEventHeadStoreDecision::NotProjectionEligible
+ assert!(matches!(
+ error,
+ RadrootsEventStoreError::Nip01Verification(
+ radroots_event_codec::verification::RadrootsNip01VerificationError::SignatureInvalid
+ )
+ ));
+ assert!(
+ store
+ .raw_event(invalid.id_str())
+ .await
+ .expect("raw event")
+ .is_none()
);
- assert!(!receipt.projection_eligible);
assert_eq!(head.event_id, original.id_str());
}
#[tokio::test]
- async fn duplicate_invalid_addressable_events_do_not_update_heads() {
+ async fn duplicate_contract_invalid_addressable_events_preserve_raw_heads() {
let store = RadrootsEventStore::open_memory().await.expect("open");
- let original = signed_event(
- KIND_CLASSIFIED_LISTING,
- 21,
- operational_listing_tags("listing-3"),
- "{}",
- );
- store
- .ingest_event(RadrootsEventIngest::new(original.clone(), 2_700))
- .await
- .expect("original");
- let coordinate = head_coordinate_for_event(&original);
- let invalid = tamper_signature(&signed_event(
+ let invalid = signed_event(
KIND_CLASSIFIED_LISTING,
22,
operational_listing_tags("listing-3"),
"{}",
- ));
+ );
+ let coordinate = head_coordinate_for_event(&invalid);
let first_invalid = store
.ingest_event(RadrootsEventIngest::new(invalid.clone(), 2_800))
@@ -2887,23 +4099,30 @@ mod tests {
.await
.expect("second invalid");
let head = store
- .event_head(&coordinate)
+ .raw_event_head(&coordinate)
.await
.expect("head")
.expect("stored head");
- assert!(first_invalid.inserted);
- assert!(!second_invalid.inserted);
- assert_eq!(first_invalid.seq, second_invalid.seq);
+ assert!(first_invalid.persistence.is_inserted());
+ assert!(second_invalid.persistence.is_duplicate());
assert_eq!(
- first_invalid.head_decision,
- RadrootsEventHeadStoreDecision::NotProjectionEligible
+ first_invalid.persistence.sequence(),
+ second_invalid.persistence.sequence()
);
assert_eq!(
- second_invalid.head_decision,
- RadrootsEventHeadStoreDecision::SkippedDuplicate
+ first_invalid.admission_status,
+ RadrootsEventAdmissionStatus::Invalid
);
- assert_eq!(head.event_id, original.id_str());
+ assert_eq!(
+ first_invalid.raw_head_decision,
+ RadrootsRawHeadDecision::Applied
+ );
+ assert_eq!(
+ second_invalid.raw_head_decision,
+ RadrootsRawHeadDecision::SkippedDuplicate
+ );
+ assert_eq!(head.event_id, invalid.id_str());
}
#[tokio::test]
@@ -2926,18 +4145,18 @@ mod tests {
.await
.expect("second");
let head = store
- .event_head(&coordinate)
+ .raw_event_head(&coordinate)
.await
.expect("head")
.expect("stored head");
- assert!(first.inserted);
- assert!(!second.inserted);
- assert_eq!(first.seq, second.seq);
- assert_eq!(first.head_decision, RadrootsEventHeadStoreDecision::Applied);
+ assert!(first.persistence.is_inserted());
+ assert!(second.persistence.is_duplicate());
+ assert_eq!(first.persistence.sequence(), second.persistence.sequence());
+ assert_eq!(first.raw_head_decision, RadrootsRawHeadDecision::Applied);
assert_eq!(
- second.head_decision,
- RadrootsEventHeadStoreDecision::SkippedDuplicate
+ second.raw_head_decision,
+ RadrootsRawHeadDecision::SkippedDuplicate
);
assert_eq!(head.event_id, event.id_str());
}
@@ -2952,38 +4171,59 @@ mod tests {
.await
.expect("ingest");
let stored = store
- .get_event(event.id_str())
+ .raw_event(event.id_str())
.await
.expect("get")
.expect("stored");
assert_eq!(
- receipt.verification_status,
- RadrootsEventVerificationStatus::Verified
+ receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert_eq!(
+ receipt.raw_head_decision,
+ RadrootsRawHeadDecision::NotHeadSelected
+ );
+ assert!(receipt.valid_stream_eligible);
+ assert!(stored.valid_stream_eligible);
+ assert!(
+ store
+ .valid_event(event.id_str())
+ .await
+ .expect("valid event")
+ .is_some()
);
assert_eq!(
- receipt.head_decision,
- RadrootsEventHeadStoreDecision::NotHeadSelected
+ store
+ .event_visibility(event.id_str())
+ .await
+ .expect("visibility"),
+ Some(RadrootsEventVisibility::Visible)
+ );
+ assert!(
+ store
+ .visible_event(event.id_str())
+ .await
+ .expect("visible event")
+ .is_some()
);
- assert!(receipt.projection_eligible);
- assert!(stored.projection_eligible);
}
#[tokio::test]
- async fn events_by_tag_validates_inputs_and_returns_projection_events_in_sequence_order() {
+ async fn tag_reads_separate_raw_events_from_the_valid_stream() {
let store = RadrootsEventStore::open_memory().await.expect("store");
assert!(matches!(
- store.events_by_tag("", "soil", 1).await,
+ store.valid_stream_by_tag("", "soil", 1).await,
Err(RadrootsEventStoreError::EmptyTagName)
));
assert!(matches!(
- store.events_by_tag("t", "soil", 0).await,
+ store.valid_stream_by_tag("t", "soil", 0).await,
Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
));
assert!(matches!(
store
- .events_by_tag("t", "soil", RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1)
+ .valid_stream_by_tag("t", "soil", RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1)
.await,
Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
));
@@ -3024,29 +4264,45 @@ mod tests {
.expect("low ingest");
let events = store
- .events_by_tag("t", "soil", 10)
+ .valid_stream_by_tag("t", "soil", 10)
.await
.expect("tag query");
assert_eq!(events.len(), 2);
- assert_eq!(events[0].event_id, high_created_at.id_str());
- assert_eq!(events[1].event_id, low_created_at.id_str());
- assert!(events.iter().all(|event| event.projection_eligible));
+ assert_eq!(events[0].raw_event().event_id, high_created_at.id_str());
+ assert_eq!(events[1].raw_event().event_id, low_created_at.id_str());
+ assert!(
+ events
+ .iter()
+ .all(|event| event.raw_event().valid_stream_eligible)
+ );
+
+ let raw_events = store
+ .raw_events_by_tag("t", "soil", 10)
+ .await
+ .expect("raw tag query");
+ assert_eq!(raw_events.len(), 3);
+ assert_eq!(raw_events[0].event_id, unsupported.id_str());
let limited = store
- .events_by_tag("t", "soil", 1)
+ .valid_stream_by_tag("t", "soil", 1)
.await
.expect("limited tag query");
assert_eq!(limited.len(), 1);
- assert_eq!(limited[0].event_id, high_created_at.id_str());
+ assert_eq!(limited[0].raw_event().event_id, high_created_at.id_str());
}
#[tokio::test]
- async fn events_by_contract_and_tag_enforces_trade_contract_tag_and_projection_filters() {
+ async fn valid_stream_by_contract_and_tag_enforces_contract_and_tag_filters() {
let store = RadrootsEventStore::open_memory().await.expect("store");
assert!(matches!(
store
- .events_by_contract_and_tag::<&str>(&[], "p", FIXTURE_ALICE_PUBLIC_KEY_HEX, 1)
+ .valid_stream_by_contract_and_tag::<&str>(
+ &[],
+ "p",
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ 1,
+ )
.await,
Err(RadrootsEventStoreError::EmptyContractList)
));
@@ -3056,7 +4312,7 @@ mod tests {
];
assert!(matches!(
store
- .events_by_contract_and_tag(
+ .valid_stream_by_contract_and_tag(
too_many_contracts.as_slice(),
"p",
FIXTURE_ALICE_PUBLIC_KEY_HEX,
@@ -3099,7 +4355,7 @@ mod tests {
}
let events = store
- .events_by_contract_and_tag(
+ .valid_stream_by_contract_and_tag(
&[RADROOTS_TRADE_PROPOSAL_CONTRACT_ID],
"p",
FIXTURE_ALICE_PUBLIC_KEY_HEX,
@@ -3108,12 +4364,12 @@ mod tests {
.await
.expect("contract tag query");
assert_eq!(events.len(), 1);
- assert_eq!(events[0].event_id, matching_trade.id_str());
+ assert_eq!(events[0].raw_event().event_id, matching_trade.id_str());
assert_eq!(
- events[0].contract_id.as_deref(),
+ events[0].raw_event().contract_id.as_deref(),
Some(RADROOTS_TRADE_PROPOSAL_CONTRACT_ID)
);
- assert!(events[0].projection_eligible);
+ assert!(events[0].raw_event().valid_stream_eligible);
}
#[tokio::test]
@@ -3144,6 +4400,37 @@ mod tests {
}
#[tokio::test]
+ async fn tag_reads_reject_non_boolean_relay_indexed_values() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let event = signed_event(
+ KIND_POST,
+ 14,
+ vec![vec!["t".to_owned(), "harvest".to_owned()]],
+ "hello",
+ );
+
+ store
+ .ingest_event(RadrootsEventIngest::new(event.clone(), 3_000))
+ .await
+ .expect("ingest");
+ sqlx::query(
+ "UPDATE event_envelope_tags SET relay_indexed = 2 WHERE event_id = ? AND tag_index = 0",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt relay_indexed");
+
+ assert!(matches!(
+ store.tags_for_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::InvalidStoredBoolean {
+ field: "relay_indexed",
+ value: 2,
+ })
+ ));
+ }
+
+ #[tokio::test]
async fn trade_mutation_tags_persist_contract_and_semantic_metadata() {
let store = RadrootsEventStore::open_memory().await.expect("open");
let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
@@ -3293,6 +4580,189 @@ mod tests {
}
#[tokio::test]
+ async fn transport_observation_reads_reject_invalid_counts_and_time_order() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let event = signed_event(KIND_POST, 16, Vec::new(), "observation corruption");
+ let observation = RadrootsTransportObservation::new(
+ RadrootsTransportKind::Nostr,
+ "wss://relay.local",
+ crate::RadrootsTransportObservationType::Subscription,
+ 4_000,
+ )
+ .expect("observation");
+ store
+ .ingest_event(
+ RadrootsEventIngest::new(event.clone(), 4_000).with_observation(observation),
+ )
+ .await
+ .expect("ingest");
+
+ sqlx::query(
+ "UPDATE event_transport_observation SET observation_count = 0 WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt observation count");
+ assert!(matches!(
+ store.observations_for_event(event.id_str()).await,
+ Err(RadrootsEventStoreError::InvalidStoredTransportObservation {
+ observation_count: 0,
+ ..
+ })
+ ));
+
+ sqlx::query(
+ "UPDATE event_transport_observation SET observation_count = 1, first_observed_at_ms = 4_100, last_observed_at_ms = 4_000 WHERE event_id = ?",
+ )
+ .bind(event.id_str())
+ .execute(store.pool())
+ .await
+ .expect("corrupt observation time order");
+ assert!(matches!(
+ store
+ .observations_for_endpoint(RadrootsTransportKind::Nostr, "wss://relay.local")
+ .await,
+ Err(RadrootsEventStoreError::InvalidStoredTransportObservation {
+ first_observed_at_ms: 4_100,
+ last_observed_at_ms: 4_000,
+ ..
+ })
+ ));
+ }
+
+ #[tokio::test]
+ async fn invalid_raw_head_never_falls_back_to_an_older_valid_revision() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ let older = signed_event(KIND_PROFILE, 18, Vec::new(), "{\"name\":\"valid\"}");
+ let newer = signed_event(KIND_PROFILE, 19, Vec::new(), "not-json");
+ let coordinate = profile_coordinate();
+
+ let older_receipt = store
+ .ingest_event(RadrootsEventIngest::new(older.clone(), 4_500))
+ .await
+ .expect("older");
+ let newer_receipt = store
+ .ingest_event(RadrootsEventIngest::new(newer.clone(), 4_600))
+ .await
+ .expect("newer");
+ assert_eq!(
+ older_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Admitted
+ );
+ assert_eq!(
+ newer_receipt.admission_status,
+ RadrootsEventAdmissionStatus::Invalid
+ );
+ assert_eq!(
+ store
+ .raw_event_head(&coordinate)
+ .await
+ .expect("raw head")
+ .expect("head")
+ .event_id,
+ newer.id_str()
+ );
+ assert!(
+ store
+ .valid_event(older.id_str())
+ .await
+ .expect("valid")
+ .is_some()
+ );
+ assert!(
+ store
+ .valid_event(newer.id_str())
+ .await
+ .expect("invalid")
+ .is_none()
+ );
+ assert_eq!(
+ store
+ .event_visibility(older.id_str())
+ .await
+ .expect("older visibility"),
+ Some(RadrootsEventVisibility::NotCurrent {
+ raw_head_event_id: newer.id_str().to_owned(),
+ })
+ );
+ assert_eq!(
+ store
+ .event_visibility(newer.id_str())
+ .await
+ .expect("newer visibility"),
+ Some(RadrootsEventVisibility::NotAdmitted)
+ );
+ assert!(
+ store
+ .visible_event(older.id_str())
+ .await
+ .expect("older visible")
+ .is_none()
+ );
+ assert!(
+ store
+ .visible_event(newer.id_str())
+ .await
+ .expect("newer visible")
+ .is_none()
+ );
+ assert!(
+ store
+ .visible_event_head(&coordinate)
+ .await
+ .expect("visible head")
+ .is_none()
+ );
+ let valid_stream = store.valid_stream_after(0, 10).await.expect("valid stream");
+ assert_eq!(valid_stream.len(), 1);
+ assert_eq!(valid_stream[0].raw_event().event_id, older.id_str());
+
+ let older_duplicate = store
+ .ingest_event(RadrootsEventIngest::new(older, 4_700))
+ .await
+ .expect("older duplicate");
+ assert!(older_duplicate.persistence.is_duplicate());
+ assert_eq!(
+ older_duplicate.raw_head_decision,
+ RadrootsRawHeadDecision::SkippedOlder
+ );
+ let newer_duplicate = store
+ .ingest_event(RadrootsEventIngest::new(newer.clone(), 4_800))
+ .await
+ .expect("newer duplicate");
+ assert!(newer_duplicate.persistence.is_duplicate());
+ assert_eq!(
+ newer_duplicate.raw_head_decision,
+ RadrootsRawHeadDecision::SkippedDuplicate
+ );
+
+ sqlx::query(
+ "DELETE FROM event_envelope_head WHERE coordinate_type = 'replaceable' AND kind = ? AND pubkey = ?",
+ )
+ .bind(i64::from(KIND_PROFILE))
+ .bind(FIXTURE_ALICE_PUBLIC_KEY_HEX)
+ .execute(store.pool())
+ .await
+ .expect("remove head");
+ let restored = store
+ .ingest_event(RadrootsEventIngest::new(newer.clone(), 4_900))
+ .await
+ .expect("restore duplicate head");
+ assert!(restored.persistence.is_duplicate());
+ assert_eq!(restored.raw_head_decision, RadrootsRawHeadDecision::Applied);
+ assert_eq!(
+ store
+ .raw_event_head(&coordinate)
+ .await
+ .expect("raw head")
+ .expect("restored head")
+ .event_id,
+ newer.id_str()
+ );
+ }
+
+ #[tokio::test]
async fn event_heads_use_protocol_tie_breaks() {
let mut events = [
signed_event(KIND_PROFILE, 20, Vec::new(), "{\"name\":\"a\"}"),
@@ -3312,16 +4782,13 @@ mod tests {
.await
.expect("second");
let head = store
- .event_head(&profile_coordinate())
+ .raw_event_head(&profile_coordinate())
.await
.expect("head")
.expect("stored head");
- assert_eq!(first.head_decision, RadrootsEventHeadStoreDecision::Applied);
- assert_eq!(
- second.head_decision,
- RadrootsEventHeadStoreDecision::Applied
- );
+ assert_eq!(first.raw_head_decision, RadrootsRawHeadDecision::Applied);
+ assert_eq!(second.raw_head_decision, RadrootsRawHeadDecision::Applied);
assert_eq!(head.event_id, lower.id_str());
let store = RadrootsEventStore::open_memory().await.expect("open");
@@ -3334,14 +4801,14 @@ mod tests {
.await
.expect("second");
let head = store
- .event_head(&profile_coordinate())
+ .raw_event_head(&profile_coordinate())
.await
.expect("head")
.expect("stored head");
assert_eq!(
- second.head_decision,
- RadrootsEventHeadStoreDecision::SkippedSameTimestampHigherEventId
+ second.raw_head_decision,
+ RadrootsRawHeadDecision::SkippedSameTimestampHigherEventId
);
assert_eq!(head.event_id, lower.id_str());
}
@@ -3359,30 +4826,107 @@ mod tests {
.ingest_event(RadrootsEventIngest::new(second.clone(), 6_100))
.await
.expect("second");
- assert!(first_receipt.seq < second_receipt.seq);
+ let first_seq = first_receipt
+ .persistence
+ .sequence()
+ .expect("first sequence");
+ let second_seq = second_receipt
+ .persistence
+ .sequence()
+ .expect("second sequence");
+ assert!(first_seq < second_seq);
let replay = store
- .events_since_cursor("social", 10)
+ .valid_stream_after(0, 10)
.await
.expect("initial replay");
assert_eq!(replay.len(), 2);
- assert_eq!(replay[0].event_id, first.id_str());
- assert_eq!(replay[1].event_id, second.id_str());
+ assert_eq!(replay[0].raw_event().event_id, first.id_str());
+ assert_eq!(replay[1].raw_event().event_id, second.id_str());
+ let first_cursor = RadrootsProjectionCursor {
+ projection_id: "social".to_owned(),
+ projection_version: 1,
+ last_event_seq: first_seq,
+ updated_at_ms: 6_200,
+ };
store
- .update_projection_cursor(&RadrootsProjectionCursor {
- projection_id: "social".to_owned(),
- projection_version: 1,
- last_event_seq: first_receipt.seq,
- updated_at_ms: 6_200,
- })
+ .compare_and_swap_projection_cursor(&first_cursor, None)
.await
.expect("cursor");
+ assert_eq!(
+ store
+ .projection_cursor("social", 1)
+ .await
+ .expect("cursor")
+ .expect("stored cursor"),
+ first_cursor
+ );
+ assert!(matches!(
+ store.projection_cursor("social", 2).await,
+ Err(RadrootsEventStoreError::ProjectionVersionMismatch { .. })
+ ));
let replay = store
- .events_since_cursor("social", 10)
+ .valid_stream_after(first_seq, 10)
.await
.expect("next replay");
assert_eq!(replay.len(), 1);
- assert_eq!(replay[0].event_id, second.id_str());
+ assert_eq!(replay[0].raw_event().event_id, second.id_str());
+
+ let second_cursor = RadrootsProjectionCursor {
+ projection_id: "social".to_owned(),
+ projection_version: 1,
+ last_event_seq: second_seq,
+ updated_at_ms: 6_300,
+ };
+ assert!(matches!(
+ store
+ .compare_and_swap_projection_cursor(&second_cursor, Some(0))
+ .await,
+ Err(RadrootsEventStoreError::ProjectionCursorConflict { .. })
+ ));
+ store
+ .compare_and_swap_projection_cursor(&second_cursor, Some(first_seq))
+ .await
+ .expect("advance cursor");
+ assert!(matches!(
+ store
+ .compare_and_swap_projection_cursor(&first_cursor, Some(second_seq))
+ .await,
+ Err(RadrootsEventStoreError::ProjectionCursorRegression { .. })
+ ));
+ assert!(matches!(
+ store
+ .compare_and_swap_projection_cursor(
+ &RadrootsProjectionCursor {
+ projection_id: "negative".to_owned(),
+ projection_version: 1,
+ last_event_seq: -1,
+ updated_at_ms: 6_400,
+ },
+ None,
+ )
+ .await,
+ Err(RadrootsEventStoreError::InvalidProjectionCursor { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn projection_cursor_reads_reject_negative_persisted_sequences() {
+ let store = RadrootsEventStore::open_memory().await.expect("open");
+ sqlx::query(
+ "INSERT INTO projection_cursor(projection_id, projection_version, last_event_seq, updated_at_ms) VALUES ('corrupt', 1, -1, 1)",
+ )
+ .execute(store.pool())
+ .await
+ .expect("insert corrupt cursor");
+
+ assert!(matches!(
+ store.projection_cursor("corrupt", 1).await,
+ Err(RadrootsEventStoreError::InvalidProjectionCursor {
+ projection_id,
+ value: -1,
+ }) if projection_id == "corrupt"
+ ));
}
#[tokio::test]
@@ -3411,7 +4955,7 @@ mod tests {
payment_state: "not_tracked".to_owned(),
projection_json: "{\"trade_id\":\"fixture\"}".to_owned(),
last_mutation_id: Some(proposal.mutation_id.clone()),
- last_transport_event_seq: Some(proposal_receipt.seq),
+ last_transport_event_seq: proposal_receipt.persistence.sequence(),
updated_at_ms: 7_100,
})
.await
@@ -3463,36 +5007,44 @@ mod tests {
.ingest_event(RadrootsEventIngest::new(event, 10_000 + i64::from(index)))
.await
.expect("ingest");
- assert!(receipt.inserted);
- assert_eq!(
- receipt.verification_status,
- RadrootsEventVerificationStatus::Verified
- );
+ assert!(receipt.persistence.is_inserted());
}
- let replay = store
- .events_since_cursor("smoke", 10_000)
- .await
- .expect("replay");
+ let mut replay = Vec::with_capacity(10_000);
+ let mut after_sequence = 0;
+ loop {
+ let page = store
+ .valid_stream_after(after_sequence, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX)
+ .await
+ .expect("replay");
+ if page.is_empty() {
+ break;
+ }
+ after_sequence = page.last().expect("page").raw_event().seq;
+ replay.extend(page);
+ }
assert_eq!(replay.len(), 10_000);
- assert_eq!(replay[0].seq, 1);
- assert_eq!(replay[9_999].seq, 10_000);
+ assert_eq!(replay[0].raw_event().seq, 1);
+ assert_eq!(replay[9_999].raw_event().seq, 10_000);
store
- .update_projection_cursor(&RadrootsProjectionCursor {
- projection_id: "smoke".to_owned(),
- projection_version: 1,
- last_event_seq: replay[4_999].seq,
- updated_at_ms: 25_000,
- })
+ .compare_and_swap_projection_cursor(
+ &RadrootsProjectionCursor {
+ projection_id: "smoke".to_owned(),
+ projection_version: 1,
+ last_event_seq: replay[4_999].raw_event().seq,
+ updated_at_ms: 25_000,
+ },
+ None,
+ )
.await
.expect("cursor");
let replay = store
- .events_since_cursor("smoke", 10_000)
+ .valid_stream_after(5_000, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX)
.await
.expect("replay after cursor");
- assert_eq!(replay.len(), 5_000);
- assert_eq!(replay[0].seq, 5_001);
- assert_eq!(replay[4_999].seq, 10_000);
+ assert_eq!(replay.len(), 1_000);
+ assert_eq!(replay[0].raw_event().seq, 5_001);
+ assert_eq!(replay[999].raw_event().seq, 6_000);
}
}
diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml
@@ -23,6 +23,7 @@ events = [
"dep:radroots_event_codec",
"radroots_event/std",
"radroots_event/serde",
+ "radroots_event_codec/nostr",
"radroots_event_codec/serde_json",
"radroots_event_codec/std",
]
diff --git a/crates/nostr/src/event_verify.rs b/crates/nostr/src/event_verify.rs
@@ -1,14 +1,9 @@
#![forbid(unsafe_code)]
-use alloc::vec::Vec;
-use core::str::FromStr;
-
-use crate::types::{
- RadrootsNostrEvent as RadrootsNostrRawEvent, RadrootsNostrEventId, RadrootsNostrKind,
- RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp,
-};
-use nostr::secp256k1::schnorr::Signature;
use radroots_event::RadrootsEventEnvelope;
+use radroots_event_codec::verification::{
+ RadrootsNip01VerificationError, verify_event_id, verify_nip01_event,
+};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsNostrEventVerification {
@@ -22,49 +17,38 @@ pub enum RadrootsNostrEventVerification {
pub fn radroots_nostr_verify_event(
event: &RadrootsEventEnvelope,
) -> RadrootsNostrEventVerification {
- let Some(raw_event) = raw_event_from_radroots(event) else {
- return RadrootsNostrEventVerification::MalformedEnvelope;
- };
- if !raw_event.verify_id() {
- return RadrootsNostrEventVerification::IdMismatch;
+ match verify_nip01_event(event.clone()) {
+ Ok(_) => RadrootsNostrEventVerification::Verified,
+ Err(error) => verification_error_status(&error),
}
- if !raw_event.verify_signature() {
- return RadrootsNostrEventVerification::SignatureInvalid;
- }
- RadrootsNostrEventVerification::Verified
}
pub fn radroots_nostr_verify_event_id(
event: &RadrootsEventEnvelope,
) -> RadrootsNostrEventVerification {
- let Some(raw_event) = raw_event_from_radroots(event) else {
- return RadrootsNostrEventVerification::MalformedEnvelope;
- };
- if raw_event.verify_id() {
- RadrootsNostrEventVerification::IdVerified
- } else {
- RadrootsNostrEventVerification::IdMismatch
+ match verify_event_id(event.clone()) {
+ Ok(_) => RadrootsNostrEventVerification::IdVerified,
+ Err(error) => verification_error_status(&error),
}
}
-fn raw_event_from_radroots(event: &RadrootsEventEnvelope) -> Option<RadrootsNostrRawEvent> {
- let id = RadrootsNostrEventId::from_hex(event.id_str()).ok()?;
- let public_key = RadrootsNostrPublicKey::from_hex(event.author_str()).ok()?;
- let kind_u16 = u16::try_from(event.kind_u32()).ok()?;
- let mut tags = Vec::with_capacity(event.tag_slices().len());
- for tag in event.tag_slices() {
- tags.push(RadrootsNostrTag::parse(tag.as_slice().to_vec()).ok()?);
+fn verification_error_status(
+ error: &RadrootsNip01VerificationError,
+) -> RadrootsNostrEventVerification {
+ match error {
+ RadrootsNip01VerificationError::IdMismatch { .. } => {
+ RadrootsNostrEventVerification::IdMismatch
+ }
+ RadrootsNip01VerificationError::SignatureInvalid => {
+ RadrootsNostrEventVerification::SignatureInvalid
+ }
+ RadrootsNip01VerificationError::MalformedEnvelope
+ | RadrootsNip01VerificationError::KindOutOfRange { .. }
+ | RadrootsNip01VerificationError::SignatureVerificationUnavailable => {
+ RadrootsNostrEventVerification::MalformedEnvelope
+ }
+ _ => RadrootsNostrEventVerification::MalformedEnvelope,
}
- let sig = Signature::from_str(event.sig_str()).ok()?;
- Some(RadrootsNostrRawEvent::new(
- id,
- public_key,
- RadrootsNostrTimestamp::from_secs(event.created_at_u64()),
- RadrootsNostrKind::Custom(kind_u16),
- tags,
- event.content().to_owned(),
- sig,
- ))
}
#[cfg(test)]
@@ -73,8 +57,10 @@ mod tests {
use crate::event_convert::radroots_event_from_nostr;
use crate::events::radroots_nostr_build_event_unchecked;
use crate::test_fixtures::FIXTURE_ALICE;
- use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey};
- use radroots_event::{RadrootsEventEnvelopeParts, kinds::KIND_POST};
+ use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp};
+ use radroots_event::{
+ RadrootsEventEnvelopeParts, kinds::KIND_POST, wire::compute_canonical_nip01_event_id,
+ };
fn fixture_keys() -> RadrootsNostrKeys {
let secret_key =
@@ -163,7 +149,7 @@ mod tests {
}
#[test]
- fn reports_malformed_envelope_for_unparseable_wire_fields() {
+ fn out_of_range_kind_precedes_id_mismatch() {
let original = signed_event();
let event = envelope_with(
&original,
@@ -176,5 +162,43 @@ mod tests {
radroots_nostr_verify_event(&event),
RadrootsNostrEventVerification::MalformedEnvelope
);
+ assert_eq!(
+ radroots_nostr_verify_event_id(&event),
+ RadrootsNostrEventVerification::MalformedEnvelope
+ );
+ }
+
+ #[test]
+ fn reports_malformed_envelope_for_id_valid_out_of_range_kind() {
+ let original = signed_event();
+ let kind = u32::from(u16::MAX) + 1;
+ let id = compute_canonical_nip01_event_id(
+ original.author_str(),
+ original.created_at_u64(),
+ kind,
+ &original.tags_as_vec(),
+ original.content(),
+ )
+ .expect("canonical id")
+ .into_string();
+ let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id,
+ author: original.author_str().to_owned(),
+ created_at: original.created_at_u64(),
+ kind,
+ tags: original.tags_as_vec(),
+ content: original.content().to_owned(),
+ sig: original.sig_str().to_owned(),
+ })
+ .expect("envelope");
+
+ assert_eq!(
+ radroots_nostr_verify_event(&event),
+ RadrootsNostrEventVerification::MalformedEnvelope
+ );
+ assert_eq!(
+ radroots_nostr_verify_event_id(&event),
+ RadrootsNostrEventVerification::MalformedEnvelope
+ );
}
}
diff --git a/crates/outbox/README b/crates/outbox/README
@@ -1,3 +1,15 @@
# radroots_outbox
-SQLx-backed deterministic Nostr publish outbox substrate for Rad Roots event workflows.
+SQLx-backed deterministic Nostr publish outbox substrate for Rad Roots event
+workflows.
+
+The generic outbox is a durable queue and rejects every NIP-16 ephemeral event
+before persistence. Live-only events, including NIP-42 relay-auth and NIP-98
+HTTP-auth signatures, must be constructed and sent inside their owning
+transport exchange. This keeps event-store insertion and duplicate outcomes
+unambiguous for every queued event.
+
+`open_pool` validates the declared SQLite backing mode, rejects
+multi-connection in-memory pools in every supported URL form, and configures
+every file-pool connection with foreign-key enforcement and the required busy
+timeout before migrations or writes.
diff --git a/crates/outbox/src/error.rs b/crates/outbox/src/error.rs
@@ -32,6 +32,17 @@ pub enum RadrootsOutboxError {
#[error("trade mutation drafts require the semantic trade mutation outbox API")]
TradeMutationRequiresSemanticOutbox,
+ #[error("ephemeral event kind {kind} cannot enter the durable generic outbox")]
+ EphemeralEventNotQueueable { kind: u32 },
+
+ #[error("in-memory SQLite pools must have exactly one connection; configured {actual}")]
+ UnsafeInMemoryPoolConnectionCount { actual: u32 },
+
+ #[error(
+ "SQLite pool backing does not match file_backed={file_backed}: configured filename {filename}"
+ )]
+ SqlitePoolBackingMismatch { file_backed: bool, filename: String },
+
#[error(
"trade mutation outbox metadata does not match the canonical mutation content: {field}"
)]
@@ -43,6 +54,12 @@ pub enum RadrootsOutboxError {
#[error("Invalid stored enum for {field}: {value}")]
InvalidStoredEnum { field: &'static str, value: String },
+ #[error("invalid stored boolean value {value} for {field}; expected 0 or 1")]
+ InvalidStoredBoolean { field: &'static str, value: i64 },
+
+ #[error("stored event-store ingest state is inconsistent for outbox event {outbox_event_id}")]
+ StoredEventStoreIngestStateInconsistent { outbox_event_id: i64 },
+
#[error("Invalid stored identifier for {field}: {value}")]
InvalidStoredIdentifier { field: &'static str, value: String },
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -14,6 +14,7 @@ use crate::model::{
RadrootsOutboxSignedTradeMutationInput, RadrootsOutboxStatusSummary,
RadrootsOutboxTradeMutationInput,
};
+use radroots_event::RadrootsEventKindClass;
use radroots_event::draft::{
RadrootsEventDraft, RadrootsSignedEvent, validate_signed_nostr_event_matches_draft,
};
@@ -33,11 +34,12 @@ use radroots_transport::{
};
use serde::Serialize;
use sha2::{Digest, Sha256};
-use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
+use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult};
use sqlx::{Row, SqlitePool};
use std::collections::BTreeSet;
use std::path::Path;
use std::str::FromStr;
+use std::time::Duration;
#[derive(Clone)]
pub struct RadrootsOutbox {
@@ -51,7 +53,7 @@ impl RadrootsOutbox {
.max_connections(1)
.connect_with(options)
.await?;
- configure_connection(&pool, false).await?;
+ configure_pool(&pool, false).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -64,7 +66,7 @@ impl RadrootsOutbox {
.max_connections(1)
.connect_with(options)
.await?;
- configure_connection(&pool, true).await?;
+ configure_pool(&pool, true).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -73,7 +75,7 @@ impl RadrootsOutbox {
pool: SqlitePool,
file_backed: bool,
) -> Result<Self, RadrootsOutboxError> {
- configure_connection(&pool, file_backed).await?;
+ configure_pool(&pool, file_backed).await?;
apply_up(&pool).await?;
Ok(Self { pool })
}
@@ -151,7 +153,7 @@ impl RadrootsOutbox {
&self,
input: &RadrootsOutboxSignedOperationInput,
) -> Result<RadrootsOutboxIdempotencyPreflight, RadrootsOutboxError> {
- ensure_not_trade_mutation_draft(&input.draft)?;
+ ensure_generic_outbox_draft_allowed(&input.draft)?;
validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?;
let prepared =
prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?;
@@ -252,7 +254,7 @@ impl RadrootsOutbox {
&self,
input: RadrootsOutboxOperationInput,
) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
- ensure_not_trade_mutation_draft(&input.draft)?;
+ ensure_generic_outbox_draft_allowed(&input.draft)?;
let prepared =
prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?;
let operation_digest = operation_idempotency_digest(
@@ -350,7 +352,7 @@ impl RadrootsOutbox {
&self,
input: RadrootsOutboxSignedOperationInput,
) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
- ensure_not_trade_mutation_draft(&input.draft)?;
+ ensure_generic_outbox_draft_allowed(&input.draft)?;
validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?;
let prepared =
prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?;
@@ -773,7 +775,7 @@ impl RadrootsOutbox {
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
input: RadrootsOutboxSignedOperationInput,
) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
- ensure_not_trade_mutation_draft(&input.draft)?;
+ ensure_generic_outbox_draft_allowed(&input.draft)?;
validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?;
let prepared =
prepare_delivery_plan(input.draft.expected_event_id_str(), &input.delivery_plan)?;
@@ -1231,13 +1233,14 @@ impl RadrootsOutbox {
observed_at_ms,
)
.expect("the static local outbox transport URI must remain valid");
- let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms)
+ let ingest = RadrootsEventIngest::from_signed_event(signed_event.clone(), observed_at_ms)?
.with_observation(observation);
let receipt = event_store.ingest_event(ingest).await?;
+ let event_store_inserted = receipt.persistence.is_inserted();
let changed = sqlx::query(
"UPDATE outbox_event SET event_store_ingested = 1, event_store_inserted = ?, event_store_ingested_at_ms = ?, state = ?, updated_at_ms = ? WHERE outbox_event_id = ? AND claim_token = ?",
)
- .bind(bool_i64(receipt.inserted))
+ .bind(bool_i64(event_store_inserted))
.bind(observed_at_ms)
.bind(RadrootsOutboxEventState::Publishing.as_str())
.bind(observed_at_ms)
@@ -1251,7 +1254,7 @@ impl RadrootsOutbox {
outbox_event_id,
event_id: receipt.event_id,
already_ingested: false,
- event_store_inserted: receipt.inserted,
+ event_store_inserted,
})
}
@@ -1799,20 +1802,52 @@ fn publish_lifecycle_from_plan_evaluation<'a>(
}
}
-async fn configure_connection(
- pool: &SqlitePool,
- file_backed: bool,
-) -> Result<(), RadrootsOutboxError> {
- sqlx::query("PRAGMA foreign_keys = ON")
- .execute(pool)
- .await?;
- sqlx::query("PRAGMA busy_timeout = 5000")
- .execute(pool)
- .await?;
+async fn configure_pool(pool: &SqlitePool, file_backed: bool) -> Result<(), RadrootsOutboxError> {
+ let max_connections = pool.options().get_max_connections();
+ let existing_options = pool.connect_options();
+ let main_filename: String =
+ sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main'")
+ .fetch_one(pool)
+ .await?;
+ let database_is_memory = main_filename.is_empty();
+ if file_backed == database_is_memory {
+ return Err(RadrootsOutboxError::SqlitePoolBackingMismatch {
+ file_backed,
+ filename: main_filename,
+ });
+ }
+ if !file_backed && max_connections != 1 {
+ return Err(RadrootsOutboxError::UnsafeInMemoryPoolConnectionCount {
+ actual: max_connections,
+ });
+ }
+
+ let mut connect_options = existing_options
+ .as_ref()
+ .clone()
+ .foreign_keys(true)
+ .busy_timeout(Duration::from_millis(5_000));
if file_backed {
- sqlx::query("PRAGMA journal_mode = WAL")
- .execute(pool)
+ connect_options = connect_options.journal_mode(SqliteJournalMode::Wal);
+ }
+ pool.set_connect_options(connect_options);
+
+ let mut connections = Vec::with_capacity(max_connections as usize);
+ for _ in 0..max_connections {
+ connections.push(pool.acquire().await?);
+ }
+ for connection in &mut connections {
+ sqlx::query("PRAGMA foreign_keys = ON")
+ .execute(&mut **connection)
+ .await?;
+ sqlx::query("PRAGMA busy_timeout = 5000")
+ .execute(&mut **connection)
.await?;
+ if file_backed {
+ sqlx::query("PRAGMA journal_mode = WAL")
+ .execute(&mut **connection)
+ .await?;
+ }
}
Ok(())
}
@@ -2684,6 +2719,22 @@ fn event_from_row(
});
}
let state = RadrootsOutboxEventState::parse(row.try_get::<String, _>("state")?.as_str())?;
+ let event_store_ingested = stored_bool(
+ "outbox_event.event_store_ingested",
+ row.try_get("event_store_ingested")?,
+ )?;
+ let event_store_inserted = stored_bool(
+ "outbox_event.event_store_inserted",
+ row.try_get("event_store_inserted")?,
+ )?;
+ let event_store_ingested_at_ms: Option<i64> = row.try_get("event_store_ingested_at_ms")?;
+ if (!event_store_ingested && (event_store_inserted || event_store_ingested_at_ms.is_some()))
+ || (event_store_ingested && event_store_ingested_at_ms.is_none())
+ {
+ return Err(
+ RadrootsOutboxError::StoredEventStoreIngestStateInconsistent { outbox_event_id },
+ );
+ }
Ok(RadrootsOutboxEventRecord {
outbox_event_id,
operation_id: row.try_get("operation_id")?,
@@ -2700,9 +2751,9 @@ fn event_from_row(
active_delivery_plan_id: row.try_get("active_delivery_plan_id")?,
next_attempt_after_ms: row.try_get("next_attempt_after_ms")?,
last_error: row.try_get("last_error")?,
- event_store_ingested: row.try_get::<i64, _>("event_store_ingested")? != 0,
- event_store_inserted: row.try_get::<i64, _>("event_store_inserted")? != 0,
- event_store_ingested_at_ms: row.try_get("event_store_ingested_at_ms")?,
+ event_store_ingested,
+ event_store_inserted,
+ event_store_ingested_at_ms,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
@@ -3080,10 +3131,17 @@ fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
-fn ensure_not_trade_mutation_draft(draft: &RadrootsEventDraft) -> Result<(), RadrootsOutboxError> {
+fn ensure_generic_outbox_draft_allowed(
+ draft: &RadrootsEventDraft,
+) -> Result<(), RadrootsOutboxError> {
if TRADE_MUTATION_EVENT_KINDS.contains(&draft.kind_u32()) {
return Err(RadrootsOutboxError::TradeMutationRequiresSemanticOutbox);
}
+ if draft.kind().class() == RadrootsEventKindClass::Ephemeral {
+ return Err(RadrootsOutboxError::EphemeralEventNotQueueable {
+ kind: draft.kind_u32(),
+ });
+ }
Ok(())
}
@@ -3242,6 +3300,14 @@ fn bool_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
+fn stored_bool(field: &'static str, value: i64) -> Result<bool, RadrootsOutboxError> {
+ match value {
+ 0 => Ok(false),
+ 1 => Ok(true),
+ _ => Err(RadrootsOutboxError::InvalidStoredBoolean { field, value }),
+ }
+}
+
fn u32_from_i64(field: &'static str, value: i64) -> Result<u32, RadrootsOutboxError> {
u32::try_from(value).map_err(|_| RadrootsOutboxError::IntegerRange { field, value })
}
@@ -3254,7 +3320,9 @@ mod tests {
RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId,
RadrootsPublicKey, RadrootsTradeId,
};
- use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT};
+ use radroots_event::kinds::{
+ KIND_CLASSIFIED_LISTING, KIND_FOLLOW, KIND_GEOCHAT, KIND_HTTP_AUTH, KIND_RELAY_AUTH,
+ };
use radroots_event::trade::{
RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION,
RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1,
@@ -3291,16 +3359,28 @@ mod tests {
fn generic_draft(expected_pubkey: &str, content: &str) -> RadrootsEventDraft {
RadrootsEventDraft::new(
- "radroots.social.geochat.v1",
- KIND_GEOCHAT,
+ "radroots.social.follow_list.v1",
+ KIND_FOLLOW,
1_700_000_000,
- vec![vec!["t".to_owned(), "soil".to_owned()]],
- content,
+ Vec::new(),
+ format!(r#"{{"label":"{content}"}}"#),
expected_pubkey,
)
.expect("generic draft")
}
+ fn durable_draft(expected_pubkey: &str, label: &str) -> RadrootsEventDraft {
+ RadrootsEventDraft::new(
+ "radroots.social.follow_list.v1",
+ KIND_FOLLOW,
+ 1_700_000_000,
+ Vec::new(),
+ format!(r#"{{"label":"{label}"}}"#),
+ expected_pubkey,
+ )
+ .expect("durable draft")
+ }
+
fn candidate_terms() -> RadrootsTradeCandidateTermsV1 {
RadrootsTradeCandidateTermsV1 {
candidate_id: None,
@@ -4202,6 +4282,109 @@ mod tests {
}
#[tokio::test]
+ async fn open_pool_configures_every_file_connection_and_rejects_unsafe_memory_pools() {
+ let memory_options = SqliteConnectOptions::from_str("sqlite::memory:")
+ .expect("memory options")
+ .foreign_keys(false);
+ let memory_pool = SqlitePoolOptions::new()
+ .max_connections(2)
+ .connect_with(memory_options)
+ .await
+ .expect("memory pool");
+ let memory_error = match RadrootsOutbox::open_pool(memory_pool, false).await {
+ Ok(_) => panic!("multi-connection memory pool must be rejected"),
+ Err(error) => error,
+ };
+ assert!(
+ matches!(
+ memory_error,
+ RadrootsOutboxError::UnsafeInMemoryPoolConnectionCount { actual: 2 }
+ ),
+ "{memory_error:?}"
+ );
+
+ let mislabeled_memory_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(
+ SqliteConnectOptions::from_str("sqlite::memory:")
+ .expect("memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mislabeled memory pool");
+ assert!(matches!(
+ RadrootsOutbox::open_pool(mislabeled_memory_pool, true).await,
+ Err(RadrootsOutboxError::SqlitePoolBackingMismatch {
+ file_backed: true,
+ ..
+ })
+ ));
+ for memory_url in ["sqlite://?mode=memory", "sqlite://named?mode=memory"] {
+ let mode_memory_pool = SqlitePoolOptions::new()
+ .max_connections(2)
+ .connect_with(
+ SqliteConnectOptions::from_str(memory_url)
+ .expect("mode-memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mode-memory pool");
+ assert!(matches!(
+ RadrootsOutbox::open_pool(mode_memory_pool, false).await,
+ Err(RadrootsOutboxError::UnsafeInMemoryPoolConnectionCount { actual: 2 })
+ ));
+
+ let mislabeled_mode_memory_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(
+ SqliteConnectOptions::from_str(memory_url)
+ .expect("mode-memory options")
+ .foreign_keys(false),
+ )
+ .await
+ .expect("mislabeled mode-memory pool");
+ assert!(matches!(
+ RadrootsOutbox::open_pool(mislabeled_mode_memory_pool, true).await,
+ Err(RadrootsOutboxError::SqlitePoolBackingMismatch {
+ file_backed: true,
+ ..
+ })
+ ));
+ }
+
+ let directory = tempfile::tempdir().expect("tempdir");
+ let file_path = directory.path().join("multi-connection-outbox.sqlite");
+ let file_options = SqliteConnectOptions::new()
+ .filename(&file_path)
+ .create_if_missing(true)
+ .foreign_keys(false);
+ let file_pool = SqlitePoolOptions::new()
+ .max_connections(3)
+ .connect_with(file_options)
+ .await
+ .expect("file pool");
+ let outbox = RadrootsOutbox::open_pool(file_pool, true)
+ .await
+ .expect("file outbox");
+ let mut connections = Vec::new();
+ for _ in 0..3 {
+ connections.push(outbox.pool().acquire().await.expect("connection"));
+ }
+ for connection in &mut connections {
+ let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
+ .fetch_one(&mut **connection)
+ .await
+ .expect("foreign keys");
+ let busy_timeout: i64 = sqlx::query_scalar("PRAGMA busy_timeout")
+ .fetch_one(&mut **connection)
+ .await
+ .expect("busy timeout");
+ assert_eq!(foreign_keys, 1);
+ assert_eq!(busy_timeout, 5_000);
+ }
+ }
+
+ #[tokio::test]
async fn defensive_storage_decoding_and_remaining_idempotency_edges_are_explicit() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "defensive storage");
@@ -4462,6 +4645,63 @@ mod tests {
.await
.expect("restore event id");
+ sqlx::query("UPDATE outbox_event SET event_store_ingested = 2 WHERE outbox_event_id = ?")
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt event-store ingested flag");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::InvalidStoredBoolean {
+ field: "outbox_event.event_store_ingested",
+ value: 2,
+ })
+ ));
+ sqlx::query(
+ "UPDATE outbox_event SET event_store_ingested = 1, event_store_inserted = 2 WHERE outbox_event_id = ?",
+ )
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt event-store inserted flag");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::InvalidStoredBoolean {
+ field: "outbox_event.event_store_inserted",
+ value: 2,
+ })
+ ));
+ sqlx::query(
+ "UPDATE outbox_event SET event_store_ingested = 0, event_store_inserted = 1 WHERE outbox_event_id = ?",
+ )
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt event-store cross-field state");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::StoredEventStoreIngestStateInconsistent { .. })
+ ));
+ sqlx::query(
+ "UPDATE outbox_event SET event_store_ingested = 1, event_store_inserted = 0, event_store_ingested_at_ms = NULL WHERE outbox_event_id = ?",
+ )
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt event-store timestamp state");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::StoredEventStoreIngestStateInconsistent { .. })
+ ));
+ sqlx::query(
+ "UPDATE outbox_event SET event_store_ingested = 1, event_store_inserted = 1, event_store_ingested_at_ms = ? WHERE outbox_event_id = ?",
+ )
+ .bind(event.event_store_ingested_at_ms)
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore event-store ingest state");
+
sqlx::query("UPDATE outbox_delivery_plan SET satisfaction_policy = 'invalid' WHERE delivery_plan_id = ?")
.bind(plans[0].delivery_plan_id)
.execute(outbox.pool())
@@ -5155,6 +5395,66 @@ mod tests {
}
#[tokio::test]
+ async fn generic_enqueue_rejects_all_ephemeral_drafts_before_persistence() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+
+ for (contract_id, kind, content) in [
+ ("radroots.social.geochat.v1", KIND_GEOCHAT, "transient"),
+ ("radroots.relay.auth.v1", KIND_RELAY_AUTH, "{}"),
+ ("radroots.http.auth.v1", KIND_HTTP_AUTH, "{}"),
+ ] {
+ let draft = RadrootsEventDraft::new(
+ contract_id,
+ kind,
+ 1_700_000_000,
+ Vec::new(),
+ content,
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ )
+ .expect("ephemeral draft");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed");
+
+ let unsigned_error = outbox
+ .enqueue_operation(operation_input(draft.clone(), 1_000))
+ .await
+ .expect_err("unsigned ephemeral event must not be queued");
+ assert!(matches!(
+ unsigned_error,
+ RadrootsOutboxError::EphemeralEventNotQueueable {
+ kind: rejected_kind
+ } if rejected_kind == kind
+ ));
+
+ let signed_input = signed_operation_input(draft, signed_event, 1_100);
+ let preflight_error = outbox
+ .preflight_signed_operation_idempotency(&signed_input)
+ .await
+ .expect_err("ephemeral preflight must fail");
+ assert!(matches!(
+ preflight_error,
+ RadrootsOutboxError::EphemeralEventNotQueueable {
+ kind: rejected_kind
+ } if rejected_kind == kind
+ ));
+ let signed_error = outbox
+ .enqueue_signed_operation(signed_input)
+ .await
+ .expect_err("signed ephemeral event must not be queued");
+ assert!(matches!(
+ signed_error,
+ RadrootsOutboxError::EphemeralEventNotQueueable {
+ kind: rejected_kind
+ } if rejected_kind == kind
+ ));
+ }
+
+ assert_eq!(table_count(&outbox, "outbox_operations").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0);
+ }
+
+ #[tokio::test]
async fn semantic_trade_mutation_enqueue_persists_metadata_and_deduplicates_by_mutation() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let canonical = canonical_trade_proposal();
@@ -7606,7 +7906,7 @@ mod tests {
let event_store = RadrootsEventStore::open_memory()
.await
.expect("event store");
- let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "local ingest");
+ let draft = durable_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "local ingest");
let receipt = outbox
.enqueue_operation(operation_input(draft, 1_000))
.await
@@ -7639,12 +7939,14 @@ mod tests {
.expect("first ingest");
assert_eq!(first.event_id, signed.id_str());
assert!(!first.already_ingested);
+ assert!(first.event_store_inserted);
let second = outbox
.ingest_signed_event_local(&event_store, receipt.outbox_event_id, "claim-b", 2_300)
.await
.expect("second ingest");
assert!(second.already_ingested);
+ assert!(!second.event_store_inserted);
let observations = event_store
.observations_for_event(signed.id_str())
.await
@@ -7659,5 +7961,73 @@ mod tests {
assert_eq!(observations[0].observation_count, 1);
assert_eq!(observations[0].first_observed_at_ms, 2_200);
assert_eq!(observations[0].last_observed_at_ms, 2_200);
+ assert_eq!(
+ event_store
+ .status_summary()
+ .await
+ .expect("event-store summary")
+ .total_events,
+ 1
+ );
+ }
+
+ #[tokio::test]
+ async fn local_ingest_rejects_an_invalid_signature_without_marking_the_outbox_or_store() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let event_store = RadrootsEventStore::open_memory()
+ .await
+ .expect("event store");
+ let draft = durable_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "invalid local signature");
+ let receipt = outbox
+ .enqueue_operation(operation_input(draft, 1_000))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "claim-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claimed");
+ let signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &claimed.draft).expect("signed");
+ let mut wire = signed.wire().clone();
+ wire.sig = "0".repeat(128);
+ let raw_json = serde_json::to_string(&wire).expect("invalid-signature wire JSON");
+ let invalid_signed = RadrootsSignedEvent::from_wire_verified_id(wire, raw_json)
+ .expect("event id remains valid when only the signature changes");
+ outbox
+ .complete_signing(
+ receipt.outbox_event_id,
+ claimed.claim_token.as_str(),
+ invalid_signed,
+ 1_100,
+ )
+ .await
+ .expect("complete signing");
+ outbox
+ .claim_next_ready_event("publisher", "claim-b", 3_000, 1_100)
+ .await
+ .expect("claim")
+ .expect("publish claim");
+
+ let error = outbox
+ .ingest_signed_event_local(&event_store, receipt.outbox_event_id, "claim-b", 2_200)
+ .await
+ .expect_err("invalid signature must fail before local storage");
+ assert!(matches!(error, RadrootsOutboxError::EventStore(_)));
+
+ let stored_outbox = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("outbox lookup")
+ .expect("outbox event");
+ assert!(!stored_outbox.event_store_ingested);
+ assert_eq!(
+ event_store
+ .status_summary()
+ .await
+ .expect("event-store summary")
+ .total_events,
+ 0
+ );
}
}
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -1,3940 +0,0 @@
-#![forbid(unsafe_code)]
-
-#[cfg(not(feature = "std"))]
-use alloc::{
- string::{String, ToString},
- vec::Vec,
-};
-
-#[cfg(feature = "serde_json")]
-use radroots_event::RadrootsEventEnvelope;
-#[cfg(feature = "event_store")]
-use radroots_event::RadrootsEventEnvelopeParts;
-use radroots_event::ids::{
- RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError,
- RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey,
-};
-#[cfg(feature = "serde_json")]
-use radroots_event::order::RadrootsOrderEventType;
-use radroots_event::order::{
- RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
- RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem,
- RadrootsOrderRequest,
-};
-#[cfg(feature = "event_store")]
-use radroots_event::tags::TAG_D;
-#[cfg(feature = "serde_json")]
-use radroots_event_codec::order::{
- RadrootsOrderEnvelopeParseError, order_cancellation_from_event, order_decision_from_event,
- order_event_context_from_tags, order_request_from_event,
-};
-#[cfg(feature = "event_store")]
-use radroots_event_store::{RadrootsEventStore, RadrootsEventStoreError, RadrootsStoredEvent};
-#[cfg(feature = "serde_json")]
-use sha2::{Digest, Sha256};
-use thiserror::Error;
-
-use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
-use crate::operational_listing::{
- RadrootsPublicClassifiedListingAddress, parse_public_classified_listing_address,
-};
-use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_commitments};
-
-#[derive(Debug, Error)]
-pub enum RadrootsOrderCanonicalizationError {
- #[error("{0} cannot be empty")]
- EmptyField(&'static str),
- #[error("buyer_pubkey must match the requested signer identity")]
- InvalidBuyerSigner,
- #[error("seller_pubkey must match listing_addr seller")]
- InvalidSellerListing,
- #[error("items must contain at least one item")]
- MissingItems,
- #[error("items[{index}].bin_count must be greater than zero")]
- InvalidBinCount { index: usize },
- #[error("seller accepted decisions must contain at least one inventory commitment")]
- MissingInventoryCommitments,
- #[error("inventory_commitments[{index}].bin_count must be greater than zero")]
- InvalidInventoryCommitmentCount { index: usize },
-}
-
-pub const ORDER_EVENT_CONTRACT_IDS: [&str; 3] = [
- "radroots.order.request.v1",
- "radroots.order.decision.v1",
- "radroots.order.cancellation.v1",
-];
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderRequestRecord {
- pub event_id: RadrootsEventId,
- pub author_pubkey: RadrootsPublicKey,
- pub payload: RadrootsOrderRequest,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderDecisionRecord {
- pub event_id: RadrootsEventId,
- pub author_pubkey: RadrootsPublicKey,
- pub counterparty_pubkey: RadrootsPublicKey,
- pub root_event_id: RadrootsEventId,
- pub prev_event_id: RadrootsEventId,
- pub payload: RadrootsOrderDecision,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderCancellationRecord {
- pub event_id: RadrootsEventId,
- pub author_pubkey: RadrootsPublicKey,
- pub counterparty_pubkey: RadrootsPublicKey,
- pub root_event_id: RadrootsEventId,
- pub prev_event_id: RadrootsEventId,
- pub payload: RadrootsOrderCancellation,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsOrderEventRecord {
- Request(RadrootsOrderRequestRecord),
- Decision(RadrootsOrderDecisionRecord),
- Cancellation(RadrootsOrderCancellationRecord),
-}
-
-impl RadrootsOrderEventRecord {
- pub fn event_id(&self) -> &RadrootsEventId {
- match self {
- Self::Request(record) => &record.event_id,
- Self::Decision(record) => &record.event_id,
- Self::Cancellation(record) => &record.event_id,
- }
- }
-
- pub fn order_id(&self) -> &RadrootsOrderId {
- match self {
- Self::Request(record) => &record.payload.order_id,
- Self::Decision(record) => &record.payload.order_id,
- Self::Cancellation(record) => &record.payload.order_id,
- }
- }
-}
-
-#[cfg(feature = "serde_json")]
-#[derive(Debug, Error)]
-pub enum RadrootsOrderEventDecodeError {
- #[error("unsupported order event kind: {kind}")]
- UnsupportedKind { kind: u32 },
- #[error("invalid order event id: {0}")]
- InvalidEventId(RadrootsIdParseError),
- #[error("invalid order event author: {0}")]
- InvalidAuthor(RadrootsIdParseError),
- #[error("order event context is missing root event id")]
- MissingRootEventId,
- #[error("order event context is missing previous event id")]
- MissingPreviousEventId,
- #[error("{0}")]
- Envelope(#[from] RadrootsOrderEnvelopeParseError),
-}
-
-#[cfg(feature = "serde_json")]
-pub fn order_event_record_from_event(
- event: &RadrootsEventEnvelope,
-) -> Result<RadrootsOrderEventRecord, RadrootsOrderEventDecodeError> {
- let message_type = RadrootsOrderEventType::from_kind(event.kind_u32()).ok_or(
- RadrootsOrderEventDecodeError::UnsupportedKind {
- kind: event.kind_u32(),
- },
- )?;
- let tags = event.tags_as_vec();
- let context = order_event_context_from_tags(message_type, &tags)?;
- let event_id = event.id().clone();
- let author_pubkey = event.author().clone();
-
- match message_type {
- RadrootsOrderEventType::OrderRequested => {
- let envelope = order_request_from_event(event)?;
- Ok(RadrootsOrderEventRecord::Request(
- RadrootsOrderRequestRecord {
- event_id,
- author_pubkey,
- payload: envelope.payload,
- },
- ))
- }
- RadrootsOrderEventType::OrderDecision => {
- let envelope = order_decision_from_event(event)?;
- Ok(RadrootsOrderEventRecord::Decision(
- RadrootsOrderDecisionRecord {
- event_id,
- author_pubkey,
- counterparty_pubkey: context.counterparty_pubkey.clone(),
- root_event_id: require_context_root_event_id(&context)?,
- prev_event_id: require_context_prev_event_id(&context)?,
- payload: envelope.payload,
- },
- ))
- }
- RadrootsOrderEventType::OrderCancelled => {
- let envelope = order_cancellation_from_event(event)?;
- Ok(RadrootsOrderEventRecord::Cancellation(
- RadrootsOrderCancellationRecord {
- event_id,
- author_pubkey,
- counterparty_pubkey: context.counterparty_pubkey.clone(),
- root_event_id: require_context_root_event_id(&context)?,
- prev_event_id: require_context_prev_event_id(&context)?,
- payload: envelope.payload,
- },
- ))
- }
- }
-}
-
-#[cfg(feature = "event_store")]
-#[derive(Debug, Error)]
-pub enum RadrootsOrderStoreQueryError {
- #[error("{0}")]
- Store(#[from] RadrootsEventStoreError),
- #[error("{0}")]
- Projection(#[from] crate::projection::RadrootsTradeProjectionError),
- #[error("stored order event {event_id} contains invalid tags_json: {source}")]
- InvalidStoredTagsJson {
- event_id: String,
- source: serde_json::Error,
- },
- #[error("stored order event {event_id} contains invalid envelope fields: {source}")]
- InvalidStoredEnvelope {
- event_id: String,
- source: radroots_event::RadrootsEventEnvelopeError,
- },
- #[error("stored order event {event_id} could not decode as an order record: {source}")]
- Decode {
- event_id: String,
- source: RadrootsOrderEventDecodeError,
- },
-}
-
-#[cfg(feature = "event_store")]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderProjectionQueryResult {
- pub projection: RadrootsOrderProjection,
- pub event_count: usize,
- pub limit_applied: u32,
- pub event_ids: Vec<RadrootsEventId>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-#[allow(clippy::large_enum_variant)]
-pub enum RadrootsTradeLocatorProjectionResolution {
- Missing {
- locator: RadrootsTradeLocator,
- },
- Ambiguous {
- locator: RadrootsTradeLocator,
- candidates: Vec<RadrootsTradeLocatorCandidate>,
- },
- Projected {
- locator: RadrootsTradeLocator,
- projection: RadrootsOrderProjection,
- },
-}
-
-#[cfg(feature = "event_store")]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsTradeLocatorProjectionQueryResult {
- pub resolution: RadrootsTradeLocatorProjectionResolution,
- pub event_count: usize,
- pub limit_applied: u32,
- pub event_ids: Vec<RadrootsEventId>,
-}
-
-#[cfg(feature = "event_store")]
-pub async fn order_events_for_order_id(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
-) -> Result<Vec<RadrootsOrderEventRecord>, RadrootsOrderStoreQueryError> {
- let stored_events = store
- .events_by_contract_and_tag(&ORDER_EVENT_CONTRACT_IDS, TAG_D, order_id.as_str(), limit)
- .await?;
- let mut records = Vec::with_capacity(stored_events.len());
- for stored_event in stored_events {
- let event = stored_order_event_to_nostr_event(&stored_event)?;
- let record = order_event_record_from_event(&event).map_err(|source| {
- RadrootsOrderStoreQueryError::Decode {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- if record.order_id() == order_id {
- records.push(record);
- }
- }
- Ok(records)
-}
-
-#[cfg(feature = "event_store")]
-pub async fn order_projection_for_order_id(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
-) -> Result<RadrootsOrderProjection, RadrootsOrderStoreQueryError> {
- order_projection_query_for_order_id(store, order_id, limit)
- .await
- .map(|result| result.projection)
-}
-
-#[cfg(feature = "event_store")]
-pub async fn order_projection_query_for_order_id(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
-) -> Result<RadrootsOrderProjectionQueryResult, RadrootsOrderStoreQueryError> {
- crate::projection::trade_projection_query_for_order_id(store, order_id, limit)
- .await
- .map_err(Into::into)
-}
-
-#[cfg(feature = "event_store")]
-pub async fn order_projection_query_for_trade_locator(
- store: &RadrootsEventStore,
- locator: &RadrootsTradeLocator,
- limit: u32,
-) -> Result<RadrootsTradeLocatorProjectionQueryResult, RadrootsOrderStoreQueryError> {
- crate::projection::trade_projection_query_for_trade_locator(store, locator, limit)
- .await
- .map_err(Into::into)
-}
-
-#[cfg(feature = "event_store")]
-fn stored_order_event_to_nostr_event(
- stored_event: &RadrootsStoredEvent,
-) -> Result<RadrootsEventEnvelope, RadrootsOrderStoreQueryError> {
- let tags = serde_json::from_str(&stored_event.tags_json).map_err(|source| {
- RadrootsOrderStoreQueryError::InvalidStoredTagsJson {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: stored_event.event_id.clone(),
- author: stored_event.pubkey.clone(),
- created_at: stored_event.created_at,
- kind: stored_event.kind,
- tags,
- content: stored_event.content.clone(),
- sig: stored_event.sig.clone(),
- })
- .map_err(
- |source| RadrootsOrderStoreQueryError::InvalidStoredEnvelope {
- event_id: stored_event.event_id.clone(),
- source,
- },
- )
-}
-
-#[cfg(feature = "serde_json")]
-fn require_context_root_event_id(
- context: &radroots_event_codec::order::RadrootsOrderEventContext,
-) -> Result<RadrootsEventId, RadrootsOrderEventDecodeError> {
- context
- .root_event_id
- .clone()
- .ok_or(RadrootsOrderEventDecodeError::MissingRootEventId)
-}
-
-#[cfg(feature = "serde_json")]
-fn require_context_prev_event_id(
- context: &radroots_event_codec::order::RadrootsOrderEventContext,
-) -> Result<RadrootsEventId, RadrootsOrderEventDecodeError> {
- context
- .prev_event_id
- .clone()
- .ok_or(RadrootsOrderEventDecodeError::MissingPreviousEventId)
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsOrderIssue {
- MissingRequest,
- MultipleRequests {
- event_ids: Vec<RadrootsEventId>,
- },
- RequestPayloadInvalid {
- event_id: RadrootsEventId,
- },
- RequestOrderIdMismatch {
- event_id: RadrootsEventId,
- },
- RequestAuthorMismatch {
- event_id: RadrootsEventId,
- },
- RequestSellerListingMismatch {
- event_id: RadrootsEventId,
- },
- DecisionPayloadInvalid {
- event_id: RadrootsEventId,
- },
- DecisionOrderIdMismatch {
- event_id: RadrootsEventId,
- },
- DecisionAuthorMismatch {
- event_id: RadrootsEventId,
- },
- DecisionCounterpartyMismatch {
- event_id: RadrootsEventId,
- },
- DecisionBuyerMismatch {
- event_id: RadrootsEventId,
- },
- DecisionSellerMismatch {
- event_id: RadrootsEventId,
- },
- DecisionListingMismatch {
- event_id: RadrootsEventId,
- },
- DecisionRootMismatch {
- event_id: RadrootsEventId,
- },
- DecisionPreviousMismatch {
- event_id: RadrootsEventId,
- },
- DecisionMissingInventoryCommitments {
- event_id: RadrootsEventId,
- },
- DecisionInventoryCommitmentMismatch {
- event_id: RadrootsEventId,
- },
- DecisionMissingReason {
- event_id: RadrootsEventId,
- },
- ConflictingDecisions {
- event_ids: Vec<RadrootsEventId>,
- },
- CancellationWithoutCancellableOrder {
- event_id: RadrootsEventId,
- },
- CancellationPayloadInvalid {
- event_id: RadrootsEventId,
- },
- CancellationOrderIdMismatch {
- event_id: RadrootsEventId,
- },
- CancellationAuthorMismatch {
- event_id: RadrootsEventId,
- },
- CancellationCounterpartyMismatch {
- event_id: RadrootsEventId,
- },
- CancellationBuyerMismatch {
- event_id: RadrootsEventId,
- },
- CancellationSellerMismatch {
- event_id: RadrootsEventId,
- },
- CancellationListingMismatch {
- event_id: RadrootsEventId,
- },
- CancellationRootMismatch {
- event_id: RadrootsEventId,
- },
- CancellationPreviousMismatch {
- event_id: RadrootsEventId,
- },
- ForkedLifecycle {
- event_ids: Vec<RadrootsEventId>,
- },
- ValidationReceiptWithoutPendingAgreement {
- event_id: RadrootsEventId,
- },
- ValidationReceiptOrderIdMismatch {
- event_id: RadrootsEventId,
- },
- ValidationReceiptTypeMismatch {
- event_id: RadrootsEventId,
- },
- ValidationReceiptRootMismatch {
- event_id: RadrootsEventId,
- },
- ValidationReceiptTargetMismatch {
- event_id: RadrootsEventId,
- },
- ValidationReceiptListingMismatch {
- event_id: RadrootsEventId,
- },
- ConflictingValidationReceipts {
- event_ids: Vec<RadrootsEventId>,
- },
- DeterministicValidationFailure {
- event_id: RadrootsEventId,
- reason: String,
- },
- StaleListingEvent {
- expected_event_id: RadrootsEventId,
- current_event_id: RadrootsEventId,
- },
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderProjection {
- pub order_id: RadrootsOrderId,
- pub status: RadrootsTradeWorkflowState,
- pub request_event_id: Option<RadrootsEventId>,
- pub decision_event_id: Option<RadrootsEventId>,
- pub cancellation_event_id: Option<RadrootsEventId>,
- pub validation_receipt_event_id: Option<RadrootsEventId>,
- pub lifecycle_terminal: bool,
- pub economics: Option<RadrootsOrderEconomics>,
- pub agreement_event_id: Option<RadrootsEventId>,
- pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub listing_addr: Option<RadrootsClassifiedListingAddress>,
- pub buyer_pubkey: Option<RadrootsPublicKey>,
- pub seller_pubkey: Option<RadrootsPublicKey>,
- pub last_event_id: Option<RadrootsEventId>,
- pub issues: Vec<RadrootsOrderIssue>,
-}
-
-impl RadrootsOrderProjection {
- pub(crate) fn finish_issue_state(&mut self) {
- self.issues.sort_by(order_issue_sort_key);
- if self.last_event_id.is_none() {
- self.last_event_id = projection_issue_event_ids(&self.issues).into_iter().last();
- }
- }
-}
-
-#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
-#[cfg_attr(feature = "dto-bindgen", dto(export))]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderWorkflowProjection {
- pub order_id: RadrootsOrderId,
- pub status: RadrootsTradeWorkflowState,
- pub request_event_id: Option<RadrootsEventId>,
- pub decision_event_id: Option<RadrootsEventId>,
- pub cancellation_event_id: Option<RadrootsEventId>,
- pub validation_receipt_event_id: Option<RadrootsEventId>,
- pub lifecycle_terminal: bool,
- pub economics: Option<RadrootsOrderEconomics>,
- pub agreement_event_id: Option<RadrootsEventId>,
- pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>,
- pub listing_addr: Option<RadrootsClassifiedListingAddress>,
- pub buyer_pubkey: Option<RadrootsPublicKey>,
- pub seller_pubkey: Option<RadrootsPublicKey>,
- pub last_event_id: Option<RadrootsEventId>,
- pub issues: Vec<RadrootsOrderIssue>,
-}
-
-impl From<RadrootsOrderProjection> for RadrootsOrderWorkflowProjection {
- fn from(projection: RadrootsOrderProjection) -> Self {
- Self {
- order_id: projection.order_id,
- status: projection.status,
- request_event_id: projection.request_event_id,
- decision_event_id: projection.decision_event_id,
- cancellation_event_id: projection.cancellation_event_id,
- validation_receipt_event_id: projection.validation_receipt_event_id,
- lifecycle_terminal: projection.lifecycle_terminal,
- economics: projection.economics,
- agreement_event_id: projection.agreement_event_id,
- pending_inventory_reservations: projection.pending_inventory_reservations,
- committed_inventory_reservations: projection.committed_inventory_reservations,
- listing_addr: projection.listing_addr,
- buyer_pubkey: projection.buyer_pubkey,
- seller_pubkey: projection.seller_pubkey,
- last_event_id: projection.last_event_id,
- issues: projection.issues,
- }
- }
-}
-
-impl From<&RadrootsOrderProjection> for RadrootsOrderWorkflowProjection {
- fn from(projection: &RadrootsOrderProjection) -> Self {
- projection.clone().into()
- }
-}
-
-#[cfg(feature = "serde_json")]
-#[derive(Debug, Error)]
-pub enum RadrootsOrderEconomicsDigestError {
- #[error("failed to serialize order economics for digest: {0}")]
- Serialize(#[from] serde_json::Error),
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingInventoryBinAvailability {
- pub bin_id: RadrootsInventoryBinId,
- pub available_count: u64,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingInventoryOrderReservation {
- pub order_id: RadrootsOrderId,
- pub agreement_event_id: RadrootsEventId,
- pub bin_count: u64,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingInventoryBinAccounting {
- pub bin_id: RadrootsInventoryBinId,
- pub available_count: u64,
- pub pending_reserved_count: u64,
- pub committed_reserved_count: u64,
- pub remaining_count: u64,
- pub over_reserved: bool,
- pub pending_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>,
- pub committed_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsOperationalListingInventoryAccountingIssue {
- InvalidOrder {
- order_id: RadrootsOrderId,
- event_ids: Vec<RadrootsEventId>,
- },
- ArithmeticOverflow {
- bin_id: RadrootsInventoryBinId,
- event_ids: Vec<RadrootsEventId>,
- },
- UnknownInventoryBin {
- bin_id: RadrootsInventoryBinId,
- event_ids: Vec<RadrootsEventId>,
- },
- OverReserved {
- bin_id: RadrootsInventoryBinId,
- available_count: u64,
- reserved_count: u64,
- event_ids: Vec<RadrootsEventId>,
- },
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingInventoryAccountingProjection {
- pub listing_addr: RadrootsClassifiedListingAddress,
- pub listing_event_id: RadrootsEventId,
- pub bins: Vec<RadrootsOperationalListingInventoryBinAccounting>,
- pub declined_order_ids: Vec<RadrootsOrderId>,
- pub cancelled_order_ids: Vec<RadrootsOrderId>,
- pub invalid_event_ids: Vec<RadrootsEventId>,
- pub issues: Vec<RadrootsOperationalListingInventoryAccountingIssue>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOrderReductionInputs<I, J, K> {
- pub requests: I,
- pub decisions: J,
- pub cancellations: K,
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct RadrootsGroupedOrderEventRecords {
- pub requests: Vec<RadrootsOrderRequestRecord>,
- pub decisions: Vec<RadrootsOrderDecisionRecord>,
- pub cancellations: Vec<RadrootsOrderCancellationRecord>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub(crate) enum RadrootsTradeLocatorGroupedOrderEventRecordsResolution {
- Missing {
- locator: RadrootsTradeLocator,
- },
- Ambiguous {
- locator: RadrootsTradeLocator,
- candidates: Vec<RadrootsTradeLocatorCandidate>,
- },
- Matched {
- locator: RadrootsTradeLocator,
- records: RadrootsGroupedOrderEventRecords,
- },
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L> {
- pub bins: I,
- pub requests: J,
- pub decisions: K,
- pub cancellations: L,
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-struct RadrootsOperationalListingInventoryAccountingRecords {
- bins: Vec<RadrootsOperationalListingInventoryBinAvailability>,
- requests: Vec<RadrootsOrderRequestRecord>,
- decisions: Vec<RadrootsOrderDecisionRecord>,
- cancellations: Vec<RadrootsOrderCancellationRecord>,
-}
-
-pub fn reduce_order_events<I, J, K>(
- order_id: &RadrootsOrderId,
- inputs: RadrootsOrderReductionInputs<I, J, K>,
-) -> RadrootsOrderProjection
-where
- I: IntoIterator<Item = RadrootsOrderRequestRecord>,
- J: IntoIterator<Item = RadrootsOrderDecisionRecord>,
- K: IntoIterator<Item = RadrootsOrderCancellationRecord>,
-{
- reduce_grouped_order_event_records(
- order_id,
- RadrootsGroupedOrderEventRecords {
- requests: inputs.requests.into_iter().collect(),
- decisions: inputs.decisions.into_iter().collect(),
- cancellations: inputs.cancellations.into_iter().collect(),
- },
- )
-}
-
-pub fn reduce_order_event_records<I>(
- order_id: &RadrootsOrderId,
- records: I,
-) -> RadrootsOrderProjection
-where
- I: IntoIterator<Item = RadrootsOrderEventRecord>,
-{
- reduce_grouped_order_event_records(order_id, group_order_event_records(records))
-}
-
-fn group_order_event_records<I>(records: I) -> RadrootsGroupedOrderEventRecords
-where
- I: IntoIterator<Item = RadrootsOrderEventRecord>,
-{
- let mut seen_event_ids = Vec::new();
- let mut grouped = RadrootsGroupedOrderEventRecords::default();
-
- for record in records {
- let event_id = record.event_id().clone();
- if seen_event_ids.iter().any(|seen| seen == &event_id) {
- continue;
- }
- seen_event_ids.push(event_id);
- match record {
- RadrootsOrderEventRecord::Request(record) => grouped.requests.push(record),
- RadrootsOrderEventRecord::Decision(record) => grouped.decisions.push(record),
- RadrootsOrderEventRecord::Cancellation(record) => grouped.cancellations.push(record),
- }
- }
-
- grouped
-}
-
-pub fn reduce_order_event_records_for_trade_locator<I>(
- locator: &RadrootsTradeLocator,
- records: I,
-) -> RadrootsTradeLocatorProjectionResolution
-where
- I: IntoIterator<Item = RadrootsOrderEventRecord>,
-{
- match grouped_order_event_records_for_trade_locator(locator, group_order_event_records(records))
- {
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing { locator } => {
- RadrootsTradeLocatorProjectionResolution::Missing { locator }
- }
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
- locator,
- candidates,
- } => RadrootsTradeLocatorProjectionResolution::Ambiguous {
- locator,
- candidates,
- },
- RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched { locator, records } => {
- let projection = reduce_grouped_order_event_records(locator.order_id(), records);
- RadrootsTradeLocatorProjectionResolution::Projected {
- locator,
- projection,
- }
- }
- }
-}
-
-pub(crate) fn grouped_order_event_records_for_trade_locator(
- locator: &RadrootsTradeLocator,
- records: RadrootsGroupedOrderEventRecords,
-) -> RadrootsTradeLocatorGroupedOrderEventRecordsResolution {
- let candidates = trade_locator_candidates(locator, &records);
- match candidates.as_slice() {
- [] => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing {
- locator: locator.clone(),
- },
- [candidate] => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched {
- locator: candidate.locator(),
- records: filter_grouped_order_records_for_trade_candidate(records, candidate),
- },
- _ => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
- locator: locator.clone(),
- candidates,
- },
- }
-}
-
-fn trade_locator_candidates(
- locator: &RadrootsTradeLocator,
- records: &RadrootsGroupedOrderEventRecords,
-) -> Vec<RadrootsTradeLocatorCandidate> {
- let mut candidates = records
- .requests
- .iter()
- .filter(|request| request_matches_trade_locator(locator, request))
- .map(|request| RadrootsTradeLocatorCandidate {
- trade_id: request.payload.order_id.clone().into(),
- root_event_id: request.event_id.clone(),
- listing_addr: request.payload.listing_addr.clone(),
- buyer_pubkey: request.payload.buyer_pubkey.clone(),
- seller_pubkey: request.payload.seller_pubkey.clone(),
- })
- .collect::<Vec<_>>();
- candidates.sort_by(trade_locator_candidate_order);
- candidates.dedup_by(|left, right| left.root_event_id == right.root_event_id);
- candidates
-}
-
-fn trade_locator_candidate_order(
- left: &RadrootsTradeLocatorCandidate,
- right: &RadrootsTradeLocatorCandidate,
-) -> core::cmp::Ordering {
- left.root_event_id
- .cmp(&right.root_event_id)
- .then_with(|| left.trade_id.cmp(&right.trade_id))
- .then_with(|| left.listing_addr.cmp(&right.listing_addr))
- .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey))
- .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey))
-}
-
-fn request_matches_trade_locator(
- locator: &RadrootsTradeLocator,
- request: &RadrootsOrderRequestRecord,
-) -> bool {
- request.payload.order_id == *locator.order_id()
- && optional_match(locator.root_event_id.as_ref(), &request.event_id)
- && optional_match(locator.listing_addr.as_ref(), &request.payload.listing_addr)
- && optional_match(locator.buyer_pubkey.as_ref(), &request.payload.buyer_pubkey)
- && optional_match(
- locator.seller_pubkey.as_ref(),
- &request.payload.seller_pubkey,
- )
-}
-
-fn optional_match<T>(expected: Option<&T>, actual: &T) -> bool
-where
- T: PartialEq,
-{
- expected.map(|expected| expected == actual).unwrap_or(true)
-}
-
-fn filter_grouped_order_records_for_trade_candidate(
- records: RadrootsGroupedOrderEventRecords,
- candidate: &RadrootsTradeLocatorCandidate,
-) -> RadrootsGroupedOrderEventRecords {
- RadrootsGroupedOrderEventRecords {
- requests: records
- .requests
- .into_iter()
- .filter(|request| {
- request.payload.order_id == *candidate.trade_id.as_order_id()
- && request.event_id == candidate.root_event_id
- })
- .collect(),
- decisions: records
- .decisions
- .into_iter()
- .filter(|decision| {
- decision.payload.order_id == *candidate.trade_id.as_order_id()
- && decision.root_event_id == candidate.root_event_id
- })
- .collect(),
- cancellations: records
- .cancellations
- .into_iter()
- .filter(|cancellation| {
- cancellation.payload.order_id == *candidate.trade_id.as_order_id()
- && cancellation.root_event_id == candidate.root_event_id
- })
- .collect(),
- }
-}
-
-pub(crate) fn reduce_grouped_order_event_records(
- order_id: &RadrootsOrderId,
- records: RadrootsGroupedOrderEventRecords,
-) -> RadrootsOrderProjection {
- let requests = unique_request_records(records.requests);
- let decisions = unique_decision_records(records.decisions);
- let cancellations = unique_cancellation_records(records.cancellations);
- if requests.is_empty() && decisions.is_empty() && cancellations.is_empty() {
- return empty_projection(order_id, RadrootsTradeWorkflowState::Missing, false);
- }
-
- let mut issues = Vec::new();
- let mut valid_requests = Vec::new();
- for request in requests {
- if validate_order_request_record(order_id, &request, &mut issues) {
- valid_requests.push(request);
- }
- }
-
- if valid_requests.len() > 1 {
- let mut event_ids = valid_requests
- .iter()
- .map(|request| request.event_id.clone())
- .collect::<Vec<_>>();
- sort_and_dedup_values(&mut event_ids);
- issues.push(RadrootsOrderIssue::MultipleRequests { event_ids });
- }
-
- let Some(request) = valid_requests.first() else {
- if !decisions.is_empty() || !cancellations.is_empty() {
- issues.push(RadrootsOrderIssue::MissingRequest);
- }
- return invalid_projection(order_id, None, issues);
- };
-
- if valid_requests.len() > 1 {
- return invalid_projection(order_id, Some(request), issues);
- }
-
- let mut valid_decisions = Vec::new();
- for decision in decisions {
- if validate_order_decision_record(request, &decision, &mut issues) {
- valid_decisions.push(decision);
- }
- }
-
- let mut valid_cancellations = Vec::new();
- for cancellation in cancellations {
- if validate_order_cancellation_record(request, &cancellation, &mut issues) {
- valid_cancellations.push(cancellation);
- }
- }
-
- if !issues.is_empty() {
- return invalid_projection(order_id, Some(request), issues);
- }
-
- if valid_cancellations.len() > 1 {
- let mut event_ids = valid_cancellations
- .iter()
- .map(|cancellation| cancellation.event_id.clone())
- .collect::<Vec<_>>();
- sort_and_dedup_values(&mut event_ids);
- return invalid_projection(
- order_id,
- Some(request),
- vec![RadrootsOrderIssue::ForkedLifecycle { event_ids }],
- );
- }
-
- if let Some(cancellation) = valid_cancellations.first() {
- return cancelled_projection(order_id, request, cancellation, &valid_decisions);
- }
-
- match valid_decisions.len() {
- 0 => request_projection(order_id, request, RadrootsTradeWorkflowState::Requested),
- 1 => decided_projection(order_id, request, &valid_decisions[0]),
- _ => {
- let mut event_ids = valid_decisions
- .iter()
- .map(|decision| decision.event_id.clone())
- .collect::<Vec<_>>();
- sort_and_dedup_values(&mut event_ids);
- invalid_projection(
- order_id,
- Some(request),
- vec![RadrootsOrderIssue::ConflictingDecisions { event_ids }],
- )
- }
- }
-}
-
-pub fn reduce_operational_listing_inventory_accounting<I, J, K, L>(
- listing_addr: &RadrootsClassifiedListingAddress,
- listing_event_id: &RadrootsEventId,
- inputs: RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L>,
-) -> RadrootsOperationalListingInventoryAccountingProjection
-where
- I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>,
- J: IntoIterator<Item = RadrootsOrderRequestRecord>,
- K: IntoIterator<Item = RadrootsOrderDecisionRecord>,
- L: IntoIterator<Item = RadrootsOrderCancellationRecord>,
-{
- reduce_operational_listing_inventory_accounting_records(
- listing_addr,
- listing_event_id,
- RadrootsOperationalListingInventoryAccountingRecords {
- bins: inputs.bins.into_iter().collect(),
- requests: inputs.requests.into_iter().collect(),
- decisions: inputs.decisions.into_iter().collect(),
- cancellations: inputs.cancellations.into_iter().collect(),
- },
- )
-}
-
-fn reduce_operational_listing_inventory_accounting_records(
- listing_addr: &RadrootsClassifiedListingAddress,
- listing_event_id: &RadrootsEventId,
- records: RadrootsOperationalListingInventoryAccountingRecords,
-) -> RadrootsOperationalListingInventoryAccountingProjection {
- let (mut bins, mut issues) = normalized_listing_inventory_bins(records.bins);
- let requests = unique_request_records(records.requests)
- .into_iter()
- .filter(|request| request.payload.listing_addr.as_str() == listing_addr.as_str())
- .collect::<Vec<_>>();
- let decisions = unique_decision_records(records.decisions)
- .into_iter()
- .filter(|decision| decision.payload.listing_addr.as_str() == listing_addr.as_str())
- .collect::<Vec<_>>();
- let cancellations = unique_cancellation_records(records.cancellations)
- .into_iter()
- .filter(|cancellation| cancellation.payload.listing_addr.as_str() == listing_addr.as_str())
- .collect::<Vec<_>>();
- let mut order_ids = listing_order_ids(&requests, &decisions, &cancellations);
- let mut declined_order_ids = Vec::new();
- let mut cancelled_order_ids = Vec::new();
- let mut invalid_event_ids = Vec::new();
-
- for order_id in order_ids.drain(..) {
- let order_requests = requests
- .iter()
- .filter(|request| request.payload.order_id == order_id)
- .cloned()
- .collect::<Vec<_>>();
- let order_decisions = decisions
- .iter()
- .filter(|decision| decision.payload.order_id == order_id)
- .cloned()
- .collect::<Vec<_>>();
- let order_cancellations = cancellations
- .iter()
- .filter(|cancellation| cancellation.payload.order_id == order_id)
- .cloned()
- .collect::<Vec<_>>();
- let projection = reduce_order_events(
- &order_id,
- RadrootsOrderReductionInputs {
- requests: order_requests.clone(),
- decisions: order_decisions.clone(),
- cancellations: order_cancellations.clone(),
- },
- );
- match projection.status {
- RadrootsTradeWorkflowState::AgreedPendingValidation => {
- for (agreement_event_id, economics) in projection
- .agreement_event_id
- .iter()
- .zip(projection.economics.iter())
- {
- add_pending_inventory_reservations_from_economics(
- &mut bins,
- &order_id,
- agreement_event_id,
- economics,
- &mut issues,
- );
- }
- }
- RadrootsTradeWorkflowState::Cancelled => cancelled_order_ids.push(order_id),
- RadrootsTradeWorkflowState::Declined => declined_order_ids.push(order_id),
- RadrootsTradeWorkflowState::Invalid => {
- let mut event_ids = projection_issue_event_ids(&projection.issues);
- if event_ids.is_empty() {
- event_ids = fallback_order_event_ids(
- &order_requests,
- &order_decisions,
- &order_cancellations,
- );
- }
- invalid_event_ids.extend(event_ids.iter().cloned());
- issues.push(
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
- order_id,
- event_ids,
- },
- );
- }
- RadrootsTradeWorkflowState::Missing
- | RadrootsTradeWorkflowState::Requested
- | RadrootsTradeWorkflowState::Committed => {}
- RadrootsTradeWorkflowState::ValidationExpired => {}
- }
- }
-
- sort_and_dedup_values(&mut declined_order_ids);
- sort_and_dedup_values(&mut cancelled_order_ids);
- sort_and_dedup_values(&mut invalid_event_ids);
- finish_inventory_accounting_bins(&mut bins, &mut issues);
- issues.sort_by(inventory_issue_sort_key);
- RadrootsOperationalListingInventoryAccountingProjection {
- listing_addr: listing_addr.clone(),
- listing_event_id: listing_event_id.clone(),
- bins,
- declined_order_ids,
- cancelled_order_ids,
- invalid_event_ids,
- issues,
- }
-}
-
-fn fallback_order_event_ids(
- requests: &[RadrootsOrderRequestRecord],
- decisions: &[RadrootsOrderDecisionRecord],
- cancellations: &[RadrootsOrderCancellationRecord],
-) -> Vec<RadrootsEventId> {
- let mut event_ids = Vec::new();
- event_ids.extend(requests.iter().map(|request| request.event_id.clone()));
- event_ids.extend(decisions.iter().map(|decision| decision.event_id.clone()));
- event_ids.extend(
- cancellations
- .iter()
- .map(|cancellation| cancellation.event_id.clone()),
- );
- sort_and_dedup_values(&mut event_ids);
- event_ids
-}
-
-pub fn canonicalize_order_request_for_signer(
- mut request: RadrootsOrderRequest,
- signer_pubkey: &str,
-) -> Result<RadrootsOrderRequest, RadrootsOrderCanonicalizationError> {
- let order_id = request.order_id.clone();
- let listing_addr = public_listing_addr(&request.listing_addr);
-
- let buyer_pubkey = request.buyer_pubkey.clone();
- if buyer_pubkey.as_str() != signer_pubkey {
- return Err(RadrootsOrderCanonicalizationError::InvalidBuyerSigner);
- }
-
- let seller_pubkey = request.seller_pubkey.clone();
- if seller_pubkey != listing_addr.seller_pubkey {
- return Err(RadrootsOrderCanonicalizationError::InvalidSellerListing);
- }
-
- canonicalize_items(&mut request.items)?;
- request.economics.canonicalize();
- request.order_id = order_id;
- request.listing_addr = listing_addr.address;
- request.buyer_pubkey = buyer_pubkey;
- request.seller_pubkey = seller_pubkey;
- Ok(request)
-}
-
-pub fn canonicalize_order_decision_for_signer(
- mut decision_event: RadrootsOrderDecision,
- signer_pubkey: &str,
-) -> Result<RadrootsOrderDecision, RadrootsOrderCanonicalizationError> {
- let order_id = decision_event.order_id.clone();
- let listing_addr = public_listing_addr(&decision_event.listing_addr);
-
- let seller_pubkey = decision_event.seller_pubkey.clone();
- if seller_pubkey.as_str() != signer_pubkey || seller_pubkey != listing_addr.seller_pubkey {
- return Err(RadrootsOrderCanonicalizationError::InvalidSellerListing);
- }
-
- let buyer_pubkey = decision_event.buyer_pubkey.clone();
- canonicalize_decision(&mut decision_event.decision)?;
-
- decision_event.order_id = order_id;
- decision_event.listing_addr = listing_addr.address;
- decision_event.buyer_pubkey = buyer_pubkey;
- decision_event.seller_pubkey = seller_pubkey;
- Ok(decision_event)
-}
-
-#[cfg(feature = "serde_json")]
-pub fn radroots_order_economics_digest(
- economics: &RadrootsOrderEconomics,
-) -> Result<String, RadrootsOrderEconomicsDigestError> {
- let encoded = serialize_order_economics(economics)?;
- let digest = Sha256::digest(encoded);
- let mut value = String::from("sha256:");
- value.push_str(&hex::encode(digest));
- Ok(value)
-}
-
-#[cfg(feature = "serde_json")]
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn serialize_order_economics(
- economics: &RadrootsOrderEconomics,
-) -> Result<Vec<u8>, RadrootsOrderEconomicsDigestError> {
- serde_json::to_vec(economics).map_err(RadrootsOrderEconomicsDigestError::Serialize)
-}
-
-fn cancelled_projection(
- order_id: &RadrootsOrderId,
- request: &RadrootsOrderRequestRecord,
- cancellation: &RadrootsOrderCancellationRecord,
- decisions: &[RadrootsOrderDecisionRecord],
-) -> RadrootsOrderProjection {
- if !decisions.is_empty() {
- let mut event_ids = Vec::new();
- event_ids.extend(decisions.iter().map(|decision| decision.event_id.clone()));
- event_ids.push(cancellation.event_id.clone());
- sort_and_dedup_values(&mut event_ids);
- return invalid_projection(
- order_id,
- Some(request),
- vec![RadrootsOrderIssue::ForkedLifecycle { event_ids }],
- );
- }
- if cancellation.prev_event_id != request.event_id {
- return invalid_projection(
- order_id,
- Some(request),
- vec![RadrootsOrderIssue::CancellationPreviousMismatch {
- event_id: cancellation.event_id.clone(),
- }],
- );
- }
-
- let mut projection =
- request_projection(order_id, request, RadrootsTradeWorkflowState::Cancelled);
- projection.cancellation_event_id = Some(cancellation.event_id.clone());
- projection.lifecycle_terminal = true;
- projection.last_event_id = Some(cancellation.event_id.clone());
- projection
-}
-
-fn decided_projection(
- order_id: &RadrootsOrderId,
- request: &RadrootsOrderRequestRecord,
- decision: &RadrootsOrderDecisionRecord,
-) -> RadrootsOrderProjection {
- match &decision.payload.decision {
- RadrootsOrderDecisionOutcome::Accepted { .. } => {
- let mut projection = request_projection(
- order_id,
- request,
- RadrootsTradeWorkflowState::AgreedPendingValidation,
- );
- projection.decision_event_id = Some(decision.event_id.clone());
- projection.economics = Some(request.payload.economics.clone());
- projection.agreement_event_id = Some(decision.event_id.clone());
- projection.pending_inventory_reservations =
- inventory_commitments_from_items(&request.payload.items);
- projection.last_event_id = Some(decision.event_id.clone());
- projection
- }
- RadrootsOrderDecisionOutcome::Declined { .. } => {
- let mut projection =
- request_projection(order_id, request, RadrootsTradeWorkflowState::Declined);
- projection.decision_event_id = Some(decision.event_id.clone());
- projection.lifecycle_terminal = true;
- projection.last_event_id = Some(decision.event_id.clone());
- projection
- }
- }
-}
-
-fn request_projection(
- order_id: &RadrootsOrderId,
- request: &RadrootsOrderRequestRecord,
- status: RadrootsTradeWorkflowState,
-) -> RadrootsOrderProjection {
- RadrootsOrderProjection {
- order_id: order_id.clone(),
- status,
- request_event_id: Some(request.event_id.clone()),
- decision_event_id: None,
- cancellation_event_id: None,
- validation_receipt_event_id: None,
- lifecycle_terminal: false,
- economics: Some(request.payload.economics.clone()),
- agreement_event_id: None,
- pending_inventory_reservations: Vec::new(),
- committed_inventory_reservations: Vec::new(),
- listing_addr: Some(request.payload.listing_addr.clone()),
- buyer_pubkey: Some(request.payload.buyer_pubkey.clone()),
- seller_pubkey: Some(request.payload.seller_pubkey.clone()),
- last_event_id: Some(request.event_id.clone()),
- issues: Vec::new(),
- }
-}
-
-fn invalid_projection(
- order_id: &RadrootsOrderId,
- request: Option<&RadrootsOrderRequestRecord>,
- mut issues: Vec<RadrootsOrderIssue>,
-) -> RadrootsOrderProjection {
- issues.sort_by(order_issue_sort_key);
- let last_event_id = projection_issue_event_ids(&issues).into_iter().last();
- match request {
- Some(request) => {
- let mut projection =
- request_projection(order_id, request, RadrootsTradeWorkflowState::Invalid);
- projection.lifecycle_terminal = true;
- projection.last_event_id = last_event_id.or_else(|| Some(request.event_id.clone()));
- projection.issues = issues;
- projection
- }
- None => {
- let mut projection =
- empty_projection(order_id, RadrootsTradeWorkflowState::Invalid, true);
- projection.last_event_id = last_event_id;
- projection.issues = issues;
- projection
- }
- }
-}
-
-fn empty_projection(
- order_id: &RadrootsOrderId,
- status: RadrootsTradeWorkflowState,
- lifecycle_terminal: bool,
-) -> RadrootsOrderProjection {
- RadrootsOrderProjection {
- order_id: order_id.clone(),
- status,
- request_event_id: None,
- decision_event_id: None,
- cancellation_event_id: None,
- validation_receipt_event_id: None,
- lifecycle_terminal,
- economics: None,
- agreement_event_id: None,
- pending_inventory_reservations: Vec::new(),
- committed_inventory_reservations: Vec::new(),
- listing_addr: None,
- buyer_pubkey: None,
- seller_pubkey: None,
- last_event_id: None,
- issues: Vec::new(),
- }
-}
-
-fn validate_order_request_record(
- order_id: &RadrootsOrderId,
- request: &RadrootsOrderRequestRecord,
- issues: &mut Vec<RadrootsOrderIssue>,
-) -> bool {
- let mut valid = true;
- if request.payload.validate().is_err() {
- issues.push(RadrootsOrderIssue::RequestPayloadInvalid {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
- if request.payload.order_id.as_str() != order_id.as_str() {
- issues.push(RadrootsOrderIssue::RequestOrderIdMismatch {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
- if request.author_pubkey != request.payload.buyer_pubkey {
- issues.push(RadrootsOrderIssue::RequestAuthorMismatch {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
- let listing_addr = public_listing_addr(&request.payload.listing_addr);
- if listing_addr.seller_pubkey != request.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::RequestSellerListingMismatch {
- event_id: request.event_id.clone(),
- });
- valid = false;
- }
- valid
-}
-
-fn validate_order_decision_record(
- request: &RadrootsOrderRequestRecord,
- decision: &RadrootsOrderDecisionRecord,
- issues: &mut Vec<RadrootsOrderIssue>,
-) -> bool {
- let mut valid = true;
- if decision_payload_issue(&decision.payload.decision, &decision.event_id, issues) {
- valid = false;
- }
- if decision.payload.validate().is_err() {
- issues.push(RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.payload.order_id != request.payload.order_id {
- issues.push(RadrootsOrderIssue::DecisionOrderIdMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.author_pubkey != decision.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::DecisionAuthorMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.counterparty_pubkey != request.payload.buyer_pubkey {
- issues.push(RadrootsOrderIssue::DecisionCounterpartyMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.payload.buyer_pubkey != request.payload.buyer_pubkey {
- issues.push(RadrootsOrderIssue::DecisionBuyerMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.payload.seller_pubkey != request.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::DecisionSellerMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- let listing_addr = public_listing_addr(&decision.payload.listing_addr);
- if decision.payload.listing_addr != request.payload.listing_addr
- || listing_addr.seller_pubkey != decision.payload.seller_pubkey
- {
- issues.push(RadrootsOrderIssue::DecisionListingMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.root_event_id != request.event_id {
- issues.push(RadrootsOrderIssue::DecisionRootMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if decision.prev_event_id != request.event_id {
- issues.push(RadrootsOrderIssue::DecisionPreviousMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = &decision.payload.decision
- && decision.payload.validate().is_ok()
- && !inventory_commitments_match_request(&request.payload.items, inventory_commitments)
- {
- issues.push(RadrootsOrderIssue::DecisionInventoryCommitmentMismatch {
- event_id: decision.event_id.clone(),
- });
- valid = false;
- }
- valid
-}
-
-fn validate_order_cancellation_record(
- request: &RadrootsOrderRequestRecord,
- cancellation: &RadrootsOrderCancellationRecord,
- issues: &mut Vec<RadrootsOrderIssue>,
-) -> bool {
- let mut valid = true;
- if cancellation.payload.validate().is_err() {
- issues.push(RadrootsOrderIssue::CancellationPayloadInvalid {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.payload.order_id != request.payload.order_id {
- issues.push(RadrootsOrderIssue::CancellationOrderIdMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.author_pubkey != cancellation.payload.buyer_pubkey {
- issues.push(RadrootsOrderIssue::CancellationAuthorMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.counterparty_pubkey != request.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::CancellationCounterpartyMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.payload.buyer_pubkey != request.payload.buyer_pubkey {
- issues.push(RadrootsOrderIssue::CancellationBuyerMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.payload.seller_pubkey != request.payload.seller_pubkey {
- issues.push(RadrootsOrderIssue::CancellationSellerMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- let listing_addr = public_listing_addr(&cancellation.payload.listing_addr);
- if cancellation.payload.listing_addr != request.payload.listing_addr
- || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey
- {
- issues.push(RadrootsOrderIssue::CancellationListingMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.root_event_id != request.event_id {
- issues.push(RadrootsOrderIssue::CancellationRootMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- if cancellation.prev_event_id == cancellation.event_id {
- issues.push(RadrootsOrderIssue::CancellationPreviousMismatch {
- event_id: cancellation.event_id.clone(),
- });
- valid = false;
- }
- valid
-}
-
-fn decision_payload_issue(
- decision: &RadrootsOrderDecisionOutcome,
- event_id: &RadrootsEventId,
- issues: &mut Vec<RadrootsOrderIssue>,
-) -> bool {
- match decision {
- RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } => {
- if inventory_commitments.is_empty() {
- issues.push(RadrootsOrderIssue::DecisionMissingInventoryCommitments {
- event_id: event_id.clone(),
- });
- true
- } else {
- false
- }
- }
- RadrootsOrderDecisionOutcome::Declined { reason } => {
- if reason.trim().is_empty() {
- issues.push(RadrootsOrderIssue::DecisionMissingReason {
- event_id: event_id.clone(),
- });
- true
- } else {
- false
- }
- }
- }
-}
-
-fn unique_request_records(
- requests: Vec<RadrootsOrderRequestRecord>,
-) -> Vec<RadrootsOrderRequestRecord> {
- unique_records_by_event_id(requests, |record| &record.event_id)
-}
-
-fn unique_decision_records(
- decisions: Vec<RadrootsOrderDecisionRecord>,
-) -> Vec<RadrootsOrderDecisionRecord> {
- unique_records_by_event_id(decisions, |record| &record.event_id)
-}
-
-fn unique_cancellation_records(
- cancellations: Vec<RadrootsOrderCancellationRecord>,
-) -> Vec<RadrootsOrderCancellationRecord> {
- unique_records_by_event_id(cancellations, |record| &record.event_id)
-}
-
-fn unique_records_by_event_id<T>(
- mut records: Vec<T>,
- event_id: impl Fn(&T) -> &RadrootsEventId,
-) -> Vec<T> {
- let mut unique = Vec::new();
- records.sort_by(|left, right| event_id(left).cmp(event_id(right)));
- for record in records {
- if unique
- .iter()
- .all(|existing: &T| event_id(existing) != event_id(&record))
- {
- unique.push(record);
- }
- }
- unique
-}
-
-fn normalized_listing_inventory_bins<I>(
- bins: I,
-) -> (
- Vec<RadrootsOperationalListingInventoryBinAccounting>,
- Vec<RadrootsOperationalListingInventoryAccountingIssue>,
-)
-where
- I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>,
-{
- let mut normalized: Vec<RadrootsOperationalListingInventoryBinAccounting> = Vec::new();
- let mut issues = Vec::new();
- for bin in bins {
- let bin_id = bin.bin_id;
- if let Some(existing) = normalized
- .iter_mut()
- .find(|existing| existing.bin_id == bin_id)
- {
- if let Some(next_count) = existing.available_count.checked_add(bin.available_count) {
- existing.available_count = next_count;
- existing.remaining_count = next_count;
- } else {
- existing.available_count = u64::MAX;
- existing.remaining_count = u64::MAX;
- issues.push(
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: existing.bin_id.clone(),
- event_ids: Vec::new(),
- },
- );
- }
- } else {
- normalized.push(RadrootsOperationalListingInventoryBinAccounting {
- bin_id,
- available_count: bin.available_count,
- pending_reserved_count: 0,
- committed_reserved_count: 0,
- remaining_count: bin.available_count,
- over_reserved: false,
- pending_orders: Vec::new(),
- committed_orders: Vec::new(),
- });
- }
- }
- normalized.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
- (normalized, issues)
-}
-
-fn listing_order_ids(
- requests: &[RadrootsOrderRequestRecord],
- decisions: &[RadrootsOrderDecisionRecord],
- cancellations: &[RadrootsOrderCancellationRecord],
-) -> Vec<RadrootsOrderId> {
- let mut order_ids = Vec::new();
- order_ids.extend(
- requests
- .iter()
- .map(|request| request.payload.order_id.clone()),
- );
- order_ids.extend(
- decisions
- .iter()
- .map(|decision| decision.payload.order_id.clone()),
- );
- order_ids.extend(
- cancellations
- .iter()
- .map(|cancellation| cancellation.payload.order_id.clone()),
- );
- sort_and_dedup_values(&mut order_ids);
- order_ids
-}
-
-fn add_pending_inventory_reservations_from_economics(
- bins: &mut [RadrootsOperationalListingInventoryBinAccounting],
- order_id: &RadrootsOrderId,
- agreement_event_id: &RadrootsEventId,
- economics: &RadrootsOrderEconomics,
- issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
-) {
- for item in &economics.items {
- if let Some(bin) = bins.iter_mut().find(|bin| bin.bin_id == item.bin_id) {
- add_inventory_reservation_event(
- bin,
- order_id,
- agreement_event_id,
- u64::from(item.bin_count),
- issues,
- );
- } else {
- issues.push(
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: item.bin_id.clone(),
- event_ids: vec![agreement_event_id.clone()],
- },
- );
- }
- }
-}
-
-fn add_inventory_reservation_event(
- bin: &mut RadrootsOperationalListingInventoryBinAccounting,
- order_id: &RadrootsOrderId,
- event_id: &RadrootsEventId,
- bin_count: u64,
- issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
-) {
- if let Some(next_count) = bin.pending_reserved_count.checked_add(bin_count) {
- bin.pending_reserved_count = next_count;
- bin.pending_orders
- .push(RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id.clone(),
- agreement_event_id: event_id.clone(),
- bin_count,
- });
- } else {
- issues.push(
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: bin.bin_id.clone(),
- event_ids: vec![event_id.clone()],
- },
- );
- }
-}
-
-fn finish_inventory_accounting_bins(
- bins: &mut [RadrootsOperationalListingInventoryBinAccounting],
- issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>,
-) {
- for bin in bins.iter_mut() {
- bin.pending_orders.sort_by(|left, right| {
- left.order_id
- .cmp(&right.order_id)
- .then_with(|| left.agreement_event_id.cmp(&right.agreement_event_id))
- });
- bin.committed_orders.sort_by(|left, right| {
- left.order_id
- .cmp(&right.order_id)
- .then_with(|| left.agreement_event_id.cmp(&right.agreement_event_id))
- });
- let reserved_count = bin
- .pending_reserved_count
- .saturating_add(bin.committed_reserved_count);
- bin.remaining_count = bin.available_count.saturating_sub(reserved_count);
- bin.over_reserved = reserved_count > bin.available_count;
- if bin.over_reserved {
- let mut event_ids = bin
- .pending_orders
- .iter()
- .chain(bin.committed_orders.iter())
- .map(|reservation| reservation.agreement_event_id.clone())
- .collect::<Vec<_>>();
- sort_and_dedup_values(&mut event_ids);
- issues.push(
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
- bin_id: bin.bin_id.clone(),
- available_count: bin.available_count,
- reserved_count,
- event_ids,
- },
- );
- }
- }
- bins.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
-}
-
-fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEventId> {
- let mut event_ids = Vec::new();
- for issue in issues {
- match issue {
- RadrootsOrderIssue::MissingRequest => {}
- RadrootsOrderIssue::MultipleRequests { event_ids: ids }
- | RadrootsOrderIssue::ConflictingDecisions { event_ids: ids }
- | RadrootsOrderIssue::ForkedLifecycle { event_ids: ids }
- | RadrootsOrderIssue::ConflictingValidationReceipts { event_ids: ids } => {
- event_ids.extend(ids.iter().cloned());
- }
- RadrootsOrderIssue::RequestPayloadInvalid { event_id }
- | RadrootsOrderIssue::RequestOrderIdMismatch { event_id }
- | RadrootsOrderIssue::RequestAuthorMismatch { event_id }
- | RadrootsOrderIssue::RequestSellerListingMismatch { event_id }
- | RadrootsOrderIssue::DecisionPayloadInvalid { event_id }
- | RadrootsOrderIssue::DecisionOrderIdMismatch { event_id }
- | RadrootsOrderIssue::DecisionAuthorMismatch { event_id }
- | RadrootsOrderIssue::DecisionCounterpartyMismatch { event_id }
- | RadrootsOrderIssue::DecisionBuyerMismatch { event_id }
- | RadrootsOrderIssue::DecisionSellerMismatch { event_id }
- | RadrootsOrderIssue::DecisionListingMismatch { event_id }
- | RadrootsOrderIssue::DecisionRootMismatch { event_id }
- | RadrootsOrderIssue::DecisionPreviousMismatch { event_id }
- | RadrootsOrderIssue::DecisionMissingInventoryCommitments { event_id }
- | RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { event_id }
- | RadrootsOrderIssue::DecisionMissingReason { event_id }
- | RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id }
- | RadrootsOrderIssue::CancellationPayloadInvalid { event_id }
- | RadrootsOrderIssue::CancellationOrderIdMismatch { event_id }
- | RadrootsOrderIssue::CancellationAuthorMismatch { event_id }
- | RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id }
- | RadrootsOrderIssue::CancellationBuyerMismatch { event_id }
- | RadrootsOrderIssue::CancellationSellerMismatch { event_id }
- | RadrootsOrderIssue::CancellationListingMismatch { event_id }
- | RadrootsOrderIssue::CancellationRootMismatch { event_id }
- | RadrootsOrderIssue::CancellationPreviousMismatch { event_id }
- | RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { event_id }
- | RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { event_id }
- | RadrootsOrderIssue::ValidationReceiptTypeMismatch { event_id }
- | RadrootsOrderIssue::ValidationReceiptRootMismatch { event_id }
- | RadrootsOrderIssue::ValidationReceiptTargetMismatch { event_id }
- | RadrootsOrderIssue::ValidationReceiptListingMismatch { event_id }
- | RadrootsOrderIssue::DeterministicValidationFailure { event_id, .. } => {
- event_ids.push(event_id.clone());
- }
- RadrootsOrderIssue::StaleListingEvent {
- expected_event_id,
- current_event_id,
- } => {
- event_ids.push(expected_event_id.clone());
- event_ids.push(current_event_id.clone());
- }
- }
- }
- sort_and_dedup_values(&mut event_ids);
- event_ids
-}
-
-fn public_listing_addr(
- value: &RadrootsClassifiedListingAddress,
-) -> RadrootsPublicClassifiedListingAddress {
- parse_public_classified_listing_address(value)
- .expect("typed classified listing address must remain a valid kind-30402 coordinate")
-}
-
-fn canonicalize_items(
- items: &mut [RadrootsOrderItem],
-) -> Result<(), RadrootsOrderCanonicalizationError> {
- if items.is_empty() {
- return Err(RadrootsOrderCanonicalizationError::MissingItems);
- }
- for (index, item) in items.iter().enumerate() {
- if item.bin_count == 0 {
- return Err(RadrootsOrderCanonicalizationError::InvalidBinCount { index });
- }
- }
- items.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
- Ok(())
-}
-
-fn canonicalize_decision(
- decision: &mut RadrootsOrderDecisionOutcome,
-) -> Result<(), RadrootsOrderCanonicalizationError> {
- match decision {
- RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } => {
- if inventory_commitments.is_empty() {
- return Err(RadrootsOrderCanonicalizationError::MissingInventoryCommitments);
- }
- for (index, commitment) in inventory_commitments.iter().enumerate() {
- if commitment.bin_count == 0 {
- return Err(
- RadrootsOrderCanonicalizationError::InvalidInventoryCommitmentCount {
- index,
- },
- );
- }
- }
- inventory_commitments.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
- Ok(())
- }
- RadrootsOrderDecisionOutcome::Declined { reason } => {
- if reason.trim().is_empty() {
- return Err(RadrootsOrderCanonicalizationError::EmptyField("reason"));
- }
- *reason = reason.trim().to_string();
- Ok(())
- }
- }
-}
-
-fn inventory_commitments_match_request(
- items: &[RadrootsOrderItem],
- commitments: &[RadrootsOrderInventoryCommitment],
-) -> bool {
- if items.len() != commitments.len() {
- return false;
- }
- let mut expected = items.to_vec();
- expected.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
- let mut actual = commitments.to_vec();
- actual.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
- expected
- .iter()
- .zip(actual.iter())
- .all(|(item, commitment)| {
- item.bin_id == commitment.bin_id && item.bin_count == commitment.bin_count
- })
-}
-
-fn inventory_commitments_from_items(
- items: &[RadrootsOrderItem],
-) -> Vec<RadrootsOrderInventoryCommitment> {
- let commitments = items
- .iter()
- .map(|item| RadrootsOrderInventoryCommitment {
- bin_id: item.bin_id.clone(),
- bin_count: item.bin_count,
- })
- .collect::<Vec<_>>();
- inventory_reservations_from_commitments(&commitments)
-}
-
-fn sort_and_dedup_values<T: Ord>(values: &mut Vec<T>) {
- values.sort();
- values.dedup();
-}
-
-fn inventory_issue_sort_key(
- left: &RadrootsOperationalListingInventoryAccountingIssue,
- right: &RadrootsOperationalListingInventoryAccountingIssue,
-) -> core::cmp::Ordering {
- inventory_issue_rank(left)
- .cmp(&inventory_issue_rank(right))
- .then_with(|| inventory_issue_id(left).cmp(inventory_issue_id(right)))
- .then_with(|| inventory_issue_event_ids(left).cmp(inventory_issue_event_ids(right)))
-}
-
-fn inventory_issue_rank(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> u8 {
- match issue {
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. } => 0,
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. } => 1,
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 2,
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. } => 3,
- }
-}
-
-fn inventory_issue_id(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> &str {
- match issue {
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id, .. } => {
- order_id
- }
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id, ..
- }
- | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id, ..
- }
- | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id,
- }
-}
-
-fn inventory_issue_event_ids(
- issue: &RadrootsOperationalListingInventoryAccountingIssue,
-) -> &[RadrootsEventId] {
- match issue {
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { event_ids, .. }
- | RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- event_ids,
- ..
- }
- | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- event_ids,
- ..
- }
- | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { event_ids, .. } => {
- event_ids
- }
- }
-}
-
-fn order_issue_sort_key(
- left: &RadrootsOrderIssue,
- right: &RadrootsOrderIssue,
-) -> core::cmp::Ordering {
- order_issue_rank(left)
- .cmp(&order_issue_rank(right))
- .then_with(|| {
- projection_issue_event_ids(core::slice::from_ref(left))
- .cmp(&projection_issue_event_ids(core::slice::from_ref(right)))
- })
-}
-
-fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 {
- match issue {
- RadrootsOrderIssue::MissingRequest => 0,
- RadrootsOrderIssue::MultipleRequests { .. } => 1,
- RadrootsOrderIssue::RequestPayloadInvalid { .. } => 2,
- RadrootsOrderIssue::RequestOrderIdMismatch { .. } => 3,
- RadrootsOrderIssue::RequestAuthorMismatch { .. } => 4,
- RadrootsOrderIssue::RequestSellerListingMismatch { .. } => 5,
- RadrootsOrderIssue::DecisionPayloadInvalid { .. } => 6,
- RadrootsOrderIssue::DecisionOrderIdMismatch { .. } => 7,
- RadrootsOrderIssue::DecisionAuthorMismatch { .. } => 8,
- RadrootsOrderIssue::DecisionCounterpartyMismatch { .. } => 9,
- RadrootsOrderIssue::DecisionBuyerMismatch { .. } => 10,
- RadrootsOrderIssue::DecisionSellerMismatch { .. } => 11,
- RadrootsOrderIssue::DecisionListingMismatch { .. } => 12,
- RadrootsOrderIssue::DecisionRootMismatch { .. } => 13,
- RadrootsOrderIssue::DecisionPreviousMismatch { .. } => 14,
- RadrootsOrderIssue::DecisionMissingInventoryCommitments { .. } => 15,
- RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { .. } => 16,
- RadrootsOrderIssue::DecisionMissingReason { .. } => 17,
- RadrootsOrderIssue::ConflictingDecisions { .. } => 18,
- RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. } => 19,
- RadrootsOrderIssue::CancellationPayloadInvalid { .. } => 20,
- RadrootsOrderIssue::CancellationOrderIdMismatch { .. } => 21,
- RadrootsOrderIssue::CancellationAuthorMismatch { .. } => 22,
- RadrootsOrderIssue::CancellationCounterpartyMismatch { .. } => 23,
- RadrootsOrderIssue::CancellationBuyerMismatch { .. } => 24,
- RadrootsOrderIssue::CancellationSellerMismatch { .. } => 25,
- RadrootsOrderIssue::CancellationListingMismatch { .. } => 26,
- RadrootsOrderIssue::CancellationRootMismatch { .. } => 27,
- RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 28,
- RadrootsOrderIssue::ForkedLifecycle { .. } => 29,
- RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 30,
- RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 31,
- RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 32,
- RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 33,
- RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 34,
- RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 35,
- RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 36,
- RadrootsOrderIssue::DeterministicValidationFailure { .. } => 37,
- RadrootsOrderIssue::StaleListingEvent { .. } => 38,
- }
-}
-
-#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
-mod tests {
- use super::{
- RadrootsOperationalListingInventoryAccountingInputs,
- RadrootsOperationalListingInventoryAccountingIssue,
- RadrootsOperationalListingInventoryBinAvailability, RadrootsOrderCancellationRecord,
- RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue,
- RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderWorkflowProjection,
- RadrootsTradeLocatorProjectionResolution, RadrootsTradeWorkflowState,
- reduce_operational_listing_inventory_accounting, reduce_order_event_records,
- reduce_order_event_records_for_trade_locator, reduce_order_events,
- };
- use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
- use core::mem::discriminant;
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
- };
- use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr,
- ids::{
- RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId,
- RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey,
- },
- kinds::KIND_CLASSIFIED_LISTING,
- order::{
- RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome,
- RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment,
- RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest,
- },
- wire::RadrootsNip01EventWireParts,
- };
- #[cfg(feature = "serde_json")]
- use radroots_event_codec::order::{
- order_cancellation_event_build, order_decision_event_build, order_request_event_build,
- };
-
- const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
- const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
- const OTHER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
-
- fn event_id(raw: u8) -> RadrootsEventId {
- RadrootsEventId::parse(format!("{raw:064x}")).expect("event id")
- }
-
- fn public_key(raw: &str) -> RadrootsPublicKey {
- RadrootsPublicKey::parse(raw).expect("public key")
- }
-
- fn order_id(raw: &str) -> RadrootsOrderId {
- RadrootsOrderId::parse(raw).expect("order id")
- }
-
- fn quote_id(raw: &str) -> RadrootsOrderQuoteId {
- RadrootsOrderQuoteId::parse(raw).expect("quote id")
- }
-
- fn bin_id(raw: &str) -> RadrootsInventoryBinId {
- RadrootsInventoryBinId::parse(raw).expect("bin id")
- }
-
- fn listing_addr() -> RadrootsClassifiedListingAddress {
- RadrootsClassifiedListingAddress::parse(format!(
- "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
- ))
- .expect("listing address")
- }
-
- fn other_seller_listing_addr() -> RadrootsClassifiedListingAddress {
- RadrootsClassifiedListingAddress::parse(format!(
- "{KIND_CLASSIFIED_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg"
- ))
- .expect("other seller listing address")
- }
-
- #[cfg(feature = "serde_json")]
- fn listing_event_ptr() -> RadrootsEventPtr {
- RadrootsEventPtr {
- id: event_id(80).into_string(),
- relays: Some("wss://relay.example.test".into()),
- }
- }
-
- #[cfg(feature = "serde_json")]
- fn event_from_parts(
- raw_id: u8,
- author: &str,
- parts: RadrootsNip01EventWireParts,
- ) -> RadrootsEventEnvelope {
- RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: event_id(raw_id).into_string(),
- author: author.to_string(),
- created_at: 1,
- kind: parts.kind,
- tags: parts.tags,
- content: parts.content,
- sig: "f".repeat(128),
- })
- .expect("event")
- }
-
- fn economics(bin_count: u32) -> RadrootsOrderEconomics {
- let currency = RadrootsCoreCurrency::USD;
- let amount = RadrootsCoreDecimal::from(1200u32);
- RadrootsOrderEconomics {
- quote_id: quote_id("quote-1"),
- quote_version: 1,
- pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
- currency,
- items: vec![RadrootsOrderEconomicItem {
- bin_id: bin_id("bin-1"),
- bin_count,
- quantity_amount: RadrootsCoreDecimal::ONE,
- quantity_unit: RadrootsCoreUnit::Each,
- unit_price_amount: amount,
- unit_price_currency: currency,
- line_subtotal: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(u64::from(bin_count) * 1200),
- currency,
- ),
- }],
- discounts: Vec::new(),
- adjustments: Vec::new(),
- subtotal: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(u64::from(bin_count) * 1200),
- currency,
- ),
- discount_total: RadrootsCoreMoney::zero(currency),
- adjustment_total: RadrootsCoreMoney::zero(currency),
- total: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(u64::from(bin_count) * 1200),
- currency,
- ),
- }
- }
-
- fn request_record() -> RadrootsOrderRequestRecord {
- RadrootsOrderRequestRecord {
- event_id: event_id(1),
- author_pubkey: public_key(BUYER),
- payload: RadrootsOrderRequest {
- order_id: order_id("order-1"),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- items: vec![RadrootsOrderItem {
- bin_id: bin_id("bin-1"),
- bin_count: 2,
- }],
- economics: economics(2),
- },
- }
- }
-
- fn accepted_decision() -> RadrootsOrderDecisionRecord {
- RadrootsOrderDecisionRecord {
- event_id: event_id(2),
- author_pubkey: public_key(SELLER),
- counterparty_pubkey: public_key(BUYER),
- root_event_id: event_id(1),
- prev_event_id: event_id(1),
- payload: RadrootsOrderDecision {
- order_id: order_id("order-1"),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- decision: RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments: vec![RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-1"),
- bin_count: 2,
- }],
- },
- },
- }
- }
-
- fn declined_decision() -> RadrootsOrderDecisionRecord {
- RadrootsOrderDecisionRecord {
- event_id: event_id(2),
- author_pubkey: public_key(SELLER),
- counterparty_pubkey: public_key(BUYER),
- root_event_id: event_id(1),
- prev_event_id: event_id(1),
- payload: RadrootsOrderDecision {
- order_id: order_id("order-1"),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- decision: RadrootsOrderDecisionOutcome::Declined {
- reason: "not available".into(),
- },
- },
- }
- }
-
- fn cancellation(prev_event_id: RadrootsEventId) -> RadrootsOrderCancellationRecord {
- RadrootsOrderCancellationRecord {
- event_id: event_id(5),
- author_pubkey: public_key(BUYER),
- counterparty_pubkey: public_key(SELLER),
- root_event_id: event_id(1),
- prev_event_id,
- payload: RadrootsOrderCancellation {
- order_id: order_id("order-1"),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- reason: "changed plans".into(),
- },
- }
- }
-
- fn assert_order_issue_kind(issues: &[RadrootsOrderIssue], expected: RadrootsOrderIssue) {
- let expected_kind = discriminant(&expected);
- assert!(
- issues
- .iter()
- .any(|issue| discriminant(issue) == expected_kind),
- "missing issue kind {expected:?} in {issues:?}"
- );
- }
-
- fn assert_inventory_issue_kind(
- issues: &[RadrootsOperationalListingInventoryAccountingIssue],
- expected: RadrootsOperationalListingInventoryAccountingIssue,
- ) {
- let expected_kind = discriminant(&expected);
- assert!(
- issues
- .iter()
- .any(|issue| discriminant(issue) == expected_kind),
- "missing inventory issue kind {expected:?} in {issues:?}"
- );
- }
-
- fn assert_request_issue(
- mutate: impl FnOnce(&mut RadrootsOrderRequestRecord),
- expected: RadrootsOrderIssue,
- ) {
- let mut request = request_record();
- mutate(&mut request);
- let mut issues = Vec::new();
- assert!(!super::validate_order_request_record(
- &order_id("order-1"),
- &request,
- &mut issues
- ));
- assert_order_issue_kind(&issues, expected);
- }
-
- fn assert_decision_issue(
- mutate: impl FnOnce(&mut RadrootsOrderDecisionRecord),
- expected: RadrootsOrderIssue,
- ) {
- let request = request_record();
- let mut decision = accepted_decision();
- mutate(&mut decision);
- let mut issues = Vec::new();
- assert!(!super::validate_order_decision_record(
- &request,
- &decision,
- &mut issues
- ));
- assert_order_issue_kind(&issues, expected);
- }
-
- fn assert_cancellation_issue(
- mutate: impl FnOnce(&mut RadrootsOrderCancellationRecord),
- expected: RadrootsOrderIssue,
- ) {
- let request = request_record();
- let mut cancellation = cancellation(event_id(1));
- mutate(&mut cancellation);
- let mut issues = Vec::new();
- assert!(!super::validate_order_cancellation_record(
- &request,
- &cancellation,
- &mut issues
- ));
- assert_order_issue_kind(&issues, expected);
- }
-
- fn reduce(
- decisions: Vec<RadrootsOrderDecisionRecord>,
- cancellations: Vec<RadrootsOrderCancellationRecord>,
- ) -> super::RadrootsOrderProjection {
- reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record()],
- decisions,
- cancellations,
- },
- )
- }
-
- #[test]
- fn order_event_record_accessors_cover_all_variants() {
- let records = [
- RadrootsOrderEventRecord::Request(request_record()),
- RadrootsOrderEventRecord::Decision(accepted_decision()),
- RadrootsOrderEventRecord::Cancellation(cancellation(event_id(1))),
- ];
-
- let event_ids = records
- .iter()
- .map(RadrootsOrderEventRecord::event_id)
- .cloned()
- .collect::<Vec<_>>();
- let order_ids = records
- .iter()
- .map(RadrootsOrderEventRecord::order_id)
- .cloned()
- .collect::<Vec<_>>();
-
- assert_eq!(event_ids, vec![event_id(1), event_id(2), event_id(5)]);
- assert_eq!(order_ids, vec![order_id("order-1"); 3]);
- }
-
- #[test]
- fn trade_locator_reports_ambiguous_roots_for_duplicate_order_id() {
- let mut second_request = request_record();
- second_request.event_id = event_id(9);
- let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"));
-
- let resolution = reduce_order_event_records_for_trade_locator(
- &locator,
- vec![
- RadrootsOrderEventRecord::Request(request_record()),
- RadrootsOrderEventRecord::Request(second_request),
- ],
- );
-
- assert!(matches!(
- resolution,
- RadrootsTradeLocatorProjectionResolution::Ambiguous { ref candidates, .. }
- if candidates.len() == 2
- && candidates.iter().any(|candidate| candidate.root_event_id == event_id(1))
- && candidates.iter().any(|candidate| candidate.root_event_id == event_id(9))
- ));
- }
-
- #[test]
- fn trade_locator_root_selects_exact_trade_projection() {
- let mut second_request = request_record();
- second_request.event_id = event_id(9);
- let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"))
- .with_root_event_id(event_id(9));
-
- let resolution = reduce_order_event_records_for_trade_locator(
- &locator,
- vec![
- RadrootsOrderEventRecord::Request(request_record()),
- RadrootsOrderEventRecord::Request(second_request),
- RadrootsOrderEventRecord::Decision(accepted_decision()),
- ],
- );
-
- assert!(matches!(
- resolution,
- RadrootsTradeLocatorProjectionResolution::Projected { projection, .. }
- if projection.request_event_id == Some(event_id(9))
- && projection.decision_event_id.is_none()
- ));
- }
-
- #[test]
- fn trade_locator_reports_missing_and_filters_all_selected_record_families() {
- let locator = RadrootsTradeLocator::from_order_id(order_id("order-missing"));
- let missing = reduce_order_event_records_for_trade_locator(
- &locator,
- Vec::<RadrootsOrderEventRecord>::new(),
- );
- assert!(matches!(
- missing,
- RadrootsTradeLocatorProjectionResolution::Missing { .. }
- ));
-
- let mut second_request = request_record();
- second_request.event_id = event_id(9);
- let mut second_decision = accepted_decision();
- second_decision.event_id = event_id(10);
- second_decision.root_event_id = event_id(9);
- second_decision.prev_event_id = event_id(9);
- let mut second_cancellation = cancellation(event_id(10));
- second_cancellation.event_id = event_id(13);
- second_cancellation.root_event_id = event_id(9);
- let mut wrong_order_request = request_record();
- wrong_order_request.event_id = event_id(14);
- wrong_order_request.payload.order_id = order_id("order-2");
- let mut wrong_order_decision = accepted_decision();
- wrong_order_decision.event_id = event_id(15);
- wrong_order_decision.payload.order_id = order_id("order-2");
- let mut wrong_order_cancellation = cancellation(event_id(2));
- wrong_order_cancellation.event_id = event_id(18);
- wrong_order_cancellation.payload.order_id = order_id("order-2");
-
- let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"))
- .with_root_event_id(event_id(1));
- let resolution = reduce_order_event_records_for_trade_locator(
- &locator,
- vec![
- RadrootsOrderEventRecord::Request(request_record()),
- RadrootsOrderEventRecord::Request(second_request),
- RadrootsOrderEventRecord::Request(wrong_order_request),
- RadrootsOrderEventRecord::Decision(accepted_decision()),
- RadrootsOrderEventRecord::Decision(second_decision),
- RadrootsOrderEventRecord::Decision(wrong_order_decision),
- RadrootsOrderEventRecord::Cancellation(cancellation(event_id(2))),
- RadrootsOrderEventRecord::Cancellation(second_cancellation),
- RadrootsOrderEventRecord::Cancellation(wrong_order_cancellation),
- ],
- );
-
- assert!(matches!(
- resolution,
- RadrootsTradeLocatorProjectionResolution::Projected { .. }
- ));
- }
-
- #[test]
- fn trade_locator_optional_qualifiers_reject_mismatched_request_fields() {
- for locator in [
- RadrootsTradeLocator::from_order_id(order_id("order-1"))
- .with_listing_addr(other_seller_listing_addr()),
- RadrootsTradeLocator::from_order_id(order_id("order-1"))
- .with_buyer_pubkey(public_key(OTHER)),
- RadrootsTradeLocator::from_order_id(order_id("order-1"))
- .with_seller_pubkey(public_key(OTHER)),
- ] {
- let resolution = reduce_order_event_records_for_trade_locator(
- &locator,
- vec![RadrootsOrderEventRecord::Request(request_record())],
- );
- assert!(matches!(
- resolution,
- RadrootsTradeLocatorProjectionResolution::Missing { .. }
- ));
- }
- }
-
- #[test]
- fn trade_locator_candidate_order_covers_each_tie_breaker() {
- let candidate = RadrootsTradeLocatorCandidate {
- trade_id: order_id("order-1").into(),
- root_event_id: event_id(1),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- };
- let mut right = candidate.clone();
- right.root_event_id = event_id(2);
- assert_eq!(
- super::trade_locator_candidate_order(&candidate, &right),
- core::cmp::Ordering::Less
- );
-
- let mut right = candidate.clone();
- right.trade_id = order_id("order-2").into();
- assert_eq!(
- super::trade_locator_candidate_order(&candidate, &right),
- core::cmp::Ordering::Less
- );
-
- let mut right = candidate.clone();
- right.listing_addr = other_seller_listing_addr();
- assert_eq!(
- super::trade_locator_candidate_order(&candidate, &right),
- core::cmp::Ordering::Less
- );
-
- let mut right = candidate.clone();
- right.buyer_pubkey = public_key(OTHER);
- assert_eq!(
- super::trade_locator_candidate_order(&candidate, &right),
- core::cmp::Ordering::Less
- );
-
- let left = RadrootsTradeLocatorCandidate {
- trade_id: order_id("order-1").into(),
- root_event_id: event_id(1),
- listing_addr: listing_addr(),
- buyer_pubkey: public_key(BUYER),
- seller_pubkey: public_key(SELLER),
- };
- let mut right = left.clone();
- right.seller_pubkey = public_key(OTHER);
- assert_eq!(
- super::trade_locator_candidate_order(&left, &right),
- core::cmp::Ordering::Less
- );
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn order_event_records_decode_wire_events_and_decode_errors() {
- let request = request_record();
- let request_parts =
- order_request_event_build(&listing_event_ptr(), &request.payload).unwrap();
- let request_record =
- super::order_event_record_from_event(&event_from_parts(11, BUYER, request_parts))
- .unwrap();
- assert!(matches!(
- request_record,
- RadrootsOrderEventRecord::Request(record)
- if record.event_id == event_id(11)
- && record.author_pubkey == public_key(BUYER)
- && record.payload.order_id == order_id("order-1")
- ));
-
- let decision = accepted_decision();
- let decision_parts = order_decision_event_build(
- &decision.root_event_id,
- &decision.prev_event_id,
- &decision.payload,
- )
- .unwrap();
- let decision_record =
- super::order_event_record_from_event(&event_from_parts(12, SELLER, decision_parts))
- .unwrap();
- assert!(matches!(
- decision_record,
- RadrootsOrderEventRecord::Decision(record)
- if record.event_id == event_id(12)
- && record.counterparty_pubkey == public_key(BUYER)
- && record.root_event_id == event_id(1)
- && record.prev_event_id == event_id(1)
- ));
-
- let cancellation = cancellation(event_id(1));
- let cancellation_parts = order_cancellation_event_build(
- &cancellation.root_event_id,
- &cancellation.prev_event_id,
- &cancellation.payload,
- )
- .unwrap();
- let cancellation_record =
- super::order_event_record_from_event(&event_from_parts(15, BUYER, cancellation_parts))
- .unwrap();
- assert!(matches!(
- cancellation_record,
- RadrootsOrderEventRecord::Cancellation(record)
- if record.event_id == event_id(15)
- && record.counterparty_pubkey == public_key(SELLER)
- && record.payload.reason == "changed plans"
- ));
-
- let unsupported = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: event_id(16).into_string(),
- author: BUYER.to_string(),
- created_at: 1,
- kind: 1,
- tags: Vec::new(),
- content: "{}".into(),
- sig: "f".repeat(128),
- })
- .expect("unsupported event");
- assert!(matches!(
- super::order_event_record_from_event(&unsupported),
- Err(super::RadrootsOrderEventDecodeError::UnsupportedKind { kind: 1 })
- ));
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn order_event_context_requirements_report_missing_chain_ids() {
- let context = radroots_event_codec::order::RadrootsOrderEventContext {
- counterparty_pubkey: public_key(BUYER),
- listing_event: None,
- root_event_id: None,
- prev_event_id: None,
- };
-
- assert!(matches!(
- super::require_context_root_event_id(&context),
- Err(super::RadrootsOrderEventDecodeError::MissingRootEventId)
- ));
- assert!(matches!(
- super::require_context_prev_event_id(&context),
- Err(super::RadrootsOrderEventDecodeError::MissingPreviousEventId)
- ));
- }
-
- #[test]
- fn reducer_groups_all_record_variants_and_skips_duplicate_event_ids() {
- let mut duplicate_decision = declined_decision();
- duplicate_decision.event_id = event_id(2);
- let projection = reduce_order_event_records(
- &order_id("order-1"),
- vec![
- RadrootsOrderEventRecord::Cancellation(cancellation(event_id(1))),
- RadrootsOrderEventRecord::Decision(accepted_decision()),
- RadrootsOrderEventRecord::Decision(duplicate_decision),
- RadrootsOrderEventRecord::Request(request_record()),
- ],
- );
-
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid);
- assert_order_issue_kind(
- &projection.issues,
- RadrootsOrderIssue::ForkedLifecycle {
- event_ids: Vec::new(),
- },
- );
- assert_eq!(
- super::projection_issue_event_ids(&projection.issues),
- vec![event_id(2), event_id(5)]
- );
-
- let mut duplicate_request = request_record();
- duplicate_request.payload.order_id = order_id("order-duplicate");
- let mut duplicate_request_later = duplicate_request.clone();
- duplicate_request_later.payload.buyer_pubkey = public_key(OTHER);
- let deduped =
- super::unique_request_records(vec![duplicate_request.clone(), duplicate_request_later]);
- assert_eq!(deduped.len(), 1);
- assert_eq!(
- deduped[0].payload.order_id,
- duplicate_request.payload.order_id
- );
- assert_eq!(
- deduped[0].payload.buyer_pubkey,
- duplicate_request.payload.buyer_pubkey
- );
- }
-
- #[test]
- fn reducer_deduplicates_same_event_id_in_each_typed_family() {
- let mut duplicate_request = request_record();
- duplicate_request.payload.order_id = order_id("order-duplicate-request");
- let requested = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record(), duplicate_request],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_eq!(requested.request_event_id, Some(event_id(1)));
-
- let mut duplicate_decision = accepted_decision();
- duplicate_decision.payload.order_id = order_id("order-duplicate-decision");
- let decided = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record()],
- decisions: vec![accepted_decision(), duplicate_decision],
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_eq!(decided.decision_event_id, Some(event_id(2)));
-
- let mut duplicate_cancellation = cancellation(event_id(1));
- duplicate_cancellation.payload.order_id = order_id("order-duplicate-cancellation");
- let cancelled = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record()],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: vec![cancellation(event_id(1)), duplicate_cancellation],
- },
- );
- assert_eq!(cancelled.cancellation_event_id, Some(event_id(5)));
- }
-
- #[test]
- fn canonicalize_order_request_reports_signer_listing_and_item_errors() {
- let canonical =
- super::canonicalize_order_request_for_signer(request_record().payload, BUYER).unwrap();
- assert_eq!(canonical.buyer_pubkey, public_key(BUYER));
- assert_eq!(canonical.seller_pubkey, public_key(SELLER));
-
- let mut unsorted_items = request_record().payload;
- unsorted_items.items.push(RadrootsOrderItem {
- bin_id: bin_id("bin-0"),
- bin_count: 1,
- });
- let canonical =
- super::canonicalize_order_request_for_signer(unsorted_items, BUYER).unwrap();
- assert_eq!(canonical.items[0].bin_id, bin_id("bin-0"));
- assert_eq!(canonical.items[1].bin_id, bin_id("bin-1"));
-
- assert!(matches!(
- super::canonicalize_order_request_for_signer(request_record().payload, SELLER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidBuyerSigner)
- ));
-
- let mut seller_mismatch = request_record().payload;
- seller_mismatch.seller_pubkey = public_key(OTHER);
- assert!(matches!(
- super::canonicalize_order_request_for_signer(seller_mismatch, BUYER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
- ));
-
- let mut missing_items = request_record().payload;
- missing_items.items.clear();
- assert!(matches!(
- super::canonicalize_order_request_for_signer(missing_items, BUYER),
- Err(super::RadrootsOrderCanonicalizationError::MissingItems)
- ));
-
- let mut zero_count = request_record().payload;
- zero_count.items[0].bin_count = 0;
- assert!(matches!(
- super::canonicalize_order_request_for_signer(zero_count, BUYER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidBinCount { index: 0 })
- ));
- }
-
- #[test]
- fn classified_listing_address_rejects_wrong_kind_before_typed_order_construction() {
- let raw = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg");
-
- assert!(matches!(
- RadrootsClassifiedListingAddress::parse(&raw),
- Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
- expected: KIND_CLASSIFIED_LISTING,
- actual: 30403,
- })
- ));
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn order_request_deserialization_rejects_wrong_listing_kind() {
- let mut value = serde_json::to_value(request_record().payload).expect("serialize request");
- value["listing_addr"] =
- serde_json::Value::String(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"));
-
- let error = serde_json::from_value::<RadrootsOrderRequest>(value)
- .expect_err("wrong listing kind must fail before constructing an order request");
- let message = error.to_string();
- assert!(message.contains("kind 30403"));
- assert!(message.contains("required kind 30402"));
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn order_event_codec_rejects_wrong_listing_kind_before_typed_record() {
- let wrong_addr = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg");
- let mut parts =
- order_request_event_build(&listing_event_ptr(), &request_record().payload).unwrap();
- let mut envelope: serde_json::Value =
- serde_json::from_str(&parts.content).expect("request envelope");
- envelope["listing_addr"] = serde_json::Value::String(wrong_addr.clone());
- parts.content = serde_json::to_string(&envelope).expect("mutated request envelope");
- let listing_tag = parts
- .tags
- .iter_mut()
- .find(|tag| tag.first().is_some_and(|name| name == "a"))
- .expect("listing address tag");
- listing_tag[1] = wrong_addr;
-
- assert!(matches!(
- super::order_event_record_from_event(&event_from_parts(17, BUYER, parts)),
- Err(super::RadrootsOrderEventDecodeError::Envelope(
- radroots_event_codec::order::RadrootsOrderEnvelopeParseError::InvalidListingAddr(
- radroots_event::ids::RadrootsIdParseError::UnexpectedKind {
- expected: KIND_CLASSIFIED_LISTING,
- actual: 30403,
- }
- )
- ))
- ));
- }
-
- #[test]
- fn canonicalize_order_decision_reports_signer_and_decision_errors() {
- let canonical =
- super::canonicalize_order_decision_for_signer(accepted_decision().payload, SELLER)
- .unwrap();
- assert_eq!(canonical.seller_pubkey, public_key(SELLER));
-
- let mut unsorted_commitments = accepted_decision().payload;
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = &mut unsorted_commitments.decision
- {
- inventory_commitments.push(RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-0"),
- bin_count: 1,
- });
- }
- let canonical =
- super::canonicalize_order_decision_for_signer(unsorted_commitments, SELLER).unwrap();
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = canonical.decision
- {
- assert_eq!(inventory_commitments[0].bin_id, bin_id("bin-0"));
- assert_eq!(inventory_commitments[1].bin_id, bin_id("bin-1"));
- }
-
- let mut listing_seller_mismatch = accepted_decision().payload;
- listing_seller_mismatch.listing_addr = other_seller_listing_addr();
- assert!(matches!(
- super::canonicalize_order_decision_for_signer(listing_seller_mismatch, SELLER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
- ));
-
- assert!(matches!(
- super::canonicalize_order_decision_for_signer(accepted_decision().payload, BUYER),
- Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing)
- ));
-
- let mut missing_commitments = accepted_decision().payload;
- missing_commitments.decision = RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments: Vec::new(),
- };
- assert!(matches!(
- super::canonicalize_order_decision_for_signer(missing_commitments, SELLER),
- Err(super::RadrootsOrderCanonicalizationError::MissingInventoryCommitments)
- ));
-
- let mut zero_commitment = accepted_decision().payload;
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = &mut zero_commitment.decision
- {
- inventory_commitments[0].bin_count = 0;
- }
- assert!(matches!(
- super::canonicalize_order_decision_for_signer(zero_commitment, SELLER),
- Err(
- super::RadrootsOrderCanonicalizationError::InvalidInventoryCommitmentCount {
- index: 0
- }
- )
- ));
-
- let mut declined = declined_decision().payload;
- declined.decision = RadrootsOrderDecisionOutcome::Declined {
- reason: " already sold ".into(),
- };
- let declined = super::canonicalize_order_decision_for_signer(declined, SELLER).unwrap();
- assert_eq!(
- declined.decision,
- RadrootsOrderDecisionOutcome::Declined {
- reason: "already sold".into()
- }
- );
-
- let mut blank_reason = declined_decision().payload;
- blank_reason.decision = RadrootsOrderDecisionOutcome::Declined { reason: " ".into() };
- assert!(matches!(
- super::canonicalize_order_decision_for_signer(blank_reason, SELLER),
- Err(super::RadrootsOrderCanonicalizationError::EmptyField(
- "reason"
- ))
- ));
- }
-
- #[cfg(feature = "serde_json")]
- #[test]
- fn order_economics_digest_is_stable_sha256_hex() {
- let digest = super::radroots_order_economics_digest(&economics(2)).unwrap();
- assert_eq!(
- digest,
- super::radroots_order_economics_digest(&economics(2)).unwrap()
- );
- assert!(digest.starts_with("sha256:"));
- assert_eq!(digest.len(), "sha256:".len() + 64);
- }
-
- #[test]
- fn order_helper_sorting_and_matching_paths_are_deterministic() {
- let request_items = vec![
- RadrootsOrderItem {
- bin_id: bin_id("bin-2"),
- bin_count: 1,
- },
- RadrootsOrderItem {
- bin_id: bin_id("bin-1"),
- bin_count: 2,
- },
- ];
- let matching_commitments = vec![
- RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-1"),
- bin_count: 2,
- },
- RadrootsOrderInventoryCommitment {
- bin_id: bin_id("bin-2"),
- bin_count: 1,
- },
- ];
- assert!(super::inventory_commitments_match_request(
- &request_items,
- &matching_commitments
- ));
- assert!(!super::inventory_commitments_match_request(
- &request_items,
- &matching_commitments[..1]
- ));
- let mut count_mismatch = matching_commitments.clone();
- count_mismatch[0].bin_count = 1;
- assert!(!super::inventory_commitments_match_request(
- &request_items,
- &count_mismatch
- ));
- let mut bin_mismatch = matching_commitments.clone();
- bin_mismatch[0].bin_id = bin_id("bin-3");
- assert!(!super::inventory_commitments_match_request(
- &request_items,
- &bin_mismatch
- ));
-
- let mut order_issues = vec![
- RadrootsOrderIssue::ForkedLifecycle {
- event_ids: vec![event_id(9), event_id(3)],
- },
- RadrootsOrderIssue::CancellationWithoutCancellableOrder {
- event_id: event_id(5),
- },
- RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: event_id(2),
- },
- RadrootsOrderIssue::MissingRequest,
- ];
- assert_eq!(
- super::projection_issue_event_ids(&order_issues),
- vec![event_id(2), event_id(3), event_id(5), event_id(9)]
- );
- order_issues.sort_by(super::order_issue_sort_key);
- assert!(matches!(
- order_issues[0],
- RadrootsOrderIssue::MissingRequest
- ));
- assert!(matches!(
- order_issues[1],
- RadrootsOrderIssue::DecisionPayloadInvalid { .. }
- ));
- assert!(matches!(
- order_issues[2],
- RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. }
- ));
- assert!(matches!(
- order_issues[3],
- RadrootsOrderIssue::ForkedLifecycle { .. }
- ));
-
- let mut tied_order_issues = [
- RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: event_id(8),
- },
- RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: event_id(7),
- },
- ];
- tied_order_issues.sort_by(super::order_issue_sort_key);
- let RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: issue_event_id,
- } = &tied_order_issues[0]
- else {
- panic!("expected decision issue");
- };
- assert_eq!(issue_event_id, &event_id(7));
-
- let mut inventory_issues = [
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
- bin_id: bin_id("bin-2"),
- available_count: 1,
- reserved_count: 2,
- event_ids: vec![event_id(8)],
- },
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-1"),
- event_ids: vec![event_id(7)],
- },
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: bin_id("bin-3"),
- event_ids: vec![event_id(6)],
- },
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
- order_id: order_id("order-1"),
- event_ids: vec![event_id(5)],
- },
- ];
- inventory_issues.sort_by(super::inventory_issue_sort_key);
- assert!(matches!(
- inventory_issues[0],
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. }
- ));
- assert!(matches!(
- inventory_issues[1],
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. }
- ));
- assert!(matches!(
- inventory_issues[2],
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. }
- ));
- assert!(matches!(
- inventory_issues[3],
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. }
- ));
-
- let mut tied_inventory_issues = [
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-2"),
- event_ids: vec![event_id(9)],
- },
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-1"),
- event_ids: vec![event_id(8)],
- },
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-1"),
- event_ids: vec![event_id(7)],
- },
- ];
- tied_inventory_issues.sort_by(super::inventory_issue_sort_key);
- assert_eq!(
- super::inventory_issue_id(&tied_inventory_issues[0]),
- "bin-1"
- );
- assert_eq!(
- super::inventory_issue_event_ids(&tied_inventory_issues[0]),
- &[event_id(7)]
- );
-
- let invalid = super::invalid_projection(
- &order_id("order-1"),
- Some(&request_record()),
- vec![RadrootsOrderIssue::MissingRequest],
- );
- assert_eq!(invalid.last_event_id, Some(event_id(1)));
- }
-
- #[test]
- fn order_issue_rank_and_event_id_helpers_cover_every_issue_variant() {
- let id = event_id(42);
- let event_ids = vec![id.clone()];
- let issues = vec![
- RadrootsOrderIssue::MissingRequest,
- RadrootsOrderIssue::MultipleRequests {
- event_ids: event_ids.clone(),
- },
- RadrootsOrderIssue::RequestPayloadInvalid {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::RequestOrderIdMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::RequestAuthorMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::RequestSellerListingMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionPayloadInvalid {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionOrderIdMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionAuthorMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionCounterpartyMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionBuyerMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionSellerMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionListingMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionRootMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionPreviousMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionMissingInventoryCommitments {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionInventoryCommitmentMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::DecisionMissingReason {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ConflictingDecisions {
- event_ids: event_ids.clone(),
- },
- RadrootsOrderIssue::CancellationWithoutCancellableOrder {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationPayloadInvalid {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationOrderIdMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationAuthorMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationCounterpartyMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationBuyerMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationSellerMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationListingMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationRootMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::CancellationPreviousMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ForkedLifecycle {
- event_ids: event_ids.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptOrderIdMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptTypeMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptRootMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptTargetMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ValidationReceiptListingMismatch {
- event_id: id.clone(),
- },
- RadrootsOrderIssue::ConflictingValidationReceipts {
- event_ids: event_ids.clone(),
- },
- RadrootsOrderIssue::DeterministicValidationFailure {
- event_id: id.clone(),
- reason: "failed".into(),
- },
- RadrootsOrderIssue::StaleListingEvent {
- expected_event_id: id.clone(),
- current_event_id: event_id(43),
- },
- ];
-
- for (rank, issue) in issues.iter().enumerate() {
- assert_eq!(super::order_issue_rank(issue), rank as u8);
- }
- assert_eq!(
- super::projection_issue_event_ids(&issues),
- vec![id, event_id(43)]
- );
-
- let mut projection = super::RadrootsOrderProjection {
- order_id: order_id("order-1"),
- status: RadrootsTradeWorkflowState::Invalid,
- request_event_id: None,
- decision_event_id: None,
- cancellation_event_id: None,
- validation_receipt_event_id: None,
- lifecycle_terminal: true,
- economics: None,
- agreement_event_id: None,
- pending_inventory_reservations: Vec::new(),
- committed_inventory_reservations: Vec::new(),
- listing_addr: None,
- buyer_pubkey: None,
- seller_pubkey: None,
- last_event_id: None,
- issues: vec![RadrootsOrderIssue::ValidationReceiptRootMismatch {
- event_id: event_id(44),
- }],
- };
- projection.finish_issue_state();
- assert_eq!(projection.last_event_id, Some(event_id(44)));
- }
-
- #[test]
- fn inventory_issue_helpers_cover_all_issue_variants() {
- let id = event_id(9);
- let issues = vec![
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
- order_id: order_id("order-1"),
- event_ids: vec![id.clone()],
- },
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: bin_id("bin-1"),
- event_ids: vec![id.clone()],
- },
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-2"),
- event_ids: vec![id.clone()],
- },
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
- bin_id: bin_id("bin-3"),
- available_count: 1,
- reserved_count: 2,
- event_ids: vec![id.clone()],
- },
- ];
-
- assert_eq!(super::inventory_issue_rank(&issues[0]), 0);
- assert_eq!(super::inventory_issue_rank(&issues[1]), 1);
- assert_eq!(super::inventory_issue_rank(&issues[2]), 2);
- assert_eq!(super::inventory_issue_rank(&issues[3]), 3);
- assert_eq!(super::inventory_issue_id(&issues[0]), "order-1");
- assert_eq!(super::inventory_issue_id(&issues[1]), "bin-1");
- assert_eq!(super::inventory_issue_id(&issues[2]), "bin-2");
- assert_eq!(super::inventory_issue_id(&issues[3]), "bin-3");
- for issue in &issues {
- assert_eq!(
- super::inventory_issue_event_ids(issue),
- std::slice::from_ref(&id)
- );
- }
- }
-
- #[test]
- fn reducer_reports_missing_request_for_each_non_request_input_family() {
- let decision_only = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: vec![accepted_decision()],
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_order_issue_kind(&decision_only.issues, RadrootsOrderIssue::MissingRequest);
-
- let cancellation_only = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: vec![cancellation(event_id(1))],
- },
- );
- assert_order_issue_kind(
- &cancellation_only.issues,
- RadrootsOrderIssue::MissingRequest,
- );
- }
-
- #[test]
- fn reducer_reports_multiple_valid_cancellations_as_forked_lifecycle() {
- let mut second_cancellation = cancellation(event_id(1));
- second_cancellation.event_id = event_id(6);
- let projection = reduce(
- Vec::new(),
- vec![cancellation(event_id(1)), second_cancellation],
- );
-
- assert_order_issue_kind(
- &projection.issues,
- RadrootsOrderIssue::ForkedLifecycle {
- event_ids: Vec::new(),
- },
- );
- assert_eq!(projection.last_event_id, Some(event_id(6)));
- }
-
- #[test]
- fn inventory_accounting_private_helpers_cover_merge_sort_and_overflow_paths() {
- let (bins, issues) = super::normalized_listing_inventory_bins(vec![
- RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-1"),
- available_count: 1,
- },
- RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-1"),
- available_count: 2,
- },
- ]);
- assert_eq!(issues, Vec::new());
- assert_eq!(bins[0].available_count, 3);
- assert_eq!(bins[0].remaining_count, 3);
-
- let mut overflow_bin = super::RadrootsOperationalListingInventoryBinAccounting {
- bin_id: bin_id("bin-overflow"),
- available_count: u64::MAX,
- pending_reserved_count: u64::MAX,
- committed_reserved_count: 0,
- remaining_count: u64::MAX,
- over_reserved: false,
- pending_orders: Vec::new(),
- committed_orders: Vec::new(),
- };
- let mut overflow_issues = Vec::new();
- super::add_inventory_reservation_event(
- &mut overflow_bin,
- &order_id("order-overflow"),
- &event_id(90),
- 1,
- &mut overflow_issues,
- );
- assert_inventory_issue_kind(
- &overflow_issues,
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: bin_id("bin-overflow"),
- event_ids: Vec::new(),
- },
- );
-
- let mut sorting_bin = super::RadrootsOperationalListingInventoryBinAccounting {
- bin_id: bin_id("bin-sort"),
- available_count: 1,
- pending_reserved_count: 2,
- committed_reserved_count: 0,
- remaining_count: 1,
- over_reserved: false,
- pending_orders: vec![
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-2"),
- agreement_event_id: event_id(92),
- bin_count: 1,
- },
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-1"),
- agreement_event_id: event_id(91),
- bin_count: 1,
- },
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-1"),
- agreement_event_id: event_id(90),
- bin_count: 1,
- },
- ],
- committed_orders: vec![
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-2"),
- agreement_event_id: event_id(95),
- bin_count: 1,
- },
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-1"),
- agreement_event_id: event_id(94),
- bin_count: 1,
- },
- super::RadrootsOperationalListingInventoryOrderReservation {
- order_id: order_id("order-1"),
- agreement_event_id: event_id(93),
- bin_count: 1,
- },
- ],
- };
- let mut finish_issues = Vec::new();
- super::finish_inventory_accounting_bins(
- core::slice::from_mut(&mut sorting_bin),
- &mut finish_issues,
- );
- assert_eq!(sorting_bin.remaining_count, 0);
- assert!(sorting_bin.over_reserved);
- assert_eq!(sorting_bin.pending_orders[0].order_id, order_id("order-1"));
- assert_eq!(
- sorting_bin.pending_orders[0].agreement_event_id,
- event_id(90)
- );
- assert_eq!(
- sorting_bin.committed_orders[0].order_id,
- order_id("order-1")
- );
- assert_eq!(
- sorting_bin.committed_orders[0].agreement_event_id,
- event_id(93)
- );
- assert_inventory_issue_kind(
- &finish_issues,
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
- bin_id: bin_id("bin-sort"),
- available_count: 1,
- reserved_count: 2,
- event_ids: Vec::new(),
- },
- );
-
- let mut fallback_request = request_record();
- fallback_request.event_id = event_id(95);
- let mut fallback_decision = accepted_decision();
- fallback_decision.event_id = event_id(93);
- let mut fallback_cancellation = cancellation(event_id(3));
- fallback_cancellation.event_id = event_id(91);
- let fallback_ids = super::fallback_order_event_ids(
- &[fallback_request],
- &[fallback_decision],
- &[fallback_cancellation],
- );
- assert_eq!(fallback_ids, vec![event_id(91), event_id(93), event_id(95)]);
- }
-
- #[test]
- fn reducer_reports_missing_duplicate_and_forked_lifecycles() {
- let missing = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_eq!(missing.status, RadrootsTradeWorkflowState::Missing);
-
- let missing_request = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: vec![accepted_decision()],
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_order_issue_kind(&missing_request.issues, RadrootsOrderIssue::MissingRequest);
-
- let mut duplicate_request = request_record();
- duplicate_request.event_id = event_id(6);
- let duplicate = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record(), duplicate_request],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_order_issue_kind(
- &duplicate.issues,
- RadrootsOrderIssue::MultipleRequests {
- event_ids: Vec::new(),
- },
- );
-
- let mut second_decision = declined_decision();
- second_decision.event_id = event_id(6);
- let conflicting = reduce(vec![accepted_decision(), second_decision], vec![]);
- assert_order_issue_kind(
- &conflicting.issues,
- RadrootsOrderIssue::ConflictingDecisions {
- event_ids: Vec::new(),
- },
- );
- }
-
- #[test]
- fn reducer_covers_cancellation_edge_paths() {
- let cancellation_after_decision =
- reduce(vec![declined_decision()], vec![cancellation(event_id(2))]);
- assert_order_issue_kind(
- &cancellation_after_decision.issues,
- RadrootsOrderIssue::ForkedLifecycle {
- event_ids: Vec::new(),
- },
- );
-
- let cancellation_previous_mismatch = reduce(Vec::new(), vec![cancellation(event_id(8))]);
- assert_order_issue_kind(
- &cancellation_previous_mismatch.issues,
- RadrootsOrderIssue::CancellationPreviousMismatch {
- event_id: event_id(5),
- },
- );
- }
-
- #[test]
- fn reducer_validators_report_request_and_decision_issue_kinds() {
- assert_request_issue(
- |request| request.payload.items.clear(),
- RadrootsOrderIssue::RequestPayloadInvalid {
- event_id: event_id(1),
- },
- );
- assert_request_issue(
- |request| request.payload.order_id = order_id("order-2"),
- RadrootsOrderIssue::RequestOrderIdMismatch {
- event_id: event_id(1),
- },
- );
- assert_request_issue(
- |request| request.author_pubkey = public_key(SELLER),
- RadrootsOrderIssue::RequestAuthorMismatch {
- event_id: event_id(1),
- },
- );
- assert_request_issue(
- |request| request.payload.seller_pubkey = public_key(OTHER),
- RadrootsOrderIssue::RequestSellerListingMismatch {
- event_id: event_id(1),
- },
- );
-
- assert_decision_issue(
- |decision| {
- decision.payload.decision = RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments: Vec::new(),
- };
- },
- RadrootsOrderIssue::DecisionMissingInventoryCommitments {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| {
- decision.payload.decision =
- RadrootsOrderDecisionOutcome::Declined { reason: " ".into() };
- },
- RadrootsOrderIssue::DecisionMissingReason {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.payload.order_id = order_id("order-2"),
- RadrootsOrderIssue::DecisionOrderIdMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.author_pubkey = public_key(BUYER),
- RadrootsOrderIssue::DecisionAuthorMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.counterparty_pubkey = public_key(SELLER),
- RadrootsOrderIssue::DecisionCounterpartyMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.payload.buyer_pubkey = public_key(SELLER),
- RadrootsOrderIssue::DecisionBuyerMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.payload.seller_pubkey = public_key(BUYER),
- RadrootsOrderIssue::DecisionSellerMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.payload.listing_addr = other_seller_listing_addr(),
- RadrootsOrderIssue::DecisionListingMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.root_event_id = event_id(8),
- RadrootsOrderIssue::DecisionRootMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| decision.prev_event_id = event_id(8),
- RadrootsOrderIssue::DecisionPreviousMismatch {
- event_id: event_id(2),
- },
- );
- assert_decision_issue(
- |decision| {
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = &mut decision.payload.decision
- {
- inventory_commitments[0].bin_count = 1;
- }
- },
- RadrootsOrderIssue::DecisionInventoryCommitmentMismatch {
- event_id: event_id(2),
- },
- );
- }
-
- #[test]
- fn reducer_validators_report_cancellation_issue_kinds() {
- assert_cancellation_issue(
- |cancellation| cancellation.payload.reason = " ".into(),
- RadrootsOrderIssue::CancellationPayloadInvalid {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.payload.order_id = order_id("order-2"),
- RadrootsOrderIssue::CancellationOrderIdMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.author_pubkey = public_key(SELLER),
- RadrootsOrderIssue::CancellationAuthorMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.counterparty_pubkey = public_key(BUYER),
- RadrootsOrderIssue::CancellationCounterpartyMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.payload.buyer_pubkey = public_key(SELLER),
- RadrootsOrderIssue::CancellationBuyerMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.payload.seller_pubkey = public_key(BUYER),
- RadrootsOrderIssue::CancellationSellerMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.payload.listing_addr = other_seller_listing_addr(),
- RadrootsOrderIssue::CancellationListingMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.root_event_id = event_id(8),
- RadrootsOrderIssue::CancellationRootMismatch {
- event_id: event_id(5),
- },
- );
- assert_cancellation_issue(
- |cancellation| cancellation.prev_event_id = event_id(5),
- RadrootsOrderIssue::CancellationPreviousMismatch {
- event_id: event_id(5),
- },
- );
- }
-
- #[test]
- fn reducer_reports_invalid_records_from_all_non_request_families() {
- let mut bad_request = request_record();
- bad_request.payload.order_id = order_id("order-2");
- let invalid_request = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![bad_request],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_order_issue_kind(
- &invalid_request.issues,
- RadrootsOrderIssue::RequestOrderIdMismatch {
- event_id: event_id(1),
- },
- );
-
- let mut bad_decision = accepted_decision();
- bad_decision.payload.order_id = order_id("order-2");
- let mut bad_cancellation = cancellation(event_id(1));
- bad_cancellation.payload.order_id = order_id("order-2");
- let invalid_non_requests = reduce_order_events(
- &order_id("order-1"),
- RadrootsOrderReductionInputs {
- requests: vec![request_record()],
- decisions: vec![bad_decision],
- cancellations: vec![bad_cancellation],
- },
- );
-
- assert_order_issue_kind(
- &invalid_non_requests.issues,
- RadrootsOrderIssue::DecisionOrderIdMismatch {
- event_id: event_id(2),
- },
- );
- assert_order_issue_kind(
- &invalid_non_requests.issues,
- RadrootsOrderIssue::CancellationOrderIdMismatch {
- event_id: event_id(5),
- },
- );
- }
-
- #[test]
- fn inventory_accounting_reports_invalid_unknown_overreserved_and_terminal_orders() {
- let mut unknown_bin_request = request_record();
- unknown_bin_request.event_id = event_id(40);
- unknown_bin_request.payload.order_id = order_id("order-4");
- unknown_bin_request.payload.items[0].bin_id = bin_id("bin-missing");
- unknown_bin_request.payload.economics.items[0].bin_id = bin_id("bin-missing");
-
- let mut unknown_bin_decision = accepted_decision();
- unknown_bin_decision.event_id = event_id(41);
- unknown_bin_decision.root_event_id = event_id(40);
- unknown_bin_decision.prev_event_id = event_id(40);
- unknown_bin_decision.payload.order_id = order_id("order-4");
- if let RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments,
- } = &mut unknown_bin_decision.payload.decision
- {
- inventory_commitments[0].bin_id = bin_id("bin-missing");
- }
-
- let mut declined_request = request_record();
- declined_request.event_id = event_id(20);
- declined_request.payload.order_id = order_id("order-2");
- let mut terminal_decline = declined_decision();
- terminal_decline.event_id = event_id(21);
- terminal_decline.root_event_id = event_id(20);
- terminal_decline.prev_event_id = event_id(20);
- terminal_decline.payload.order_id = order_id("order-2");
-
- let mut cancelled_request = request_record();
- cancelled_request.event_id = event_id(30);
- cancelled_request.payload.order_id = order_id("order-3");
- let mut terminal_cancellation = cancellation(event_id(30));
- terminal_cancellation.event_id = event_id(31);
- terminal_cancellation.root_event_id = event_id(30);
- terminal_cancellation.payload.order_id = order_id("order-3");
-
- let projection = reduce_operational_listing_inventory_accounting(
- &listing_addr(),
- &event_id(9),
- RadrootsOperationalListingInventoryAccountingInputs {
- bins: vec![
- RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-1"),
- available_count: 1,
- },
- RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-overflow"),
- available_count: u64::MAX,
- },
- RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-overflow"),
- available_count: 1,
- },
- ],
- requests: vec![
- request_record(),
- unknown_bin_request,
- declined_request,
- cancelled_request,
- ],
- decisions: vec![accepted_decision(), unknown_bin_decision, terminal_decline],
- cancellations: vec![terminal_cancellation],
- },
- );
-
- assert_eq!(projection.declined_order_ids, vec![order_id("order-2")]);
- assert_eq!(projection.cancelled_order_ids, vec![order_id("order-3")]);
- assert_inventory_issue_kind(
- &projection.issues,
- RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow {
- bin_id: bin_id("bin-overflow"),
- event_ids: Vec::new(),
- },
- );
- assert_inventory_issue_kind(
- &projection.issues,
- RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin {
- bin_id: bin_id("bin-missing"),
- event_ids: Vec::new(),
- },
- );
- assert_inventory_issue_kind(
- &projection.issues,
- RadrootsOperationalListingInventoryAccountingIssue::OverReserved {
- bin_id: bin_id("bin-1"),
- available_count: 0,
- reserved_count: 0,
- event_ids: Vec::new(),
- },
- );
-
- let invalid_without_request = reduce_operational_listing_inventory_accounting(
- &listing_addr(),
- &event_id(9),
- RadrootsOperationalListingInventoryAccountingInputs {
- bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(),
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: vec![accepted_decision()],
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_eq!(invalid_without_request.invalid_event_ids, vec![event_id(2)]);
- assert_inventory_issue_kind(
- &invalid_without_request.issues,
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
- order_id: order_id("order-1"),
- event_ids: Vec::new(),
- },
- );
-
- let mut duplicate_request = request_record();
- duplicate_request.event_id = event_id(11);
- let invalid_duplicate_requests = reduce_operational_listing_inventory_accounting(
- &listing_addr(),
- &event_id(9),
- RadrootsOperationalListingInventoryAccountingInputs {
- bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(),
- requests: vec![request_record(), duplicate_request],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- assert_eq!(
- invalid_duplicate_requests.invalid_event_ids,
- vec![event_id(1), event_id(11)]
- );
- assert_inventory_issue_kind(
- &invalid_duplicate_requests.issues,
- RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder {
- order_id: order_id("order-1"),
- event_ids: Vec::new(),
- },
- );
- }
-
- #[test]
- fn reducer_projects_requested_order() {
- let projection = reduce(Vec::new(), Vec::new());
-
- assert_eq!(projection.issues, Vec::new());
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Requested);
- assert_eq!(projection.request_event_id, Some(event_id(1)));
- assert!(!projection.lifecycle_terminal);
- assert!(projection.agreement_event_id.is_none());
- }
-
- #[test]
- fn workflow_projection_dto_preserves_missing_and_requested_state() {
- let missing = reduce_order_events(
- &order_id("missing-order"),
- RadrootsOrderReductionInputs {
- requests: Vec::<RadrootsOrderRequestRecord>::new(),
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
- let missing_dto = RadrootsOrderWorkflowProjection::from(&missing);
-
- assert_eq!(missing_dto.status, RadrootsTradeWorkflowState::Missing);
- assert!(missing_dto.request_event_id.is_none());
- assert!(missing_dto.last_event_id.is_none());
- assert!(missing_dto.listing_addr.is_none());
- assert!(missing_dto.buyer_pubkey.is_none());
- assert!(missing_dto.seller_pubkey.is_none());
- assert!(missing_dto.economics.is_none());
- assert!(missing_dto.issues.is_empty());
-
- #[cfg(feature = "serde_json")]
- {
- let json = serde_json::to_value(&missing_dto).expect("missing projection json");
- assert_eq!(json["status"], "missing");
- assert!(json["request_event_id"].is_null());
- assert!(json["last_event_id"].is_null());
- assert!(json.get("root_event_id").is_none());
- assert!(json.get("last_message_type").is_none());
- assert!(json.get("last_discount_request").is_none());
- }
-
- let requested = reduce(Vec::new(), Vec::new());
- let requested_dto = RadrootsOrderWorkflowProjection::from(&requested);
-
- assert_eq!(requested_dto.status, RadrootsTradeWorkflowState::Requested);
- assert_eq!(requested_dto.request_event_id, Some(event_id(1)));
- assert_eq!(requested_dto.last_event_id, Some(event_id(1)));
- assert_eq!(requested_dto.listing_addr, Some(listing_addr()));
- assert_eq!(requested_dto.buyer_pubkey, Some(public_key(BUYER)));
- assert_eq!(requested_dto.seller_pubkey, Some(public_key(SELLER)));
- assert!(requested_dto.economics.is_some());
- assert!(requested_dto.issues.is_empty());
- }
-
- #[test]
- fn reducer_projects_accepted_order_agreement() {
- let projection = reduce(vec![accepted_decision()], Vec::new());
-
- assert_eq!(
- projection.status,
- RadrootsTradeWorkflowState::AgreedPendingValidation
- );
- assert_eq!(projection.decision_event_id, Some(event_id(2)));
- assert_eq!(projection.agreement_event_id, Some(event_id(2)));
- assert!(!projection.lifecycle_terminal);
- assert_eq!(projection.pending_inventory_reservations.len(), 1);
- assert!(projection.committed_inventory_reservations.is_empty());
- }
-
- #[test]
- fn reducer_projects_declined_order() {
- let projection = reduce(vec![declined_decision()], Vec::new());
-
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Declined);
- assert_eq!(projection.decision_event_id, Some(event_id(2)));
- assert!(projection.lifecycle_terminal);
- }
-
- #[test]
- fn reducer_allows_pre_agreement_cancellation() {
- let projection = reduce(Vec::new(), vec![cancellation(event_id(1))]);
-
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Cancelled);
- assert_eq!(projection.cancellation_event_id, Some(event_id(5)));
- assert!(projection.lifecycle_terminal);
- }
-
- #[test]
- fn reducer_rejects_cancellation_after_agreement() {
- let projection = reduce(vec![accepted_decision()], vec![cancellation(event_id(2))]);
-
- assert_eq!(projection.status, RadrootsTradeWorkflowState::Invalid);
- assert!(projection.lifecycle_terminal);
- }
-
- #[test]
- fn reducer_groups_event_records() {
- let projection = reduce_order_event_records(
- &order_id("order-1"),
- vec![
- RadrootsOrderEventRecord::Request(request_record()),
- RadrootsOrderEventRecord::Decision(accepted_decision()),
- ],
- );
-
- assert_eq!(
- projection.status,
- RadrootsTradeWorkflowState::AgreedPendingValidation
- );
- assert_eq!(projection.agreement_event_id, Some(event_id(2)));
- }
-
- #[test]
- fn inventory_accounting_reserves_only_accepted_agreements() {
- let requested_projection = reduce_operational_listing_inventory_accounting(
- &listing_addr(),
- &event_id(8),
- RadrootsOperationalListingInventoryAccountingInputs {
- bins: vec![RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-1"),
- available_count: 3,
- }],
- requests: vec![request_record()],
- decisions: Vec::<RadrootsOrderDecisionRecord>::new(),
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
-
- assert_eq!(requested_projection.bins[0].pending_reserved_count, 0);
- assert_eq!(requested_projection.bins[0].remaining_count, 3);
-
- let projection = reduce_operational_listing_inventory_accounting(
- &listing_addr(),
- &event_id(9),
- RadrootsOperationalListingInventoryAccountingInputs {
- bins: vec![RadrootsOperationalListingInventoryBinAvailability {
- bin_id: bin_id("bin-1"),
- available_count: 3,
- }],
- requests: vec![request_record()],
- decisions: vec![accepted_decision()],
- cancellations: Vec::<RadrootsOrderCancellationRecord>::new(),
- },
- );
-
- assert_eq!(projection.bins[0].pending_reserved_count, 2);
- assert_eq!(projection.bins[0].remaining_count, 1);
- assert_eq!(
- projection.bins[0].pending_orders[0].agreement_event_id,
- event_id(2)
- );
- }
-}
diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs
@@ -1,1508 +0,0 @@
-#![forbid(unsafe_code)]
-
-use std::collections::{BTreeMap, BTreeSet};
-
-use radroots_event::{
- RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts,
- classified_listing::{
- RadrootsClassifiedListingPartition, classify_classified_listing_tags,
- },
- ids::{RadrootsEventId, RadrootsIdParseError, RadrootsClassifiedListingAddress, RadrootsOrderId},
- kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_classified_listing_kind, is_order_event_kind},
- operational_listing::{RadrootsOperationalListingAvailability, RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingStatus},
- order::RadrootsOrderEventType,
- tags::TAG_D,
-};
-use radroots_event_codec::{
- order::{RadrootsOrderEnvelopeParseError, order_event_context_from_tags},
- verification::{RadrootsNip01VerificationError, verify_nip01_event},
-};
-use radroots_event_store::{
- RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore, RadrootsEventStoreError,
- RadrootsProjectionCursor, RadrootsStoredEvent,
-};
-use sqlx::Row;
-use thiserror::Error;
-
-use crate::{
- identity::RadrootsTradeLocator,
- operational_listing::validation::{RadrootsOperationalListingTradeProjection, validate_operational_listing_event},
- order::{
- RadrootsGroupedOrderEventRecords, RadrootsOrderEventDecodeError, RadrootsOrderEventRecord,
- RadrootsOrderProjectionQueryResult, RadrootsTradeLocatorProjectionQueryResult,
- RadrootsTradeLocatorProjectionResolution, order_event_record_from_event,
- },
- validation_receipt::{RadrootsValidationReceiptError, validation_receipt_from_event},
- workflow::{
- RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState,
- RadrootsTradeWorkflowValidationReceiptRecord, reduce_trade_workflow_records,
- reduce_trade_workflow_records_for_trade_locator,
- },
-};
-use sha2::{Digest, Sha256};
-
-pub const RADROOTS_PRODUCT_PROJECTION_ID: &str = "radroots.product_projection.v1";
-pub const RADROOTS_PRODUCT_PROJECTION_VERSION: u32 = 1;
-pub const RADROOTS_TRADE_VALIDATION_RECEIPT_CONTRACT_ID: &str =
- "radroots.trade.validation_receipt.v1";
-
-const PRODUCT_PROJECTION_CONTRACT_IDS: [&str; 4] = [
- "radroots.order.request.v1",
- "radroots.order.decision.v1",
- "radroots.order.cancellation.v1",
- RADROOTS_TRADE_VALIDATION_RECEIPT_CONTRACT_ID,
-];
-
-#[derive(Debug, Error)]
-pub enum RadrootsTradeProjectionError {
- #[error("{0}")]
- Store(#[from] RadrootsEventStoreError),
- #[error("projection sqlite query failed: {0}")]
- Sqlite(#[from] sqlx::Error),
- #[error("stored event {event_id} contains invalid tags_json: {source}")]
- InvalidStoredTagsJson {
- event_id: String,
- source: serde_json::Error,
- },
- #[error("stored event {event_id} contains invalid envelope data: {source}")]
- InvalidStoredEnvelope {
- event_id: String,
- source: RadrootsEventEnvelopeError,
- },
- #[error("stored event {event_id} failed NIP-01 verification: {source}")]
- StoredEventVerification {
- event_id: String,
- source: RadrootsNip01VerificationError,
- },
- #[error("stored event {event_id} created_at {created_at} exceeds sqlite integer range")]
- StoredCreatedAtRange { event_id: String, created_at: u64 },
- #[error("stored listing event {event_id} failed validation: {source}")]
- ListingValidation {
- event_id: String,
- source: radroots_event::trade_validation::RadrootsOperationalListingValidationError,
- },
- #[error("stored order event {event_id} could not decode as an order record: {source}")]
- OrderDecode {
- event_id: String,
- source: RadrootsOrderEventDecodeError,
- },
- #[error("stored order event {event_id} has invalid context tags: {source}")]
- OrderContext {
- event_id: String,
- source: RadrootsOrderEnvelopeParseError,
- },
- #[error("stored validation receipt event {event_id} failed validation: {source}")]
- ValidationReceipt {
- event_id: String,
- source: RadrootsValidationReceiptError,
- },
- #[error("stored validation receipt event {event_id} has invalid order id: {source}")]
- ValidationReceiptOrderId {
- event_id: String,
- source: RadrootsIdParseError,
- },
- #[error("stored validation receipt event {event_id} has invalid event id: {source}")]
- ValidationReceiptEventId {
- event_id: String,
- source: RadrootsIdParseError,
- },
- #[error("stored listing projection has invalid listing_addr {listing_addr}: {source}")]
- ClassifiedListingAddress {
- listing_addr: String,
- source: RadrootsIdParseError,
- },
- #[error("projection serialization failed for {model}: {source}")]
- Serialize {
- model: &'static str,
- source: serde_json::Error,
- },
- #[error("projection query limit must be between 1 and {max}")]
- InvalidLimit { max: u32 },
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct RadrootsProjectionRefreshRequest {
- pub limit: u32,
-}
-
-impl Default for RadrootsProjectionRefreshRequest {
- fn default() -> Self {
- Self {
- limit: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- }
- }
-}
-
-impl RadrootsProjectionRefreshRequest {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn with_limit(mut self, limit: u32) -> Self {
- self.limit = limit;
- self
- }
-
- fn validate(self) -> Result<Self, RadrootsTradeProjectionError> {
- if self.limit == 0 || self.limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX {
- return Err(RadrootsTradeProjectionError::InvalidLimit {
- max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- });
- }
- Ok(self)
- }
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct RadrootsProjectionRefreshReceipt {
- pub scanned_events: usize,
- pub operational_listing_upserts: usize,
- pub trade_upserts: usize,
- pub validation_receipts: usize,
- pub transport_observations: i64,
- pub last_event_seq: Option<i64>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingProjectionRow {
- pub listing_addr: RadrootsClassifiedListingAddress,
- pub listing_event_id: String,
- pub seller_pubkey: String,
- pub title: String,
- pub description: String,
- pub product_type: String,
- pub price_amount: String,
- pub price_currency: String,
- pub inventory_available: String,
- pub delivery_method: String,
- pub locality_primary: String,
- pub locality_city: Option<String>,
- pub locality_region: Option<String>,
- pub locality_country: Option<String>,
- pub geohash5: String,
- pub updated_at_ms: i64,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RadrootsOperationalListingSearchRequest {
- pub query: String,
- pub limit: u32,
-}
-
-impl RadrootsOperationalListingSearchRequest {
- pub fn new(query: impl Into<String>) -> Self {
- Self {
- query: query.into(),
- limit: 50,
- }
- }
-
- pub fn with_limit(mut self, limit: u32) -> Self {
- self.limit = limit;
- self
- }
-
- fn validate(&self) -> Result<(), RadrootsTradeProjectionError> {
- if self.limit == 0 || self.limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX {
- return Err(RadrootsTradeProjectionError::InvalidLimit {
- max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- });
- }
- Ok(())
- }
-}
-
-pub async fn refresh_product_projections(
- store: &RadrootsEventStore,
- request: RadrootsProjectionRefreshRequest,
- updated_at_ms: i64,
-) -> Result<RadrootsProjectionRefreshReceipt, RadrootsTradeProjectionError> {
- let request = request.validate()?;
- let events = store
- .events_since_cursor(RADROOTS_PRODUCT_PROJECTION_ID, request.limit)
- .await?;
- let mut receipt = RadrootsProjectionRefreshReceipt {
- scanned_events: events.len(),
- ..RadrootsProjectionRefreshReceipt::default()
- };
- let mut affected_orders = BTreeSet::new();
-
- for stored_event in &events {
- receipt.last_event_seq = Some(stored_event.seq);
- receipt.transport_observations +=
- transport_observation_count_for_event(store, &stored_event.event_id).await?;
- if is_classified_listing_kind(stored_event.kind) {
- let event = stored_event_to_nostr_event(stored_event)?;
- if classify_classified_listing_tags(event.tags())
- != RadrootsClassifiedListingPartition::OperationalListing
- {
- continue;
- }
- let verified_event = verify_nip01_event(event).map_err(|source| {
- RadrootsTradeProjectionError::StoredEventVerification {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- let listing = validate_operational_listing_event(&verified_event).map_err(|source| {
- RadrootsTradeProjectionError::ListingValidation {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- upsert_operational_listing_projection(store, stored_event, &listing, updated_at_ms)
- .await?;
- receipt.operational_listing_upserts += 1;
- } else if is_order_event_kind(stored_event.kind) {
- let event = stored_event_to_nostr_event(stored_event)?;
- let record = order_event_record_from_event(&event).map_err(|source| {
- RadrootsTradeProjectionError::OrderDecode {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- affected_orders.insert(record.order_id().clone());
- } else if stored_event.kind == KIND_TRADE_VALIDATION_RECEIPT {
- let event = stored_event_to_nostr_event(stored_event)?;
- let verified = validation_receipt_from_event(&event).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceipt {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- let order_id =
- RadrootsOrderId::parse(verified.tags.order_id.as_str()).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceiptOrderId {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- affected_orders.insert(order_id);
- receipt.validation_receipts += 1;
- }
- }
-
- for order_id in affected_orders {
- receipt.trade_upserts +=
- upsert_trade_projection(store, &order_id, request.limit, updated_at_ms).await?;
- }
-
- if let Some(last_event_seq) = receipt.last_event_seq {
- store
- .update_projection_cursor(&RadrootsProjectionCursor {
- projection_id: RADROOTS_PRODUCT_PROJECTION_ID.to_owned(),
- projection_version: RADROOTS_PRODUCT_PROJECTION_VERSION,
- last_event_seq,
- updated_at_ms,
- })
- .await?;
- }
-
- Ok(receipt)
-}
-
-pub async fn search_operational_listing_projection(
- store: &RadrootsEventStore,
- request: &RadrootsOperationalListingSearchRequest,
-) -> Result<Vec<RadrootsOperationalListingProjectionRow>, RadrootsTradeProjectionError> {
- request.validate()?;
- let rows = if let Some(query) = operational_listing_fts_query(&request.query) {
- sqlx::query(
- "SELECT p.listing_addr, p.listing_event_id, p.seller_pubkey, p.title, p.description, p.product_type, p.price_amount, p.price_currency, p.inventory_available, p.delivery_method, p.locality_primary, p.locality_city, p.locality_region, p.locality_country, p.geohash5, p.updated_at_ms FROM listing_projection p JOIN listing_search_fts f ON f.listing_addr = p.listing_addr WHERE listing_search_fts MATCH ? ORDER BY bm25(listing_search_fts), p.updated_at_ms DESC, p.listing_addr LIMIT ?",
- )
- .bind(query)
- .bind(i64::from(request.limit))
- .fetch_all(store.pool())
- .await?
- } else {
- sqlx::query(
- "SELECT listing_addr, listing_event_id, seller_pubkey, title, description, product_type, price_amount, price_currency, inventory_available, delivery_method, locality_primary, locality_city, locality_region, locality_country, geohash5, updated_at_ms FROM listing_projection ORDER BY updated_at_ms DESC, listing_addr LIMIT ?",
- )
- .bind(i64::from(request.limit))
- .fetch_all(store.pool())
- .await?
- };
- rows.into_iter()
- .map(operational_listing_projection_row)
- .collect()
-}
-
-pub async fn trade_projection_query_for_order_id(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
-) -> Result<RadrootsOrderProjectionQueryResult, RadrootsTradeProjectionError> {
- if limit == 0 || limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX {
- return Err(RadrootsTradeProjectionError::InvalidLimit {
- max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- });
- }
- let inputs = trade_projection_inputs_for_order_id(store, order_id, limit).await?;
- let projection = reduce_trade_workflow_records(order_id, inputs.workflow_records);
- Ok(RadrootsOrderProjectionQueryResult {
- projection,
- event_count: inputs.event_ids.len(),
- limit_applied: limit,
- event_ids: inputs.event_ids,
- })
-}
-
-pub async fn trade_projection_query_for_trade_locator(
- store: &RadrootsEventStore,
- locator: &RadrootsTradeLocator,
- limit: u32,
-) -> Result<RadrootsTradeLocatorProjectionQueryResult, RadrootsTradeProjectionError> {
- if limit == 0 || limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX {
- return Err(RadrootsTradeProjectionError::InvalidLimit {
- max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
- });
- }
- let inputs = trade_projection_inputs_for_order_id(store, locator.order_id(), limit).await?;
- let resolution =
- reduce_trade_workflow_records_for_trade_locator(locator, inputs.workflow_records);
- Ok(RadrootsTradeLocatorProjectionQueryResult {
- resolution,
- event_count: inputs.event_ids.len(),
- limit_applied: limit,
- event_ids: inputs.event_ids,
- })
-}
-
-async fn upsert_operational_listing_projection(
- store: &RadrootsEventStore,
- stored_event: &RadrootsStoredEvent,
- listing: &RadrootsOperationalListingTradeProjection,
- updated_at_ms: i64,
-) -> Result<(), RadrootsTradeProjectionError> {
- let listing_json = serde_json::to_string(&listing.listing).map_err(|source| {
- RadrootsTradeProjectionError::Serialize {
- model: "listing",
- source,
- }
- })?;
- let location = &listing.location;
- let locality = listing_locality_search_text(listing);
- sqlx::query(
- "INSERT INTO listing_projection(listing_addr, listing_event_id, seller_pubkey, farm_pubkey, farm_d_tag, listing_d_tag, title, description, product_type, primary_bin_id, quantity_amount, quantity_unit, price_amount, price_currency, inventory_available, availability_status, delivery_method, locality_primary, locality_city, locality_region, locality_country, geohash5, listing_json, source_event_seq, created_at, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(listing_addr) DO UPDATE SET listing_event_id = excluded.listing_event_id, seller_pubkey = excluded.seller_pubkey, farm_pubkey = excluded.farm_pubkey, farm_d_tag = excluded.farm_d_tag, listing_d_tag = excluded.listing_d_tag, title = excluded.title, description = excluded.description, product_type = excluded.product_type, primary_bin_id = excluded.primary_bin_id, quantity_amount = excluded.quantity_amount, quantity_unit = excluded.quantity_unit, price_amount = excluded.price_amount, price_currency = excluded.price_currency, inventory_available = excluded.inventory_available, availability_status = excluded.availability_status, delivery_method = excluded.delivery_method, locality_primary = excluded.locality_primary, locality_city = excluded.locality_city, locality_region = excluded.locality_region, locality_country = excluded.locality_country, geohash5 = excluded.geohash5, listing_json = excluded.listing_json, source_event_seq = excluded.source_event_seq, created_at = excluded.created_at, updated_at_ms = excluded.updated_at_ms",
- )
- .bind(listing.listing_addr.as_str())
- .bind(stored_event.event_id.as_str())
- .bind(listing.seller_pubkey.as_str())
- .bind(listing.listing.farm.pubkey.as_str())
- .bind(listing.listing.farm.d_tag.as_str())
- .bind(listing.listing.d_tag.as_str())
- .bind(listing.title.as_str())
- .bind(listing.description.as_str())
- .bind(listing.product_type.as_str())
- .bind(listing.primary_bin_id.as_str())
- .bind(listing.bin_quantity.amount.to_string())
- .bind(listing.unit.to_string())
- .bind(listing.unit_price.amount.to_string())
- .bind(listing.unit_price.currency.to_string())
- .bind(listing.inventory_available.to_string())
- .bind(listing_availability_label(&listing.availability))
- .bind(listing_delivery_method_label(&listing.delivery_method))
- .bind(location.primary.as_str())
- .bind(location.city.as_deref())
- .bind(location.region.as_deref())
- .bind(location.country.as_deref())
- .bind(location.geohash.as_str())
- .bind(listing_json)
- .bind(stored_event.seq)
- .bind(i64::try_from(stored_event.created_at).map_err(|_| {
- RadrootsTradeProjectionError::StoredCreatedAtRange {
- event_id: stored_event.event_id.clone(),
- created_at: stored_event.created_at,
- }
- })?)
- .bind(updated_at_ms)
- .execute(store.pool())
- .await?;
-
- sqlx::query("DELETE FROM listing_search_fts WHERE listing_addr = ?")
- .bind(listing.listing_addr.as_str())
- .execute(store.pool())
- .await?;
- sqlx::query(
- "INSERT INTO listing_search_fts(listing_addr, title, description, product_type, locality, seller_pubkey) VALUES (?, ?, ?, ?, ?, ?)",
- )
- .bind(listing.listing_addr.as_str())
- .bind(listing.title.as_str())
- .bind(listing.description.as_str())
- .bind(listing.product_type.as_str())
- .bind(locality)
- .bind(listing.seller_pubkey.as_str())
- .execute(store.pool())
- .await?;
- Ok(())
-}
-
-async fn upsert_trade_projection(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
- updated_at_ms: i64,
-) -> Result<usize, RadrootsTradeProjectionError> {
- let inputs = trade_projection_inputs_for_order_id(store, order_id, limit).await?;
- let mut root_event_ids = inputs
- .workflow_records
- .order_events
- .requests
- .iter()
- .map(|request| request.event_id.clone())
- .collect::<Vec<_>>();
- root_event_ids.sort();
- root_event_ids.dedup();
- let mut upserts = 0;
- for root_event_id in root_event_ids {
- let mut workflow_records = inputs.workflow_records.clone();
- let expected_listing_event_id = inputs
- .expected_listing_event_ids
- .get(&root_event_id)
- .cloned()
- .flatten();
- let current_listing_event_id = inputs
- .current_listing_event_ids
- .get(&root_event_id)
- .cloned()
- .flatten();
- workflow_records.expected_listing_event_id = expected_listing_event_id.clone();
- workflow_records.current_listing_event_id = current_listing_event_id.clone();
- let locator =
- RadrootsTradeLocator::from_order_id(order_id.clone()).with_root_event_id(root_event_id);
- let RadrootsTradeLocatorProjectionResolution::Projected {
- locator,
- projection,
- } = reduce_trade_workflow_records_for_trade_locator(&locator, workflow_records)
- else {
- continue;
- };
- let Some(root_event_id) = locator.root_event_id else {
- continue;
- };
- let event_ids = projection_source_event_ids(&root_event_id, &projection);
- let source_event_count = event_ids.len();
- let transport_observation_count =
- transport_observation_count_for_events(store, &event_ids).await?;
- let evidence_hash = projection_evidence_hash(&event_ids);
- upsert_trade_projection_row(
- store,
- order_id,
- &root_event_id,
- &projection,
- expected_listing_event_id.as_ref(),
- current_listing_event_id.as_ref(),
- source_event_count,
- transport_observation_count,
- &evidence_hash,
- inputs.last_source_event_seq,
- updated_at_ms,
- )
- .await?;
- upserts += 1;
- }
- Ok(upserts)
-}
-
-#[allow(clippy::too_many_arguments)]
-async fn upsert_trade_projection_row(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- root_event_id: &RadrootsEventId,
- projection: &crate::order::RadrootsOrderProjection,
- expected_listing_event_id: Option<&RadrootsEventId>,
- current_listing_event_id: Option<&RadrootsEventId>,
- source_event_count: usize,
- transport_observation_count: i64,
- evidence_hash: &str,
- last_source_event_seq: Option<i64>,
- updated_at_ms: i64,
-) -> Result<(), RadrootsTradeProjectionError> {
- let economics_json = projection
- .economics
- .as_ref()
- .map(|economics| {
- serde_json::to_string(economics).map_err(|source| {
- RadrootsTradeProjectionError::Serialize {
- model: "trade_economics",
- source,
- }
- })
- })
- .transpose()?;
- let pending_inventory_json = serde_json::to_string(&projection.pending_inventory_reservations)
- .map_err(|source| RadrootsTradeProjectionError::Serialize {
- model: "pending_inventory",
- source,
- })?;
- let committed_inventory_json =
- serde_json::to_string(&projection.committed_inventory_reservations).map_err(|source| {
- RadrootsTradeProjectionError::Serialize {
- model: "committed_inventory",
- source,
- }
- })?;
- let issue_labels = projection
- .issues
- .iter()
- .map(|issue| format!("{issue:?}"))
- .collect::<Vec<_>>();
- let issues_json = serde_json::to_string(&issue_labels).map_err(|source| {
- RadrootsTradeProjectionError::Serialize {
- model: "trade_issues",
- source,
- }
- })?;
-
- sqlx::query(
- "INSERT INTO trade_projection(order_id, root_event_id, projection_version, status, lifecycle_terminal, rhi_state, listing_addr, buyer_pubkey, seller_pubkey, request_event_id, decision_event_id, agreement_event_id, cancellation_event_id, validation_receipt_event_id, last_event_id, expected_listing_event_id, current_listing_event_id, economics_json, pending_inventory_json, committed_inventory_json, issues_json, issue_count, source_event_count, transport_observation_count, evidence_hash, last_source_event_seq, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_id, root_event_id, projection_version) DO UPDATE SET status = excluded.status, lifecycle_terminal = excluded.lifecycle_terminal, rhi_state = excluded.rhi_state, listing_addr = excluded.listing_addr, buyer_pubkey = excluded.buyer_pubkey, seller_pubkey = excluded.seller_pubkey, request_event_id = excluded.request_event_id, decision_event_id = excluded.decision_event_id, agreement_event_id = excluded.agreement_event_id, cancellation_event_id = excluded.cancellation_event_id, validation_receipt_event_id = excluded.validation_receipt_event_id, last_event_id = excluded.last_event_id, expected_listing_event_id = excluded.expected_listing_event_id, current_listing_event_id = excluded.current_listing_event_id, economics_json = excluded.economics_json, pending_inventory_json = excluded.pending_inventory_json, committed_inventory_json = excluded.committed_inventory_json, issues_json = excluded.issues_json, issue_count = excluded.issue_count, source_event_count = excluded.source_event_count, transport_observation_count = excluded.transport_observation_count, evidence_hash = excluded.evidence_hash, last_source_event_seq = excluded.last_source_event_seq, updated_at_ms = excluded.updated_at_ms",
- )
- .bind(order_id.as_str())
- .bind(root_event_id.as_str())
- .bind(i64::from(RADROOTS_PRODUCT_PROJECTION_VERSION))
- .bind(trade_workflow_status_label(&projection.status))
- .bind(bool_i64(projection.lifecycle_terminal))
- .bind(trade_rhi_state_label(&projection.status, projection.validation_receipt_event_id.as_ref()))
- .bind(projection.listing_addr.as_ref().map(RadrootsClassifiedListingAddress::as_str))
- .bind(projection.buyer_pubkey.as_ref().map(|value| value.as_str()))
- .bind(projection.seller_pubkey.as_ref().map(|value| value.as_str()))
- .bind(projection.request_event_id.as_ref().map(|value| value.as_str()))
- .bind(projection.decision_event_id.as_ref().map(|value| value.as_str()))
- .bind(projection.agreement_event_id.as_ref().map(|value| value.as_str()))
- .bind(projection.cancellation_event_id.as_ref().map(|value| value.as_str()))
- .bind(projection.validation_receipt_event_id.as_ref().map(|value| value.as_str()))
- .bind(projection.last_event_id.as_ref().map(|value| value.as_str()))
- .bind(expected_listing_event_id.as_ref().map(|value| value.as_str()))
- .bind(current_listing_event_id.as_ref().map(|value| value.as_str()))
- .bind(economics_json)
- .bind(pending_inventory_json)
- .bind(committed_inventory_json)
- .bind(issues_json)
- .bind(i64::try_from(projection.issues.len()).unwrap_or(i64::MAX))
- .bind(i64::try_from(source_event_count).unwrap_or(i64::MAX))
- .bind(transport_observation_count)
- .bind(evidence_hash)
- .bind(last_source_event_seq)
- .bind(updated_at_ms)
- .execute(store.pool())
- .await?;
- Ok(())
-}
-
-struct TradeProjectionInputs {
- workflow_records: RadrootsTradeWorkflowRecords,
- event_ids: Vec<RadrootsEventId>,
- expected_listing_event_ids: BTreeMap<RadrootsEventId, Option<RadrootsEventId>>,
- current_listing_event_ids: BTreeMap<RadrootsEventId, Option<RadrootsEventId>>,
- last_source_event_seq: Option<i64>,
-}
-
-async fn trade_projection_inputs_for_order_id(
- store: &RadrootsEventStore,
- order_id: &RadrootsOrderId,
- limit: u32,
-) -> Result<TradeProjectionInputs, RadrootsTradeProjectionError> {
- let stored_events = store
- .events_by_contract_and_tag(
- &PRODUCT_PROJECTION_CONTRACT_IDS,
- TAG_D,
- order_id.as_str(),
- limit,
- )
- .await?;
- let mut workflow_records = RadrootsTradeWorkflowRecords::default();
- let mut event_ids = Vec::with_capacity(stored_events.len());
- let mut expected_listing_event_id = None;
- let mut listing_addr = None;
- let mut expected_listing_event_ids = BTreeMap::new();
- let mut current_listing_event_ids = BTreeMap::new();
- let mut last_source_event_seq = None;
-
- for stored_event in stored_events {
- last_source_event_seq = Some(stored_event.seq);
- let event = stored_event_to_nostr_event(&stored_event)?;
- if stored_event.kind == KIND_TRADE_VALIDATION_RECEIPT {
- let verified = validation_receipt_from_event(&event).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceipt {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- let receipt_order_id = RadrootsOrderId::parse(verified.tags.order_id.as_str())
- .map_err(
- |source| RadrootsTradeProjectionError::ValidationReceiptOrderId {
- event_id: stored_event.event_id.clone(),
- source,
- },
- )?;
- let receipt_event_id =
- RadrootsEventId::parse(stored_event.event_id.as_str()).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceiptEventId {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- event_ids.push(receipt_event_id.clone());
- workflow_records.validation_receipts.push(
- RadrootsTradeWorkflowValidationReceiptRecord {
- event_id: receipt_event_id,
- order_id: receipt_order_id,
- receipt: verified.receipt,
- tags: verified.tags,
- },
- );
- continue;
- }
-
- let record = order_event_record_from_event(&event).map_err(|source| {
- RadrootsTradeProjectionError::OrderDecode {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- event_ids.push(record.event_id().clone());
- if let RadrootsOrderEventRecord::Request(request) = &record {
- listing_addr.get_or_insert_with(|| request.payload.listing_addr.clone());
- let request_listing_event_id = request_listing_event_id(&event)?;
- let current_listing_event_id =
- current_listing_event_id(store, request.payload.listing_addr.as_str()).await?;
- expected_listing_event_ids
- .insert(request.event_id.clone(), request_listing_event_id.clone());
- current_listing_event_ids.insert(request.event_id.clone(), current_listing_event_id);
- if expected_listing_event_id.is_none() {
- expected_listing_event_id = request_listing_event_id;
- }
- }
- push_order_record(&mut workflow_records.order_events, record);
- }
-
- workflow_records.expected_listing_event_id = expected_listing_event_id.clone();
- let current_listing_event_id = match listing_addr.as_ref() {
- Some(listing_addr) => current_listing_event_id(store, listing_addr.as_str()).await?,
- None => None,
- };
- workflow_records.current_listing_event_id = current_listing_event_id.clone();
-
- Ok(TradeProjectionInputs {
- workflow_records,
- event_ids,
- expected_listing_event_ids,
- current_listing_event_ids,
- last_source_event_seq,
- })
-}
-
-fn projection_source_event_ids(
- root_event_id: &RadrootsEventId,
- projection: &crate::order::RadrootsOrderProjection,
-) -> Vec<RadrootsEventId> {
- let mut event_ids = [
- Some(root_event_id.clone()),
- projection.request_event_id.clone(),
- projection.decision_event_id.clone(),
- projection.agreement_event_id.clone(),
- projection.cancellation_event_id.clone(),
- projection.validation_receipt_event_id.clone(),
- projection.last_event_id.clone(),
- ]
- .into_iter()
- .flatten()
- .collect::<Vec<_>>();
- event_ids.sort();
- event_ids.dedup();
- event_ids
-}
-
-fn projection_evidence_hash(event_ids: &[RadrootsEventId]) -> String {
- let mut hasher = Sha256::new();
- for event_id in event_ids {
- hasher.update(event_id.as_str().as_bytes());
- hasher.update([0]);
- }
- hex::encode(hasher.finalize())
-}
-
-fn push_order_record(
- records: &mut RadrootsGroupedOrderEventRecords,
- record: RadrootsOrderEventRecord,
-) {
- match record {
- RadrootsOrderEventRecord::Request(record) => records.requests.push(record),
- RadrootsOrderEventRecord::Decision(record) => records.decisions.push(record),
- RadrootsOrderEventRecord::Cancellation(record) => records.cancellations.push(record),
- }
-}
-
-fn request_listing_event_id(
- event: &RadrootsEventEnvelope,
-) -> Result<Option<RadrootsEventId>, RadrootsTradeProjectionError> {
- let tags = event.tags_as_vec();
- let context = order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &tags)
- .map_err(|source| RadrootsTradeProjectionError::OrderContext {
- event_id: event.id_str().to_owned(),
- source,
- })?;
- context
- .listing_event
- .map(|listing_event| {
- RadrootsEventId::parse(listing_event.id.as_str()).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceiptEventId {
- event_id: event.id_str().to_owned(),
- source,
- }
- })
- })
- .transpose()
-}
-
-async fn current_listing_event_id(
- store: &RadrootsEventStore,
- listing_addr: &str,
-) -> Result<Option<RadrootsEventId>, RadrootsTradeProjectionError> {
- let row = sqlx::query("SELECT listing_event_id FROM listing_projection WHERE listing_addr = ?")
- .bind(listing_addr)
- .fetch_optional(store.pool())
- .await?;
- row.map(|row| {
- let value: String = row.try_get("listing_event_id")?;
- RadrootsEventId::parse(value).map_err(|source| {
- RadrootsTradeProjectionError::ValidationReceiptEventId {
- event_id: listing_addr.to_owned(),
- source,
- }
- })
- })
- .transpose()
-}
-
-fn stored_event_to_nostr_event(
- stored_event: &RadrootsStoredEvent,
-) -> Result<RadrootsEventEnvelope, RadrootsTradeProjectionError> {
- let tags = serde_json::from_str(&stored_event.tags_json).map_err(|source| {
- RadrootsTradeProjectionError::InvalidStoredTagsJson {
- event_id: stored_event.event_id.clone(),
- source,
- }
- })?;
- RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
- id: stored_event.event_id.clone(),
- author: stored_event.pubkey.clone(),
- created_at: stored_event.created_at,
- kind: stored_event.kind,
- tags,
- content: stored_event.content.clone(),
- sig: stored_event.sig.clone(),
- })
- .map_err(
- |source| RadrootsTradeProjectionError::InvalidStoredEnvelope {
- event_id: stored_event.event_id.clone(),
- source,
- },
- )
-}
-
-async fn transport_observation_count_for_events(
- store: &RadrootsEventStore,
- event_ids: &[RadrootsEventId],
-) -> Result<i64, RadrootsTradeProjectionError> {
- let mut count = 0;
- for event_id in event_ids {
- count += transport_observation_count_for_event(store, event_id.as_str()).await?;
- }
- Ok(count)
-}
-
-async fn transport_observation_count_for_event(
- store: &RadrootsEventStore,
- event_id: &str,
-) -> Result<i64, RadrootsTradeProjectionError> {
- let row =
- sqlx::query("SELECT COUNT(*) AS count FROM event_transport_observation WHERE event_id = ?")
- .bind(event_id)
- .fetch_one(store.pool())
- .await?;
- Ok(row.try_get("count")?)
-}
-
-fn operational_listing_projection_row(
- row: sqlx::sqlite::SqliteRow,
-) -> Result<RadrootsOperationalListingProjectionRow, RadrootsTradeProjectionError> {
- let listing_addr = row.try_get::<String, _>("listing_addr")?;
- let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr).map_err(|source| {
- RadrootsTradeProjectionError::ClassifiedListingAddress {
- listing_addr: listing_addr.clone(),
- source,
- }
- })?;
- Ok(RadrootsOperationalListingProjectionRow {
- listing_addr,
- listing_event_id: row.try_get("listing_event_id")?,
- seller_pubkey: row.try_get("seller_pubkey")?,
- title: row.try_get("title")?,
- description: row.try_get("description")?,
- product_type: row.try_get("product_type")?,
- price_amount: row.try_get("price_amount")?,
- price_currency: row.try_get("price_currency")?,
- inventory_available: row.try_get("inventory_available")?,
- delivery_method: row.try_get("delivery_method")?,
- locality_primary: row.try_get("locality_primary")?,
- locality_city: row.try_get("locality_city")?,
- locality_region: row.try_get("locality_region")?,
- locality_country: row.try_get("locality_country")?,
- geohash5: row.try_get("geohash5")?,
- updated_at_ms: row.try_get("updated_at_ms")?,
- })
-}
-
-fn listing_availability_label(availability: &RadrootsOperationalListingAvailability) -> String {
- match availability {
- RadrootsOperationalListingAvailability::Window { .. } => "window".to_owned(),
- RadrootsOperationalListingAvailability::Status { status } => match status {
- RadrootsOperationalListingStatus::Active => "active".to_owned(),
- RadrootsOperationalListingStatus::Sold => "sold".to_owned(),
- RadrootsOperationalListingStatus::Other { value } => value.trim().to_owned(),
- },
- }
-}
-
-fn listing_delivery_method_label(delivery_method: &RadrootsOperationalListingDeliveryMethod) -> String {
- match delivery_method {
- RadrootsOperationalListingDeliveryMethod::Pickup => "pickup".to_owned(),
- RadrootsOperationalListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(),
- RadrootsOperationalListingDeliveryMethod::Shipping => "shipping".to_owned(),
- RadrootsOperationalListingDeliveryMethod::Other { method } => method.trim().to_owned(),
- }
-}
-
-fn listing_locality_search_text(listing: &RadrootsOperationalListingTradeProjection) -> String {
- [
- Some(listing.location.primary.as_str()),
- listing.location.city.as_deref(),
- listing.location.region.as_deref(),
- listing.location.country.as_deref(),
- ]
- .into_iter()
- .flatten()
- .filter(|value| !value.trim().is_empty())
- .collect::<Vec<_>>()
- .join(" ")
-}
-
-fn operational_listing_fts_query(query: &str) -> Option<String> {
- let terms = query
- .split(|character: char| !character.is_alphanumeric())
- .map(str::trim)
- .filter(|term| !term.is_empty())
- .map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
- .collect::<Vec<_>>();
- if terms.is_empty() {
- None
- } else {
- Some(terms.join(" "))
- }
-}
-
-fn trade_workflow_status_label(status: &RadrootsTradeWorkflowState) -> &'static str {
- match status {
- RadrootsTradeWorkflowState::Missing => "missing",
- RadrootsTradeWorkflowState::Requested => "requested",
- RadrootsTradeWorkflowState::AgreedPendingValidation => "agreed_pending_validation",
- RadrootsTradeWorkflowState::Committed => "committed",
- RadrootsTradeWorkflowState::Declined => "declined",
- RadrootsTradeWorkflowState::Cancelled => "cancelled",
- RadrootsTradeWorkflowState::ValidationExpired => "validation_expired",
- RadrootsTradeWorkflowState::Invalid => "invalid",
- }
-}
-
-fn trade_rhi_state_label(
- status: &RadrootsTradeWorkflowState,
- validation_receipt_event_id: Option<&RadrootsEventId>,
-) -> &'static str {
- match status {
- RadrootsTradeWorkflowState::AgreedPendingValidation => "pending",
- RadrootsTradeWorkflowState::Committed => "final",
- RadrootsTradeWorkflowState::ValidationExpired => "expired",
- RadrootsTradeWorkflowState::Invalid if validation_receipt_event_id.is_some() => "invalid",
- RadrootsTradeWorkflowState::Invalid => "invalid",
- _ => "not_required",
- }
-}
-
-fn bool_i64(value: bool) -> i64 {
- if value { 1 } else { 0 }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use nostr::EventBuilder;
- use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
- };
- use radroots_event::{
- RadrootsEventPtr,
- draft::RadrootsSignedEvent,
- farm::RadrootsFarmRef,
- ids::RadrootsOrderQuoteId,
- kinds::KIND_CLASSIFIED_LISTING,
- operational_listing::{
- RadrootsOperationalListing, RadrootsOperationalListingBin, RadrootsOperationalListingProduct,
- RadrootsOperationalListingPublicLocation,
- },
- order::{
- RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem,
- RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem,
- RadrootsOrderPricingBasis, RadrootsOrderRequest,
- },
- wire::RadrootsNip01EventWire,
- };
- use radroots_event_codec::order::{order_decision_event_build, order_request_event_build};
- use radroots_event_store::{
- RadrootsEventIngest, RadrootsTransportObservation, RadrootsTransportObservationType,
- };
- use radroots_nostr::prelude::{
- RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrTag,
- RadrootsNostrTagKind, RadrootsNostrTimestamp,
- };
- use radroots_transport::RadrootsTransportKind;
-
- use crate::validation_receipt::{
- RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof,
- RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
- RadrootsValidationReceiptStatement, RadrootsValidationReceiptType,
- validation_receipt_event_build, validation_receipt_public_values_hash_hex,
- validator_set_address_from_str,
- };
-
- const SELLER_SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
- const SELLER: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
- const BUYER_SECRET: &str = "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
- const BUYER: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
-
- fn keys(secret: &str) -> RadrootsNostrKeys {
- RadrootsNostrKeys::new(RadrootsNostrSecretKey::from_hex(secret).expect("secret"))
- }
-
- fn test_event_builder(
- kind: u32,
- content: impl Into<String>,
- tags: Vec<Vec<String>>,
- ) -> EventBuilder {
- let tags = tags
- .into_iter()
- .filter(|tag| !tag.is_empty())
- .map(|mut tag| {
- let key = tag.remove(0);
- RadrootsNostrTag::custom(RadrootsNostrTagKind::Custom(key.into()), tag)
- })
- .collect();
- EventBuilder::new(
- RadrootsNostrKind::Custom(u16::try_from(kind).expect("test kind must fit NIP-01")),
- content.into(),
- )
- .tags(tags)
- .allow_self_tagging()
- }
-
- fn decimal(raw: &str) -> RadrootsCoreDecimal {
- raw.parse().expect("decimal")
- }
-
- fn listing() -> RadrootsOperationalListing {
- RadrootsOperationalListing {
- d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("d tag"),
- published_at: Some(1_700_000_000),
- farm: RadrootsFarmRef {
- pubkey: SELLER.to_owned(),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(),
- },
- product: RadrootsOperationalListingProduct {
- key: "pea-shoots".to_owned(),
- title: "Pea shoots".to_owned(),
- category: "greens".to_owned(),
- summary: Some("Tender early greens".to_owned()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: "bin-1".parse().expect("bin"),
- bins: vec![RadrootsOperationalListingBin {
- bin_id: "bin-1".parse().expect("bin"),
- quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each),
- price_per_canonical_unit: RadrootsCoreQuantityPrice {
- amount: RadrootsCoreMoney::new(decimal("5"), RadrootsCoreCurrency::USD),
- quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each),
- },
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: Some(decimal("9")),
- availability: Some(RadrootsOperationalListingAvailability::Status {
- status: RadrootsOperationalListingStatus::Active,
- }),
- delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
- location: Some(RadrootsOperationalListingPublicLocation {
- primary: "Old Town".to_owned(),
- city: Some("Victoria".to_owned()),
- region: Some("BC".to_owned()),
- country: Some("CA".to_owned()),
- geohash: "c2b2q".to_owned(),
- }),
- images: None,
- }
- }
-
- fn signed_listing_event() -> RadrootsSignedEvent {
- let parts = radroots_event_codec::operational_listing::encode::to_wire_parts(&listing())
- .expect("listing parts");
- sign_parts(
- parts.kind,
- parts.content,
- parts.tags,
- 1_700_000_000,
- &keys(SELLER_SECRET),
- )
- }
-
- fn listing_addr(event: &RadrootsSignedEvent) -> RadrootsClassifiedListingAddress {
- RadrootsClassifiedListingAddress::parse(format!(
- "{}:{}:{}",
- KIND_CLASSIFIED_LISTING,
- event.pubkey_str(),
- listing().d_tag
- ))
- .expect("listing address")
- }
-
- fn order_id() -> RadrootsOrderId {
- RadrootsOrderId::parse("projection-order").expect("order id")
- }
-
- fn economics() -> RadrootsOrderEconomics {
- let currency = RadrootsCoreCurrency::USD;
- RadrootsOrderEconomics {
- quote_id: RadrootsOrderQuoteId::parse("quote-1").expect("quote"),
- quote_version: 1,
- pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
- currency,
- items: vec![RadrootsOrderEconomicItem {
- bin_id: "bin-1".parse().expect("bin"),
- bin_count: 1,
- quantity_amount: decimal("1"),
- quantity_unit: RadrootsCoreUnit::Each,
- unit_price_amount: decimal("5"),
- unit_price_currency: currency,
- line_subtotal: RadrootsCoreMoney::new(decimal("5"), currency),
- }],
- discounts: Vec::new(),
- adjustments: Vec::new(),
- subtotal: RadrootsCoreMoney::new(decimal("5"), currency),
- discount_total: RadrootsCoreMoney::zero(currency),
- adjustment_total: RadrootsCoreMoney::zero(currency),
- total: RadrootsCoreMoney::new(decimal("5"), currency),
- }
- }
-
- fn order_request(listing_event: &RadrootsSignedEvent) -> RadrootsOrderRequest {
- RadrootsOrderRequest {
- order_id: order_id(),
- listing_addr: listing_addr(listing_event),
- buyer_pubkey: BUYER.parse().expect("buyer"),
- seller_pubkey: SELLER.parse().expect("seller"),
- items: vec![RadrootsOrderItem {
- bin_id: "bin-1".parse().expect("bin"),
- bin_count: 1,
- }],
- economics: economics(),
- }
- }
-
- fn signed_order_request_event(listing_event: &RadrootsSignedEvent) -> RadrootsSignedEvent {
- signed_order_request_event_at(listing_event, 1_700_000_010)
- }
-
- fn signed_order_request_event_at(
- listing_event: &RadrootsSignedEvent,
- created_at: u32,
- ) -> RadrootsSignedEvent {
- let parts = order_request_event_build(
- &RadrootsEventPtr {
- id: listing_event.id_str().to_owned(),
- relays: Some("wss://relay.example.test".to_owned()),
- },
- &order_request(listing_event),
- )
- .expect("request parts");
- sign_parts(
- parts.kind,
- parts.content,
- parts.tags,
- created_at,
- &keys(BUYER_SECRET),
- )
- }
-
- fn signed_order_decision_event(
- request: &RadrootsSignedEvent,
- listing_event: &RadrootsSignedEvent,
- ) -> RadrootsSignedEvent {
- let decision = RadrootsOrderDecision {
- order_id: order_id(),
- listing_addr: listing_addr(listing_event),
- buyer_pubkey: BUYER.parse().expect("buyer"),
- seller_pubkey: SELLER.parse().expect("seller"),
- decision: RadrootsOrderDecisionOutcome::Accepted {
- inventory_commitments: vec![RadrootsOrderInventoryCommitment {
- bin_id: "bin-1".parse().expect("bin"),
- bin_count: 1,
- }],
- },
- };
- let root = request.id().clone();
- let parts = order_decision_event_build(&root, &root, &decision).expect("decision parts");
- sign_parts(
- parts.kind,
- parts.content,
- parts.tags,
- 1_700_000_020,
- &keys(SELLER_SECRET),
- )
- }
-
- fn signed_receipt_event(
- listing_event: &RadrootsSignedEvent,
- request: &RadrootsSignedEvent,
- decision: &RadrootsSignedEvent,
- result: RadrootsValidationReceiptResult,
- ) -> RadrootsSignedEvent {
- let request_id = request.id().clone();
- let listing_event_id = listing_event.id().clone();
- let decision_id = decision.id().clone();
- let receipt = RadrootsTradeValidationReceipt {
- changed_records_root: hash32('a'),
- domain: "radroots.receipt".to_owned(),
- error_bitmap: match result {
- RadrootsValidationReceiptResult::Valid => {
- "0x00000000000000000000000000000000".to_owned()
- }
- RadrootsValidationReceiptResult::Invalid => {
- "0x00000000000000000000000000000001".to_owned()
- }
- },
- event_set_root: hash32('b'),
- new_state_root: hash32('c'),
- previous_state_root: hash32('d'),
- proof: RadrootsValidationReceiptProof {
- inline_proof_base64: None,
- mode: None,
- program_hash: None,
- proof_reference: None,
- system: RadrootsValidationReceiptProofSystem::None,
- verifying_key_hash: None,
- },
- public_values_hash: validation_receipt_public_values_hash_hex(
- format!("{}:{}", request_id.as_str(), decision_id.as_str()).as_bytes(),
- ),
- receipt_type: RadrootsValidationReceiptType::TradeTransition,
- result,
- statement: RadrootsValidationReceiptStatement {
- listing_event_id: listing_event_id.into_string(),
- root_event_id: request_id.into_string(),
- target_event_id: decision_id.into_string(),
- validator_set_addr: validator_set_address_from_str(
- "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde",
- )
- .expect("validator set address"),
- validator_set_event_id:
- "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
- .to_owned(),
- statement_type: RadrootsValidationReceiptType::TradeTransition,
- },
- version: 1,
- };
- let parts = validation_receipt_event_build(order_id().as_str(), &receipt).expect("receipt");
- sign_parts(
- parts.kind,
- parts.content,
- parts.tags,
- 1_700_000_030,
- &keys(SELLER_SECRET),
- )
- }
-
- fn sign_parts(
- kind: u32,
- content: String,
- tags: Vec<Vec<String>>,
- created_at: u32,
- keys: &RadrootsNostrKeys,
- ) -> RadrootsSignedEvent {
- let raw_event = test_event_builder(kind, content, tags)
- .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at)))
- .sign_with_keys(keys)
- .expect("signed");
- let raw_json = serde_json::to_string(&raw_event).expect("raw event json");
- let wire = RadrootsNip01EventWire::parse_json(raw_json.as_str()).expect("wire");
- RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event")
- }
-
- fn hash32(character: char) -> String {
- format!(
- "0x{}",
- core::iter::repeat_n(character, 64).collect::<String>()
- )
- }
-
- #[tokio::test]
- async fn refresh_materializes_listing_search_and_receipt_aware_trade_projection() {
- let store = RadrootsEventStore::open_memory().await.expect("store");
- let listing_event = signed_listing_event();
- let request_event = signed_order_request_event(&listing_event);
- let decision_event = signed_order_decision_event(&request_event, &listing_event);
- let receipt_event = signed_receipt_event(
- &listing_event,
- &request_event,
- &decision_event,
- RadrootsValidationReceiptResult::Valid,
- );
-
- store
- .ingest_event(RadrootsEventIngest::new(listing_event.clone(), 10))
- .await
- .expect("listing");
- store
- .ingest_event(
- RadrootsEventIngest::new(request_event.clone(), 20).with_observation(
- RadrootsTransportObservation::new(
- RadrootsTransportKind::Nostr,
- "wss://relay.example.test",
- RadrootsTransportObservationType::LocalImport,
- 20,
- )
- .expect("observation"),
- ),
- )
- .await
- .expect("request");
- store
- .ingest_event(RadrootsEventIngest::new(decision_event.clone(), 30))
- .await
- .expect("decision");
- store
- .ingest_event(RadrootsEventIngest::new(receipt_event.clone(), 40))
- .await
- .expect("receipt");
-
- let refresh =
- refresh_product_projections(&store, RadrootsProjectionRefreshRequest::new(), 50)
- .await
- .expect("refresh");
- assert_eq!(refresh.scanned_events, 4);
- assert_eq!(refresh.operational_listing_upserts, 1);
- assert_eq!(refresh.trade_upserts, 1);
- assert_eq!(refresh.validation_receipts, 1);
- assert_eq!(refresh.transport_observations, 1);
-
- let rows = search_operational_listing_projection(
- &store,
- &RadrootsOperationalListingSearchRequest::new("pea victoria").with_limit(10),
- )
- .await
- .expect("search");
- assert_eq!(rows.len(), 1);
- assert_eq!(rows[0].title, "Pea shoots");
- assert_eq!(rows[0].geohash5, "c2b2q");
-
- let status = trade_projection_query_for_order_id(&store, &order_id(), 100)
- .await
- .expect("status");
- assert_eq!(
- status.projection.status,
- RadrootsTradeWorkflowState::Committed
- );
- assert_eq!(
- status.projection.validation_receipt_event_id,
- Some(receipt_event.id().clone())
- );
-
- let root_event_id = request_event.id().clone();
- let trade_row = sqlx::query(
- "SELECT root_event_id, projection_version, status, rhi_state, transport_observation_count, source_event_count, evidence_hash FROM trade_projection WHERE order_id = ? AND root_event_id = ? AND projection_version = ?",
- )
- .bind(order_id().as_str())
- .bind(root_event_id.as_str())
- .bind(i64::from(RADROOTS_PRODUCT_PROJECTION_VERSION))
- .fetch_one(store.pool())
- .await
- .expect("trade row");
- assert_eq!(
- trade_row.try_get::<String, _>("root_event_id").unwrap(),
- root_event_id.as_str()
- );
- assert_eq!(
- trade_row.try_get::<i64, _>("projection_version").unwrap(),
- i64::from(RADROOTS_PRODUCT_PROJECTION_VERSION)
- );
- assert_eq!(
- trade_row.try_get::<String, _>("status").unwrap(),
- "committed"
- );
- assert_eq!(
- trade_row.try_get::<String, _>("rhi_state").unwrap(),
- "final"
- );
- assert_eq!(
- trade_row
- .try_get::<i64, _>("transport_observation_count")
- .unwrap(),
- 1
- );
- assert_eq!(
- trade_row.try_get::<i64, _>("source_event_count").unwrap(),
- 3
- );
- assert_eq!(
- trade_row
- .try_get::<String, _>("evidence_hash")
- .unwrap()
- .len(),
- 64
- );
- }
-
- #[tokio::test]
- async fn refresh_materializes_duplicate_order_roots_as_distinct_trade_projections() {
- let store = RadrootsEventStore::open_memory().await.expect("store");
- let listing_event = signed_listing_event();
- let first_request_event = signed_order_request_event_at(&listing_event, 1_700_000_010);
- let second_request_event = signed_order_request_event_at(&listing_event, 1_700_000_011);
- let first_root = first_request_event.id().clone();
- let second_root = second_request_event.id().clone();
-
- store
- .ingest_event(RadrootsEventIngest::new(listing_event.clone(), 10))
- .await
- .expect("listing");
- store
- .ingest_event(RadrootsEventIngest::new(first_request_event, 20))
- .await
- .expect("first request");
- store
- .ingest_event(RadrootsEventIngest::new(second_request_event, 30))
- .await
- .expect("second request");
-
- let refresh =
- refresh_product_projections(&store, RadrootsProjectionRefreshRequest::new(), 40)
- .await
- .expect("refresh");
- assert_eq!(refresh.trade_upserts, 2);
-
- let rows = sqlx::query(
- "SELECT root_event_id, request_event_id, status, source_event_count, evidence_hash FROM trade_projection WHERE order_id = ? AND projection_version = ? ORDER BY root_event_id",
- )
- .bind(order_id().as_str())
- .bind(i64::from(RADROOTS_PRODUCT_PROJECTION_VERSION))
- .fetch_all(store.pool())
- .await
- .expect("trade rows");
- assert_eq!(rows.len(), 2);
- let materialized_roots = rows
- .iter()
- .map(|row| row.try_get::<String, _>("root_event_id").expect("root"))
- .collect::<Vec<_>>();
- assert!(
- materialized_roots
- .iter()
- .any(|root| root == first_root.as_str())
- );
- assert!(
- materialized_roots
- .iter()
- .any(|root| root == second_root.as_str())
- );
- for row in &rows {
- let root = row.try_get::<String, _>("root_event_id").unwrap();
- assert_eq!(row.try_get::<String, _>("request_event_id").unwrap(), root);
- assert_eq!(row.try_get::<String, _>("status").unwrap(), "requested");
- assert_eq!(row.try_get::<i64, _>("source_event_count").unwrap(), 1);
- assert_eq!(row.try_get::<String, _>("evidence_hash").unwrap().len(), 64);
- }
- assert_ne!(
- rows[0].try_get::<String, _>("evidence_hash").unwrap(),
- rows[1].try_get::<String, _>("evidence_hash").unwrap()
- );
-
- let ambiguous = trade_projection_query_for_order_id(&store, &order_id(), 100)
- .await
- .expect("ambiguous");
- assert!(ambiguous.projection.issues.iter().any(|issue| {
- matches!(
- issue,
- crate::order::RadrootsOrderIssue::MultipleRequests { event_ids }
- if event_ids.iter().any(|event_id| event_id == &first_root)
- && event_ids.iter().any(|event_id| event_id == &second_root)
- )
- }));
-
- let first_status = trade_projection_query_for_trade_locator(
- &store,
- &RadrootsTradeLocator::from_order_id(order_id()).with_root_event_id(first_root.clone()),
- 100,
- )
- .await
- .expect("first status");
- assert!(matches!(
- first_status.resolution,
- RadrootsTradeLocatorProjectionResolution::Projected { ref projection, .. }
- if projection.request_event_id.as_ref() == Some(&first_root)
- ));
-
- let second_status = trade_projection_query_for_trade_locator(
- &store,
- &RadrootsTradeLocator::from_order_id(order_id())
- .with_root_event_id(second_root.clone()),
- 100,
- )
- .await
- .expect("second status");
- assert!(matches!(
- second_status.resolution,
- RadrootsTradeLocatorProjectionResolution::Projected { ref projection, .. }
- if projection.request_event_id.as_ref() == Some(&second_root)
- ));
- }
-
- #[tokio::test]
- async fn refresh_rejects_out_of_range_limits_without_advancing_cursor() {
- let store = RadrootsEventStore::open_memory().await.expect("store");
- let error = refresh_product_projections(
- &store,
- RadrootsProjectionRefreshRequest::new().with_limit(0),
- 1,
- )
- .await
- .expect_err("limit");
- assert!(matches!(
- error,
- RadrootsTradeProjectionError::InvalidLimit { .. }
- ));
- assert!(
- store
- .get_projection_cursor(RADROOTS_PRODUCT_PROJECTION_ID)
- .await
- .expect("cursor")
- .is_none()
- );
- }
-}
diff --git a/crates/transport_nostr/README b/crates/transport_nostr/README
@@ -1,3 +1,15 @@
# radroots_transport_nostr
-Deterministic Nostr relay transport substrate for exact signed-event publish, fetch ingest, and outbox delivery target coordination.
+Deterministic Nostr relay transport substrate for exact signed-event publish,
+fetch ingest, and outbox delivery target coordination.
+
+Fetch ingest verifies each accepted relay event before storage. Per-event
+receipts distinguish unsupported contracts, invalid registered shapes, malformed
+NIP-01 input, ephemeral events that were not persisted, and immutable
+valid-stream eligibility. `admission_code` carries the stable classifier
+diagnostic when the store performed classification; it is absent for duplicates
+because the baseline schema does not persist that code. Inserted, duplicate, and
+not-persisted outcomes have separate flags and aggregate counts.
+Local event-store failures abort the operation and remain typed transport
+errors, so callers can retry without confusing storage failure with bad relay
+input.
diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs
@@ -5,8 +5,8 @@ use core::time::Duration;
use futures::future::BoxFuture;
use nostr::{JsonUtil, filter::MatchEventOptions};
use radroots_event_store::{
- RadrootsEventContractStatus, RadrootsEventIngest, RadrootsEventStore,
- RadrootsTransportObservation, RadrootsTransportObservationType,
+ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventPersistence,
+ RadrootsEventStore, RadrootsTransportObservation, RadrootsTransportObservationType,
};
use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter};
use radroots_transport::RadrootsTransportKind;
@@ -235,12 +235,15 @@ pub struct RadrootsRelayFetchEventReceipt {
pub event_id: Option<String>,
pub inserted: bool,
pub duplicate: bool,
+ pub not_persisted: bool,
pub unsupported: bool,
+ pub invalid: bool,
pub malformed: bool,
pub out_of_filter: bool,
pub skipped_over_limit: bool,
- pub projection_eligible: bool,
- pub verification_status: Option<String>,
+ pub valid_stream_eligible: bool,
+ pub admission_status: Option<String>,
+ pub admission_code: Option<String>,
pub message: Option<String>,
}
@@ -278,10 +281,12 @@ pub struct RadrootsRelayFetchedEventsReceipt {
pub struct RadrootsRelayFetchReceipt {
pub inserted_count: usize,
pub duplicate_count: usize,
+ pub not_persisted_count: usize,
pub malformed_count: usize,
pub out_of_filter_count: usize,
pub skipped_over_limit_count: usize,
pub unsupported_count: usize,
+ pub invalid_count: usize,
pub eose_count: usize,
pub closed_count: usize,
pub notice_count: usize,
@@ -379,62 +384,61 @@ where
event_id: Some(raw_event.id.to_hex()),
inserted: false,
duplicate: false,
+ not_persisted: false,
unsupported: false,
+ invalid: false,
malformed: true,
out_of_filter: false,
skipped_over_limit: false,
- projection_eligible: false,
- verification_status: None,
+ valid_stream_eligible: false,
+ admission_status: None,
+ admission_code: None,
message: Some(error.to_string()),
});
continue;
}
};
- match event_store.ingest_event(ingest).await {
- Ok(store_receipt) => {
- let unsupported =
- store_receipt.contract_status != RadrootsEventContractStatus::Supported;
- if store_receipt.inserted {
- receipt.inserted_count += 1;
- } else {
- receipt.duplicate_count += 1;
- }
- if unsupported {
- receipt.unsupported_count += 1;
- }
- receipt.events.push(RadrootsRelayFetchEventReceipt {
- relay_url,
- event_id: Some(store_receipt.event_id),
- inserted: store_receipt.inserted,
- duplicate: !store_receipt.inserted,
- unsupported,
- malformed: false,
- out_of_filter: false,
- skipped_over_limit: false,
- projection_eligible: store_receipt.projection_eligible,
- verification_status: Some(
- store_receipt.verification_status.as_str().to_owned(),
- ),
- message: None,
- });
+ let store_receipt = event_store.ingest_event(ingest).await?;
+ let unsupported =
+ store_receipt.admission_status == RadrootsEventAdmissionStatus::Unsupported;
+ let invalid =
+ store_receipt.admission_status == RadrootsEventAdmissionStatus::Invalid;
+ let (inserted, duplicate, not_persisted) = match store_receipt.persistence {
+ RadrootsEventPersistence::Inserted { .. } => {
+ receipt.inserted_count += 1;
+ (true, false, false)
}
- Err(error) => {
- receipt.malformed_count += 1;
- receipt.events.push(RadrootsRelayFetchEventReceipt {
- relay_url,
- event_id: Some(raw_event.id.to_hex()),
- inserted: false,
- duplicate: false,
- unsupported: false,
- malformed: true,
- out_of_filter: false,
- skipped_over_limit: false,
- projection_eligible: false,
- verification_status: None,
- message: Some(error.to_string()),
- });
+ RadrootsEventPersistence::Duplicate { .. } => {
+ receipt.duplicate_count += 1;
+ (false, true, false)
+ }
+ RadrootsEventPersistence::NotPersisted => {
+ receipt.not_persisted_count += 1;
+ (false, false, true)
}
+ };
+ if unsupported {
+ receipt.unsupported_count += 1;
+ }
+ if invalid {
+ receipt.invalid_count += 1;
}
+ receipt.events.push(RadrootsRelayFetchEventReceipt {
+ relay_url,
+ event_id: Some(store_receipt.event_id),
+ inserted,
+ duplicate,
+ not_persisted,
+ unsupported,
+ invalid,
+ malformed: false,
+ out_of_filter: false,
+ skipped_over_limit: false,
+ valid_stream_eligible: store_receipt.valid_stream_eligible,
+ admission_status: Some(store_receipt.admission_status.as_str().to_owned()),
+ admission_code: store_receipt.admission_code,
+ message: None,
+ });
}
}
}
@@ -510,10 +514,12 @@ impl RadrootsRelayFetchReceipt {
Self {
inserted_count: 0,
duplicate_count: 0,
+ not_persisted_count: 0,
malformed_count: processed.malformed_count,
out_of_filter_count: processed.out_of_filter_count,
skipped_over_limit_count: processed.skipped_over_limit_count,
unsupported_count: 0,
+ invalid_count: 0,
eose_count: processed.eose_count,
closed_count: processed.closed_count,
notice_count: processed.notice_count,
@@ -566,12 +572,15 @@ fn process_relay_fetch_items(
event_id: None,
inserted: false,
duplicate: false,
+ not_persisted: false,
unsupported: false,
+ invalid: false,
malformed: true,
out_of_filter: false,
skipped_over_limit: false,
- projection_eligible: false,
- verification_status: None,
+ valid_stream_eligible: false,
+ admission_status: None,
+ admission_code: None,
message: Some("event JSON parse failed".to_owned()),
},
));
@@ -587,12 +596,15 @@ fn process_relay_fetch_items(
event_id: Some(raw_event.id.to_hex()),
inserted: false,
duplicate: false,
+ not_persisted: false,
unsupported: false,
+ invalid: false,
malformed: false,
out_of_filter: true,
skipped_over_limit: false,
- projection_eligible: false,
- verification_status: None,
+ valid_stream_eligible: false,
+ admission_status: None,
+ admission_code: None,
message: Some("event did not match relay fetch filters".to_owned()),
},
));
@@ -608,12 +620,15 @@ fn process_relay_fetch_items(
event_id: Some(raw_event.id.to_hex()),
inserted: false,
duplicate: false,
+ not_persisted: false,
unsupported: false,
+ invalid: false,
malformed: false,
out_of_filter: false,
skipped_over_limit: true,
- projection_eligible: false,
- verification_status: None,
+ valid_stream_eligible: false,
+ admission_status: None,
+ admission_code: None,
message: Some(
"accepted relay fetch event limit reached".to_owned(),
),
@@ -679,12 +694,15 @@ fn accepted_fetch_event_receipt(
event_id: Some(event.event.id.to_hex()),
inserted: false,
duplicate: false,
+ not_persisted: false,
unsupported: false,
+ invalid: false,
malformed: false,
out_of_filter: false,
skipped_over_limit: false,
- projection_eligible: false,
- verification_status: None,
+ valid_stream_eligible: false,
+ admission_status: None,
+ admission_code: None,
message: Some("event accepted by relay fetch filters".to_owned()),
}
}
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -1008,7 +1008,7 @@ async fn ingest_publish_observation(
if let Some(message) = message {
observation = observation.with_redacted_message(message);
}
- let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms)
+ let ingest = RadrootsEventIngest::from_signed_event(signed_event.clone(), observed_at_ms)?
.with_observation(observation);
event_store.ingest_event(ingest).await?;
Ok(())
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -1,9 +1,9 @@
use futures::future::BoxFuture;
use nostr::{EventBuilder, JsonUtil};
use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent};
-use radroots_event::kinds::{KIND_GEOCHAT, KIND_POST};
+use radroots_event::kinds::{KIND_FOLLOW, KIND_GEOCHAT, KIND_POST};
use radroots_event_store::{
- RadrootsEventStore, RadrootsEventVerificationStatus, RadrootsTransportObservationRow,
+ RadrootsEventAdmissionStatus, RadrootsEventStore, RadrootsTransportObservationRow,
RadrootsTransportObservationType,
};
use radroots_nostr::prelude::{
@@ -229,13 +229,23 @@ fn signed_post(content: &str) -> RadrootsSignedEvent {
signed_event_with_kind_and_hashtag(content, KIND_POST, "soil")
}
+fn signed_ephemeral(content: &str) -> RadrootsSignedEvent {
+ let raw_event = test_event_builder(KIND_GEOCHAT, content, Vec::new())
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
+ .sign_with_keys(&fixture_keys())
+ .expect("signed ephemeral event");
+ let raw_json = raw_event.as_json();
+ let wire = radroots_event::wire::RadrootsNip01EventWire::parse_json(&raw_json).expect("wire");
+ RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event")
+}
+
fn generic_draft(content: &str) -> RadrootsEventDraft {
RadrootsEventDraft::new(
- "radroots.social.geochat.v1",
- KIND_GEOCHAT,
+ "radroots.social.follow_list.v1",
+ KIND_FOLLOW,
1_700_000_000,
- vec![vec!["t".to_owned(), "soil".to_owned()]],
- content,
+ Vec::new(),
+ serde_json::json!({ "label": content }).to_string(),
FIXTURE_ALICE_PUBLIC_KEY_HEX,
)
.expect("generic draft")
@@ -370,6 +380,26 @@ fn unsupported_raw_event() -> String {
event.as_json()
}
+fn invalid_contract_shape_raw_event() -> String {
+ let event = test_event_builder(
+ KIND_POST,
+ "invalid reply",
+ vec![
+ vec!["t".to_owned(), "soil".to_owned()],
+ vec![
+ "e".to_owned(),
+ "invalid-event-id".to_owned(),
+ String::new(),
+ "root".to_owned(),
+ ],
+ ],
+ )
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_002))
+ .sign_with_keys(&fixture_keys())
+ .expect("signed contract-invalid event");
+ event.as_json()
+}
+
fn post_relay_fetch_filter(limit: usize) -> RadrootsNostrFilter {
radroots_nostr_filter_tag(
RadrootsNostrFilter::new()
@@ -1375,14 +1405,19 @@ async fn fetch_ingests_events_and_records_transport_observations() {
},
RadrootsRelayFetchItem::Event {
relay_url: RELAY_SECONDARY_WSS.to_owned(),
- raw_json: tampered_raw_event(),
+ raw_json: invalid_contract_shape_raw_event(),
observed_at_ms: 1_003,
},
RadrootsRelayFetchItem::Event {
relay_url: RELAY_TERTIARY_WSS.to_owned(),
- raw_json: "{not json".to_owned(),
+ raw_json: tampered_raw_event(),
observed_at_ms: 1_004,
},
+ RadrootsRelayFetchItem::Event {
+ relay_url: RELAY_TERTIARY_WSS.to_owned(),
+ raw_json: "{not json".to_owned(),
+ observed_at_ms: 1_005,
+ },
RadrootsRelayFetchItem::Eose {
relay_url: RELAY_PRIMARY_WSS.to_owned(),
},
@@ -1405,13 +1440,65 @@ async fn fetch_ingests_events_and_records_transport_observations() {
.await
.expect("fetch ingest");
- assert_eq!(receipt.inserted_count, 2);
+ assert_eq!(receipt.inserted_count, 3);
assert_eq!(receipt.duplicate_count, 1);
+ assert_eq!(receipt.not_persisted_count, 0);
assert_eq!(receipt.unsupported_count, 1);
+ assert_eq!(receipt.invalid_count, 1);
assert_eq!(receipt.malformed_count, 2);
assert_eq!(receipt.eose_count, 1);
assert_eq!(receipt.closed_count, 2);
assert_eq!(receipt.notice_count, 1);
+ assert_eq!(
+ receipt.inserted_count,
+ receipt.events.iter().filter(|event| event.inserted).count()
+ );
+ assert_eq!(
+ receipt.duplicate_count,
+ receipt
+ .events
+ .iter()
+ .filter(|event| event.duplicate)
+ .count()
+ );
+ assert_eq!(
+ receipt.not_persisted_count,
+ receipt
+ .events
+ .iter()
+ .filter(|event| event.not_persisted)
+ .count()
+ );
+ assert_eq!(
+ receipt.unsupported_count,
+ receipt
+ .events
+ .iter()
+ .filter(|event| event.unsupported)
+ .count()
+ );
+ assert_eq!(
+ receipt.invalid_count,
+ receipt.events.iter().filter(|event| event.invalid).count()
+ );
+ assert_eq!(
+ receipt.malformed_count,
+ receipt
+ .events
+ .iter()
+ .filter(|event| event.malformed)
+ .count()
+ );
+ assert!(receipt.events.iter().all(|event| {
+ usize::from(event.inserted)
+ + usize::from(event.duplicate)
+ + usize::from(event.not_persisted)
+ <= 1
+ && usize::from(event.unsupported)
+ + usize::from(event.invalid)
+ + usize::from(event.malformed)
+ <= 1
+ }));
assert_eq!(receipt.relay_outcomes.len(), 4);
assert_eq!(receipt.relay_outcomes[0].relay_url, RELAY_PRIMARY_WSS);
assert_eq!(
@@ -1443,24 +1530,72 @@ async fn fetch_ingests_events_and_records_transport_observations() {
);
assert!(receipt.relay_outcomes[3].relay_outcome.is_none());
assert_eq!(
- receipt.events[0].verification_status.as_deref(),
- Some(RadrootsEventVerificationStatus::Verified.as_str())
+ receipt.events[0].admission_status.as_deref(),
+ Some(RadrootsEventAdmissionStatus::Admitted.as_str())
+ );
+ assert!(receipt.events[0].valid_stream_eligible);
+ assert_eq!(
+ receipt.events[1].admission_status.as_deref(),
+ Some(RadrootsEventAdmissionStatus::Admitted.as_str())
+ );
+ assert!(receipt.events[1].valid_stream_eligible);
+ assert_eq!(
+ receipt.events[2].admission_status.as_deref(),
+ Some(RadrootsEventAdmissionStatus::Unsupported.as_str())
);
- assert!(receipt.events[0].projection_eligible);
+ assert!(receipt.events[2].unsupported);
+ assert!(!receipt.events[2].invalid);
assert_eq!(
- receipt.events[1].verification_status.as_deref(),
- Some(RadrootsEventVerificationStatus::Verified.as_str())
+ receipt.events[2].admission_code.as_deref(),
+ Some("unsupported_kind")
);
- assert!(!receipt.events[1].projection_eligible);
+ assert!(!receipt.events[2].valid_stream_eligible);
assert_eq!(
- receipt.events[2].verification_status.as_deref(),
- Some(RadrootsEventVerificationStatus::Verified.as_str())
+ receipt.events[3].admission_status.as_deref(),
+ Some(RadrootsEventAdmissionStatus::Invalid.as_str())
);
- assert!(!receipt.events[2].projection_eligible);
- assert_eq!(receipt.events[3].verification_status, None);
- assert!(!receipt.events[3].projection_eligible);
- assert_eq!(receipt.events[4].verification_status, None);
- assert!(!receipt.events[4].projection_eligible);
+ assert!(!receipt.events[3].unsupported);
+ assert!(receipt.events[3].invalid);
+ assert_eq!(
+ receipt.events[3].admission_code.as_deref(),
+ Some("reply_event_id_invalid")
+ );
+ assert!(!receipt.events[3].valid_stream_eligible);
+ assert_eq!(receipt.events[4].admission_status, None);
+ assert!(!receipt.events[4].valid_stream_eligible);
+ assert_eq!(receipt.events[5].admission_status, None);
+ assert!(!receipt.events[5].valid_stream_eligible);
+
+ let serialized = serde_json::to_value(&receipt).expect("serialized fetch receipt");
+ assert!(serialized.get("invalid_count").is_some());
+ assert!(serialized.get("not_persisted_count").is_some());
+ let serialized_event = serialized["events"][0]
+ .as_object()
+ .expect("serialized event receipt");
+ assert_eq!(serialized_event.len(), 14);
+ for field in [
+ "relay_url",
+ "event_id",
+ "inserted",
+ "duplicate",
+ "not_persisted",
+ "unsupported",
+ "invalid",
+ "malformed",
+ "out_of_filter",
+ "skipped_over_limit",
+ "valid_stream_eligible",
+ "admission_status",
+ "admission_code",
+ "message",
+ ] {
+ assert!(
+ serialized_event.contains_key(field),
+ "serialized receipt must contain {field}"
+ );
+ }
+ assert!(!serialized_event.contains_key("projection_eligible"));
+ assert!(!serialized_event.contains_key("verification_status"));
let observations = store
.observations_for_event(signed.id_str())
@@ -1477,6 +1612,72 @@ async fn fetch_ingests_events_and_records_transport_observations() {
}
#[tokio::test]
+async fn fetch_reports_ephemeral_events_as_not_persisted_without_duplicate_or_store_state() {
+ let signed = signed_ephemeral("live geochat");
+ let event_id = signed.id_str().to_owned();
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let adapter = RadrootsMockRelayFetchAdapter::new(vec![
+ RadrootsRelayFetchItem::Event {
+ relay_url: RELAY_PRIMARY_WSS.to_owned(),
+ raw_json: signed.raw_json().to_owned(),
+ observed_at_ms: 1_010,
+ },
+ RadrootsRelayFetchItem::Event {
+ relay_url: RELAY_PRIMARY_WSS.to_owned(),
+ raw_json: signed.raw_json().to_owned(),
+ observed_at_ms: 1_011,
+ },
+ ]);
+ let filter = RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::Custom(
+ u16::try_from(KIND_GEOCHAT).expect("ephemeral kind"),
+ ))
+ .limit(10);
+ let request = RadrootsRelayFetchRequest::fetch(1_010, 10, [filter])
+ .expect("ephemeral fetch request")
+ .with_relay_urls([RELAY_PRIMARY_WSS]);
+
+ let receipt = fetch_and_ingest_relay_events(&adapter, &store, request)
+ .await
+ .expect("ephemeral fetch ingest");
+
+ assert_eq!(receipt.inserted_count, 0);
+ assert_eq!(receipt.duplicate_count, 0);
+ assert_eq!(receipt.not_persisted_count, 2);
+ assert_eq!(receipt.malformed_count, 0);
+ assert_eq!(receipt.unsupported_count, 0);
+ assert_eq!(receipt.invalid_count, 0);
+ assert_eq!(receipt.events.len(), 2);
+ assert!(receipt.events.iter().all(|event| {
+ !event.inserted
+ && !event.duplicate
+ && event.not_persisted
+ && event.admission_status.as_deref()
+ == Some(RadrootsEventAdmissionStatus::Admitted.as_str())
+ && event.admission_code.is_none()
+ && !event.valid_stream_eligible
+ }));
+ assert!(
+ store
+ .raw_event(event_id.as_str())
+ .await
+ .expect("raw event")
+ .is_none()
+ );
+ assert!(
+ store
+ .observations_for_event(event_id.as_str())
+ .await
+ .expect("observations")
+ .is_empty()
+ );
+ let summary = store.status_summary().await.expect("status summary");
+ assert_eq!(summary.total_events, 0);
+ assert_eq!(summary.valid_stream_events, 0);
+ assert_eq!(summary.transport_observations, 0);
+}
+
+#[tokio::test]
async fn fetch_rejects_out_of_filter_events_before_store_mutation() {
let accepted = signed_post("filter match");
let wrong_tag = signed_event_with_kind_and_hashtag("filter wrong tag", KIND_POST, "compost");
@@ -1532,21 +1733,21 @@ async fn fetch_rejects_out_of_filter_events_before_store_mutation() {
assert!(receipt.events[2].out_of_filter);
assert!(
store
- .get_event(accepted.id_str())
+ .raw_event(accepted.id_str())
.await
.expect("accepted lookup")
.is_some()
);
assert!(
store
- .get_event(wrong_tag.id_str())
+ .raw_event(wrong_tag.id_str())
.await
.expect("wrong tag lookup")
.is_none()
);
assert!(
store
- .get_event(wrong_kind_event_id.as_str())
+ .raw_event(wrong_kind_event_id.as_str())
.await
.expect("wrong kind lookup")
.is_none()
@@ -1634,21 +1835,21 @@ async fn fetch_event_cap_counts_accepted_in_filter_events_and_preserves_later_co
);
assert!(
store
- .get_event(accepted_id.as_str())
+ .raw_event(accepted_id.as_str())
.await
.expect("accepted lookup")
.is_some()
);
assert!(
store
- .get_event(skipped_id.as_str())
+ .raw_event(skipped_id.as_str())
.await
.expect("skipped lookup")
.is_none()
);
assert!(
store
- .get_event(wrong_tag_id.as_str())
+ .raw_event(wrong_tag_id.as_str())
.await
.expect("wrong tag lookup")
.is_none()
@@ -1782,7 +1983,7 @@ async fn fetch_raw_scan_limit_bounds_noisy_adapter_output() {
assert_eq!(receipt.eose_count, 1);
assert!(
store
- .get_event(accepted_id.as_str())
+ .raw_event(accepted_id.as_str())
.await
.expect("accepted lookup")
.is_none()
@@ -1790,7 +1991,7 @@ async fn fetch_raw_scan_limit_bounds_noisy_adapter_output() {
}
#[tokio::test]
-async fn fetch_subscription_mode_and_store_errors_are_reported() {
+async fn fetch_subscription_mode_and_store_errors_are_propagated() {
let signed = signed_post("subscription");
let store = RadrootsEventStore::open_memory().await.expect("store");
let adapter = RadrootsMockRelayFetchAdapter::new(vec![RadrootsRelayFetchItem::Event {
@@ -1826,15 +2027,12 @@ async fn fetch_subscription_mode_and_store_errors_are_reported() {
raw_json: signed.raw_json().to_owned(),
observed_at_ms: 1_210,
}]);
- let receipt =
+ let error =
fetch_and_ingest_relay_events(&adapter, &closed_store, post_relay_fetch_request(1_210, 10))
.await
- .expect("fetch ingest");
+ .expect_err("closed local store must fail the fetch ingest");
- assert_eq!(receipt.inserted_count, 0);
- assert_eq!(receipt.malformed_count, 1);
- assert!(receipt.events[0].malformed);
- assert!(receipt.events[0].message.is_some());
+ assert!(matches!(error, RadrootsRelayTransportError::EventStore(_)));
}
#[tokio::test]
@@ -3485,11 +3683,14 @@ async fn smoke_relay_fetch_processes_one_thousand_event_receipts() {
assert_eq!(receipt.duplicate_count, 0);
assert_eq!(receipt.malformed_count, 0);
assert_eq!(receipt.unsupported_count, 0);
+ assert_eq!(receipt.invalid_count, 0);
assert_eq!(receipt.events.len(), 1_000);
- assert!(receipt.events.iter().all(|event| event.projection_eligible));
- let replay = store
- .events_since_cursor("fetch-smoke", 1_000)
- .await
- .expect("replay");
+ assert!(
+ receipt
+ .events
+ .iter()
+ .all(|event| event.valid_stream_eligible)
+ );
+ let replay = store.valid_stream_after(0, 1_000).await.expect("replay");
assert_eq!(replay.len(), 1_000);
}
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -1,9 +1,11 @@
#![forbid(unsafe_code)]
+mod admission_authority;
mod comment_authority;
mod deletion_authority;
use crate::coverage::{CoveragePolicyFile, CoverageThresholds, read_coverage_policy};
+use admission_authority::validate_admission_operation_authority;
use comment_authority::{
COMMENT_CASE_KINDS, COMMENT_CONFORMANCE_VECTOR_RELATIVE, COMMENT_OPERATION_EXPECTATIONS,
COMMENT_VECTOR_EXPECTATIONS, REQUIRED_COMMENT_PUBLIC_TYPES,
@@ -47,7 +49,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract";
const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION";
const REPLICA_TRANSFER_VERSION: u32 = 2;
const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3";
-const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 18] = [
+const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 19] = [
(
"contracts/conformance/vectors/blossom/bud11_claims.v1.json",
"crates/blossom/tests/fixtures/bud11_claims.v1.json",
@@ -81,6 +83,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 18] = [
"crates/event_codec/tests/fixtures/deletion_suppression.v1.json",
),
(
+ "contracts/conformance/vectors/event/verified_admission.v1.json",
+ "crates/event_codec/tests/fixtures/verified_admission.v1.json",
+ ),
+ (
"contracts/conformance/vectors/events/operational_listing_tags_full.v1.json",
"crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json",
),
@@ -5431,6 +5437,7 @@ fn validate_capsule_operation_authority(
)?;
validate_comment_operation_authority(operations_manifest, workspace_root)?;
validate_deletion_operation_authority(operations_manifest, workspace_root)?;
+ validate_admission_operation_authority(operations_manifest, workspace_root)?;
validate_post_operation_authority(operations_manifest, workspace_root)?;
validate_calendar_operation_authority(operations_manifest, &shared_types)?;
validate_food_availability_operation_authority(operations_manifest, workspace_root)
@@ -8420,6 +8427,18 @@ mod tests {
(manifest, vector)
}
+ fn current_admission_authority() -> (OperationsContractManifest, ConformanceVectorFile) {
+ let root = workspace_root();
+ let manifest =
+ parse_toml::<OperationsContractManifest>(&root.join("contracts/operations.toml"))
+ .expect("current operations manifest");
+ let vector = parse_json::<ConformanceVectorFile>(
+ &root.join(admission_authority::ADMISSION_CONFORMANCE_VECTOR_RELATIVE),
+ )
+ .expect("current verified admission conformance vector");
+ (manifest, vector)
+ }
+
fn current_comment_authority() -> (OperationsContractManifest, ConformanceVectorFile) {
let root = workspace_root();
let manifest =
@@ -9285,6 +9304,47 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"]
}
#[test]
+ fn verified_admission_authority_rejects_manifest_fixture_and_secret_drift() {
+ let (manifest, vector) = current_admission_authority();
+ admission_authority::validate_admission_operation_inventory(&manifest, &vector)
+ .expect("current verified admission authority");
+
+ let (mut manifest, vector) = current_admission_authority();
+ manifest.operations.remove("event_admit_verified");
+ let error = admission_authority::validate_admission_operation_inventory(&manifest, &vector)
+ .expect_err("missing central admission operation must fail");
+ assert!(error.contains("operation authority drift"), "{error}");
+
+ let (mut manifest, vector) = current_admission_authority();
+ manifest
+ .shared_types
+ .public
+ .retain(|value| value != "RadrootsEventAdmissionError");
+ let error = admission_authority::validate_admission_operation_inventory(&manifest, &vector)
+ .expect_err("missing central admission public type must fail");
+ assert!(error.contains("requires shared public type"), "{error}");
+
+ let (manifest, mut vector) = current_admission_authority();
+ vector.vectors[0]
+ .input
+ .as_object_mut()
+ .expect("admission input")
+ .insert(
+ "secret_key".to_string(),
+ Value::String("forbidden".to_string()),
+ );
+ let error = admission_authority::validate_admission_operation_inventory(&manifest, &vector)
+ .expect_err("fixture secret material must fail exact input inventory");
+ assert!(error.contains("field inventory drift"), "{error}");
+
+ let (manifest, mut vector) = current_admission_authority();
+ vector.vectors[0].id = "renamed_admission_case".to_string();
+ let error = admission_authority::validate_admission_operation_inventory(&manifest, &vector)
+ .expect_err("renamed admission vector must fail exact inventory");
+ assert!(error.contains("unexpected id"), "{error}");
+ }
+
+ #[test]
fn post_operation_authority_rejects_another_vector_namespace_operation() {
let (mut manifest, vector) = current_post_authority();
let mut unexpected = manifest
diff --git a/tools/xtask/src/contract/admission_authority.rs b/tools/xtask/src/contract/admission_authority.rs
@@ -0,0 +1,488 @@
+use super::{
+ ConformanceVectorEntry, OperationsContractManifest, collect_non_empty_set,
+ validate_conformance_vector_file, validate_operation_case_kinds,
+};
+use serde_json::{Map, Value};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::Path,
+};
+
+pub(super) const ADMISSION_CONFORMANCE_VECTOR_RELATIVE: &str =
+ "contracts/conformance/vectors/event/verified_admission.v1.json";
+
+const REQUIRED_ADMISSION_PUBLIC_TYPES: [&str; 4] = [
+ "RadrootsSignatureVerifiedEvent",
+ "RadrootsContractValidatedEvent",
+ "RadrootsAdmittedEvent",
+ "RadrootsEventAdmissionError",
+];
+
+const ADMISSION_CASE_KINDS: [&str; 2] =
+ ["event.admit_verified.valid", "event.admit_verified.invalid"];
+
+#[derive(Clone, Copy)]
+enum ExpectedAdmission {
+ Valid {
+ variant: &'static str,
+ contract_id: &'static str,
+ },
+ Invalid {
+ error_variant: &'static str,
+ error_code: &'static str,
+ },
+}
+
+#[derive(Clone, Copy)]
+struct AdmissionVectorExpectation {
+ id: &'static str,
+ outcome: ExpectedAdmission,
+}
+
+const ADMISSION_VECTOR_EXPECTATIONS: [AdmissionVectorExpectation; 13] = [
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_profile_001",
+ outcome: ExpectedAdmission::Valid {
+ variant: "profile",
+ contract_id: "radroots.profile.metadata.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_root_update_002",
+ outcome: ExpectedAdmission::Valid {
+ variant: "root_post",
+ contract_id: "radroots.social.update.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_root_photo_update_003",
+ outcome: ExpectedAdmission::Valid {
+ variant: "root_post",
+ contract_id: "radroots.social.photo_update.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_root_ask_004",
+ outcome: ExpectedAdmission::Valid {
+ variant: "root_post",
+ contract_id: "radroots.social.ask.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_post_to_reply_005",
+ outcome: ExpectedAdmission::Valid {
+ variant: "reply",
+ contract_id: "radroots.social.reply.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_comment_006",
+ outcome: ExpectedAdmission::Valid {
+ variant: "comment",
+ contract_id: "radroots.social.comment.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_deletion_request_007",
+ outcome: ExpectedAdmission::Valid {
+ variant: "deletion_request",
+ contract_id: "radroots.social.deletion_request.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_food_availability_008",
+ outcome: ExpectedAdmission::Valid {
+ variant: "food_availability",
+ contract_id: "radroots.food.availability.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_operational_fallback_009",
+ outcome: ExpectedAdmission::Valid {
+ variant: "contract_validated",
+ contract_id: "radroots.operational_listing.published.v1",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_unsupported_kind_010",
+ outcome: ExpectedAdmission::Invalid {
+ error_variant: "contract_match",
+ error_code: "unsupported_kind",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_generic_nip99_excluded_011",
+ outcome: ExpectedAdmission::Invalid {
+ error_variant: "contract_match",
+ error_code: "unsupported_shape",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_operational_invalid_shape_012",
+ outcome: ExpectedAdmission::Invalid {
+ error_variant: "contract_validation",
+ error_code: "missing_tag",
+ },
+ },
+ AdmissionVectorExpectation {
+ id: "event_admit_verified_ambiguous_food_markers_013",
+ outcome: ExpectedAdmission::Invalid {
+ error_variant: "food_availability",
+ error_code: "food_profile_ambiguous",
+ },
+ },
+];
+
+pub(super) fn validate_admission_operation_authority(
+ manifest: &OperationsContractManifest,
+ workspace_root: &Path,
+) -> Result<(), String> {
+ let vector = validate_conformance_vector_file(
+ &workspace_root.join(ADMISSION_CONFORMANCE_VECTOR_RELATIVE),
+ &manifest.contract.version,
+ )?;
+ validate_admission_operation_inventory(manifest, &vector)?;
+ validate_source_witnesses(workspace_root)?;
+ Ok(())
+}
+
+pub(super) fn validate_admission_operation_inventory(
+ manifest: &OperationsContractManifest,
+ vector: &super::ConformanceVectorFile,
+) -> Result<(), String> {
+ validate_manifest_authority(manifest, vector)?;
+ validate_vector_inventory(&vector.vectors)
+}
+
+fn validate_manifest_authority(
+ manifest: &OperationsContractManifest,
+ vector: &super::ConformanceVectorFile,
+) -> Result<(), String> {
+ let shared_types = collect_non_empty_set(
+ &manifest.shared_types.public,
+ "verified admission shared_types.public",
+ )?;
+ for required in REQUIRED_ADMISSION_PUBLIC_TYPES {
+ if !shared_types.contains(required) {
+ return Err(format!(
+ "verified admission authority requires shared public type {required}"
+ ));
+ }
+ }
+
+ let actual_keys = manifest
+ .operations
+ .iter()
+ .filter(|(key, operation)| {
+ key.starts_with("event_admit_verified")
+ || operation.id.starts_with("event.admit_verified")
+ || operation.conformance.vector == ADMISSION_CONFORMANCE_VECTOR_RELATIVE
+ })
+ .map(|(key, _)| key.as_str())
+ .collect::<BTreeSet<_>>();
+ let expected_keys = BTreeSet::from(["event_admit_verified"]);
+ if actual_keys != expected_keys {
+ return Err(format!(
+ "verified admission operation authority drift: expected {expected_keys:?}, got {actual_keys:?}"
+ ));
+ }
+
+ let operation = manifest
+ .operations
+ .get("event_admit_verified")
+ .ok_or_else(|| {
+ "verified admission operation event_admit_verified is required".to_string()
+ })?;
+ require_scalar("domain", &operation.domain, "event")?;
+ require_scalar("id", &operation.id, "event.admit_verified")?;
+ require_scalar("stability", &operation.stability, "beta")?;
+ require_scalar("error_class", &operation.error_class, "admission_error")?;
+ require_scalar("signing", &operation.signing, "none")?;
+ require_scalar("transport", &operation.transport, "none")?;
+ if !operation.deterministic {
+ return Err(
+ "verified admission operation deterministic drift: expected true, got false"
+ .to_string(),
+ );
+ }
+ require_sequence(
+ "inputs",
+ &operation.inputs,
+ &["RadrootsSignatureVerifiedEvent"],
+ )?;
+ require_sequence("outputs", &operation.outputs, &["RadrootsAdmittedEvent"])?;
+ require_sequence(
+ "implementation.rust_modules",
+ &operation.implementation.rust_modules,
+ &[
+ "crates/event_codec/src/admission.rs",
+ "crates/event_codec/src/verification.rs",
+ ],
+ )?;
+ require_sequence(
+ "implementation.rust_types",
+ &operation.implementation.rust_types,
+ &[
+ "radroots_event_codec::admission::RadrootsAdmittedEvent",
+ "radroots_event_codec::admission::RadrootsEventAdmissionError",
+ "radroots_event_codec::verification::RadrootsContractValidatedEvent",
+ "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent",
+ ],
+ )?;
+ require_scalar(
+ "conformance.vector",
+ &operation.conformance.vector,
+ ADMISSION_CONFORMANCE_VECTOR_RELATIVE,
+ )?;
+ validate_operation_case_kinds(operation, vector)?;
+ require_sequence(
+ "conformance.case_kinds",
+ &operation.conformance.case_kinds,
+ &ADMISSION_CASE_KINDS,
+ )
+}
+
+fn validate_source_witnesses(workspace_root: &Path) -> Result<(), String> {
+ for (relative, fragments) in [
+ (
+ "crates/event_codec/src/admission.rs",
+ &[
+ "pub enum RadrootsAdmittedEvent",
+ "pub enum RadrootsEventAdmissionError",
+ "pub fn admit_verified_event(",
+ ][..],
+ ),
+ (
+ "crates/event_codec/src/verification.rs",
+ &[
+ "pub struct RadrootsSignatureVerifiedEvent",
+ "pub struct RadrootsContractValidatedEvent",
+ ][..],
+ ),
+ ] {
+ let source = fs::read_to_string(workspace_root.join(relative)).map_err(|error| {
+ format!("failed to read verified admission witness {relative}: {error}")
+ })?;
+ for fragment in fragments {
+ if !source.contains(fragment) {
+ return Err(format!(
+ "verified admission witness {relative} is missing `{fragment}`"
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_vector_inventory(vectors: &[ConformanceVectorEntry]) -> Result<(), String> {
+ let expected = ADMISSION_VECTOR_EXPECTATIONS
+ .iter()
+ .map(|entry| (entry.id, entry.outcome))
+ .collect::<BTreeMap<_, _>>();
+ let mut actual = BTreeMap::new();
+ let mut event_ids = BTreeSet::new();
+
+ for vector in vectors {
+ let expectation = expected.get(vector.id.as_str()).ok_or_else(|| {
+ format!(
+ "verified admission conformance vector has unexpected id {}",
+ vector.id
+ )
+ })?;
+ if actual
+ .insert(vector.id.as_str(), vector.kind.as_str())
+ .is_some()
+ {
+ return Err(format!(
+ "verified admission conformance vector has duplicate id {}",
+ vector.id
+ ));
+ }
+ validate_vector(vector, *expectation, &mut event_ids)?;
+ }
+
+ let expected_inventory = ADMISSION_VECTOR_EXPECTATIONS
+ .iter()
+ .map(|entry| {
+ let kind = match entry.outcome {
+ ExpectedAdmission::Valid { .. } => "event.admit_verified.valid",
+ ExpectedAdmission::Invalid { .. } => "event.admit_verified.invalid",
+ };
+ (entry.id, kind)
+ })
+ .collect::<BTreeMap<_, _>>();
+ if actual != expected_inventory {
+ return Err(format!(
+ "verified admission conformance inventory drift: expected {expected_inventory:?}, got {actual:?}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_vector(
+ vector: &ConformanceVectorEntry,
+ expectation: ExpectedAdmission,
+ event_ids: &mut BTreeSet<String>,
+) -> Result<(), String> {
+ let input = exact_object(&vector.input, &["event"], &format!("{}.input", vector.id))?;
+ let event = exact_object(
+ &input["event"],
+ &[
+ "content",
+ "created_at",
+ "id",
+ "kind",
+ "pubkey",
+ "sig",
+ "tags",
+ ],
+ &format!("{}.input.event", vector.id),
+ )?;
+ let event_id = required_string(event, "id", &vector.id)?;
+ let pubkey = required_string(event, "pubkey", &vector.id)?;
+ let signature = required_string(event, "sig", &vector.id)?;
+ require_lower_hex(event_id, 64, "event id", &vector.id)?;
+ require_lower_hex(pubkey, 64, "pubkey", &vector.id)?;
+ require_lower_hex(signature, 128, "signature", &vector.id)?;
+ if !event_ids.insert(event_id.to_string()) {
+ return Err(format!(
+ "verified admission vector {} reuses event id {event_id}",
+ vector.id
+ ));
+ }
+ if !event["created_at"].is_u64()
+ || !event["kind"].is_u64()
+ || !event["tags"].is_array()
+ || !event["content"].is_string()
+ {
+ return Err(format!(
+ "verified admission vector {} must contain a complete typed signed event",
+ vector.id
+ ));
+ }
+
+ match expectation {
+ ExpectedAdmission::Valid {
+ variant,
+ contract_id,
+ } => {
+ if vector.kind != "event.admit_verified.valid" {
+ return Err(format!(
+ "verified admission vector {} kind drift",
+ vector.id
+ ));
+ }
+ let expected = exact_object(
+ &vector.expected,
+ &["contract_id", "event_id", "variant"],
+ &format!("{}.expected", vector.id),
+ )?;
+ require_expected(expected, "variant", variant, &vector.id)?;
+ require_expected(expected, "contract_id", contract_id, &vector.id)?;
+ require_expected(expected, "event_id", event_id, &vector.id)?;
+ }
+ ExpectedAdmission::Invalid {
+ error_variant,
+ error_code,
+ } => {
+ if vector.kind != "event.admit_verified.invalid" {
+ return Err(format!(
+ "verified admission vector {} kind drift",
+ vector.id
+ ));
+ }
+ let expected = exact_object(
+ &vector.expected,
+ &["error_code", "error_variant", "event_id"],
+ &format!("{}.expected", vector.id),
+ )?;
+ require_expected(expected, "error_variant", error_variant, &vector.id)?;
+ require_expected(expected, "error_code", error_code, &vector.id)?;
+ require_expected(expected, "event_id", event_id, &vector.id)?;
+ }
+ }
+ Ok(())
+}
+
+fn exact_object<'a>(
+ value: &'a Value,
+ fields: &[&str],
+ label: &str,
+) -> Result<&'a Map<String, Value>, String> {
+ let object = value
+ .as_object()
+ .ok_or_else(|| format!("verified admission {label} must be an object"))?;
+ let actual = object.keys().map(String::as_str).collect::<BTreeSet<_>>();
+ let expected = fields.iter().copied().collect::<BTreeSet<_>>();
+ if actual != expected {
+ return Err(format!(
+ "verified admission {label} field inventory drift: expected {expected:?}, got {actual:?}"
+ ));
+ }
+ Ok(object)
+}
+
+fn required_string<'a>(
+ object: &'a Map<String, Value>,
+ field: &str,
+ vector_id: &str,
+) -> Result<&'a str, String> {
+ object[field].as_str().ok_or_else(|| {
+ format!("verified admission vector {vector_id} field {field} must be a string")
+ })
+}
+
+fn require_lower_hex(
+ value: &str,
+ length: usize,
+ label: &str,
+ vector_id: &str,
+) -> Result<(), String> {
+ if value.len() != length
+ || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
+ || value.bytes().any(|byte| byte.is_ascii_uppercase())
+ {
+ return Err(format!(
+ "verified admission vector {vector_id} {label} must be {length} lowercase hex characters"
+ ));
+ }
+ Ok(())
+}
+
+fn require_expected(
+ object: &Map<String, Value>,
+ field: &str,
+ expected: &str,
+ vector_id: &str,
+) -> Result<(), String> {
+ let actual = required_string(object, field, vector_id)?;
+ if actual != expected {
+ return Err(format!(
+ "verified admission vector {vector_id} expected.{field} drift: expected {expected}, got {actual}"
+ ));
+ }
+ Ok(())
+}
+
+fn require_scalar(field: &str, actual: &str, expected: &str) -> Result<(), String> {
+ if actual != expected {
+ return Err(format!(
+ "verified admission operation {field} drift: expected {expected}, got {actual}"
+ ));
+ }
+ Ok(())
+}
+
+fn require_sequence(field: &str, actual: &[String], expected: &[&str]) -> Result<(), String> {
+ if !actual
+ .iter()
+ .map(String::as_str)
+ .eq(expected.iter().copied())
+ {
+ return Err(format!(
+ "verified admission operation {field} drift: expected {expected:?}, got {actual:?}"
+ ));
+ }
+ Ok(())
+}