From 8e6cec95785fdb7f8deb5d1161e67e586a5f2ce4 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Fri, 25 Sep 2026 21:58:54 +0300 Subject: [PATCH 1/3] feat(protected-content): buy from any live offer, found by link or in Explore Buying and reading become two workflows with two datasets. A purchase names an item, a seller and the terms the buyer agreed to, and rests on chain terms alone; opening keeps the existing custody read path. - Chain-provider: resolve an item's operative and token URI, a content id's binding, every live offer (32-seller cap), an item-keyed access read (hasAccess(address,address,uint256)) and the verified listing. Every corroborated read pins its sources to the lowest finalized block they agree on. A rate-limited source is retried briefly, then read around for 60 s, and a read stops once two sources agree. - Listing object: POST /api/apps/marketplace/listing rebuilds an item's listing from a token URI, a content id or (ledger, tokenId), with every live seller's terms. The shared metadata.json and the KID binding are readability evidence only (verified, unverified, foreign or unknown), never a purchase gate. A proven KID contradiction is refused. - buy_offer: one copy on exactly the agreed terms; terms_changed, attempt_in_progress and own_offer answers. Ownership is checked on chain by item before paying. A buyer holds at most one open purchase per item across buy and buy_offer, created under a per-principal lock and updated or retired only by its own attempt; a declined or reverted attempt is retired. Wallet addresses are redacted from error detail. - Adoption: a completed purchase of an ElastOS-protected item becomes the records the open path reads, built from the shared metadata.json once its KID is proven; other items stay foreign. - Marketplace capsule: offer sheet with every live seller, buy from a link or from Explore, resumable recorded attempts, Download copy for a pending adoption, a Details view of a bought item's properties, owned labels that follow what this Home holds, and Explore opening on the market with one automatic retry. Modals open on Close. - Providers: an idled Kubo is restarted instead of failing every fetch; the market index gets time for a cold cache and one retry. Design: docs/audits/2026-09-24-protected-content-market-buy-design.md Report: docs/audits/2026-09-25-protected-content-market-buy-implementation-report.md --- TASKS.md | 68 + capsules/chain-provider/src/abi.rs | 132 ++ capsules/chain-provider/src/backends.rs | 165 +- capsules/chain-provider/src/main.rs | 1643 ++++++++++++- capsules/chain-provider/src/protocol.rs | 51 +- capsules/chain-provider/src/tests.rs | 2050 +++++++++++++++-- .../chain-provider/src/tests/rate_limit.rs | 518 +++++ capsules/chain-provider/src/tests/support.rs | 129 ++ capsules/ipfs-provider/src/main.rs | 12 +- capsules/marketplace/browser/index.html | 8 +- capsules/marketplace/browser/marketplace.css | 141 ++ capsules/marketplace/browser/marketplace.js | 1151 ++++++++- .../src/fixtures/buy-offer-answers.json | 117 + .../src/fixtures/market-listing-variants.json | 420 ++++ .../browser/src/fixtures/market-listing.json | 41 + .../src/market-buy-offer.fixture.test.mjs | 76 + .../src/market-listing.fixture.test.mjs | 80 + .../marketplace/browser/src/market-listing.js | 513 +++++ .../browser/src/market-listing.test.mjs | 627 +++++ docs/PROTECTED_CONTENT.md | 83 +- ...-24-protected-content-market-buy-design.md | 618 +++++ ...ontent-market-buy-implementation-report.md | 134 ++ elastos/CHANGELOG.md | 43 + .../crates/elastos-server/src/api/gateway.rs | 10 + .../src/api/gateway_marketplace_buy.rs | 1434 ++++++++++++ .../src/api/gateway_marketplace_directory.rs | 148 +- .../src/api/gateway_marketplace_listing.rs | 1226 ++++++++++ .../src/api/gateway_onchain_directory.rs | 139 +- .../src/api/gateway_provider_proxy.rs | 837 +++++-- .../src/api/gateway_tests/buy_offer.rs | 1788 ++++++++++++++ .../fixtures/chain-market-ops.json | 250 ++ .../src/api/gateway_tests/library.rs | 1300 ++++++++++- .../src/api/gateway_tests/listing_object.rs | 1618 +++++++++++++ .../src/api/gateway_tests/market_directory.rs | 170 ++ .../src/api/gateway_tests/mod.rs | 2 + .../api/gateway_tests/support_providers.rs | 37 + elastos/crates/elastos-server/src/content.rs | 31 +- elastos/crates/elastos-server/src/lib.rs | 1 + elastos/crates/elastos-server/src/library.rs | 225 +- .../src/protected_content_market.rs | 960 ++++++++ .../src/protected_content_market/tests.rs | 649 ++++++ .../src/protected_content_runtime.rs | 737 +++++- .../src/protected_content_runtime/tests.rs | 494 +++- .../marketplace-product-behavior-smoke.mjs | 119 +- scripts/marketplace-product-layout-smoke.mjs | 1398 ++++++++++- 45 files changed, 21686 insertions(+), 707 deletions(-) create mode 100644 capsules/chain-provider/src/tests/rate_limit.rs create mode 100644 capsules/marketplace/browser/src/fixtures/buy-offer-answers.json create mode 100644 capsules/marketplace/browser/src/fixtures/market-listing-variants.json create mode 100644 capsules/marketplace/browser/src/fixtures/market-listing.json create mode 100644 capsules/marketplace/browser/src/market-buy-offer.fixture.test.mjs create mode 100644 capsules/marketplace/browser/src/market-listing.fixture.test.mjs create mode 100644 capsules/marketplace/browser/src/market-listing.js create mode 100644 capsules/marketplace/browser/src/market-listing.test.mjs create mode 100644 docs/audits/2026-09-24-protected-content-market-buy-design.md create mode 100644 docs/audits/2026-09-25-protected-content-market-buy-implementation-report.md create mode 100644 elastos/crates/elastos-server/src/api/gateway_marketplace_buy.rs create mode 100644 elastos/crates/elastos-server/src/api/gateway_marketplace_listing.rs create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/buy_offer.rs create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/fixtures/chain-market-ops.json create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/listing_object.rs create mode 100644 elastos/crates/elastos-server/src/protected_content_market.rs create mode 100644 elastos/crates/elastos-server/src/protected_content_market/tests.rs diff --git a/TASKS.md b/TASKS.md index b75c83900..9189db3ef 100644 --- a/TASKS.md +++ b/TASKS.md @@ -131,6 +131,74 @@ records what is verified and what is not. Open: +- [ ] Buy from any live offer, found by link or in Explore. Buying and + reading become two workflows: a purchase reads the item and every seller's + terms from the chain and answers `terms_changed` instead of charging at + terms the buyer did not see; opening keeps the existing read path, fed from + the shared Elacity `metadata.json` rather than an ElastOS-only document. + A listing is rebuildable from a `tokenURI`, a content id or + `(ledger, tokenId)`. Amends `PROTECTED_CONTENT.md` steps 7–8, done. + Design: [market buy](docs/audits/2026-09-24-protected-content-market-buy-design.md). + Branch `feat/protected-content-listing-buy`, stacked on PR #62. Source is + implemented and verified on the branch; open is installed proof only, and + needs funded principals: buy by shared link, buy after a seller changes + terms mid-flow, buy an item found only through the index (no prior link), + resale where two offers exist for the same item, a purchase of a + foreign (non-ElastOS-scheme) item, and a pending adoption finished with + Download copy. Installed 25 September on Base mainnet: an index-found + (Explore) ERC-20 buy of a foreign, Lit-custody item minted on + base.ela.city completed (`0x4d8ba231…71bc12`, access proven, adoption + `foreign`) — see the + [implementation report](docs/audits/2026-09-25-protected-content-market-buy-implementation-report.md). + Still open: shared-link buy, terms change mid-flow, two-offer resale, and + an ElastOS v1 item proving adoption, Download copy and playback. + - [ ] Follow-ups recorded from the final fix wave (R37), not required for + this slice: + - [ ] Share one stage driver between `buy` and `buy_offer` on the money + path, after the proof journeys land. + - [ ] Unify the duplicated text-bound helper. + - [ ] Replace text-matched chain errors with typed chain-provider error + codes. + - [ ] Add a chain-provider corroborate helper — also removing the three + `_at` variant duplicates — with source logging. + - [ ] Hint a buyer to wait for finality after a revert within the + finality lag, instead of a plain failure. + - [ ] Read the buy receipt at the finalized block instead of latest. + - [ ] Decode ERC-20 approval/buy calldata server-side instead of + trusting the chain-provider's steps. + - [ ] Adopt a market purchase when this Home already holds a listing of + the same mint on different terms. Adoption reuses an existing listing + only when its terms equal the purchase's (R39); with different terms + it stays `pending`. Supporting it needs per-purchase terms on the open + path. + - [ ] Cap the shared-document read inside `ipfs-provider`. The server + asks for a 256 KiB range and trims the answer (R30), but the provider's + `cat` still reads the whole file first; a true cap needs a range or + `max_bytes` on `cat`. + - [ ] Give content providers a typed not-found error. The listing tells + a missing `metadata.json` from an unreadable one by matching the + provider's error text, and probes `listing.json` only on not-found. + - [ ] Let the Marketplace capsule list adopted (`Asset`-origin) rows in + My listings, which today requires `published_at` — accept a null + `published_at` / origin `asset` row. + - [ ] Recommend a keyed RPC endpoint for production market and rights + reads. Public Base endpoints answer HTTP 429 under one Buy press; the + bounded retry and source cooldown (R47) spread the load, and a keyed + endpoint removes the limit itself. + - [ ] Reduce the chain reads per Buy by reusing the item read (operative + and `tokenURI`) across the listing, `buy_offer` and the purchase + builder's verified-listing read, instead of reading it again at each + step. + - [ ] Show the purchase transaction on a bought item's Details view. + The page does not receive it today; it needs a wire field. + - [ ] Ask the chain (item-keyed `hasAccess`) for a KID-less listing's + `access_state`; the Buy already does (R50), the listing badge does + not. + - [ ] Log a market-index failure once (the transport layer and the + catalog handler each log it today). + - [ ] Give `buy_offer`'s unavailable refusal a typed code. Marketplace + recognizes it today by the `library_error` code together with the + stable `Runtime custody purchase is unavailable` sentence (R48). - [ ] Let a mint recover from a declined wallet approval. A rejected or expired approval is reported accurately now instead of reading as "pending" forever, but it leaves the mint at `EffectRaised`, and `discard_creator_state` refuses diff --git a/capsules/chain-provider/src/abi.rs b/capsules/chain-provider/src/abi.rs index b7d2c7af8..f9237e21b 100644 --- a/capsules/chain-provider/src/abi.rs +++ b/capsules/chain-provider/src/abi.rs @@ -17,6 +17,16 @@ pub(super) const CHANNEL_HAS_ROLE_SELECTOR: &str = "0x91d14854"; pub(super) const CHANNEL_DEFAULT_ADMIN_ROLE_WORD: [u8; 32] = [0u8; 32]; pub(super) const ACCESS_TOKEN_ID_HEX: &str = "0x1"; pub(super) const PROTECTED_CONTENT_PURCHASE_QUANTITY_HEX: &str = "0x1"; +pub(super) const AUTHORITY_GATEWAY_CSTORE_SELECTOR: &str = "0xd36f509d"; +pub(super) const IP_TRACKER_IP_REFERENCE_SELECTOR: &str = "0x93d9f5ab"; +pub(super) const OPERATIVE_TOKEN_URI_SELECTOR: &str = "0xc87b56dd"; +pub(super) const AUTHORITY_GATEWAY_SELLERS_OF_SELECTOR: &str = "0x997eab2d"; +/// `AuthorityGateway.hasAccess(address accessor, address ledger, uint256 +/// tokenId)`: the item-keyed twin of `hasAccessByContentId`, answered by the +/// same `_checkUserAccess` (R50). It needs no KID, so it answers for an item +/// whose KID is unknown. +pub(super) const AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR: &str = "0xcf56b4eb"; +pub(super) const EVM_ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct ProtectedContentAssetCreatedLog { @@ -182,6 +192,22 @@ pub(super) fn encode_authority_gateway_operative_call( Ok(format!("0x{}", encode_hex(&bytes))) } +pub(super) fn encode_authority_gateway_has_access_call( + accessor: &str, + ledger: &str, + token_id: &str, +) -> Result { + let mut bytes = decode_hex( + AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR, + Some(4), + "hasAccess selector", + )?; + bytes.extend_from_slice(&abi_word_address(accessor)?); + bytes.extend_from_slice(&abi_word_address(ledger)?); + bytes.extend_from_slice(&abi_word_hex_quantity(token_id, "token_id")?); + Ok(format!("0x{}", encode_hex(&bytes))) +} + pub(super) fn encode_authority_gateway_listing_call( operative: &str, seller: &str, @@ -200,6 +226,112 @@ pub(super) fn encode_authority_gateway_listing_call( Ok(format!("0x{}", encode_hex(&bytes))) } +pub(super) fn encode_authority_gateway_cstore_call() -> Result { + let bytes = decode_hex( + AUTHORITY_GATEWAY_CSTORE_SELECTOR, + Some(4), + "cstore selector", + )?; + Ok(format!("0x{}", encode_hex(&bytes))) +} + +/// `ipReference(bytes16)`: a bytes16 is LEFT-aligned in its word, which the +/// existing `abi_word_bytes16` already does. +pub(super) fn encode_ip_reference_call(content_access_id: &str) -> Result { + let mut bytes = decode_hex( + IP_TRACKER_IP_REFERENCE_SELECTOR, + Some(4), + "ipReference selector", + )?; + let kid: [u8; 16] = decode_hex(content_access_id, Some(16), "content_access_id")? + .try_into() + .map_err(|_| "content_access_id must be 16 bytes".to_string())?; + bytes.extend_from_slice(&abi_word_bytes16(&kid)); + Ok(format!("0x{}", encode_hex(&bytes))) +} + +pub(super) fn encode_operative_token_uri_call() -> Result { + let mut bytes = decode_hex(OPERATIVE_TOKEN_URI_SELECTOR, Some(4), "tokenURI selector")?; + bytes.extend_from_slice(&abi_word_hex_quantity( + ACCESS_TOKEN_ID_HEX, + "access_token_id", + )?); + Ok(format!("0x{}", encode_hex(&bytes))) +} + +pub(super) fn encode_authority_gateway_sellers_of_call(operative: &str) -> Result { + let mut bytes = decode_hex( + AUTHORITY_GATEWAY_SELLERS_OF_SELECTOR, + Some(4), + "sellersOf selector", + )?; + bytes.extend_from_slice(&abi_word_address(operative)?); + bytes.extend_from_slice(&abi_word_hex_quantity( + ACCESS_TOKEN_ID_HEX, + "access_token_id", + )?); + Ok(format!("0x{}", encode_hex(&bytes))) +} + +/// `(address channel, uint256 tokenId)`. `word_to_address` refuses a word +/// with non-zero high bytes, exactly as `decode_evm_address_word` does. +pub(super) fn decode_ip_reference(value: &Value) -> Result<(String, String), String> { + let value = value + .as_str() + .ok_or_else(|| "ipReference result must be hex string".to_string())?; + let bytes = decode_hex(value, Some(64), "ipReference result")?; + Ok(( + word_to_address(&bytes[0..32])?, + normalize_hex_quantity_bytes(&bytes[32..64]), + )) +} + +/// A single ABI `string` return value: word 0 is the offset of the length +/// word, which `decode_abi_string` then reads. +pub(super) fn decode_evm_string(value: &Value, label: &str) -> Result { + let value = value + .as_str() + .ok_or_else(|| format!("{label} result must be hex string"))?; + let bytes = decode_hex(value, None, label)?; + if bytes.len() < 64 { + return Err(format!("{label} result is too short for an ABI string")); + } + let offset = usize_from_word(&bytes[0..32], label)?; + decode_abi_string(&bytes, offset, label) +} + +/// A single ABI `address[]` return value. The length is bounded before +/// anything is allocated, so a hostile RPC cannot make this reserve memory. +pub(super) fn decode_evm_address_array(value: &Value, label: &str) -> Result, String> { + const MAX: usize = 1024; + let value = value + .as_str() + .ok_or_else(|| format!("{label} result must be hex string"))?; + let bytes = decode_hex(value, None, label)?; + if bytes.len() < 64 { + return Err(format!("{label} result is too short for an ABI array")); + } + let offset = usize_from_word(&bytes[0..32], label)?; + let len_end = offset + .checked_add(32) + .ok_or_else(|| format!("{label} offset overflows"))?; + if len_end > bytes.len() { + return Err(format!("{label} length word is out of bounds")); + } + let length = usize_from_word(&bytes[offset..len_end], label)?; + if length > MAX { + return Err(format!("{label} has more than {MAX} entries")); + } + let end = len_end + length * 32; + if end > bytes.len() { + return Err(format!("{label} entries are truncated")); + } + bytes[len_end..end] + .chunks_exact(32) + .map(word_to_address) + .collect() +} + pub(super) fn encode_operatives_payment_processor_call() -> Result { let bytes = decode_hex( PROTECTED_CONTENT_PAYMENT_PROCESSOR_SELECTOR, diff --git a/capsules/chain-provider/src/backends.rs b/capsules/chain-provider/src/backends.rs index be4547687..a6f283463 100644 --- a/capsules/chain-provider/src/backends.rs +++ b/capsules/chain-provider/src/backends.rs @@ -19,6 +19,64 @@ use super::*; /// answered and knew nothing. pub(super) const EVM_RPC_BATCH_MAX: usize = 3; +/// How many more times a request answered HTTP 429 is sent (R47). +const EVM_RPC_RATE_LIMIT_RETRIES: usize = 2; +/// The waits before those retries when the source names no usable +/// `Retry-After`. +const EVM_RPC_RATE_LIMIT_BACKOFF: [Duration; EVM_RPC_RATE_LIMIT_RETRIES] = + [Duration::from_millis(250), Duration::from_millis(750)]; +/// The longest `Retry-After` honoured; a longer one uses the backoff above. +const EVM_RPC_RATE_LIMIT_MAX_RETRY_AFTER_SECS: u64 = 2; +/// How long an origin that stayed rate-limited is asked last. +pub(super) const RATE_LIMITED_ORIGIN_COOLDOWN_SECS: u64 = 60; + +/// The wait before retry `retry` (0-based): the source's own `Retry-After` +/// when it asks for at most two seconds, else the bounded backoff. +fn rate_limit_wait(retry_after_secs: Option, retry: usize) -> Duration { + match retry_after_secs { + Some(secs) if secs <= EVM_RPC_RATE_LIMIT_MAX_RETRY_AFTER_SECS => Duration::from_secs(secs), + _ => EVM_RPC_RATE_LIMIT_BACKOFF[retry], + } +} + +/// `Retry-After` in delta-seconds; the HTTP-date form counts as absent. +fn retry_after_secs(response: &reqwest::blocking::Response) -> Option { + response + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok() +} + +/// The key a cooldown is held under: scheme, host and port. Two paths on one +/// origin share one rate limit. +fn rpc_origin(rpc_url: &str) -> String { + reqwest::Url::parse(rpc_url) + .map(|url| url.origin().ascii_serialization()) + .unwrap_or_else(|_| rpc_url.to_string()) +} + +/// The log line for one 429 retry. Only the host is named: a configured RPC +/// URL may carry an API key in its path or query. +pub(super) fn rate_limit_retry_log(rpc_url: &str, retry: usize, wait: Duration) -> String { + format!( + "chain-provider: rpc source rate limited source={} retry={retry} wait_ms={}", + rpc_source_host(rpc_url), + wait.as_millis(), + ) +} + +/// The log line for an origin put in cooldown. Host only, as above. +pub(super) fn rate_limit_cooldown_log(rpc_url: &str) -> String { + format!( + "chain-provider: rpc source cooled source={} reason=http_429 cooldown_secs={RATE_LIMITED_ORIGIN_COOLDOWN_SECS}", + rpc_source_host(rpc_url), + ) +} + /// Reads a batch answer, and decides whether it is an answer at all. /// /// Two refusals to tell apart, both seen from configured sources: @@ -81,6 +139,76 @@ pub(super) struct MainchainTip { } impl ChainProvider { + /// The order a corroborated read asks its sources in: the configured + /// order, with origins still cooling after a persistent 429 moved last. + /// A cooled source stays in the list, so it still answers when nothing + /// else can. + pub(super) fn corroboration_order(&self, evidence_rpc_urls: &[String]) -> Vec { + let now = (self.now_unix_seconds)(); + let (ready, cooled): (Vec, Vec) = evidence_rpc_urls + .iter() + .cloned() + .partition(|rpc_url| !self.rpc_origin_cooling(rpc_url, now)); + ready.into_iter().chain(cooled).collect() + } + + fn rpc_origin_cooling(&self, rpc_url: &str, now: u64) -> bool { + let cooldowns = self + .rate_limited_origins + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cooldowns + .get(&rpc_origin(rpc_url)) + .is_some_and(|until| now < *until) + } + + fn cool_rpc_origin(&self, rpc_url: &str) { + let until = (self.now_unix_seconds)().saturating_add(RATE_LIMITED_ORIGIN_COOLDOWN_SECS); + self.rate_limited_origins + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(rpc_origin(rpc_url), until); + eprintln!("{}", rate_limit_cooldown_log(rpc_url)); + } + + /// Posts one JSON-RPC payload (single or batch). A 429 is retried at most + /// twice (R47); an origin still answering 429 after that is cooled. Every + /// other outcome returns at once, exactly as before. + fn post_evm_rpc( + &self, + rpc_url: &str, + payload: &Value, + unreachable: &str, + ) -> Result { + let mut retry = 0; + loop { + let response = self + .client + .post(rpc_url) + .json(payload) + .send() + .map_err(|_| Response::error("upstream_unreachable", unreachable))?; + let status = response.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + if retry < EVM_RPC_RATE_LIMIT_RETRIES { + let wait = rate_limit_wait(retry_after_secs(&response), retry); + retry += 1; + eprintln!("{}", rate_limit_retry_log(rpc_url, retry, wait)); + (self.sleep)(wait); + continue; + } + self.cool_rpc_origin(rpc_url); + } + if !status.is_success() { + return Err(Response::error( + "upstream_http_error", + &format!("upstream returned HTTP {}", status.as_u16()), + )); + } + return Ok(response); + } + } + pub(super) fn network_for_status(&self, network_id: &str) -> Result<&ChainNetwork, Response> { if let Err(err) = validate_network_id(network_id) { return Err(Response::error("invalid_network", &err)); @@ -236,24 +364,16 @@ impl ChainProvider { method: &str, params: Value, ) -> Result { - let response = self - .client - .post(&network.rpc_url) - .json(&json!({ + let response = self.post_evm_rpc( + &network.rpc_url, + &json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, - })) - .send() - .map_err(|_| Response::error("upstream_unreachable", "EVM RPC request failed"))?; - let status = response.status(); - if !status.is_success() { - return Err(Response::error( - "upstream_http_error", - &format!("upstream returned HTTP {}", status.as_u16()), - )); - } + }), + "EVM RPC request failed", + )?; let body = response .json::() .map_err(|_| Response::error("upstream_invalid_json", "EVM RPC response malformed"))?; @@ -311,7 +431,7 @@ impl ChainProvider { "too many calls in one EVM RPC batch", )); } - let payload: Vec = calls + let payload: Value = calls .iter() .enumerate() .map(|(index, (method, params))| { @@ -323,19 +443,8 @@ impl ChainProvider { }) }) .collect(); - let response = self - .client - .post(&network.rpc_url) - .json(&payload) - .send() - .map_err(|_| Response::error("upstream_unreachable", "EVM RPC batch request failed"))?; - let status = response.status(); - if !status.is_success() { - return Err(Response::error( - "upstream_http_error", - &format!("upstream returned HTTP {}", status.as_u16()), - )); - } + let response = + self.post_evm_rpc(&network.rpc_url, &payload, "EVM RPC batch request failed")?; let body = response .json::() .map_err(|_| Response::error("upstream_invalid_json", "EVM RPC batch malformed"))?; diff --git a/capsules/chain-provider/src/main.rs b/capsules/chain-provider/src/main.rs index c57813302..c41de597b 100644 --- a/capsules/chain-provider/src/main.rs +++ b/capsules/chain-provider/src/main.rs @@ -13,8 +13,10 @@ use elastos_protected_content_contracts::{ MAX_RIGHTS_EVIDENCE_LIFETIME_SECS, }; use serde_json::{json, Value}; +use std::collections::HashMap; use std::io::{self, BufRead, Write}; use std::path::PathBuf; +use std::sync::Mutex; use std::time::{Duration, Instant}; mod abi; @@ -221,6 +223,12 @@ struct ChainProvider { node_supervisor: NodeSupervisorConfig, protected_content_runtime_issuer: Option, now_unix_seconds: fn() -> u64, + /// How a rate-limited RPC request waits before its retry. Injected, like + /// the clock, so tests never wait. + sleep: fn(Duration), + /// Origins that still answered HTTP 429 after their retries, with the + /// Unix second their cooldown ends. Corroborated reads ask them last. + rate_limited_origins: Mutex>, } impl ChainProvider { @@ -249,6 +257,8 @@ impl ChainProvider { node_supervisor: NodeSupervisorConfig::default(), protected_content_runtime_issuer: None, now_unix_seconds: now_ts, + sleep: std::thread::sleep, + rate_limited_origins: Mutex::new(HashMap::new()), } } @@ -368,6 +378,20 @@ impl ChainProvider { token_id, } => self .resolve_protected_content_verified_listing(&network, &seller, &ledger, &token_id), + Request::ResolveProtectedContentItem { + network, + ledger, + token_id, + } => self.resolve_protected_content_item(&network, &ledger, &token_id), + Request::ResolveProtectedContentKidBinding { + network, + content_access_id, + } => self.resolve_protected_content_kid_binding(&network, &content_access_id), + Request::ResolveProtectedContentItemOffers { + network, + ledger, + token_id, + } => self.resolve_protected_content_item_offers(&network, &ledger, &token_id), Request::ResolveProtectedContentPaymentProcessor { network, operative } => { self.resolve_protected_content_payment_processor(&network, &operative) } @@ -400,6 +424,21 @@ impl ChainProvider { &wallet, &content_access_id, ), + Request::ResolveProtectedContentItemAccess { + request_id, + network, + wallet, + ledger, + token_id, + block, + } => self.resolve_protected_content_item_access( + &request_id, + &network, + &wallet, + &ledger, + &token_id, + block, + ), Request::Proof { network, proof_kind, @@ -1133,7 +1172,7 @@ impl ChainProvider { ProtectedContentRightsCall { contract: &method.contract, data: &data, - expected_content_access_id: &policy.content_access_id(), + expected_content_access_id: Some(&policy.content_access_id()), // Evidence that authorizes releasing a content key. // Irreversible once released, so it rests only on finalized // state. @@ -1779,7 +1818,9 @@ impl ChainProvider { // A source that will not answer a batch at all is asked // singly rather than dropped: sources refusing batches // would otherwise leave every card permanently unknown. - Err(_) => self.protected_content_channel_calls_serially(&source_network, calls), + Err(_) => { + self.protected_content_channel_calls_serially(&source_network, calls) + } }; let offset = chunk * EVM_RPC_BATCH_MAX; let mut guard = results.lock().unwrap_or_else(|err| err.into_inner()); @@ -1929,6 +1970,176 @@ impl ChainProvider { Response::ok(data) } + /// An item's operative and `tokenURI`, from `(ledger, token_id)` alone -- + /// the other common identifiers converge here through the binding check + /// in D15, not through a second read of this method. + fn resolve_protected_content_item( + &self, + network_id: &str, + ledger: &str, + token_id: &str, + ) -> Response { + let network = match self.evm_network(network_id) { + Ok(network) => network, + Err(response) => return response, + }; + if let Err(err) = validate_evm_address(ledger) { + return Response::error("invalid_protected_content_item_request", &err); + } + if let Err(err) = validate_hex_quantity(token_id, "token_id") { + return Response::error("invalid_protected_content_item_request", &err); + } + let ledger = normalize_evm_address(ledger); + let token_id = match normalize_hex_quantity(token_id, "token_id") { + Ok(value) => value, + Err(err) => return Response::error("invalid_protected_content_item_request", &err), + }; + let market = match self.configured_protected_content_market_source(network_id) { + Ok(market) => market, + Err(response) => return response, + }; + let item = match self.observe_protected_content_item(network, market, &ledger, &token_id) { + Ok(item) => item, + Err(response) => return response, + }; + if item.operative == EVM_ZERO_ADDRESS { + return Response::error( + "unbound_protected_content_item", + "protected-content item has no bound operative", + ); + } + let Some(token_uri) = item.token_uri else { + return Response::error( + "upstream_invalid_protected_content_item", + "protected-content item operative did not return a tokenURI", + ); + }; + Response::ok(json!({ + "schema": PROTECTED_CONTENT_ITEM_SCHEMA, + "network": network.id, + "chain_id": item.chain_id, + "ledger": ledger, + "token_id": token_id, + "operative": item.operative, + "token_uri": token_uri, + "finalized_block_number": item.finalized_block_number, + })) + } + + /// A KID's binding to `(ledger, token_id)`, the other leg of the D15 + /// binding triangle. `ipReference` of `(0x0, 0)` means the id is not + /// bound on chain -- not "pending", an answer in its own right. + fn resolve_protected_content_kid_binding( + &self, + network_id: &str, + content_access_id: &str, + ) -> Response { + let network = match self.evm_network(network_id) { + Ok(network) => network, + Err(response) => return response, + }; + if let Err(err) = validate_hex(content_access_id, Some(16), "content_access_id") { + return Response::error("invalid_protected_content_kid_binding_request", &err); + } + let content_access_id = content_access_id.to_ascii_lowercase(); + let market = match self.configured_protected_content_market_source(network_id) { + Ok(market) => market, + Err(response) => return response, + }; + let binding = + match self.observe_protected_content_kid_binding(network, market, &content_access_id) { + Ok(binding) => binding, + Err(response) => return response, + }; + if binding.ledger == EVM_ZERO_ADDRESS && binding.token_id == "0x0" { + return Response::error( + "unbound_protected_content_kid", + "protected-content content access id is not bound on chain", + ); + } + Response::ok(json!({ + "schema": PROTECTED_CONTENT_KID_BINDING_SCHEMA, + "network": network.id, + "chain_id": binding.chain_id, + "content_access_id": content_access_id, + "ledger": binding.ledger, + "token_id": binding.token_id, + "finalized_block_number": binding.finalized_block_number, + })) + } + + /// Every live offer (every seller) for an item, at one finalized block, + /// corroborated across every configured source the same as a single + /// seller's verified listing. + fn resolve_protected_content_item_offers( + &self, + network_id: &str, + ledger: &str, + token_id: &str, + ) -> Response { + let network = match self.evm_network(network_id) { + Ok(network) => network, + Err(response) => return response, + }; + if let Err(err) = validate_evm_address(ledger) { + return Response::error("invalid_protected_content_item_offers_request", &err); + } + if let Err(err) = validate_hex_quantity(token_id, "token_id") { + return Response::error("invalid_protected_content_item_offers_request", &err); + } + let ledger = normalize_evm_address(ledger); + let token_id = match normalize_hex_quantity(token_id, "token_id") { + Ok(value) => value, + Err(err) => { + return Response::error("invalid_protected_content_item_offers_request", &err) + } + }; + let market = match self.configured_protected_content_market_source(network_id) { + Ok(market) => market, + Err(response) => return response, + }; + let offers = + match self.observe_protected_content_item_offers(network, market, &ledger, &token_id) { + Ok(offers) => offers, + Err(response) => return response, + }; + if offers.operative == EVM_ZERO_ADDRESS { + return Response::error( + "unbound_protected_content_item", + "protected-content item has no bound operative", + ); + } + let offers_json: Vec = offers + .offers + .iter() + .map(|offer| { + let mut offer_json = json!({ + "seller": offer.seller, + "quantity": offer.quantity, + "price": offer.price, + "pay_token": offer.pay_token, + }); + if offer.pay_token != EVM_ZERO_ADDRESS { + if let Some(payment_processor) = &offers.payment_processor { + offer_json["payment_processor"] = json!(payment_processor); + } + } + offer_json + }) + .collect(); + Response::ok(json!({ + "schema": PROTECTED_CONTENT_ITEM_OFFERS_SCHEMA, + "network": network.id, + "chain_id": offers.chain_id, + "ledger": ledger, + "token_id": token_id, + "operative": offers.operative, + "finalized_block_number": offers.finalized_block_number, + "truncated": offers.truncated, + "offers": offers_json, + })) + } + fn resolve_protected_content_purchase( &self, seller: &str, @@ -2116,7 +2327,7 @@ impl ChainProvider { ProtectedContentRightsCall { contract: &method.contract, data: &data, - expected_content_access_id: &content_access_id, + expected_content_access_id: Some(&content_access_id), // The upfront "is this copy theirs" check. The grant is // readable at the block that carries the acquisition, so // reading it at finality would deny a transaction the caller @@ -2161,6 +2372,110 @@ impl ChainProvider { })) } + /// R50: `resolve_protected_content_purchase_access`, asked by the item. + /// Same view-policy sources, same corroboration (R47 retry, fallback and + /// early stop included), same freshness bound; the call is + /// `AuthorityGateway.hasAccess(wallet, ledger, token_id)` on the rights + /// method's contract, so an item whose KID is unknown still has an + /// access answer. + fn resolve_protected_content_item_access( + &self, + request_id: &str, + network_id: &str, + wallet: &str, + ledger: &str, + token_id: &str, + block: ProtectedContentAccessBlock, + ) -> Response { + let invalid = + |err: &str| Response::error("invalid_protected_content_item_access_request", err); + if request_id.trim().is_empty() || request_id.len() > 256 { + return invalid("protected-content item access request identity is invalid"); + } + if let Err(err) = validate_evm_address(wallet) { + return invalid(&err); + } + if let Err(err) = validate_evm_address(ledger) { + return invalid(&err); + } + let token_id = match validate_hex_quantity(token_id, "token_id") + .and_then(|()| normalize_hex_quantity(token_id, "token_id")) + { + Ok(value) => value, + Err(err) => return invalid(&err), + }; + let wallet = normalize_evm_address(wallet); + let ledger = normalize_evm_address(ledger); + let (network, method, policy_source) = match self + .configured_protected_content_policy_source_for_network( + network_id, + ProtectedContentPolicyAction::View, + ) { + Ok(source) => source, + Err(response) => return response, + }; + let Some(expected_chain_id) = network.chain_id else { + return Response::error( + "protected_content_purchase_access_not_configured", + "no configured protected-content purchase access source matches listing network", + ); + }; + let data = match encode_authority_gateway_has_access_call(&wallet, &ledger, &token_id) { + Ok(data) => data, + Err(err) => return invalid(&err), + }; + let observation = match self.observe_protected_content_rights( + network, + &policy_source.evidence_rpc_urls, + expected_chain_id, + ProtectedContentRightsCall { + contract: &method.contract, + data: &data, + expected_content_access_id: None, + block: match block { + ProtectedContentAccessBlock::Finalized => { + ProtectedContentRightsBlock::Finalized + } + ProtectedContentAccessBlock::Latest => ProtectedContentRightsBlock::Head, + }, + }, + ) { + Ok(observation) => observation, + Err(response) => return response, + }; + let ProtectedContentRightsObservationKind::HasAccess(has_access) = observation.outcome + else { + // Unreachable: an item-keyed call never decodes as unbound. + return Response::error( + "insufficient_rights_observations", + "protected-content evidence sources produced no item access answer", + ); + }; + let now = (self.now_unix_seconds)(); + if let Err(err) = validate_finalized_observation_freshness( + observation.finalized_block_timestamp, + now, + PROTECTED_CONTENT_PURCHASE_ACCESS_MAX_FINALIZED_AGE_SECS, + PROTECTED_CONTENT_PURCHASE_ACCESS_MAX_FUTURE_SKEW_SECS, + ) { + return Response::error("stale_protected_content_purchase_access_observation", &err); + } + Response::ok(json!({ + "schema": PROTECTED_CONTENT_ITEM_ACCESS_SCHEMA, + "request_id": request_id, + "network": network.id, + "chain_id": observation.chain_id, + "wallet": wallet, + "ledger": ledger, + "token_id": token_id, + "has_access": has_access, + "finalized_block_number": observation.finalized_block_number, + "finalized_block_hash": format!("0x{}", encode_hex(observation.finalized_block_hash.as_bytes())), + "finalized_block_timestamp": observation.finalized_block_timestamp, + "observed_at": now, + })) + } + fn configured_global_protected_content_creator_mint_source( &self, ) -> Result<(&ChainNetwork, &ProtectedContentCreatorMintMethod), Response> { @@ -2823,30 +3138,29 @@ impl ChainProvider { ledger: &str, token_id: &str, ) -> Result { - let mut successful = Vec::new(); - let mut first_error = None; - for rpc_url in &market.evidence_rpc_urls { - match self.observe_protected_content_verified_listing_source( - network, market, rpc_url, seller, ledger, token_id, - ) { - Ok(Some(observation)) => successful.push(observation), - Ok(None) => {} - Err(response) => { - if first_error.is_none() { - first_error = Some(response); - } - } - } - } - if successful + let (observed, first_error) = + self.observe_corroborating_sources(&market.evidence_rpc_urls, |rpc_url| { + self.observe_protected_content_verified_listing_source( + network, market, rpc_url, seller, ledger, token_id, + ) + }); + if observed .iter() - .any(|observation| observation.chain_id != network.chain_id.unwrap_or_default()) + .any(|(_, observation)| observation.chain_id != network.chain_id.unwrap_or_default()) { return Err(Response::error( "conflicting_protected_content_verified_listing_observations", "protected-content verified listing sources disagree with configured chain id", )); } + // R32: sources finalize at different heights, so every source is + // pinned to the lowest finalized block among them before the tuples + // are compared -- the same algorithm the other market reads use. + let successful = pin_to_lowest_finalized(&observed, |rpc_url, pin| { + self.observe_protected_content_verified_listing_source_at( + network, market, rpc_url, seller, ledger, token_id, pin, + ) + }); if successful.len() >= 2 { let reference = successful[0].clone(); if successful[1..] @@ -2902,95 +3216,1042 @@ impl ChainProvider { Some(finalized) => finalized, None => return Ok(None), }; - let operative_data = encode_authority_gateway_operative_call(ledger, token_id) - .map_err(|err| Response::error("invalid_protected_content_purchase_request", &err))?; - let operative_result = match self + self.observe_protected_content_verified_listing_at_block( + &source_network, + market, + seller, + ledger, + token_id, + chain_id, + finalized.finalized_block_number, + finalized.finalized_block_hash, + ) + } + + /// Re-observes one source at a pinned finalized block (R32), so sources + /// whose finalized heads differ compare the same state instead of + /// "disagreeing" on the block itself. The pinned block must be canonical + /// on this source by hash. + #[allow(clippy::too_many_arguments)] + fn observe_protected_content_verified_listing_source_at( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + seller: &str, + ledger: &str, + token_id: &str, + pin: &ProtectedContentVerifiedListingObservation, + ) -> Option { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let block = self .evm_rpc( &source_network, - "eth_call", - json!([ - { "to": market.authority_gateway_contract, "data": operative_data }, - { - "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), - "requireCanonical": true, - } - ]), + "eth_getBlockByNumber", + json!([format!("0x{:x}", pin.finalized_block_number), false]), ) .ok() + .and_then(|value| evm_finalized_block(&value).ok())?; + if block.finalized_block_number != pin.finalized_block_number + || block.finalized_block_hash != pin.finalized_block_hash { - Some(result) => result, - None => return Ok(None), - }; - let operative = decode_evm_address_word(&operative_result, "operative").map_err(|err| { - Response::error("upstream_invalid_protected_content_verified_listing", &err) - })?; - let listing_data = encode_authority_gateway_listing_call(&operative, seller) + return None; + } + self.observe_protected_content_verified_listing_at_block( + &source_network, + market, + seller, + ledger, + token_id, + pin.chain_id, + pin.finalized_block_number, + pin.finalized_block_hash, + ) + .ok() + .flatten() + } + + #[allow(clippy::too_many_arguments)] + fn observe_protected_content_verified_listing_at_block( + &self, + source_network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + seller: &str, + ledger: &str, + token_id: &str, + chain_id: u64, + block_number: u64, + block_hash: Digest32, + ) -> Result, Response> { + let operative_data = encode_authority_gateway_operative_call(ledger, token_id) + .map_err(|err| Response::error("invalid_protected_content_purchase_request", &err))?; + let operative_result = match self + .evm_rpc( + source_network, + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": operative_data }, + { + "blockHash": format!("0x{}", encode_hex(block_hash.as_bytes())), + "requireCanonical": true, + } + ]), + ) + .ok() + { + Some(result) => result, + None => return Ok(None), + }; + let operative = decode_evm_address_word(&operative_result, "operative").map_err(|err| { + Response::error("upstream_invalid_protected_content_verified_listing", &err) + })?; + let listing_data = encode_authority_gateway_listing_call(&operative, seller) .map_err(|err| Response::error("invalid_protected_content_purchase_request", &err))?; let listing_result = match self .evm_rpc( + source_network, + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": listing_data }, + { + "blockHash": format!("0x{}", encode_hex(block_hash.as_bytes())), + "requireCanonical": true, + } + ]), + ) + .ok() + { + Some(result) => result, + None => return Ok(None), + }; + let listing = decode_protected_content_listing(&listing_result).map_err(|err| { + Response::error("upstream_invalid_protected_content_verified_listing", &err) + })?; + let payment_processor = if listing.pay_token != "0x0000000000000000000000000000000000000000" + { + let payment_processor_data = + encode_operatives_payment_processor_call().map_err(|err| { + Response::error("invalid_protected_content_purchase_request", &err) + })?; + let payment_processor_result = match self + .evm_rpc( + source_network, + "eth_call", + json!([ + { "to": operative, "data": payment_processor_data }, + { + "blockHash": format!("0x{}", encode_hex(block_hash.as_bytes())), + "requireCanonical": true, + } + ]), + ) + .ok() + { + Some(result) => result, + None => return Ok(None), + }; + Some( + decode_evm_address_word(&payment_processor_result, "paymentProcessor").map_err( + |err| { + Response::error("upstream_invalid_protected_content_verified_listing", &err) + }, + )?, + ) + } else { + None + }; + Ok(Some(ProtectedContentVerifiedListingObservation { + chain_id, + finalized_block_number: block_number, + finalized_block_hash: block_hash, + operative, + quantity: listing.quantity, + price: listing.price, + pay_token: listing.pay_token, + payment_processor, + })) + } + + /// Observes a corroborated read's sources one by one (R47): origins still + /// cooling after a persistent 429 are asked last, and asking stops once + /// two observations agree on the same finalized block hash and tuple. + /// Returns what was observed, by source, and the first hard error a + /// source raised. Chain-id, pinning and disagreement rules stay with the + /// caller and apply to everything observed. + fn observe_corroborating_sources( + &self, + evidence_rpc_urls: &[String], + mut observe: F, + ) -> (Vec<(String, T)>, Option) + where + T: FinalizedObservation + PartialEq, + F: FnMut(&str) -> Result, Response>, + { + let mut observed = Vec::new(); + let mut first_error = None; + for rpc_url in self.corroboration_order(evidence_rpc_urls) { + match observe(&rpc_url) { + Ok(Some(observation)) => observed.push((rpc_url, observation)), + Ok(None) => {} + Err(response) => { + if first_error.is_none() { + first_error = Some(response); + } + } + } + if two_observations_agree(&observed) { + break; + } + } + (observed, first_error) + } + + /// One market read's RPC call to one source. A failed call drops that + /// source from the read, so the reason is logged here -- read, step, + /// source host and the node's verdict -- instead of vanishing into an + /// "insufficient observations" answer that cannot say which call failed. + /// Only the host is logged: a configured RPC URL may carry an API key. + fn market_rpc( + &self, + network: &ChainNetwork, + read: &str, + step: &str, + method: &str, + params: Value, + ) -> Option { + match self.evm_rpc(network, method, params) { + Ok(value) => Some(value), + Err(Response::Error { code, message }) => { + eprintln!( + "chain-provider: market source dropped read={read} step={step} source={} reason={code}: {message}", + rpc_source_host(&network.rpc_url), + ); + None + } + Err(_) => None, + } + } + + fn observe_protected_content_item( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + ledger: &str, + token_id: &str, + ) -> Result { + let (observed, first_error) = + self.observe_corroborating_sources(&market.evidence_rpc_urls, |rpc_url| { + self.observe_protected_content_item_source( + network, market, rpc_url, ledger, token_id, + ) + }); + if observed + .iter() + .any(|(_, observation)| observation.chain_id != network.chain_id.unwrap_or_default()) + { + return Err(Response::error( + "conflicting_protected_content_item_observations", + "protected-content item sources disagree with configured chain id", + )); + } + // R32: independent sources legitimately lag each other by a block or + // more in what they consider finalized; pin every source to the + // lowest finalized block among them -- the same algorithm the rights + // read uses (see `pin_to_lowest_finalized`) -- instead of requiring + // every source's finalized head to match exactly. + let successful = pin_to_lowest_finalized(&observed, |rpc_url, pin| { + self.observe_protected_content_item_source_at( + network, market, rpc_url, ledger, token_id, pin, + ) + }); + if successful.len() >= 2 { + let reference = successful[0].clone(); + if successful[1..] + .iter() + .any(|observation| *observation != reference) + { + return Err(Response::error( + "conflicting_protected_content_item_observations", + "protected-content item sources disagree on the finalized tuple", + )); + } + return Ok(reference); + } + if successful.is_empty() { + if let Some(response) = first_error { + return Err(response); + } + } + eprintln!( + "chain-provider: market read item short of sources configured={} observed={} agreeing_after_pin={}", + market.evidence_rpc_urls.len(), + observed.len(), + successful.len(), + ); + Err(Response::error( + "insufficient_protected_content_item_observations", + "protected-content item sources produced fewer than two matching finalized tuples", + )) + } + + fn observe_protected_content_item_source( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + ledger: &str, + token_id: &str, + ) -> Result, Response> { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let chain_id = match self + .market_rpc( + &source_network, + "item", + "chain_id", + "eth_chainId", + json!([]), + ) + .and_then(|value| value.as_str().and_then(|value| parse_hex_u64(value).ok())) + { + Some(chain_id) => chain_id, + None => return Ok(None), + }; + let finalized = match self + .market_rpc( + &source_network, + "item", + "finalized_block", + "eth_getBlockByNumber", + json!(["finalized", false]), + ) + .and_then(|value| evm_finalized_block(&value).ok()) + { + Some(finalized) => finalized, + None => return Ok(None), + }; + let at = json!({ + "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }); + let operative_data = encode_authority_gateway_operative_call(ledger, token_id) + .map_err(|err| Response::error("invalid_protected_content_item_request", &err))?; + let operative_result = match self.market_rpc( + &source_network, + "item", + "operative", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": operative_data }, + at, + ]), + ) { + Some(result) => result, + None => return Ok(None), + }; + let operative = decode_evm_address_word(&operative_result, "operative") + .map_err(|err| Response::error("upstream_invalid_protected_content_item", &err))?; + if operative == EVM_ZERO_ADDRESS { + return Ok(Some(ProtectedContentItemObservation { + chain_id, + finalized_block_number: finalized.finalized_block_number, + finalized_block_hash: finalized.finalized_block_hash, + operative, + token_uri: None, + })); + } + let token_uri_data = encode_operative_token_uri_call() + .map_err(|err| Response::error("invalid_protected_content_item_request", &err))?; + let token_uri_result = match self + .market_rpc( + &source_network, + "item", + "token_uri", + "eth_call", + json!([ + { "to": operative, "data": token_uri_data }, + { + "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }, + ]), + ) + { + Some(result) => result, + None => return Ok(None), + }; + let token_uri = decode_evm_string(&token_uri_result, "tokenURI") + .map_err(|err| Response::error("upstream_invalid_protected_content_item", &err))?; + Ok(Some(ProtectedContentItemObservation { + chain_id, + finalized_block_number: finalized.finalized_block_number, + finalized_block_hash: finalized.finalized_block_hash, + operative, + token_uri: Some(token_uri), + })) + } + + /// Re-observes one source at a pinned finalized block (R32): the block + /// must be canonical there by hash, and the item read is re-evaluated at + /// exactly that block. Mirrors `observe_protected_content_rights_source_at`. + fn observe_protected_content_item_source_at( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + ledger: &str, + token_id: &str, + pin: &ProtectedContentItemObservation, + ) -> Option { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let block = self + .market_rpc( + &source_network, + "item", + "finalized_block", + "eth_getBlockByNumber", + json!([format!("0x{:x}", pin.finalized_block_number), false]), + ) + .and_then(|value| evm_finalized_block(&value).ok())?; + if block.finalized_block_number != pin.finalized_block_number + || block.finalized_block_hash != pin.finalized_block_hash + { + return None; + } + let at = json!({ + "blockHash": format!("0x{}", encode_hex(pin.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }); + let operative_data = encode_authority_gateway_operative_call(ledger, token_id).ok()?; + let operative_result = self.market_rpc( + &source_network, + "item", + "operative", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": operative_data }, + at.clone(), + ]), + )?; + let operative = decode_evm_address_word(&operative_result, "operative").ok()?; + if operative == EVM_ZERO_ADDRESS { + return Some(ProtectedContentItemObservation { + chain_id: pin.chain_id, + finalized_block_number: pin.finalized_block_number, + finalized_block_hash: pin.finalized_block_hash, + operative, + token_uri: None, + }); + } + let token_uri_data = encode_operative_token_uri_call().ok()?; + let token_uri_result = self.market_rpc( + &source_network, + "item", + "token_uri", + "eth_call", + json!([ + { "to": operative, "data": token_uri_data }, + at, + ]), + )?; + let token_uri = decode_evm_string(&token_uri_result, "tokenURI").ok()?; + Some(ProtectedContentItemObservation { + chain_id: pin.chain_id, + finalized_block_number: pin.finalized_block_number, + finalized_block_hash: pin.finalized_block_hash, + operative, + token_uri: Some(token_uri), + }) + } + + fn observe_protected_content_kid_binding( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + content_access_id: &str, + ) -> Result { + let (observed, first_error) = + self.observe_corroborating_sources(&market.evidence_rpc_urls, |rpc_url| { + self.observe_protected_content_kid_binding_source( + network, + market, + rpc_url, + content_access_id, + ) + }); + if observed + .iter() + .any(|(_, observation)| observation.chain_id != network.chain_id.unwrap_or_default()) + { + return Err(Response::error( + "conflicting_protected_content_kid_binding_observations", + "protected-content kid binding sources disagree with configured chain id", + )); + } + // R32: pin every source to the lowest finalized block among them, + // reusing the algorithm the rights read uses. + let successful = pin_to_lowest_finalized(&observed, |rpc_url, pin| { + self.observe_protected_content_kid_binding_source_at( + network, + market, + rpc_url, + content_access_id, + pin, + ) + }); + if successful.len() >= 2 { + let reference = successful[0].clone(); + if successful[1..] + .iter() + .any(|observation| *observation != reference) + { + return Err(Response::error( + "conflicting_protected_content_kid_binding_observations", + "protected-content kid binding sources disagree on the finalized tuple", + )); + } + return Ok(reference); + } + if successful.is_empty() { + if let Some(response) = first_error { + return Err(response); + } + } + eprintln!( + "chain-provider: market read kid_binding short of sources configured={} observed={} agreeing_after_pin={}", + market.evidence_rpc_urls.len(), + observed.len(), + successful.len(), + ); + Err(Response::error( + "insufficient_protected_content_kid_binding_observations", + "protected-content kid binding sources produced fewer than two matching finalized tuples", + )) + } + + fn observe_protected_content_kid_binding_source( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + content_access_id: &str, + ) -> Result, Response> { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let chain_id = match self + .market_rpc( + &source_network, + "kid_binding", + "chain_id", + "eth_chainId", + json!([]), + ) + .and_then(|value| value.as_str().and_then(|value| parse_hex_u64(value).ok())) + { + Some(chain_id) => chain_id, + None => return Ok(None), + }; + let finalized = match self + .market_rpc( + &source_network, + "kid_binding", + "finalized_block", + "eth_getBlockByNumber", + json!(["finalized", false]), + ) + .and_then(|value| evm_finalized_block(&value).ok()) + { + Some(finalized) => finalized, + None => return Ok(None), + }; + let at = json!({ + "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }); + let cstore_data = encode_authority_gateway_cstore_call().map_err(|err| { + Response::error("invalid_protected_content_kid_binding_request", &err) + })?; + let cstore_result = match self.market_rpc( + &source_network, + "kid_binding", + "cstore", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": cstore_data }, + at.clone(), + ]), + ) { + Some(result) => result, + None => return Ok(None), + }; + let cstore = decode_evm_address_word(&cstore_result, "cstore").map_err(|err| { + Response::error("upstream_invalid_protected_content_kid_binding", &err) + })?; + // Cheap guard: a zero cstore means the authority gateway has no + // protected-content store bound for this network at all. Calling + // ipReference on the zero address would just revert (or worse, + // silently answer nothing) -- this fails fast with a named error + // instead of surfacing an opaque upstream decode failure. + if cstore == EVM_ZERO_ADDRESS { + return Err(Response::error( + "unbound_protected_content_store", + "authority gateway has no protected-content store bound for this network", + )); + } + let ip_reference_data = encode_ip_reference_call(content_access_id).map_err(|err| { + Response::error("invalid_protected_content_kid_binding_request", &err) + })?; + let ip_reference_result = match self.market_rpc( + &source_network, + "kid_binding", + "ip_reference", + "eth_call", + json!([ + { "to": cstore, "data": ip_reference_data }, + at, + ]), + ) { + Some(result) => result, + None => return Ok(None), + }; + let (ledger, token_id) = decode_ip_reference(&ip_reference_result).map_err(|err| { + Response::error("upstream_invalid_protected_content_kid_binding", &err) + })?; + Ok(Some(ProtectedContentKidBindingObservation { + chain_id, + finalized_block_number: finalized.finalized_block_number, + finalized_block_hash: finalized.finalized_block_hash, + ledger, + token_id, + })) + } + + /// Re-observes one source at a pinned finalized block (R32). Mirrors + /// `observe_protected_content_rights_source_at`. + fn observe_protected_content_kid_binding_source_at( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + content_access_id: &str, + pin: &ProtectedContentKidBindingObservation, + ) -> Option { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let block = self + .market_rpc( + &source_network, + "kid_binding", + "finalized_block", + "eth_getBlockByNumber", + json!([format!("0x{:x}", pin.finalized_block_number), false]), + ) + .and_then(|value| evm_finalized_block(&value).ok())?; + if block.finalized_block_number != pin.finalized_block_number + || block.finalized_block_hash != pin.finalized_block_hash + { + return None; + } + let at = json!({ + "blockHash": format!("0x{}", encode_hex(pin.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }); + let cstore_data = encode_authority_gateway_cstore_call().ok()?; + let cstore_result = self.market_rpc( + &source_network, + "kid_binding", + "cstore", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": cstore_data }, + at.clone(), + ]), + )?; + let cstore = decode_evm_address_word(&cstore_result, "cstore").ok()?; + if cstore == EVM_ZERO_ADDRESS { + return None; + } + let ip_reference_data = encode_ip_reference_call(content_access_id).ok()?; + let ip_reference_result = self.market_rpc( + &source_network, + "kid_binding", + "ip_reference", + "eth_call", + json!([ + { "to": cstore, "data": ip_reference_data }, + at, + ]), + )?; + let (ledger, token_id) = decode_ip_reference(&ip_reference_result).ok()?; + Some(ProtectedContentKidBindingObservation { + chain_id: pin.chain_id, + finalized_block_number: pin.finalized_block_number, + finalized_block_hash: pin.finalized_block_hash, + ledger, + token_id, + }) + } + + fn observe_protected_content_item_offers( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + ledger: &str, + token_id: &str, + ) -> Result { + let (observed, first_error) = + self.observe_corroborating_sources(&market.evidence_rpc_urls, |rpc_url| { + self.observe_protected_content_item_offers_source( + network, market, rpc_url, ledger, token_id, + ) + }); + if observed + .iter() + .any(|(_, observation)| observation.chain_id != network.chain_id.unwrap_or_default()) + { + return Err(Response::error( + "conflicting_protected_content_item_offers_observations", + "protected-content item offers sources disagree with configured chain id", + )); + } + // R32: pin every source to the lowest finalized block among them, + // reusing the algorithm the rights read uses. + let successful = pin_to_lowest_finalized(&observed, |rpc_url, pin| { + self.observe_protected_content_item_offers_source_at( + network, market, rpc_url, ledger, token_id, pin, + ) + }); + if successful.len() >= 2 { + let reference = successful[0].clone(); + if successful[1..] + .iter() + .any(|observation| *observation != reference) + { + return Err(Response::error( + "conflicting_protected_content_item_offers_observations", + "protected-content item offers sources disagree on the finalized tuple", + )); + } + return Ok(reference); + } + if successful.is_empty() { + if let Some(response) = first_error { + return Err(response); + } + } + eprintln!( + "chain-provider: market read item_offers short of sources configured={} observed={} agreeing_after_pin={}", + market.evidence_rpc_urls.len(), + observed.len(), + successful.len(), + ); + Err(Response::error( + "insufficient_protected_content_item_offers_observations", + "protected-content item offers sources produced fewer than two matching finalized tuples", + )) + } + + fn observe_protected_content_item_offers_source( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + ledger: &str, + token_id: &str, + ) -> Result, Response> { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let chain_id = match self + .market_rpc( &source_network, - "eth_call", - json!([ - { "to": market.authority_gateway_contract, "data": listing_data }, - { - "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), - "requireCanonical": true, - } - ]), + "item_offers", + "chain_id", + "eth_chainId", + json!([]), ) - .ok() + .and_then(|value| value.as_str().and_then(|value| parse_hex_u64(value).ok())) + { + Some(chain_id) => chain_id, + None => return Ok(None), + }; + let finalized = match self + .market_rpc( + &source_network, + "item_offers", + "finalized_block", + "eth_getBlockByNumber", + json!(["finalized", false]), + ) + .and_then(|value| evm_finalized_block(&value).ok()) { + Some(finalized) => finalized, + None => return Ok(None), + }; + let at = || { + json!({ + "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }) + }; + let operative_data = + encode_authority_gateway_operative_call(ledger, token_id).map_err(|err| { + Response::error("invalid_protected_content_item_offers_request", &err) + })?; + let operative_result = match self.market_rpc( + &source_network, + "item_offers", + "operative", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": operative_data }, + at(), + ]), + ) { Some(result) => result, None => return Ok(None), }; - let listing = decode_protected_content_listing(&listing_result).map_err(|err| { - Response::error("upstream_invalid_protected_content_verified_listing", &err) + let operative = decode_evm_address_word(&operative_result, "operative").map_err(|err| { + Response::error("upstream_invalid_protected_content_item_offers", &err) })?; - let payment_processor = if listing.pay_token != "0x0000000000000000000000000000000000000000" - { + if operative == EVM_ZERO_ADDRESS { + return Ok(Some(ProtectedContentItemOffersObservation { + chain_id, + finalized_block_number: finalized.finalized_block_number, + finalized_block_hash: finalized.finalized_block_hash, + operative, + truncated: false, + offers: Vec::new(), + payment_processor: None, + })); + } + let sellers_data = encode_authority_gateway_sellers_of_call(&operative).map_err(|err| { + Response::error("invalid_protected_content_item_offers_request", &err) + })?; + let sellers_result = match self.market_rpc( + &source_network, + "item_offers", + "sellers", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": sellers_data }, + at(), + ]), + ) { + Some(result) => result, + None => return Ok(None), + }; + let mut sellers = + decode_evm_address_array(&sellers_result, "sellersOf").map_err(|err| { + Response::error("upstream_invalid_protected_content_item_offers", &err) + })?; + sellers.sort(); + let truncated = sellers.len() > PROTECTED_CONTENT_OFFERS_MAX; + sellers.truncate(PROTECTED_CONTENT_OFFERS_MAX); + let mut offers = Vec::new(); + let mut any_erc20_pay_token = false; + for seller in &sellers { + let listing_data = + encode_authority_gateway_listing_call(&operative, seller).map_err(|err| { + Response::error("invalid_protected_content_item_offers_request", &err) + })?; + let listing_result = match self.market_rpc( + &source_network, + "item_offers", + "listing", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": listing_data }, + at(), + ]), + ) { + Some(result) => result, + None => return Ok(None), + }; + let listing = decode_protected_content_listing(&listing_result).map_err(|err| { + Response::error("upstream_invalid_protected_content_item_offers", &err) + })?; + // ERC-20 flag fix: a sold-out listing is not a live offer, so it + // must not be the reason `paymentProcessor()` gets called -- a + // sold-out ERC-20 listing with every other offer native or gone + // should trigger no processor read at all. + if listing.quantity == "0x0" { + continue; + } + if listing.pay_token != EVM_ZERO_ADDRESS { + any_erc20_pay_token = true; + } + offers.push(ProtectedContentItemOfferObservation { + seller: seller.clone(), + quantity: listing.quantity, + price: listing.price, + pay_token: listing.pay_token, + }); + } + let payment_processor = if any_erc20_pay_token { let payment_processor_data = encode_operatives_payment_processor_call().map_err(|err| { - Response::error("invalid_protected_content_purchase_request", &err) + Response::error("invalid_protected_content_item_offers_request", &err) })?; - let payment_processor_result = match self - .evm_rpc( - &source_network, - "eth_call", - json!([ - { "to": operative, "data": payment_processor_data }, - { - "blockHash": format!("0x{}", encode_hex(finalized.finalized_block_hash.as_bytes())), - "requireCanonical": true, - } - ]), - ) - .ok() - { + let payment_processor_result = match self.market_rpc( + &source_network, + "item_offers", + "payment_processor", + "eth_call", + json!([ + { "to": operative, "data": payment_processor_data }, + at(), + ]), + ) { Some(result) => result, None => return Ok(None), }; Some( decode_evm_address_word(&payment_processor_result, "paymentProcessor").map_err( - |err| { - Response::error("upstream_invalid_protected_content_verified_listing", &err) - }, + |err| Response::error("upstream_invalid_protected_content_item_offers", &err), )?, ) } else { None }; - Ok(Some(ProtectedContentVerifiedListingObservation { + Ok(Some(ProtectedContentItemOffersObservation { chain_id, finalized_block_number: finalized.finalized_block_number, finalized_block_hash: finalized.finalized_block_hash, operative, - quantity: listing.quantity, - price: listing.price, - pay_token: listing.pay_token, + truncated, + offers, payment_processor, })) } + /// Re-observes one source at a pinned finalized block (R32). Mirrors + /// `observe_protected_content_rights_source_at`. + fn observe_protected_content_item_offers_source_at( + &self, + network: &ChainNetwork, + market: &ProtectedContentMarketMethod, + rpc_url: &str, + ledger: &str, + token_id: &str, + pin: &ProtectedContentItemOffersObservation, + ) -> Option { + let mut source_network = network.clone(); + source_network.rpc_url = rpc_url.to_string(); + let block = self + .market_rpc( + &source_network, + "item_offers", + "finalized_block", + "eth_getBlockByNumber", + json!([format!("0x{:x}", pin.finalized_block_number), false]), + ) + .and_then(|value| evm_finalized_block(&value).ok())?; + if block.finalized_block_number != pin.finalized_block_number + || block.finalized_block_hash != pin.finalized_block_hash + { + return None; + } + let at = || { + json!({ + "blockHash": format!("0x{}", encode_hex(pin.finalized_block_hash.as_bytes())), + "requireCanonical": true, + }) + }; + let operative_data = encode_authority_gateway_operative_call(ledger, token_id).ok()?; + let operative_result = self.market_rpc( + &source_network, + "item_offers", + "operative", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": operative_data }, + at(), + ]), + )?; + let operative = decode_evm_address_word(&operative_result, "operative").ok()?; + if operative == EVM_ZERO_ADDRESS { + return Some(ProtectedContentItemOffersObservation { + chain_id: pin.chain_id, + finalized_block_number: pin.finalized_block_number, + finalized_block_hash: pin.finalized_block_hash, + operative, + truncated: false, + offers: Vec::new(), + payment_processor: None, + }); + } + let sellers_data = encode_authority_gateway_sellers_of_call(&operative).ok()?; + let sellers_result = self.market_rpc( + &source_network, + "item_offers", + "sellers", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": sellers_data }, + at(), + ]), + )?; + let mut sellers = decode_evm_address_array(&sellers_result, "sellersOf").ok()?; + sellers.sort(); + let truncated = sellers.len() > PROTECTED_CONTENT_OFFERS_MAX; + sellers.truncate(PROTECTED_CONTENT_OFFERS_MAX); + let mut offers = Vec::new(); + let mut any_erc20_pay_token = false; + for seller in &sellers { + let listing_data = encode_authority_gateway_listing_call(&operative, seller).ok()?; + let listing_result = self.market_rpc( + &source_network, + "item_offers", + "listing", + "eth_call", + json!([ + { "to": market.authority_gateway_contract, "data": listing_data }, + at(), + ]), + )?; + let listing = decode_protected_content_listing(&listing_result).ok()?; + if listing.quantity == "0x0" { + continue; + } + if listing.pay_token != EVM_ZERO_ADDRESS { + any_erc20_pay_token = true; + } + offers.push(ProtectedContentItemOfferObservation { + seller: seller.clone(), + quantity: listing.quantity, + price: listing.price, + pay_token: listing.pay_token, + }); + } + let payment_processor = if any_erc20_pay_token { + let payment_processor_data = encode_operatives_payment_processor_call().ok()?; + let payment_processor_result = self.market_rpc( + &source_network, + "item_offers", + "payment_processor", + "eth_call", + json!([ + { "to": operative, "data": payment_processor_data }, + at(), + ]), + )?; + Some(decode_evm_address_word(&payment_processor_result, "paymentProcessor").ok()?) + } else { + None + }; + Some(ProtectedContentItemOffersObservation { + chain_id: pin.chain_id, + finalized_block_number: pin.finalized_block_number, + finalized_block_hash: pin.finalized_block_hash, + operative, + truncated, + offers, + payment_processor, + }) + } + fn observe_protected_content_rights( &self, network: &ChainNetwork, @@ -2999,28 +4260,19 @@ impl ChainProvider { call: ProtectedContentRightsCall<'_>, ) -> Result { let started = Instant::now(); - let mut observed: Vec<(&String, ProtectedContentRightsObservation)> = Vec::new(); - for rpc_url in evidence_rpc_urls { - if let Some(observation) = - self.observe_protected_content_rights_source(network, rpc_url, call) - { - observed.push((rpc_url, observation)); - } - } - let mut successful: Vec = observed - .iter() - .map(|(_, observation)| *observation) - .collect(); - if successful + let (observed, _) = self.observe_corroborating_sources(evidence_rpc_urls, |rpc_url| { + Ok(self.observe_protected_content_rights_source(network, rpc_url, call)) + }); + if observed .iter() - .any(|observation| observation.chain_id != expected_chain_id) + .any(|(_, observation)| observation.chain_id != expected_chain_id) { return Err(Response::error( "conflicting_rights_observations", "protected-content evidence sources disagree with configured chain policy", )); } - if successful.len() < 2 { + if observed.len() < 2 { return Err(Response::error( "insufficient_rights_observations", "protected-content evidence sources produced fewer than two matching finalized observations", @@ -3028,35 +4280,20 @@ impl ChainProvider { } // Independent sources legitimately lag each other by a block or more // in what they consider finalized (finality is monotone, so the - // lowest finalized head is final on every source). When the heads - // differ, pin every source to that lowest finalized block: the - // block must be canonical there by hash, and the rights call is - // re-evaluated at exactly that block, so the corroboration compares - // the same finalized state everywhere instead of racing finality. - let heads_agree = successful[1..].iter().all(|observation| { - observation.finalized_block_number == successful[0].finalized_block_number - && observation.finalized_block_hash == successful[0].finalized_block_hash + // lowest finalized head is final on every source). `pin_to_lowest_finalized` + // pins every source to that lowest finalized block -- the block must + // be canonical there by hash -- and the rights call is re-evaluated + // at exactly that block, so the corroboration compares the same + // finalized state everywhere instead of racing finality. R32 reuses + // this same algorithm for the item / kid-binding / item-offers reads. + let successful = pin_to_lowest_finalized(&observed, |rpc_url, pin| { + self.observe_protected_content_rights_source_at(network, rpc_url, call, pin) }); - if !heads_agree { - let pin = *successful - .iter() - .min_by_key(|observation| observation.finalized_block_number) - .expect("at least two observations"); - let mut pinned = Vec::with_capacity(observed.len()); - for (rpc_url, _) in &observed { - if let Some(repinned) = - self.observe_protected_content_rights_source_at(network, rpc_url, call, &pin) - { - pinned.push(repinned); - } - } - successful = pinned; - if successful.len() < 2 { - return Err(Response::error( - "insufficient_rights_observations", - "protected-content evidence sources produced fewer than two matching finalized observations", - )); - } + if successful.len() < 2 { + return Err(Response::error( + "insufficient_rights_observations", + "protected-content evidence sources produced fewer than two matching finalized observations", + )); } let reference = successful[0]; if successful[1..] @@ -3187,7 +4424,7 @@ impl ChainProvider { if let Some(error) = body.get("error") { return decode_protected_content_unbound_content_id( error, - call.expected_content_access_id, + call.expected_content_access_id?, ) .map(ProtectedContentRightsObservationKind::Unbound); } @@ -3502,6 +4739,52 @@ struct ProtectedContentVerifiedListingObservation { payment_processor: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProtectedContentItemObservation { + chain_id: u64, + finalized_block_number: u64, + finalized_block_hash: Digest32, + operative: String, + /// `None` only when `operative` is the zero address -- there is no + /// operative to ask for a `tokenURI`, and asking anyway would just + /// revert. + token_uri: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProtectedContentKidBindingObservation { + chain_id: u64, + finalized_block_number: u64, + finalized_block_hash: Digest32, + ledger: String, + token_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProtectedContentItemOfferObservation { + seller: String, + quantity: String, + price: String, + pay_token: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProtectedContentItemOffersObservation { + chain_id: u64, + finalized_block_number: u64, + finalized_block_hash: Digest32, + operative: String, + /// More than `PROTECTED_CONTENT_OFFERS_MAX` sellers existed; only the + /// first `PROTECTED_CONTENT_OFFERS_MAX` in ascending address order were + /// read. + truncated: bool, + /// Sold-out (`quantity == 0x0`) sellers are dropped already. + offers: Vec, + /// The operative's `paymentProcessor()`, read once and only when some + /// read offer's `pay_token` was non-zero. + payment_processor: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ProtectedContentRightsObservation { chain_id: u64, @@ -3511,6 +4794,119 @@ struct ProtectedContentRightsObservation { outcome: ProtectedContentRightsObservationKind, } +/// A per-source answer to one of the protected-content chain reads, tagged +/// with the finalized block it was read at. `pin_to_lowest_finalized` uses +/// only this head to decide whether sources need re-pinning; it never looks +/// at the rest of the observation. +trait FinalizedObservation { + fn finalized_block_number(&self) -> u64; + fn finalized_block_hash(&self) -> Digest32; +} + +impl FinalizedObservation for ProtectedContentRightsObservation { + fn finalized_block_number(&self) -> u64 { + self.finalized_block_number + } + fn finalized_block_hash(&self) -> Digest32 { + self.finalized_block_hash + } +} + +impl FinalizedObservation for ProtectedContentVerifiedListingObservation { + fn finalized_block_number(&self) -> u64 { + self.finalized_block_number + } + fn finalized_block_hash(&self) -> Digest32 { + self.finalized_block_hash + } +} + +impl FinalizedObservation for ProtectedContentItemObservation { + fn finalized_block_number(&self) -> u64 { + self.finalized_block_number + } + fn finalized_block_hash(&self) -> Digest32 { + self.finalized_block_hash + } +} + +impl FinalizedObservation for ProtectedContentKidBindingObservation { + fn finalized_block_number(&self) -> u64 { + self.finalized_block_number + } + fn finalized_block_hash(&self) -> Digest32 { + self.finalized_block_hash + } +} + +impl FinalizedObservation for ProtectedContentItemOffersObservation { + fn finalized_block_number(&self) -> u64 { + self.finalized_block_number + } + fn finalized_block_hash(&self) -> Digest32 { + self.finalized_block_hash + } +} + +/// R47: a corroborated read has what it needs once two observations agree on +/// the same finalized block hash and the same tuple. Asking further sources +/// then only multiplies load on public RPCs that rate-limit. +fn two_observations_agree(observed: &[(String, T)]) -> bool +where + T: FinalizedObservation + PartialEq, +{ + observed.iter().enumerate().any(|(index, (_, first))| { + observed[index + 1..].iter().any(|(_, second)| { + first.finalized_block_hash() == second.finalized_block_hash() && first == second + }) + }) +} + +/// R32 / SM-I5: independent evidence sources legitimately lag each other by +/// a block or more in what they consider finalized (finality is monotone, +/// so the lowest finalized head is final on every source). When the sources' +/// finalized heads do not already agree, this pins every source to the +/// lowest finalized block among them and asks `refetch_at` to re-observe +/// each one there -- the block must be canonical there by hash -- so the +/// corroboration that follows compares the same finalized state everywhere +/// instead of requiring the sources to race finality into agreement. +/// +/// Shared by the rights read and by the item / kid-binding / item-offers +/// reads so the algorithm exists exactly once. +fn pin_to_lowest_finalized(observed: &[(String, T)], mut refetch_at: F) -> Vec +where + T: FinalizedObservation + Clone, + F: FnMut(&str, &T) -> Option, +{ + let successful: Vec = observed + .iter() + .map(|(_, observation)| observation.clone()) + .collect(); + if successful.len() < 2 { + return successful; + } + let reference = successful[0].clone(); + let heads_agree = successful[1..].iter().all(|observation| { + observation.finalized_block_number() == reference.finalized_block_number() + && observation.finalized_block_hash() == reference.finalized_block_hash() + }); + if heads_agree { + return successful; + } + let pin = successful + .iter() + .min_by_key(|observation| observation.finalized_block_number()) + .cloned() + .expect("at least two observations"); + let mut pinned = Vec::with_capacity(observed.len()); + for (rpc_url, _) in observed { + if let Some(repinned) = refetch_at(rpc_url, &pin) { + pinned.push(repinned); + } + } + pinned +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ProtectedContentRightsObservationKind { HasAccess(bool), @@ -3546,7 +4942,9 @@ enum ProtectedContentRightsBlock { struct ProtectedContentRightsCall<'a> { contract: &'a str, data: &'a str, - expected_content_access_id: &'a ContentAccessIdV1, + /// The KID a content-id call is about; `None` for an item-keyed call, + /// which has no "unbound" answer -- any error there observed nothing. + expected_content_access_id: Option<&'a ContentAccessIdV1>, block: ProtectedContentRightsBlock, } @@ -3837,3 +5235,12 @@ fn main() { eprintln!("chain-provider exiting"); } + +/// The host of a configured RPC URL, for logs: never the path or query, +/// which may carry an API key. +fn rpc_source_host(rpc_url: &str) -> String { + reqwest::Url::parse(rpc_url) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .unwrap_or_else(|| "unparseable".to_string()) +} diff --git a/capsules/chain-provider/src/protocol.rs b/capsules/chain-provider/src/protocol.rs index 1657c0081..853b3b45a 100644 --- a/capsules/chain-provider/src/protocol.rs +++ b/capsules/chain-provider/src/protocol.rs @@ -17,10 +17,30 @@ pub(super) const PROTECTED_CONTENT_MINT_RECEIPT_SCHEMA: &str = "elastos.chain.protected-content-mint-receipt/v1"; pub(super) const PROTECTED_CONTENT_VERIFIED_LISTING_SCHEMA: &str = "elastos.chain.protected-content-verified-listing/v1"; +pub(super) const PROTECTED_CONTENT_ITEM_SCHEMA: &str = "elastos.chain.protected-content-item/v1"; +pub(super) const PROTECTED_CONTENT_KID_BINDING_SCHEMA: &str = + "elastos.chain.protected-content-kid-binding/v1"; +pub(super) const PROTECTED_CONTENT_ITEM_OFFERS_SCHEMA: &str = + "elastos.chain.protected-content-item-offers/v1"; +pub(super) const PROTECTED_CONTENT_OFFERS_MAX: usize = 32; pub(super) const PROTECTED_CONTENT_PURCHASE_SCHEMA: &str = "elastos.chain.protected-content-purchase/v1"; pub(super) const PROTECTED_CONTENT_PURCHASE_ACCESS_SCHEMA: &str = "elastos.chain.protected-content-purchase-access/v1"; +pub(super) const PROTECTED_CONTENT_ITEM_ACCESS_SCHEMA: &str = + "elastos.chain.protected-content-item-access/v1"; + +/// The block an item-keyed access read is evaluated at: `latest` for the +/// same reason the content-id purchase-access read reads the head (a grant is +/// readable in the block that carries it), `finalized` where only state no +/// reorg can take back will do. Either way every source is pinned to one +/// common block and must agree there. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(super) enum ProtectedContentAccessBlock { + Finalized, + Latest, +} #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -362,7 +382,6 @@ pub(super) enum Request { /// offered token, which is what a caller that does not choose means. #[serde(default)] pay_token: Option, - }, ResolveProtectedContentMintReceipt { network: String, @@ -378,6 +397,25 @@ pub(super) enum Request { ledger: String, token_id: String, }, + /// An item's operative and `tokenURI`, so a `ListingObject` can be built + /// from any of the common identifiers in D15 without a seller in hand. + ResolveProtectedContentItem { + network: String, + ledger: String, + token_id: String, + }, + /// A KID's on-chain binding to `(ledger, token_id)`, the other leg of the + /// D15 binding triangle. + ResolveProtectedContentKidBinding { + network: String, + content_access_id: String, + }, + /// Every live offer (all sellers) for an item, at one finalized block. + ResolveProtectedContentItemOffers { + network: String, + ledger: String, + token_id: String, + }, /// The payment processor an operative pays an ERC-20 sale through. /// /// Narrower than reading the whole listing back, and deliberately so: a @@ -409,6 +447,17 @@ pub(super) enum Request { wallet: String, content_access_id: String, }, + /// R50: whether `wallet` holds access to the item `(ledger, token_id)`, + /// asked by the item (`AuthorityGateway.hasAccess`) rather than by its + /// KID, corroborated like the content-id read. + ResolveProtectedContentItemAccess { + request_id: String, + network: String, + wallet: String, + ledger: String, + token_id: String, + block: ProtectedContentAccessBlock, + }, Proof { network: String, proof_kind: ChainProofKind, diff --git a/capsules/chain-provider/src/tests.rs b/capsules/chain-provider/src/tests.rs index ad19bad0a..1f93aa1ca 100644 --- a/capsules/chain-provider/src/tests.rs +++ b/capsules/chain-provider/src/tests.rs @@ -1,5 +1,5 @@ -use crate::backends::interpret_evm_rpc_batch; use super::*; +use crate::backends::{interpret_evm_rpc_batch, rate_limit_cooldown_log, rate_limit_retry_log}; /// The channel the creator-mint fixtures publish into. A mint now names its /// channel, so the tests name one too rather than leaning on a configured @@ -31,6 +31,7 @@ use sha3::Digest as _; use x_wing::kem::{Decapsulator as _, KeyExport as _}; use x_wing::TryKeyInit as _; +mod rate_limit; mod support; use support::*; @@ -782,6 +783,30 @@ fn finalized_block_json_at(number: &str, hash: &str, timestamp: u64) -> Value { }) } +/// ABI-encodes a single `string` return value the way `eth_call` would: +/// offset word `0x20`, then the length word, then the UTF-8 bytes +/// right-padded to a multiple of 32 bytes. +fn abi_encoded_string_for_test(value: &str) -> Value { + let bytes = value.as_bytes(); + let mut out = format!("0x{:0>64x}", 0x20); + out.push_str(&format!("{:0>64x}", bytes.len())); + out.push_str(&encode_hex(bytes)); + let padding = (32 - (bytes.len() % 32)) % 32; + out.push_str(&"0".repeat(padding * 2)); + json!(out) +} + +/// ABI-encodes a single `address[]` return value: offset word `0x20`, the +/// length word, then each address right-aligned in its own 32-byte word. +fn abi_encoded_address_array_for_test(addresses: &[&str]) -> Value { + let mut out = format!("0x{:0>64x}", 0x20); + out.push_str(&format!("{:0>64x}", addresses.len())); + for address in addresses { + out.push_str(&format!("{:0>64}", address.trim_start_matches("0x"))); + } + json!(out) +} + #[test] fn encode_has_access_by_content_id_call_uses_address_and_bytes16_abi() { let access_id = [0x41; 16]; @@ -3416,19 +3441,17 @@ fn resolve_protected_content_creator_mint_prices_in_the_token_that_was_chosen() let access_id = [0x41; 16]; let creator = "0x0000000000000000000000000000000000000011"; let configured = "0x0000000000000000000000000000000000000033"; - let request = |pay_token: Option<&str>| { - Request::ResolveProtectedContentCreatorMint { - creator: creator.to_string(), - token_uri: "ipfs://protected-content".to_string(), - content_access_id: format!("0x{}", encode_hex(&access_id)), - copies: "0x7".to_string(), - price: "0x5".to_string(), - royalties: Vec::new(), - op_type_code: 1, - reseller_cut: None, - ledger: Some(CONFIGURED_TEST_LEDGER.to_string()), - pay_token: pay_token.map(str::to_string), - } + let request = |pay_token: Option<&str>| Request::ResolveProtectedContentCreatorMint { + creator: creator.to_string(), + token_uri: "ipfs://protected-content".to_string(), + content_access_id: format!("0x{}", encode_hex(&access_id)), + copies: "0x7".to_string(), + price: "0x5".to_string(), + royalties: Vec::new(), + op_type_code: 1, + reseller_cut: None, + ledger: Some(CONFIGURED_TEST_LEDGER.to_string()), + pay_token: pay_token.map(str::to_string), }; // Naming no token takes the source's first offered one, and states what a // price in it means. @@ -3448,9 +3471,7 @@ fn resolve_protected_content_creator_mint_prices_in_the_token_that_was_chosen() // in it has unknown meaning. let mut provider = provider_with_creator_mint_rpc("http://127.0.0.1:9".to_string()); assert_eq!( - error_code(provider.handle(request(Some( - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" - )))), + error_code(provider.handle(request(Some("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913")))), "invalid_protected_content_creator_mint_request" ); } @@ -5162,72 +5183,78 @@ fn resolve_protected_content_verified_listing_rejects_noncanonical_address_word( } #[test] -fn resolve_protected_content_purchase_returns_exact_network_target_value_and_data() { - let finalized_hash = "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; +fn resolve_protected_content_item_reads_operative_and_token_uri() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; let operative = "0x0000000000000000000000000000000000000044"; - let payment_processor = "0x00000000000000000000000000000000000000bb"; - let erc20_sequence = vec![ + let token_uri = + "ipfs://bafyfolder/0000000000000000000000000000000000000000000000000000000000000001.json"; + let sequence = vec![ ("eth_chainId", json!([]), json!("0x14")), ( "eth_getBlockByNumber", json!(["finalized", false]), - finalized_block_json("0x2b", finalized_hash), + finalized_block_json("0x2c", finalized_hash), ), ( "eth_call", json!([ - { - "to": "0x00000000000000000000000000000000000000aa", - "data": encode_authority_gateway_operative_call( - "0x0000000000000000000000000000000000000022", - "0x03" - ).unwrap() - }, - { - "blockHash": finalized_hash, - "requireCanonical": true - } + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() ]), json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), ), ( "eth_call", json!([ - { - "to": "0x00000000000000000000000000000000000000aa", - "data": encode_authority_gateway_listing_call( - operative, - "0x0000000000000000000000000000000000000011" - ).unwrap() - }, - { - "blockHash": finalized_hash, - "requireCanonical": true - } + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + at ]), - json!(concat!( - "0x", - "000000000000000000000000000000000000000000000000000000000000270f", - "0000000000000000000000000000000000000000000000000000000000000005", - "0000000000000000000000000000000000000000000000000000000000000033" - )), + abi_encoded_string_for_test(token_uri), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + let item = ok_data(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(item["schema"], PROTECTED_CONTENT_ITEM_SCHEMA); + assert_eq!(item["operative"], operative); + assert_eq!(item["token_uri"], token_uri); + assert_eq!(item["token_id"], "0x3"); +} + +#[test] +fn resolve_protected_content_item_rejects_an_unbound_item() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), ), ( "eth_call", json!([ - { - "to": operative, - "data": encode_operatives_payment_processor_call().unwrap() - }, - { - "blockHash": finalized_hash, - "requireCanonical": true - } + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at ]), - json!(format!( - "0x{:0>64}", - payment_processor.trim_start_matches("0x") - )), + json!(format!("0x{:0>64}", "")), ), ]; let mut provider = provider_with_rights_rpc_policies_and_purchase( @@ -5235,163 +5262,1486 @@ fn resolve_protected_content_purchase_returns_exact_network_target_value_and_dat "0x12345678", json!([]), protected_content_market_source(vec![ - spawn_rpc_sequence_asserting_server(erc20_sequence.clone()), - spawn_rpc_sequence_asserting_server(erc20_sequence), + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), ]), ); - let data = ok_data(provider.handle(Request::ResolveProtectedContentPurchase { - seller: "0x0000000000000000000000000000000000000011".to_string(), - chain_namespace: "eip155:20".to_string(), - network: "esc-local".to_string(), - ledger: "0x0000000000000000000000000000000000000022".to_string(), - token_id: "0x03".to_string(), - })); - let rendered = serde_json::to_string(&data).unwrap(); - assert!(!rendered.contains("http://127.0.0.1:9")); - assert_eq!(data["schema"], PROTECTED_CONTENT_PURCHASE_SCHEMA); - assert_eq!(data["network"], "esc-local"); - assert_eq!(data["purchase_quantity"], "0x1"); - assert_eq!(data["verified_listing"]["token_id"], "0x3"); - assert_eq!(data["verified_listing"]["available_quantity"], "0x270f"); - assert_eq!(data["verified_listing"]["price"], "0x5"); - assert_eq!( - data["verified_listing"]["pay_token"], - "0x0000000000000000000000000000000000000033" - ); - assert_eq!( - data["verified_listing"]["payment_processor"], - payment_processor - ); - let steps = data["steps"].as_array().expect("ordered transaction steps"); - assert_eq!(steps.len(), 2); - assert_eq!(steps[0]["stage"], "approval"); - assert_eq!(steps[0]["to"], "0x0000000000000000000000000000000000000033"); - assert_eq!(steps[0]["value"], "0x0"); - assert_eq!( - steps[0]["data"], - encode_erc20_approve_call(payment_processor, "0x5",).unwrap() - ); - assert_eq!(steps[1]["stage"], "buy"); - assert_eq!(steps[1]["to"], "0x00000000000000000000000000000000000000aa"); - assert_eq!(steps[1]["value"], "0x0"); assert_eq!( - steps[1]["data"], - encode_authority_gateway_buy_access_call( - PROTECTED_CONTENT_BUY_ACCESS_ERC20_SELECTOR, - "0x0000000000000000000000000000000000000011", - "0x0000000000000000000000000000000000000022", - "0x3", - "0x1", - "0x05", - Some("0x0000000000000000000000000000000000000033"), - ) - .unwrap() + error_code(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })), + "unbound_protected_content_item" ); +} - let native_sequence = vec![ +#[test] +fn resolve_protected_content_item_rejects_conflicting_sources() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let sequence_a = vec![ ("eth_chainId", json!([]), json!("0x14")), ( "eth_getBlockByNumber", json!(["finalized", false]), - finalized_block_json("0x2b", finalized_hash), + finalized_block_json("0x2c", finalized_hash), ), ( "eth_call", json!([ - { - "to": "0x00000000000000000000000000000000000000aa", - "data": encode_authority_gateway_operative_call( - "0x0000000000000000000000000000000000000022", - "0x03" - ).unwrap() - }, - { - "blockHash": finalized_hash, - "requireCanonical": true - } + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() ]), json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), ), ( "eth_call", json!([ - { - "to": "0x00000000000000000000000000000000000000aa", - "data": encode_authority_gateway_listing_call( - operative, - "0x0000000000000000000000000000000000000011" - ).unwrap() - }, - { - "blockHash": finalized_hash, - "requireCanonical": true - } + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + at.clone() ]), - json!(concat!( - "0x", - "0000000000000000000000000000000000000000000000000000000000009999", - "0000000000000000000000000000000000000000000000000000000000000005", - "0000000000000000000000000000000000000000000000000000000000000000" - )), + abi_encoded_string_for_test("ipfs://bafyfolder/one.json"), ), ]; - let mut native_provider = provider_with_rights_rpc_policies_and_purchase( + let sequence_b = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + at + ]), + abi_encoded_string_for_test("ipfs://bafyfolder/other.json"), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( "http://127.0.0.1:9".to_string(), "0x12345678", json!([]), protected_content_market_source(vec![ - spawn_rpc_sequence_asserting_server(native_sequence.clone()), - spawn_rpc_sequence_asserting_server(native_sequence), + spawn_rpc_sequence_asserting_server(sequence_a), + spawn_rpc_sequence_asserting_server(sequence_b), ]), ); - let native = ok_data( - native_provider.handle(Request::ResolveProtectedContentPurchase { - seller: "0x0000000000000000000000000000000000000011".to_string(), - chain_namespace: "eip155:20".to_string(), - network: "esc-local".to_string(), - ledger: "0x0000000000000000000000000000000000000022".to_string(), - token_id: "0x03".to_string(), - }), - ); - assert_eq!(native["purchase_quantity"], "0x1"); - assert_eq!(native["verified_listing"]["available_quantity"], "0x9999"); - let native_steps = native["steps"].as_array().expect("native steps"); - assert_eq!(native_steps.len(), 1); - assert_eq!(native_steps[0]["stage"], "buy"); - assert_eq!( - native_steps[0]["to"], - "0x00000000000000000000000000000000000000aa" - ); - assert_eq!(native_steps[0]["value"], "0x5"); assert_eq!( - native_steps[0]["data"], - encode_authority_gateway_buy_access_call( - PROTECTED_CONTENT_BUY_ACCESS_NATIVE_SELECTOR, - "0x0000000000000000000000000000000000000011", - "0x0000000000000000000000000000000000000022", - "0x3", - "0x1", - "0x05", - None, - ) - .unwrap() + error_code(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })), + "conflicting_protected_content_item_observations" ); +} - let zero_stock_sequence = vec![ +#[test] +fn resolve_protected_content_kid_binding_reads_ip_reference() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let store = "0x00000000000000000000000000000000000000cc"; + let ledger = "0x0000000000000000000000000000000000000022"; + let kid = "0x0123456789abcdef0123456789abcdef"; + let sequence = vec![ ("eth_chainId", json!([]), json!("0x14")), ( "eth_getBlockByNumber", json!(["finalized", false]), - finalized_block_json("0x2b", finalized_hash), + finalized_block_json("0x2c", finalized_hash), ), ( "eth_call", json!([ - { - "to": "0x00000000000000000000000000000000000000aa", - "data": encode_authority_gateway_operative_call( - "0x0000000000000000000000000000000000000022", - "0x03" + { "to": gateway, "data": encode_authority_gateway_cstore_call().unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", store.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": store, "data": encode_ip_reference_call(kid).unwrap() }, + at + ]), + json!(format!( + "0x{:0>64}{:0>64}", + ledger.trim_start_matches("0x"), + "3" + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + let binding = ok_data(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })); + assert_eq!(binding["schema"], PROTECTED_CONTENT_KID_BINDING_SCHEMA); + assert_eq!(binding["ledger"], ledger); + assert_eq!(binding["token_id"], "0x3"); +} + +#[test] +fn resolve_protected_content_kid_binding_rejects_an_unbound_kid() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let store = "0x00000000000000000000000000000000000000cc"; + let kid = "0x0123456789abcdef0123456789abcdef"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_cstore_call().unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", store.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": store, "data": encode_ip_reference_call(kid).unwrap() }, + at + ]), + json!(format!("0x{:0>64}{:0>64}", "", "")), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + assert_eq!( + error_code(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })), + "unbound_protected_content_kid" + ); +} + +#[test] +fn resolve_protected_content_item_offers_reads_every_seller() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let seller1 = "0x0000000000000000000000000000000000000011"; + let seller2 = "0x0000000000000000000000000000000000000099"; + let pay_token = "0x0000000000000000000000000000000000000033"; + let payment_processor = "0x00000000000000000000000000000000000000bb"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + // Sellers are returned out of order to prove the resolver sorts + // them ascending before it decides which to read. + abi_encoded_address_array_for_test(&[seller2, seller1]), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller1).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000033" + )), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller2).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000009", + "0000000000000000000000000000000000000000000000000000000000000033" + )), + ), + ( + "eth_call", + json!([ + { "to": operative, "data": encode_operatives_payment_processor_call().unwrap() }, + at + ]), + json!(format!( + "0x{:0>64}", + payment_processor.trim_start_matches("0x") + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + let offers = ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(offers["schema"], PROTECTED_CONTENT_ITEM_OFFERS_SCHEMA); + assert_eq!(offers["operative"], operative); + assert_eq!(offers["truncated"], false); + let offers_array = offers["offers"].as_array().unwrap(); + assert_eq!(offers_array.len(), 1); + assert_eq!(offers_array[0]["seller"], seller1); + assert_eq!(offers_array[0]["quantity"], "0x7"); + assert_eq!(offers_array[0]["price"], "0x5"); + assert_eq!(offers_array[0]["pay_token"], pay_token); + assert_eq!(offers_array[0]["payment_processor"], payment_processor); +} + +#[test] +fn resolve_protected_content_item_offers_truncates_beyond_the_cap() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let sellers: Vec = (1..=33).map(|n: u32| format!("0x{n:040x}")).collect(); + let sellers_str: Vec<&str> = sellers.iter().map(String::as_str).collect(); + let mut reversed = sellers_str.clone(); + reversed.reverse(); + let mut sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + // Out of order, same as above -- and one more than the cap. + abi_encoded_address_array_for_test(&reversed), + ), + ]; + for seller in &sellers_str[..32] { + sequence.push(( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + )); + } + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + let offers = ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(offers["truncated"], true); + let offers_array = offers["offers"].as_array().unwrap(); + assert_eq!(offers_array.len(), 32); + assert_eq!(offers_array[0]["seller"], sellers_str[0]); + assert_eq!(offers_array[31]["seller"], sellers_str[31]); +} + +#[test] +fn resolve_protected_content_item_offers_rejects_conflicting_sources() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let seller = "0x0000000000000000000000000000000000000011"; + let sequence_a = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + abi_encoded_address_array_for_test(&[seller]), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ]; + let sequence_b = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + abi_encoded_address_array_for_test(&[seller]), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + at + ]), + json!(concat!( + "0x", + // Disagrees on price with source A. + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000009", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence_a), + spawn_rpc_sequence_asserting_server(sequence_b), + ]), + ); + assert_eq!( + error_code(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })), + "conflicting_protected_content_item_offers_observations" + ); +} + +/// R32 / SM-I5: sources may legitimately disagree on the finalized head by +/// a block or more; the item read pins every source to the lowest finalized +/// block (the same algorithm the rights read uses -- see +/// `pin_to_lowest_finalized`) instead of demanding the sources' finalized +/// heads match exactly. +#[test] +fn resolve_protected_content_item_pins_lagging_sources_to_the_lowest_finalized_block() { + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let token_uri = + "ipfs://bafyfolder/0000000000000000000000000000000000000000000000000000000000000001.json"; + let older_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let newer_hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let operative_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let token_uri_call = |hash: &str| { + json!([ + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let operative_word = json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))); + // Source A already finalized block 0x2b; after pinning it must confirm + // 0x2a is canonical under the older hash and answer both calls there. + let ahead_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", newer_hash), + ), + ( + "eth_call", + operative_call(newer_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(newer_hash), + abi_encoded_string_for_test(token_uri), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(older_hash), + abi_encoded_string_for_test(token_uri), + ), + ]; + // Source B still finalizes 0x2a; pinned to its own head, it re-confirms + // the block and re-answers both calls there. + let behind_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(older_hash), + abi_encoded_string_for_test(token_uri), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", operative_call(older_hash), operative_word), + ( + "eth_call", + token_uri_call(older_hash), + abi_encoded_string_for_test(token_uri), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(ahead_sequence), + spawn_rpc_sequence_asserting_server(behind_sequence), + ]), + ); + let item = ok_data(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(item["operative"], operative); + assert_eq!(item["token_uri"], token_uri); + assert_eq!(item["finalized_block_number"], json!(0x2a)); +} + +/// Pinning to the lowest finalized block does not launder a real +/// disagreement: if the two sources' data differs once re-evaluated at the +/// same pinned block, it is still a conflict. +#[test] +fn resolve_protected_content_item_rejects_conflicting_data_at_the_pinned_block() { + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let older_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let newer_hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let operative_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let token_uri_call = |hash: &str| { + json!([ + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let operative_word = json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))); + let ahead_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", newer_hash), + ), + ( + "eth_call", + operative_call(newer_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(newer_hash), + abi_encoded_string_for_test("ipfs://bafyfolder/initial-a.json"), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(older_hash), + // Disagrees with source B's re-pinned answer below. + abi_encoded_string_for_test("ipfs://bafyfolder/repinned-a.json"), + ), + ]; + let behind_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ( + "eth_call", + token_uri_call(older_hash), + abi_encoded_string_for_test("ipfs://bafyfolder/initial-b.json"), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", operative_call(older_hash), operative_word), + ( + "eth_call", + token_uri_call(older_hash), + abi_encoded_string_for_test("ipfs://bafyfolder/repinned-b.json"), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(ahead_sequence), + spawn_rpc_sequence_asserting_server(behind_sequence), + ]), + ); + assert_eq!( + error_code(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })), + "conflicting_protected_content_item_observations" + ); +} + +/// R32 applied to the kid-binding read: same lag-tolerant pinning, proven +/// independently of the item read to show the algorithm is reused rather +/// than copied and reimplemented per op. +#[test] +fn resolve_protected_content_kid_binding_pins_lagging_sources_to_the_lowest_finalized_block() { + let gateway = "0x00000000000000000000000000000000000000aa"; + let store = "0x00000000000000000000000000000000000000cc"; + let ledger = "0x0000000000000000000000000000000000000022"; + let kid = "0x0123456789abcdef0123456789abcdef"; + let older_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let newer_hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let cstore_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_cstore_call().unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let ip_reference_call = |hash: &str| { + json!([ + { "to": store, "data": encode_ip_reference_call(kid).unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let cstore_word = json!(format!("0x{:0>64}", store.trim_start_matches("0x"))); + let ip_reference_word = json!(format!( + "0x{:0>64}{:0>64}", + ledger.trim_start_matches("0x"), + "3" + )); + let ahead_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", newer_hash), + ), + ("eth_call", cstore_call(newer_hash), cstore_word.clone()), + ( + "eth_call", + ip_reference_call(newer_hash), + ip_reference_word.clone(), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", cstore_call(older_hash), cstore_word.clone()), + ( + "eth_call", + ip_reference_call(older_hash), + ip_reference_word.clone(), + ), + ]; + let behind_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", cstore_call(older_hash), cstore_word.clone()), + ( + "eth_call", + ip_reference_call(older_hash), + ip_reference_word.clone(), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", cstore_call(older_hash), cstore_word), + ("eth_call", ip_reference_call(older_hash), ip_reference_word), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(ahead_sequence), + spawn_rpc_sequence_asserting_server(behind_sequence), + ]), + ); + let binding = ok_data(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })); + assert_eq!(binding["ledger"], ledger); + assert_eq!(binding["token_id"], "0x3"); + assert_eq!(binding["finalized_block_number"], json!(0x2a)); +} + +/// R32 applied to the item-offers read. +#[test] +fn resolve_protected_content_item_offers_pins_lagging_sources_to_the_lowest_finalized_block() { + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let seller = "0x0000000000000000000000000000000000000011"; + let older_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let newer_hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let operative_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let sellers_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let listing_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let operative_word = json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))); + let sellers_word = abi_encoded_address_array_for_test(&[seller]); + let listing_word = json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )); + let ahead_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", newer_hash), + ), + ( + "eth_call", + operative_call(newer_hash), + operative_word.clone(), + ), + ("eth_call", sellers_call(newer_hash), sellers_word.clone()), + ("eth_call", listing_call(newer_hash), listing_word.clone()), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ("eth_call", sellers_call(older_hash), sellers_word.clone()), + ("eth_call", listing_call(older_hash), listing_word.clone()), + ]; + let behind_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ("eth_call", sellers_call(older_hash), sellers_word.clone()), + ("eth_call", listing_call(older_hash), listing_word.clone()), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", operative_call(older_hash), operative_word), + ("eth_call", sellers_call(older_hash), sellers_word), + ("eth_call", listing_call(older_hash), listing_word), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(ahead_sequence), + spawn_rpc_sequence_asserting_server(behind_sequence), + ]), + ); + let offers = ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(offers["finalized_block_number"], json!(0x2a)); + let offers_array = offers["offers"].as_array().unwrap(); + assert_eq!(offers_array.len(), 1); + assert_eq!(offers_array[0]["seller"], seller); +} + +/// ERC-20 flag fix: a sold-out listing's `pay_token` must not be the reason +/// `paymentProcessor()` gets called. The mock sequence has no trailing call +/// for it -- if the fix regresses, the request has nothing left to answer +/// that extra call and fails instead of succeeding with an empty offer set. +#[test] +fn resolve_protected_content_item_offers_skips_payment_processor_for_a_sold_out_erc20_listing() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let seller = "0x0000000000000000000000000000000000000011"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + abi_encoded_address_array_for_test(&[seller]), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + at + ]), + // Sold out (quantity 0x0) with a nonzero (ERC-20) pay_token. + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000033" + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + let offers = ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(offers["offers"].as_array().unwrap().len(), 0); +} + +/// cstore zero-check: a zero cstore means the authority gateway has no +/// protected-content store bound on this network at all. No ipReference +/// call belongs in the mock sequence -- calling it on the zero address is +/// exactly the bug this guard exists to prevent. +#[test] +fn resolve_protected_content_kid_binding_rejects_a_zero_cstore() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let kid = "0x0123456789abcdef0123456789abcdef"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_cstore_call().unwrap() }, + at + ]), + json!(format!("0x{:0>64}", "")), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + assert_eq!( + error_code(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })), + "unbound_protected_content_store" + ); +} + +/// R36 / PS-I4: chain-provider is the producer of the typed `Resolved...` +/// wire shapes the server test deserializes; this writes one real answer of +/// each of the three ops (plus a truncated item_offers) to the fixture, to +/// hold the eventual server-side structs to what Runtime really sends. +#[test] +fn chain_market_ops_writes_the_server_fixture() { + let finalized_hash = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let at = json!({ "blockHash": finalized_hash, "requireCanonical": true }); + let gateway = "0x00000000000000000000000000000000000000aa"; + let store = "0x00000000000000000000000000000000000000cc"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let kid = "0x0123456789abcdef0123456789abcdef"; + + let item = { + let token_uri = "ipfs://bafyfolder/0000000000000000000000000000000000000000000000000000000000000001.json"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": operative, "data": encode_operative_token_uri_call().unwrap() }, + at.clone() + ]), + abi_encoded_string_for_test(token_uri), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + ok_data(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })) + }; + + let kid_binding = { + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_cstore_call().unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", store.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": store, "data": encode_ip_reference_call(kid).unwrap() }, + at.clone() + ]), + json!(format!( + "0x{:0>64}{:0>64}", + ledger.trim_start_matches("0x"), + "3" + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + ok_data(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })) + }; + + // One ERC-20 offer, one native offer. + let item_offers = { + let native_seller = "0x0000000000000000000000000000000000000011"; + let erc20_seller = "0x0000000000000000000000000000000000000099"; + let payment_processor = "0x00000000000000000000000000000000000000bb"; + let sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + abi_encoded_address_array_for_test(&[native_seller, erc20_seller]), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, native_seller).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, erc20_seller).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000009", + "0000000000000000000000000000000000000000000000000000000000000006", + "0000000000000000000000000000000000000000000000000000000000000033" + )), + ), + ( + "eth_call", + json!([ + { "to": operative, "data": encode_operatives_payment_processor_call().unwrap() }, + at.clone() + ]), + json!(format!( + "0x{:0>64}", + payment_processor.trim_start_matches("0x") + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })) + }; + + // 33 sellers, one more than the cap. + let item_offers_truncated = { + let sellers: Vec = (1..=33).map(|n: u32| format!("0x{n:040x}")).collect(); + let sellers_str: Vec<&str> = sellers.iter().map(String::as_str).collect(); + let mut reversed = sellers_str.clone(); + reversed.reverse(); + let mut sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", finalized_hash), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x3").unwrap() }, + at.clone() + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_sellers_of_call(operative).unwrap() }, + at.clone() + ]), + abi_encoded_address_array_for_test(&reversed), + ), + ]; + for seller in &sellers_str[..32] { + sequence.push(( + "eth_call", + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + at.clone() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + )); + } + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ]), + ); + ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: ledger.to_string(), + token_id: "0x3".to_string(), + })) + }; + + let fixture = json!({ + "item": item, + "kid_binding": kid_binding, + "item_offers": item_offers, + "item_offers_truncated": item_offers_truncated, + }); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../elastos/crates/elastos-server/src/api/gateway_tests/fixtures/chain-market-ops.json", + ); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&fixture).unwrap(); + bytes.push(b'\n'); + std::fs::write(&path, bytes).unwrap(); +} + +#[test] +fn resolve_protected_content_purchase_returns_exact_network_target_value_and_data() { + let finalized_hash = "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let operative = "0x0000000000000000000000000000000000000044"; + let payment_processor = "0x00000000000000000000000000000000000000bb"; + let erc20_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", finalized_hash), + ), + ( + "eth_call", + json!([ + { + "to": "0x00000000000000000000000000000000000000aa", + "data": encode_authority_gateway_operative_call( + "0x0000000000000000000000000000000000000022", + "0x03" + ).unwrap() + }, + { + "blockHash": finalized_hash, + "requireCanonical": true + } + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { + "to": "0x00000000000000000000000000000000000000aa", + "data": encode_authority_gateway_listing_call( + operative, + "0x0000000000000000000000000000000000000011" + ).unwrap() + }, + { + "blockHash": finalized_hash, + "requireCanonical": true + } + ]), + json!(concat!( + "0x", + "000000000000000000000000000000000000000000000000000000000000270f", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000033" + )), + ), + ( + "eth_call", + json!([ + { + "to": operative, + "data": encode_operatives_payment_processor_call().unwrap() + }, + { + "blockHash": finalized_hash, + "requireCanonical": true + } + ]), + json!(format!( + "0x{:0>64}", + payment_processor.trim_start_matches("0x") + )), + ), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(erc20_sequence.clone()), + spawn_rpc_sequence_asserting_server(erc20_sequence), + ]), + ); + let data = ok_data(provider.handle(Request::ResolveProtectedContentPurchase { + seller: "0x0000000000000000000000000000000000000011".to_string(), + chain_namespace: "eip155:20".to_string(), + network: "esc-local".to_string(), + ledger: "0x0000000000000000000000000000000000000022".to_string(), + token_id: "0x03".to_string(), + })); + let rendered = serde_json::to_string(&data).unwrap(); + assert!(!rendered.contains("http://127.0.0.1:9")); + assert_eq!(data["schema"], PROTECTED_CONTENT_PURCHASE_SCHEMA); + assert_eq!(data["network"], "esc-local"); + assert_eq!(data["purchase_quantity"], "0x1"); + assert_eq!(data["verified_listing"]["token_id"], "0x3"); + assert_eq!(data["verified_listing"]["available_quantity"], "0x270f"); + assert_eq!(data["verified_listing"]["price"], "0x5"); + assert_eq!( + data["verified_listing"]["pay_token"], + "0x0000000000000000000000000000000000000033" + ); + assert_eq!( + data["verified_listing"]["payment_processor"], + payment_processor + ); + let steps = data["steps"].as_array().expect("ordered transaction steps"); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0]["stage"], "approval"); + assert_eq!(steps[0]["to"], "0x0000000000000000000000000000000000000033"); + assert_eq!(steps[0]["value"], "0x0"); + assert_eq!( + steps[0]["data"], + encode_erc20_approve_call(payment_processor, "0x5",).unwrap() + ); + assert_eq!(steps[1]["stage"], "buy"); + assert_eq!(steps[1]["to"], "0x00000000000000000000000000000000000000aa"); + assert_eq!(steps[1]["value"], "0x0"); + assert_eq!( + steps[1]["data"], + encode_authority_gateway_buy_access_call( + PROTECTED_CONTENT_BUY_ACCESS_ERC20_SELECTOR, + "0x0000000000000000000000000000000000000011", + "0x0000000000000000000000000000000000000022", + "0x3", + "0x1", + "0x05", + Some("0x0000000000000000000000000000000000000033"), + ) + .unwrap() + ); + + let native_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", finalized_hash), + ), + ( + "eth_call", + json!([ + { + "to": "0x00000000000000000000000000000000000000aa", + "data": encode_authority_gateway_operative_call( + "0x0000000000000000000000000000000000000022", + "0x03" + ).unwrap() + }, + { + "blockHash": finalized_hash, + "requireCanonical": true + } + ]), + json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))), + ), + ( + "eth_call", + json!([ + { + "to": "0x00000000000000000000000000000000000000aa", + "data": encode_authority_gateway_listing_call( + operative, + "0x0000000000000000000000000000000000000011" + ).unwrap() + }, + { + "blockHash": finalized_hash, + "requireCanonical": true + } + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000009999", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ]; + let mut native_provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(native_sequence.clone()), + spawn_rpc_sequence_asserting_server(native_sequence), + ]), + ); + let native = ok_data( + native_provider.handle(Request::ResolveProtectedContentPurchase { + seller: "0x0000000000000000000000000000000000000011".to_string(), + chain_namespace: "eip155:20".to_string(), + network: "esc-local".to_string(), + ledger: "0x0000000000000000000000000000000000000022".to_string(), + token_id: "0x03".to_string(), + }), + ); + assert_eq!(native["purchase_quantity"], "0x1"); + assert_eq!(native["verified_listing"]["available_quantity"], "0x9999"); + let native_steps = native["steps"].as_array().expect("native steps"); + assert_eq!(native_steps.len(), 1); + assert_eq!(native_steps[0]["stage"], "buy"); + assert_eq!( + native_steps[0]["to"], + "0x00000000000000000000000000000000000000aa" + ); + assert_eq!(native_steps[0]["value"], "0x5"); + assert_eq!( + native_steps[0]["data"], + encode_authority_gateway_buy_access_call( + PROTECTED_CONTENT_BUY_ACCESS_NATIVE_SELECTOR, + "0x0000000000000000000000000000000000000011", + "0x0000000000000000000000000000000000000022", + "0x3", + "0x1", + "0x05", + None, + ) + .unwrap() + ); + + let zero_stock_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", finalized_hash), + ), + ( + "eth_call", + json!([ + { + "to": "0x00000000000000000000000000000000000000aa", + "data": encode_authority_gateway_operative_call( + "0x0000000000000000000000000000000000000022", + "0x03" ).unwrap() }, { @@ -5688,6 +7038,245 @@ fn resolve_protected_content_purchase_access_rejects_stale_or_future_finalized_o } } +/// R50: `AuthorityGateway.hasAccess(address accessor, address ledger, uint256 +/// tokenId)` -- the item-keyed twin of `hasAccessByContentId`, answered by the +/// same `_checkUserAccess`. The selector is the reviewed signature's. +#[test] +fn authority_gateway_has_access_selector_and_encoding_match_the_abi() { + let selector = &sha3::Keccak256::digest(b"hasAccess(address,address,uint256)")[..4]; + assert_eq!( + format!("0x{}", encode_hex(selector)), + AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR + ); + assert_eq!(AUTHORITY_GATEWAY_HAS_ACCESS_SELECTOR, "0xcf56b4eb"); + assert_eq!( + encode_authority_gateway_has_access_call( + "0x0000000000000000000000000000000000000007", + "0x0000000000000000000000000000000000000022", + "0x3", + ) + .unwrap(), + concat!( + "0xcf56b4eb", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000022", + "0000000000000000000000000000000000000000000000000000000000000003" + ) + ); +} + +fn item_access_sequence( + tag: &'static str, + block_hash: &str, + block_timestamp: u64, + wallet: &str, + has_access: bool, +) -> Vec<(&'static str, Value, Value)> { + let expected_data = encode_authority_gateway_has_access_call( + wallet, + "0x0000000000000000000000000000000000000022", + "0x3", + ) + .unwrap(); + vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!([tag, false]), + json!({ + "number": "0x2c", + "hash": block_hash, + "timestamp": format!("0x{:x}", block_timestamp), + }), + ), + ( + "eth_call", + json!([ + { + "to": "0x0000000000000000000000000000000000000001", + "data": expected_data + }, + { + "blockHash": block_hash, + "requireCanonical": true + } + ]), + evm_bool_word(has_access), + ), + ] +} + +fn item_access_request(block: ProtectedContentAccessBlock) -> Request { + Request::ResolveProtectedContentItemAccess { + request_id: "item-access:exact".to_string(), + network: "esc-local".to_string(), + wallet: "0x0000000000000000000000000000000000000007".to_string(), + ledger: "0x0000000000000000000000000000000000000022".to_string(), + token_id: "0x03".to_string(), + block, + } +} + +/// R50: the item-keyed access read is corroborated by the view policy's +/// sources, exactly like the content-id read, and answers the item it was +/// asked about -- never the sources, the contract or the selector. +#[test] +fn resolve_protected_content_item_access_reads_the_head_by_item_and_hides_topology() { + let wallet = "0x0000000000000000000000000000000000000007"; + let hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let sequence = item_access_sequence("latest", hash, RIGHTS_EVIDENCE_NOW - 5, wallet, true); + let mut provider = provider_with_rights_rpc_and_policies( + "http://127.0.0.1:9".to_string(), + "0x12345678", + protected_content_policy_sources( + "view", + vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ], + ), + ); + let data = ok_data(provider.handle(item_access_request(ProtectedContentAccessBlock::Latest))); + let rendered = serde_json::to_string(&data).unwrap(); + assert_eq!( + data, + json!({ + "schema": PROTECTED_CONTENT_ITEM_ACCESS_SCHEMA, + "request_id": "item-access:exact", + "network": "esc-local", + "chain_id": 20, + "wallet": wallet, + "ledger": "0x0000000000000000000000000000000000000022", + "token_id": "0x3", + "has_access": true, + "finalized_block_number": 44, + "finalized_block_hash": hash, + "finalized_block_timestamp": RIGHTS_EVIDENCE_NOW - 5, + "observed_at": RIGHTS_EVIDENCE_NOW, + }) + ); + assert!(!rendered.contains("http://127.0.0.1:9")); + assert!(!rendered.contains("\"contract\"")); + assert!(!rendered.contains("\"selector\"")); +} + +/// R50: the finalized variant asks every source for its `finalized` block -- +/// the mock answers that tag and nothing else -- and a denial is an answer. +#[test] +fn resolve_protected_content_item_access_finalized_variant_reads_the_finalized_block() { + let wallet = "0x0000000000000000000000000000000000000007"; + let hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let sequence = + item_access_sequence("finalized", hash, RIGHTS_EVIDENCE_NOW - 900, wallet, false); + let mut provider = provider_with_rights_rpc_and_policies( + "http://127.0.0.1:9".to_string(), + "0x12345678", + protected_content_policy_sources( + "view", + vec![ + spawn_rpc_sequence_asserting_server(sequence.clone()), + spawn_rpc_sequence_asserting_server(sequence), + ], + ), + ); + let data = + ok_data(provider.handle(item_access_request(ProtectedContentAccessBlock::Finalized))); + assert_eq!(data["has_access"], false); + assert_eq!(data["finalized_block_hash"], hash); +} + +/// R50: `hasAccess(accessor, ledger, tokenId)` has no "unbound" answer. A +/// source whose call reverts -- even with the content-id path's +/// `UnboundContentId` -- observed nothing, so two reverting sources are too +/// few observations, never a denial. +#[test] +fn resolve_protected_content_item_access_treats_a_revert_as_no_observation() { + let wallet = "0x0000000000000000000000000000000000000007"; + let hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mut sequence: Vec<(&'static str, Value, RpcReply)> = + item_access_sequence("latest", hash, RIGHTS_EVIDENCE_NOW - 5, wallet, true) + .into_iter() + .map(|(method, params, result)| (method, params, RpcReply::Result(result))) + .collect(); + sequence[2].2 = RpcReply::Error(unbound_content_id_error(&content_access_id(0x51))); + let mut provider = provider_with_rights_rpc_and_policies( + "http://127.0.0.1:9".to_string(), + "0x12345678", + protected_content_policy_sources( + "view", + vec![ + spawn_rpc_sequence_asserting_server_with_replies(sequence.clone()), + spawn_rpc_sequence_asserting_server_with_replies(sequence), + ], + ), + ); + assert_eq!( + error_code(provider.handle(item_access_request(ProtectedContentAccessBlock::Latest))), + "insufficient_rights_observations" + ); +} + +#[test] +fn resolve_protected_content_item_access_rejects_a_bad_request_before_any_read() { + let mut provider = provider_with_rights_rpc("http://127.0.0.1:9".to_string(), "0x12345678"); + for (wallet, ledger, token_id) in [ + ("0x07", "0x0000000000000000000000000000000000000022", "0x3"), + ( + "0x0000000000000000000000000000000000000007", + "ledger", + "0x3", + ), + ( + "0x0000000000000000000000000000000000000007", + "0x0000000000000000000000000000000000000022", + "3", + ), + ] { + assert_eq!( + error_code(provider.handle(Request::ResolveProtectedContentItemAccess { + request_id: "item-access:bad".to_string(), + network: "esc-local".to_string(), + wallet: wallet.to_string(), + ledger: ledger.to_string(), + token_id: token_id.to_string(), + block: ProtectedContentAccessBlock::Latest, + })), + "invalid_protected_content_item_access_request" + ); + } +} + +#[test] +fn resolve_protected_content_item_access_request_names_its_block() { + let request: Request = serde_json::from_value(json!({ + "op": "resolve_protected_content_item_access", + "request_id": "r", + "network": "esc-local", + "wallet": "0x0000000000000000000000000000000000000007", + "ledger": "0x0000000000000000000000000000000000000022", + "token_id": "0x3", + "block": "finalized", + })) + .unwrap(); + assert!(matches!( + request, + Request::ResolveProtectedContentItemAccess { + block: ProtectedContentAccessBlock::Finalized, + .. + } + )); + assert!(serde_json::from_value::(json!({ + "op": "resolve_protected_content_item_access", + "request_id": "r", + "network": "esc-local", + "wallet": "0x0000000000000000000000000000000000000007", + "ledger": "0x0000000000000000000000000000000000000022", + "token_id": "0x3", + "block": "safe", + })) + .is_err()); +} + #[test] fn init_rejects_unknown_protected_content_market_selector_field() { let mut provider = ChainProvider::new(); @@ -6598,3 +8187,104 @@ fn evm_rpc_batch_tells_a_refusal_from_an_answer() { let answers = interpret_evm_rpc_batch(&stray, 2).unwrap(); assert_eq!(answers, vec![Some(json!("0x1")), None]); } + +/// Installed failure (2026-09-25): independent RPC providers finalize at +/// different heights, and the purchase's verified-listing read compared the +/// raw observations -- block included -- so every Buy "disagreed on the +/// finalized tuple". It now pins every source to the lowest finalized block +/// first (R32), like the other market reads. +#[test] +fn resolve_protected_content_verified_listing_pins_lagging_sources_to_the_lowest_finalized_block() { + let gateway = "0x00000000000000000000000000000000000000aa"; + let ledger = "0x0000000000000000000000000000000000000022"; + let operative = "0x0000000000000000000000000000000000000044"; + let seller = "0x0000000000000000000000000000000000000011"; + let older_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let newer_hash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let operative_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_operative_call(ledger, "0x03").unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let listing_call = |hash: &str| { + json!([ + { "to": gateway, "data": encode_authority_gateway_listing_call(operative, seller).unwrap() }, + { "blockHash": hash, "requireCanonical": true } + ]) + }; + let operative_word = json!(format!("0x{:0>64}", operative.trim_start_matches("0x"))); + // Native offer: no payment processor read. + let listing_word = json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )); + let ahead_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2b", newer_hash), + ), + ( + "eth_call", + operative_call(newer_hash), + operative_word.clone(), + ), + ("eth_call", listing_call(newer_hash), listing_word.clone()), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ("eth_call", listing_call(older_hash), listing_word.clone()), + ]; + let behind_sequence = vec![ + ("eth_chainId", json!([]), json!("0x14")), + ( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", older_hash), + ), + ( + "eth_call", + operative_call(older_hash), + operative_word.clone(), + ), + ("eth_call", listing_call(older_hash), listing_word.clone()), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + finalized_block_json("0x2a", older_hash), + ), + ("eth_call", operative_call(older_hash), operative_word), + ("eth_call", listing_call(older_hash), listing_word), + ]; + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(vec![ + spawn_rpc_sequence_asserting_server(ahead_sequence), + spawn_rpc_sequence_asserting_server(behind_sequence), + ]), + ); + let listing = ok_data( + provider.handle(Request::ResolveProtectedContentVerifiedListing { + network: "esc-local".to_string(), + seller: seller.to_string(), + ledger: ledger.to_string(), + token_id: "0x03".to_string(), + }), + ); + assert_eq!(listing["operative"], operative); + assert_eq!(listing["quantity"], "0x7"); + assert_eq!(listing["price"], "0x5"); +} diff --git a/capsules/chain-provider/src/tests/rate_limit.rs b/capsules/chain-provider/src/tests/rate_limit.rs new file mode 100644 index 000000000..77279ff7c --- /dev/null +++ b/capsules/chain-provider/src/tests/rate_limit.rs @@ -0,0 +1,518 @@ +//! R47: a source that answers HTTP 429 is retried briefly, then cooled so +//! that corroborated reads ask it last, and a corroborated read stops asking +//! once two sources agree. + +use super::*; + +const SELLER: &str = "0x0000000000000000000000000000000000000011"; +const LEDGER: &str = "0x0000000000000000000000000000000000000022"; +const GATEWAY: &str = "0x00000000000000000000000000000000000000aa"; +const OPERATIVE: &str = "0x0000000000000000000000000000000000000044"; +const FINALIZED_HASH: &str = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + +fn rate_limited_provider(rpc_url: String) -> ChainProvider { + let mut provider = provider_with_rpc(rpc_url); + provider.sleep = record_sleep; + provider +} + +fn esc_network(provider: &ChainProvider) -> ChainNetwork { + provider.evm_network("esc-local").unwrap().clone() +} + +fn requests(counter: &std::sync::Arc) -> usize { + counter.load(std::sync::atomic::Ordering::SeqCst) +} + +fn chain_id_reply() -> ScriptedReply { + ScriptedReply::rpc("eth_chainId", json!([]), json!("0x14")) +} + +fn error_code_of(result: Result) -> String { + match result { + Err(Response::Error { code, .. }) => code, + other => panic!("expected error, got {other:?}"), + } +} + +#[test] +fn evm_rpc_retries_a_rate_limited_request_until_it_succeeds() { + let (url, counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + chain_id_reply(), + ]); + let provider = rate_limited_provider(url.clone()); + let value = provider + .evm_rpc(&esc_network(&provider), "eth_chainId", json!([])) + .expect("the third attempt answers"); + assert_eq!(value, json!("0x14")); + assert_eq!(requests(&counter), 3); + assert_eq!( + recorded_sleeps(), + vec![Duration::from_millis(250), Duration::from_millis(750)] + ); + // A source that recovered within its retries is not cooled. + let sources = vec![url.clone(), "http://127.0.0.1:9".to_string()]; + assert_eq!(provider.corroboration_order(&sources), sources); +} + +#[test] +fn evm_rpc_honours_a_retry_after_of_at_most_two_seconds() { + let (url, counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(Some("1")), + ScriptedReply::RateLimited(Some("2")), + chain_id_reply(), + ]); + let provider = rate_limited_provider(url); + provider + .evm_rpc(&esc_network(&provider), "eth_chainId", json!([])) + .expect("the third attempt answers"); + assert_eq!(requests(&counter), 3); + assert_eq!( + recorded_sleeps(), + vec![Duration::from_secs(1), Duration::from_secs(2)] + ); +} + +#[test] +fn evm_rpc_caps_a_long_retry_after_and_stops_after_two_retries() { + let (url, counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(Some("10")), + ScriptedReply::RateLimited(Some("10")), + ScriptedReply::RateLimited(Some("10")), + chain_id_reply(), + ]); + let provider = rate_limited_provider(url.clone()); + assert_eq!( + error_code_of(provider.evm_rpc(&esc_network(&provider), "eth_chainId", json!([]))), + "upstream_http_error" + ); + assert_eq!(requests(&counter), 3, "at most two retries after the first"); + assert_eq!( + recorded_sleeps(), + vec![Duration::from_millis(250), Duration::from_millis(750)], + "a Retry-After above two seconds falls back to the bounded schedule" + ); +} + +#[test] +fn evm_rpc_does_not_retry_errors_other_than_429() { + let (url, counter) = spawn_scripted_rpc_server(vec![ScriptedReply::Status(503)]); + let provider = rate_limited_provider(url.clone()); + assert_eq!( + error_code_of(provider.evm_rpc(&esc_network(&provider), "eth_chainId", json!([]))), + "upstream_http_error" + ); + assert_eq!(requests(&counter), 1); + + let (rejecting, rejecting_counter) = spawn_scripted_rpc_server(vec![ScriptedReply::Rpc( + "eth_chainId", + json!([]), + RpcReply::Error(json!({ "code": -32005, "message": "limit exceeded" })), + )]); + let mut network = esc_network(&provider); + network.rpc_url = rejecting; + assert_eq!( + error_code_of(provider.evm_rpc(&network, "eth_chainId", json!([]))), + "upstream_rpc_error" + ); + assert_eq!(requests(&rejecting_counter), 1); + + network.rpc_url = "http://127.0.0.1:9".to_string(); + assert_eq!( + error_code_of(provider.evm_rpc(&network, "eth_chainId", json!([]))), + "upstream_unreachable" + ); + assert!(recorded_sleeps().is_empty()); + // None of these errors cools a source. + let sources = vec![url, network.rpc_url.clone()]; + assert_eq!(provider.corroboration_order(&sources), sources); +} + +#[test] +fn evm_rpc_batch_retries_a_rate_limited_batch() { + let (url, counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(None), + ScriptedReply::Json(json!([{ "jsonrpc": "2.0", "id": 0, "result": "0x1" }])), + ]); + let provider = rate_limited_provider(url); + let answers = provider + .evm_rpc_batch( + &esc_network(&provider), + &[("eth_call".to_string(), json!([]))], + ) + .expect("the retried batch answers"); + assert_eq!(answers, vec![Some(json!("0x1"))]); + assert_eq!(requests(&counter), 2); + assert_eq!(recorded_sleeps(), vec![Duration::from_millis(250)]); +} + +#[test] +fn evm_rpc_batch_cools_an_origin_that_stays_rate_limited() { + let (url, counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ]); + let provider = rate_limited_provider(url.clone()); + assert_eq!( + error_code_of(provider.evm_rpc_batch( + &esc_network(&provider), + &[("eth_call".to_string(), json!([]))], + )), + "upstream_http_error" + ); + assert_eq!(requests(&counter), 3); + assert_eq!( + recorded_sleeps(), + vec![Duration::from_millis(250), Duration::from_millis(750)] + ); + let other = "http://127.0.0.1:9".to_string(); + assert_eq!( + provider.corroboration_order(&[url.clone(), other.clone()]), + vec![other, url] + ); +} + +#[test] +fn rate_limit_logs_name_the_host_only() { + let url = "https://user:secret@rpc.example.com:8443/v2/SECRETKEY?apikey=abc123"; + for line in [ + rate_limit_retry_log(url, 1, Duration::from_millis(250)), + rate_limit_cooldown_log(url), + ] { + assert!(line.contains("rpc.example.com"), "{line}"); + for leaked in ["SECRETKEY", "apikey", "abc123", "secret", "/v2", "user"] { + assert!(!line.contains(leaked), "log leaked {leaked}: {line}"); + } + } + assert!(rate_limit_retry_log(url, 2, Duration::from_millis(750)).contains("retry=2")); + assert!(rate_limit_retry_log(url, 2, Duration::from_millis(750)).contains("wait_ms=750")); +} + +fn at_finalized() -> Value { + json!({ "blockHash": FINALIZED_HASH, "requireCanonical": true }) +} + +/// One source's full answer to a verified-listing read (native pay token, so +/// no payment-processor call). +fn verified_listing_script() -> Vec { + vec![ + chain_id_reply(), + ScriptedReply::rpc( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", FINALIZED_HASH), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_operative_call(LEDGER, "0x03").unwrap() }, + at_finalized() + ]), + json!(format!("0x{:0>64}", OPERATIVE.trim_start_matches("0x"))), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_listing_call(OPERATIVE, SELLER).unwrap() }, + at_finalized() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ] +} + +fn twice(script: Vec) -> Vec { + let mut both = script.clone(); + both.extend(script); + both +} + +fn market_provider(evidence_rpc_urls: Vec) -> ChainProvider { + let mut provider = provider_with_rights_rpc_policies_and_purchase( + "http://127.0.0.1:9".to_string(), + "0x12345678", + json!([]), + protected_content_market_source(evidence_rpc_urls), + ); + provider.sleep = record_sleep; + provider +} + +fn resolve_verified_listing(provider: &mut ChainProvider) -> Response { + provider.handle(Request::ResolveProtectedContentVerifiedListing { + network: "esc-local".to_string(), + seller: SELLER.to_string(), + ledger: LEDGER.to_string(), + token_id: "0x03".to_string(), + }) +} + +#[test] +fn a_persistently_rate_limited_source_is_cooled_and_the_read_corroborates_elsewhere() { + let (limited, limited_counter) = spawn_scripted_rpc_server(vec![ + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ]); + let (second, _) = spawn_scripted_rpc_server(twice(verified_listing_script())); + let (third, _) = spawn_scripted_rpc_server(twice(verified_listing_script())); + let sources = vec![limited.clone(), second.clone(), third.clone()]; + let mut provider = market_provider(sources.clone()); + + let listing = ok_data(resolve_verified_listing(&mut provider)); + assert_eq!(listing["operative"], OPERATIVE); + assert_eq!(requests(&limited_counter), 3); + assert_eq!( + recorded_sleeps(), + vec![Duration::from_millis(250), Duration::from_millis(750)] + ); + assert_eq!( + provider.corroboration_order(&sources), + vec![second.clone(), third.clone(), limited.clone()], + "the cooled origin is asked last" + ); + + // The next read asks the cooled origin last; two others agree first, so + // it is not asked at all. + ok_data(resolve_verified_listing(&mut provider)); + assert_eq!(requests(&limited_counter), 3); + + // Sixty seconds on, the cooldown is over and the configured order returns. + provider.now_unix_seconds = || RIGHTS_EVIDENCE_NOW + 60; + assert_eq!(provider.corroboration_order(&sources), sources); +} + +#[test] +fn a_cooled_source_still_answers_when_nothing_else_can() { + // The first source is cooled by one persistently rate-limited request, + // then serves the listing; the only other working source is behind a + // dead one. + let mut limited_script = vec![ + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ScriptedReply::RateLimited(None), + ]; + limited_script.extend(verified_listing_script()); + let (limited, _) = spawn_scripted_rpc_server(limited_script); + let (dead, _) = spawn_scripted_rpc_server(Vec::new()); + let (working, _) = spawn_scripted_rpc_server(verified_listing_script()); + let mut provider = market_provider(vec![limited.clone(), dead.clone(), working.clone()]); + let mut network = esc_network(&provider); + network.rpc_url = limited.clone(); + assert!(provider + .evm_rpc(&network, "eth_chainId", json!([])) + .is_err()); + assert_eq!( + provider.corroboration_order(&[limited.clone(), dead.clone(), working.clone()]), + vec![dead, working, limited] + ); + + let listing = ok_data(resolve_verified_listing(&mut provider)); + assert_eq!(listing["operative"], OPERATIVE); +} + +#[test] +fn verified_listing_stops_once_two_sources_agree() { + let (first, _) = spawn_scripted_rpc_server(verified_listing_script()); + let (second, _) = spawn_scripted_rpc_server(verified_listing_script()); + let (third, third_counter) = spawn_scripted_rpc_server(Vec::new()); + let mut provider = market_provider(vec![first, second, third]); + ok_data(resolve_verified_listing(&mut provider)); + assert_eq!(requests(&third_counter), 0); +} + +fn item_script() -> Vec { + let token_uri = + "ipfs://bafyfolder/0000000000000000000000000000000000000000000000000000000000000001.json"; + vec![ + chain_id_reply(), + ScriptedReply::rpc( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", FINALIZED_HASH), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_operative_call(LEDGER, "0x3").unwrap() }, + at_finalized() + ]), + json!(format!("0x{:0>64}", OPERATIVE.trim_start_matches("0x"))), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": OPERATIVE, "data": encode_operative_token_uri_call().unwrap() }, + at_finalized() + ]), + abi_encoded_string_for_test(token_uri), + ), + ] +} + +#[test] +fn market_item_read_stops_once_two_sources_agree() { + let (first, _) = spawn_scripted_rpc_server(item_script()); + let (second, _) = spawn_scripted_rpc_server(item_script()); + let (third, third_counter) = spawn_scripted_rpc_server(Vec::new()); + let mut provider = market_provider(vec![first, second, third]); + let item = ok_data(provider.handle(Request::ResolveProtectedContentItem { + network: "esc-local".to_string(), + ledger: LEDGER.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(item["operative"], OPERATIVE); + assert_eq!(requests(&third_counter), 0); +} + +#[test] +fn market_kid_binding_read_stops_once_two_sources_agree() { + let store = "0x00000000000000000000000000000000000000cc"; + let kid = "0x0123456789abcdef0123456789abcdef"; + let script = || { + vec![ + chain_id_reply(), + ScriptedReply::rpc( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", FINALIZED_HASH), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_cstore_call().unwrap() }, + at_finalized() + ]), + json!(format!("0x{:0>64}", store.trim_start_matches("0x"))), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": store, "data": encode_ip_reference_call(kid).unwrap() }, + at_finalized() + ]), + json!(format!( + "0x{:0>64}{:0>64}", + LEDGER.trim_start_matches("0x"), + "3" + )), + ), + ] + }; + let (first, _) = spawn_scripted_rpc_server(script()); + let (second, _) = spawn_scripted_rpc_server(script()); + let (third, third_counter) = spawn_scripted_rpc_server(Vec::new()); + let mut provider = market_provider(vec![first, second, third]); + let binding = ok_data(provider.handle(Request::ResolveProtectedContentKidBinding { + network: "esc-local".to_string(), + content_access_id: kid.to_string(), + })); + assert_eq!(binding["ledger"], LEDGER); + assert_eq!(requests(&third_counter), 0); +} + +#[test] +fn rights_read_stops_once_two_sources_agree() { + let operation = protected_content_signed_operation(); + let policy = operation.statement().policy_body(); + let expected_data = encode_has_access_by_content_id_call( + "0x12345678", + policy.content_access_id().as_bytes(), + &wallet_subject_hex(&operation), + ) + .unwrap(); + let script = || { + vec![ + chain_id_reply(), + ScriptedReply::rpc( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2a", FINALIZED_HASH), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": "0x0000000000000000000000000000000000000001", "data": expected_data.clone() }, + at_finalized() + ]), + evm_bool_word(true), + ), + ] + }; + let (first, _) = spawn_scripted_rpc_server(script()); + let (second, _) = spawn_scripted_rpc_server(script()); + let (third, third_counter) = spawn_scripted_rpc_server(Vec::new()); + let mut provider = provider_with_rights_rpc_and_policies( + "http://127.0.0.1:9".to_string(), + "0x12345678", + protected_content_policy_sources("view", vec![first, second, third]), + ); + provider.sleep = record_sleep; + let evidence = ok_data(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })); + assert_eq!(evidence["finalized_block_hash"], json!(FINALIZED_HASH)); + assert_eq!(requests(&third_counter), 0); +} + +#[test] +fn market_item_offers_read_stops_once_two_sources_agree() { + let script = || { + vec![ + chain_id_reply(), + ScriptedReply::rpc( + "eth_getBlockByNumber", + json!(["finalized", false]), + finalized_block_json("0x2c", FINALIZED_HASH), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_operative_call(LEDGER, "0x3").unwrap() }, + at_finalized() + ]), + json!(format!("0x{:0>64}", OPERATIVE.trim_start_matches("0x"))), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_sellers_of_call(OPERATIVE).unwrap() }, + at_finalized() + ]), + abi_encoded_address_array_for_test(&[SELLER]), + ), + ScriptedReply::rpc( + "eth_call", + json!([ + { "to": GATEWAY, "data": encode_authority_gateway_listing_call(OPERATIVE, SELLER).unwrap() }, + at_finalized() + ]), + json!(concat!( + "0x", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000005", + "0000000000000000000000000000000000000000000000000000000000000000" + )), + ), + ] + }; + let (first, _) = spawn_scripted_rpc_server(script()); + let (second, _) = spawn_scripted_rpc_server(script()); + let (third, third_counter) = spawn_scripted_rpc_server(Vec::new()); + let mut provider = market_provider(vec![first, second, third]); + let offers = ok_data(provider.handle(Request::ResolveProtectedContentItemOffers { + network: "esc-local".to_string(), + ledger: LEDGER.to_string(), + token_id: "0x3".to_string(), + })); + assert_eq!(offers["offers"].as_array().unwrap().len(), 1); + assert_eq!(requests(&third_counter), 0); +} diff --git a/capsules/chain-provider/src/tests/support.rs b/capsules/chain-provider/src/tests/support.rs index ed0935f2c..b8bfb7cad 100644 --- a/capsules/chain-provider/src/tests/support.rs +++ b/capsules/chain-provider/src/tests/support.rs @@ -325,3 +325,132 @@ pub(super) fn assert_json_strings_do_not_contain(value: &Value, needle: &str) { _ => {} } } + +/// One scripted HTTP answer from a source that may rate-limit. +#[derive(Clone)] +pub(super) enum ScriptedReply { + /// HTTP 429, with an optional `Retry-After` header value. + RateLimited(Option<&'static str>), + /// Any other non-success HTTP status. + Status(u16), + /// A JSON-RPC answer to a request that must carry this method and params. + Rpc(&'static str, Value, RpcReply), + /// A 200 answer with this exact JSON body, request not inspected (batches). + Json(Value), +} + +impl ScriptedReply { + pub(super) fn rpc(method: &'static str, params: Value, result: Value) -> Self { + Self::Rpc(method, params, RpcReply::Result(result)) + } +} + +fn read_http_request(stream: &mut std::net::TcpStream) -> Option> { + let mut request = Vec::new(); + let mut buf = [0u8; 1024]; + let body_start = loop { + let n = stream.read(&mut buf).ok()?; + if n == 0 { + return None; + } + request.extend_from_slice(&buf[..n]); + if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break pos + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..body_start]).to_string(); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length:") + .or_else(|| line.strip_prefix("content-length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + while request.len() < body_start + content_length { + let n = stream.read(&mut buf).ok()?; + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + } + Some(request[body_start..body_start + content_length].to_vec()) +} + +fn write_http_reply(stream: &mut std::net::TcpStream, status: &str, extra: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); +} + +/// A source that answers `replies` in order, one per connection, and counts +/// every request it receives. Once the script is spent it keeps counting and +/// answers HTTP 500, so a test can prove a source was never asked. +pub(super) fn spawn_scripted_rpc_server( + replies: Vec, +) -> (String, std::sync::Arc) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = requests.clone(); + thread::spawn(move || { + let mut replies = replies.into_iter(); + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let Some(body) = read_http_request(&mut stream) else { + continue; + }; + counter.fetch_add(1, Ordering::SeqCst); + match replies.next() { + Some(ScriptedReply::RateLimited(retry_after)) => { + let extra = retry_after + .map(|value| format!("Retry-After: {value}\r\n")) + .unwrap_or_default(); + write_http_reply(&mut stream, "429 Too Many Requests", &extra, "{}"); + } + Some(ScriptedReply::Status(code)) => { + write_http_reply(&mut stream, &format!("{code} Scripted"), "", "{}"); + } + Some(ScriptedReply::Rpc(method, params, reply)) => { + let rpc: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(rpc["method"], method); + assert_eq!(rpc["params"], params); + let body = match reply { + RpcReply::Result(result) => { + json!({ "jsonrpc": "2.0", "id": 1, "result": result }) + } + RpcReply::Error(error) => { + json!({ "jsonrpc": "2.0", "id": 1, "error": error }) + } + }; + write_http_reply(&mut stream, "200 OK", "", &body.to_string()); + } + Some(ScriptedReply::Json(body)) => { + write_http_reply(&mut stream, "200 OK", "", &body.to_string()); + } + None => { + write_http_reply(&mut stream, "500 Script Spent", "", "{}"); + } + } + } + }); + (format!("http://{addr}"), requests) +} + +thread_local! { + static RECORDED_SLEEPS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// A `ChainProvider::sleep` that waits for nothing and remembers what it was +/// asked to wait. Thread-local, so parallel tests never see each other. +pub(super) fn record_sleep(wait: Duration) { + RECORDED_SLEEPS.with(|sleeps| sleeps.borrow_mut().push(wait)); +} + +pub(super) fn recorded_sleeps() -> Vec { + RECORDED_SLEEPS.with(|sleeps| sleeps.borrow().clone()) +} diff --git a/capsules/ipfs-provider/src/main.rs b/capsules/ipfs-provider/src/main.rs index fa5d95f20..e2aa67c68 100644 --- a/capsules/ipfs-provider/src/main.rs +++ b/capsules/ipfs-provider/src/main.rs @@ -499,7 +499,7 @@ impl IpfsProvider { fn fetch_bytes(&mut self, arg: &str) -> Result, String> { let mut failures = Vec::new(); - if self.state == KuboState::Ready || self.ensure_kubo().is_ok() { + if self.ensure_kubo().is_ok() { match self.kubo_cat_bytes(arg, LARGE_HTTP_TIMEOUT) { Ok(bytes) => return Ok(bytes), Err(err) => failures.push(err), @@ -636,6 +636,10 @@ impl IpfsProvider { // ── Ensure Kubo is running ────────────────────────────────────── + /// Every Kubo use goes through here, never past it on `state == Ready`: + /// the idle watcher stops Kubo from another thread and only removes the + /// coord file, so `Ready` can outlive the daemon. The liveness check + /// below is what notices that and starts Kubo again. fn ensure_kubo(&mut self) -> Result<(), String> { if self.state == KuboState::Ready { // Verify still alive @@ -1058,7 +1062,7 @@ impl IpfsProvider { fn ls(&mut self, cid: &str) -> Response { // Try Kubo first - if self.state == KuboState::Ready || self.ensure_kubo().is_ok() { + if self.ensure_kubo().is_ok() { let url = format!("{}/api/v0/ls?arg={}", self.api_url(), cid); if let Ok(resp) = ureq::post(&url).timeout(HTTP_TIMEOUT).call() { if resp.status() == 200 { @@ -1128,7 +1132,7 @@ impl IpfsProvider { let arg = format!("{}/{}", cid, file_path); - let bytes = if self.state == KuboState::Ready || self.ensure_kubo().is_ok() { + let bytes = if self.ensure_kubo().is_ok() { let url = format!("{}/api/v0/cat?arg={}", self.api_url(), arg); match ureq::post(&url).timeout(LARGE_HTTP_TIMEOUT).call() { Ok(resp) if resp.status() == 200 => { @@ -1536,7 +1540,7 @@ impl IpfsProvider { fn list_dir_files(&mut self, cid: &str) -> Result, String> { // Try Kubo API first - if self.state == KuboState::Ready || self.ensure_kubo().is_ok() { + if self.ensure_kubo().is_ok() { let url = format!("{}/api/v0/ls?arg={}", self.api_url(), cid); if let Ok(resp) = ureq::post(&url).timeout(HTTP_TIMEOUT).call() { if resp.status() == 200 { diff --git a/capsules/marketplace/browser/index.html b/capsules/marketplace/browser/index.html index 1bdd97b6b..04b6d1dfd 100644 --- a/capsules/marketplace/browser/index.html +++ b/capsules/marketplace/browser/index.html @@ -6,8 +6,8 @@ Apps · ElastOS - - + +