lib

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

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:
MCHANGELOG.md | 30++++++++++++++++++++++++++++++
MCargo.lock | 2+-
Acontracts/conformance/vectors/event/verified_admission.v1.json | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/event_boundary_matrix.md | 25+++++++++++++++++++++++++
Mcontracts/operations.toml | 30++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/event_head.rs | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/event_codec/README | 12++++++++++++
Acrates/event_codec/src/admission.rs | 528+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/knowledge/verification.rs | 69+++++++++++++++++----------------------------------------------------
Mcrates/event_codec/src/lib.rs | 13++++++++-----
Mcrates/event_codec/src/profile/admission.rs | 11++++++++++-
Mcrates/event_codec/src/verification.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/event_codec/tests/fixtures/verified_admission.v1.json | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/verified_admission_conformance.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/Cargo.toml | 5+++--
Mcrates/event_store/README | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event_store/src/error.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/event_store/src/lib.rs | 17+++++++++--------
Mcrates/event_store/src/model.rs | 388+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/event_store/src/store.rs | 2786+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/nostr/Cargo.toml | 1+
Mcrates/nostr/src/event_verify.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/outbox/README | 14+++++++++++++-
Mcrates/outbox/src/error.rs | 17+++++++++++++++++
Mcrates/outbox/src/store.rs | 436+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Dcrates/trade/src/order.rs | 3940-------------------------------------------------------------------------------
Dcrates/trade/src/projection.rs | 1508-------------------------------------------------------------------------------
Mcrates/transport_nostr/README | 14+++++++++++++-
Mcrates/transport_nostr/src/fetch.rs | 130+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/transport_nostr/src/outbox.rs | 2+-
Mcrates/transport_nostr/tests/transport.rs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mtools/xtask/src/contract.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atools/xtask/src/contract/admission_authority.rs | 488+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&regular), + 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(()) +}