diff --git a/capsules/chain-provider/src/abi.rs b/capsules/chain-provider/src/abi.rs index 0937f839..0220cac9 100644 --- a/capsules/chain-provider/src/abi.rs +++ b/capsules/chain-provider/src/abi.rs @@ -387,6 +387,53 @@ pub(super) fn decode_asset_created_log(entry: &Value) -> Option<(String, String, Some((operative, token_id, block_number, log_index)) } +/// From decoded `AssetCreated` candidates — each `(operative, token_id, block, log_index, tx_hash)`, +/// newest-first — plus a `tx_hash -> mint calldata input` map, return the `(operative, token_id)` of the +/// first candidate whose mint calldata BINDS the target `bytes16` KID. `AssetCreated` is the only mint +/// event that emits on Base, and it carries NO contentId — so identity is proven by the mint `opRawData` +/// (a precise canonical `decode_mint_content_id`, else the relayer-safe `mint_input_binds_content_id` +/// substring match). FAIL-CLOSED: `None` if no candidate binds the KID. Pure (no RPC) so it is +/// unit-testable; the caller scans the `AssetCreated` logs + fetches each candidate's input live. +pub(super) fn pick_asset_created_binding_kid( + decoded: &[(String, String, u64, u64, String)], + inputs: &std::collections::HashMap, + want_content_id: &str, +) -> Option<(String, String)> { + // Gather every candidate whose mint calldata binds the KID, split by strength: PRECISE = the + // canonical `mint` decode yields exactly this contentId; SUBSTRING = the relayer-safe fallback + // (the 16 content-derived KID bytes appear in the calldata). A unique asset has a unique KID, so + // in normal operation exactly ONE candidate binds. The AssetCreated scan is NOT creator-constrained + // (topic[1] is null), so a hostile co-channel minter could embed the victim's KID in their OWN mint + // calldata; we require a UNIQUE binding and FAIL CLOSED on ambiguity (>1 distinct (operative,tokenId) + // binding the same KID) rather than bind the wrong tokenId and mis-charge the buyer. Preferring the + // canonical decode means a substring-only hostile candidate cannot displace the precise legit one. + // (Creator-constraining the scan would also let us RESOLVE the legit asset under such griefing — the + // follow-on documented in protocol.rs; this pass fails closed, which protects funds.) + let mut precise: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new(); + let mut substring: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new(); + for (operative, token_id, _block, _log, tx_hash) in decoded { + let Some(input) = inputs.get(tx_hash) else { + continue; + }; + let is_precise = decode_mint_content_id(input) + .and_then(|cid| normalize_content_id_bytes16(&cid)) + .as_deref() + == Some(want_content_id); + if is_precise { + precise.insert((operative.clone(), token_id.clone())); + } else if mint_input_binds_content_id(input, want_content_id) { + substring.insert((operative.clone(), token_id.clone())); + } + } + // Prefer the canonical decode; fall back to the substring binder only when no precise binder exists. + // In either tier a UNIQUE binding is required — otherwise fail closed. + let chosen = if precise.is_empty() { &substring } else { &precise }; + match chosen.len() { + 1 => chosen.iter().next().cloned(), + _ => None, // 0 binders, or an ambiguous KID binding -> fail closed (the buy must not proceed) + } +} + /// Decode the leading `bytes16` contentId (KID) from a `mint(string,uint16,bytes,bytes)` /// calldata. `opRawData` is argument #2 (a dynamic `bytes`); its payload always begins with /// the abi-encoded `bytes16 contentId` (left-aligned in the first word), so the first 16 bytes @@ -630,4 +677,63 @@ mod tests { assert!(!mint_input_binds_content_id("not-hex", kid)); assert!(!mint_input_binds_content_id(&relayed_hex, "deadbeef")); } + + #[test] + fn pick_asset_created_binding_kid_matches_via_mint_calldata() { + use std::collections::HashMap; + let kid = "9c2a000000000000000000000000e1a1"; + let other = "00112233445566778899aabbccddeeff"; + // candidate A (tokenId 7, tx 0xaa): canonical mint binds `kid`. candidate B (tokenId 9, tx 0xbb): binds `other`. + let mint_a = + encode_mint_calldata("0x47cbeeb4", "ipfs://a", 0, &encode_op_raw_free(kid).unwrap(), &[]) + .unwrap(); + let mint_b = + encode_mint_calldata("0x47cbeeb4", "ipfs://b", 0, &encode_op_raw_free(other).unwrap(), &[]) + .unwrap(); + let decoded = vec![ + ("0xopA".to_string(), "0x07".to_string(), 21u64, 0u64, "0xaa".to_string()), + ("0xopB".to_string(), "0x09".to_string(), 20u64, 0u64, "0xbb".to_string()), + ]; + let mut inputs = HashMap::new(); + inputs.insert("0xaa".to_string(), mint_a); + inputs.insert("0xbb".to_string(), mint_b.clone()); + // Hit: the KID resolves to candidate A's (operative, tokenId), proven by the mint calldata. + assert_eq!( + pick_asset_created_binding_kid(&decoded, &inputs, kid), + Some(("0xopA".to_string(), "0x07".to_string())), + ); + // Miss: an unknown KID -> None (fail closed; the buy must not proceed). + assert!(pick_asset_created_binding_kid(&decoded, &inputs, "deadbeefdeadbeefdeadbeefdeadbeef").is_none()); + // Fail-closed: if the matching candidate's input is missing, it is skipped (no false bind). + let mut partial = HashMap::new(); + partial.insert("0xbb".to_string(), mint_b); + assert!(pick_asset_created_binding_kid(&decoded, &partial, kid).is_none()); + } + + #[test] + fn pick_asset_created_binding_kid_fails_closed_on_ambiguous_binding() { + use std::collections::HashMap; + let kid = "9c2a000000000000000000000000e1a1"; + // Two AssetCreated candidates on the (creator-unconstrained) channel BOTH bind the same KID + // with DIFFERENT tokenIds — the legit asset (tokenId 7) and a hostile co-channel mint that + // re-uses the victim's KID (tokenId 0x66, newest). Binding either would mis-charge the buyer, + // so the resolver must FAIL CLOSED (None) rather than pick the newest candidate. + let legit = + encode_mint_calldata("0x47cbeeb4", "ipfs://legit", 0, &encode_op_raw_free(kid).unwrap(), &[]) + .unwrap(); + let hostile = + encode_mint_calldata("0x47cbeeb4", "ipfs://hostile", 0, &encode_op_raw_free(kid).unwrap(), &[]) + .unwrap(); + let decoded = vec![ + ("0xopH".to_string(), "0x66".to_string(), 22u64, 0u64, "0xhh".to_string()), + ("0xopA".to_string(), "0x07".to_string(), 21u64, 0u64, "0xaa".to_string()), + ]; + let mut inputs = HashMap::new(); + inputs.insert("0xaa".to_string(), legit); + inputs.insert("0xhh".to_string(), hostile); + assert!( + pick_asset_created_binding_kid(&decoded, &inputs, kid).is_none(), + "ambiguous KID binding must fail closed, not bind the newest (hostile) tokenId", + ); + } } diff --git a/capsules/chain-provider/src/main.rs b/capsules/chain-provider/src/main.rs index d0defa42..7798f93d 100644 --- a/capsules/chain-provider/src/main.rs +++ b/capsules/chain-provider/src/main.rs @@ -259,6 +259,12 @@ impl ChainProvider { &creator, from_block.as_deref(), ), + Request::ResolveTokenId { + network, + ledger, + content_id, + from_block, + } => self.resolve_token_id(&network, &ledger, &content_id, from_block.as_deref()), Request::AssembleCreateChannel { channel } => self.assemble_create_channel(*channel), Request::AssembleTradeApproval { network, @@ -1296,6 +1302,145 @@ impl ChainProvider { Ok(found) } + /// Resolve the real ledger `tokenId` for a `bytes16` KID by scanning the channel/ledger's + /// `AssetCreated` logs (the only mint event that emits on Base) and binding the KID via each + /// candidate's mint calldata (`opRawData`) — newest-first, split-and-retry. READ-ONLY + /// (`eth_getLogs` + `eth_getTransactionByHash`); no keys. The Phase-1 buy binds THIS, never a hash + /// of the content id. Fails closed if no `AssetCreated` on the ledger binds the KID. + fn resolve_token_id( + &mut self, + network_id: &str, + ledger: &str, + content_id: &str, + from_block: Option<&str>, + ) -> Response { + let network = match self.evm_network(network_id) { + Ok(network) => network.clone(), + Err(response) => return response, + }; + if let Err(err) = validate_evm_address(ledger) { + return Response::error("invalid_ledger", &err); + } + let want = match normalize_content_id_bytes16(content_id) { + Some(kid) => kid, + None => return Response::error("invalid_content_id", "content_id is not a bytes16 KID"), + }; + let channel_topic = match address_topic(ledger) { + Ok(topic) => topic, + Err(err) => return Response::error("invalid_ledger", &err), + }; + let deploy_block = match from_block { + Some(value) => match parse_hex_u64(value.trim()) { + Ok(value) => value, + Err(err) => return Response::error("invalid_from_block", &err), + }, + None => DEFAULT_CHANNEL_FROM_BLOCK, + }; + let latest = match self.evm_latest_block(&network) { + Ok(latest) => latest, + Err(response) => return response, + }; + let window = Self::max_log_range().max(1); + // Newest-first: an asset is minted before it can be listed, so walking down from the tip + // surfaces the match quickly and bounds RPC for the common (recent) case. + let mut to = latest; + loop { + let from = to.saturating_sub(window - 1).max(deploy_block); + match self.scan_asset_created_window_for_kid(&network, &channel_topic, &want, from, to) { + Ok(Some((operative, token_id, block))) => { + return Response::ok(json!({ + "content_id": format!("0x{want}"), + "token_id": token_id, + "operative": operative, + "ledger": ledger, + "block": block, + "chain": network.id, + })); + } + Ok(None) => {} + Err(response) => return response, + } + if from <= deploy_block { + break; + } + to = from.saturating_sub(1); + } + Response::error( + "token_id_not_found", + &format!( + "no AssetCreated on ledger {ledger} whose mint binds KID 0x{want} in [{deploy_block}, {latest}]" + ), + ) + } + + /// Scan one window of the channel's `AssetCreated` logs (any creator), fetch each candidate mint + /// tx's calldata, and return the `(operative, token_id, block)` whose mint binds the KID. Split-and- + /// retry on a range-limit error (mirrors `fetch_asset_created_logs`); the pure bind is + /// `pick_asset_created_binding_kid`. + fn scan_asset_created_window_for_kid( + &self, + network: &ChainNetwork, + channel_topic: &str, + want_kid: &str, + from: u64, + to: u64, + ) -> Result, Response> { + let filter = json!({ + "fromBlock": format!("0x{from:x}"), + "toBlock": format!("0x{to:x}"), + "topics": [ASSET_CREATED_TOPIC0, Value::Null, channel_topic], + }); + let logs = match self.evm_rpc_logs(network, filter) { + Ok(logs) => logs, + Err(response) => { + if Self::is_range_limit_error(&response) && to.saturating_sub(from) >= MIN_LOG_RANGE { + let mid = from + (to - from) / 2; + if let Some(hit) = self + .scan_asset_created_window_for_kid(network, channel_topic, want_kid, mid + 1, to)? + { + return Ok(Some(hit)); + } + return self + .scan_asset_created_window_for_kid(network, channel_topic, want_kid, from, mid); + } + return Err(response); + } + }; + let entries = logs.as_array().ok_or_else(|| { + Response::error("upstream_invalid_logs", "eth_getLogs result was not an array") + })?; + let mut decoded: Vec<(String, String, u64, u64, String)> = Vec::new(); + for log in entries { + let Some((operative, token_id, block, log_index)) = decode_asset_created_log(log) else { + continue; + }; + let Some(tx_hash) = log.get("transactionHash").and_then(Value::as_str) else { + continue; + }; + decoded.push((operative, token_id, block, log_index, tx_hash.to_string())); + } + decoded.sort_by(|a, b| (b.2, b.3).cmp(&(a.2, a.3))); // newest-first + // Fetch each candidate's mint calldata (live), then bind the KID purely. + let mut inputs = std::collections::HashMap::new(); + for (_, _, _, _, tx_hash) in &decoded { + if !inputs.contains_key(tx_hash) { + if let Some(input) = self.tx_input(network, tx_hash)? { + inputs.insert(tx_hash.clone(), input); + } + } + } + let Some((operative, token_id)) = pick_asset_created_binding_kid(&decoded, &inputs, want_kid) + else { + return Ok(None); + }; + let block = decoded + .iter() + .find(|entry| entry.1 == token_id && entry.0 == operative) + .map(|entry| entry.2) + .unwrap_or(from); + Ok(Some((operative, token_id, block))) + } + /// Discover a creator's dDRM channels via a PERSISTED, RESUMABLE factory scan. Mirrors /// PC2's `ContentIndexerService` cursor model (forward `head` + backfill `floor`), adapted /// to the runtime's synchronous provider model: each call scans new blocks since `head` diff --git a/capsules/chain-provider/src/protocol.rs b/capsules/chain-provider/src/protocol.rs index 9ad4b54f..601561e7 100644 --- a/capsules/chain-provider/src/protocol.rs +++ b/capsules/chain-provider/src/protocol.rs @@ -216,6 +216,24 @@ pub(super) enum Request { #[serde(default)] from_block: Option, }, + /// Resolve the REAL on-chain ledger `tokenId` for a `bytes16` content id (KID) by scanning the + /// channel's `AssetCreated` logs (the only mint event that emits on Base; it carries NO contentId) + /// and binding the KID through each candidate's mint `opRawData` calldata — preferring the canonical + /// `mint(string,uint16,bytes,bytes)` decode over the relayer-safe substring match. READ-ONLY + /// (`eth_getLogs` + per-candidate `eth_getTransactionByHash`); no keys. The Phase-1 buy MUST bind + /// this, never `word_from_id(content_id)`. FAILS CLOSED on no match OR an AMBIGUOUS binding (>1 + /// distinct tokenId binds the KID) — the scan is not yet creator-constrained, so ambiguity is + /// treated as a hostile co-channel mint and the buy is refused. `ledger` is the channel ERC-1155 + /// (`metadata.properties.ledger`). Follow-on: creator-constrain the scan to also RESOLVE the legit + /// asset under such griefing (and/or use `DigitalAssetRegistered` on the channel/factory contract, + /// which carries the indexed tokenId + bytes16 contentId but does NOT emit on EventHub). + ResolveTokenId { + network: String, + ledger: String, + content_id: String, + #[serde(default)] + from_block: Option, + }, /// Assemble the `createChannel(uint8,uint8,string,string,bytes)` calldata (PURE: no RPC, /// no keys) — the `{ to, data, value }` an external signer (wallet-provider) signs and /// `broadcast_transaction` sends to deploy a new channel. Mirrors `AssembleMint`. diff --git a/capsules/ddrm-envelope/src/lib.rs b/capsules/ddrm-envelope/src/lib.rs index 93e499e5..f070c64d 100644 --- a/capsules/ddrm-envelope/src/lib.rs +++ b/capsules/ddrm-envelope/src/lib.rs @@ -67,6 +67,152 @@ const KDF_LABEL: &[u8] = b"elastos-pq-hybrid-threshold-v0/cek-wrap/v1"; /// The decrypt-material suite tag this envelope implements. pub const SUITE_PQ_HYBRID: &str = "elastos-pq-hybrid-threshold-v0"; +/// MPEG Common Encryption (CENC, ISO/IEC 23001-7) Protection System Specific Header (`pssh`) box +/// construction + parsing for the Elacity PQ-hybrid-threshold scheme. Lives HERE (shared) so the +/// producer (gateway mint), the runtime decrypt read-path, and both playback clients agree on ONE +/// wire form and can never drift (Principle 12). The PSSH `Data` payload carries the scheme's +/// `.asset.protections` slot as JSON — the same envelope the media-player license handlers consume +/// — so emitting it has the same outcome as `mp4dash --pssh :pssh.json`. +/// +/// `build_pssh`/`parse_pssh` use only std (no optional deps) so any consumer gets them in the +/// default feature set without opting into `access-grant`. +pub mod pssh { + /// Registered DRM System ID for `cenc:elastos-pq-hybrid-threshold-v0`. + /// UUID `b6e254ef-0dc5-47fe-94e7-0e72ed1dc7b0` · PSSH base64 `tuJU7w3FR/6U5w5y7R3HsA==`. + pub const ELASTOS_PQ_SYSTEM_ID: [u8; 16] = [ + 0xb6, 0xe2, 0x54, 0xef, 0x0d, 0xc5, 0x47, 0xfe, 0x94, 0xe7, 0x0e, 0x72, 0xed, 0x1d, 0xc7, + 0xb0, + ]; + + /// A parsed `pssh` box (version 0 or 1). + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct PsshBox { + pub system_id: [u8; 16], + pub kids: Vec<[u8; 16]>, + pub data: Vec, + } + + /// Build a **version-1** `pssh` box (ISO/IEC 23001-7 §8.1) for `system_id`, listing `kids` as + /// the embedded default-KID set and carrying `data` as the opaque key-acquisition payload. + /// Version 1 is used so CENC players that key on the in-box KID list can resolve the asset. + /// + /// Layout: `size:u32be ‖ "pssh" ‖ version=1:u8 ‖ flags:[0;3] ‖ system_id:16 ‖ kid_count:u32be ‖ + /// kids:(16·n) ‖ data_size:u32be ‖ data`. + pub fn build_pssh(system_id: &[u8; 16], kids: &[[u8; 16]], data: &[u8]) -> Vec { + let mut body = Vec::new(); + body.push(1u8); // version + body.extend_from_slice(&[0u8, 0, 0]); // flags + body.extend_from_slice(system_id); + body.extend_from_slice(&(kids.len() as u32).to_be_bytes()); + for kid in kids { + body.extend_from_slice(kid); + } + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + + let total = 8 + body.len(); // 4 (size) + 4 ("pssh") + let mut out = Vec::with_capacity(total); + out.extend_from_slice(&(total as u32).to_be_bytes()); + out.extend_from_slice(b"pssh"); + out.extend_from_slice(&body); + out + } + + /// Parse a single `pssh` box (version 0 = no KID list, version 1 = KID list). Returns `None` on + /// malformed input. Tolerant of trailing bytes after the box (e.g. when handed a slice of a + /// larger `moov`): the declared `size` bounds the read. + pub fn parse_pssh(bytes: &[u8]) -> Option { + if bytes.len() < 12 || &bytes[4..8] != b"pssh" { + return None; + } + let size = u32::from_be_bytes(bytes[0..4].try_into().ok()?) as usize; + // size == 0 is not used for pssh here; otherwise the box is exactly `size` bytes. + let end = if size == 0 { + bytes.len() + } else { + size.min(bytes.len()) + }; + let buf = &bytes[..end]; + let version = *buf.get(8)?; + // buf[9..12] = flags (ignored) + let mut off = 12usize; + let system_id: [u8; 16] = buf.get(off..off + 16)?.try_into().ok()?; + off += 16; + let mut kids = Vec::new(); + if version >= 1 { + let kid_count = u32::from_be_bytes(buf.get(off..off + 4)?.try_into().ok()?) as usize; + off += 4; + for _ in 0..kid_count { + let kid: [u8; 16] = buf.get(off..off + 16)?.try_into().ok()?; + off += 16; + kids.push(kid); + } + } + let data_len = u32::from_be_bytes(buf.get(off..off + 4)?.try_into().ok()?) as usize; + off += 4; + let data = buf.get(off..off + data_len)?.to_vec(); + Some(PsshBox { + system_id, + kids, + data, + }) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn system_id_is_the_assigned_uuid() { + // b6e254ef-0dc5-47fe-94e7-0e72ed1dc7b0 (base64 tuJU7w3FR/6U5w5y7R3HsA==). + assert_eq!( + ELASTOS_PQ_SYSTEM_ID, + [ + 0xb6, 0xe2, 0x54, 0xef, 0x0d, 0xc5, 0x47, 0xfe, 0x94, 0xe7, 0x0e, 0x72, 0xed, + 0x1d, 0xc7, 0xb0 + ] + ); + } + + #[test] + fn build_then_parse_roundtrips() { + let kid = [0xabu8; 16]; + let data = br#"{"protectionType":"cenc:elastos-pq-hybrid-threshold-v0"}"#; + let boxed = build_pssh(&ELASTOS_PQ_SYSTEM_ID, &[kid], data); + + // header sanity: size matches, type is "pssh", version 1. + assert_eq!(&boxed[4..8], b"pssh"); + assert_eq!(boxed[8], 1); + assert_eq!( + u32::from_be_bytes(boxed[0..4].try_into().unwrap()) as usize, + boxed.len() + ); + + let parsed = parse_pssh(&boxed).expect("parse"); + assert_eq!(parsed.system_id, ELASTOS_PQ_SYSTEM_ID); + assert_eq!(parsed.kids, vec![kid]); + assert_eq!(parsed.data, data); + } + + #[test] + fn parse_rejects_short_and_wrong_type() { + assert!(parse_pssh(b"short").is_none()); + let mut b = build_pssh(&ELASTOS_PQ_SYSTEM_ID, &[], b"x"); + b[4] = b'm'; // "pssh" -> "mssh" + assert!(parse_pssh(&b).is_none()); + } + + #[test] + fn parse_tolerates_trailing_moov_bytes() { + let mut b = build_pssh(&ELASTOS_PQ_SYSTEM_ID, &[[1u8; 16]], b"payload"); + b.extend_from_slice(b"TRAILING-MOOV-BYTES"); + let parsed = parse_pssh(&b).expect("parse"); + assert_eq!(parsed.data, b"payload"); + assert_eq!(parsed.kids, vec![[1u8; 16]]); + } + } +} + /// Forensic-watermark anchor: the 16-byte SHA-256 prefix over a grant's EIP-191 delegation /// signature hex. The invisible pixel-lock watermark embeds this digest (not the raw wallet) so the /// mark is **authenticated by the buyer's own signature** — to plant it against a victim wallet an diff --git a/capsules/ddrm-media/src/mp4.rs b/capsules/ddrm-media/src/mp4.rs index 00cfe310..4572b05a 100644 --- a/capsules/ddrm-media/src/mp4.rs +++ b/capsules/ddrm-media/src/mp4.rs @@ -371,7 +371,315 @@ pub fn strip_senc(frag: &[u8]) -> Result, String> { Ok(out) } -/// Wrap arbitrary bytes as a single-sample fragmented-MP4 fragment so a NON-MEDIA +/// True for known AUDIO sample-entry 4CCs. This is the FALLBACK for the `enca`-vs-`encv` choice per +/// ISO/IEC 23001-7 §4 when a track's authoritative `hdlr` handler type is unavailable — the handler +/// type ('soun'/'vide') is the primary signal (see [`is_audio_sample_entry`]). The list covers the +/// codecs a DASH/CMAF audio track realistically carries so an uncommon-but-valid audio codec is not +/// mis-signaled as video (which would make a non-compliant init AND missize the strip fixed fields). +/// This is also the single source of truth for [`parse_codec_string`]'s audio/video split. +fn is_audio_fourcc(fourcc: &[u8; 4]) -> bool { + matches!( + fourcc, + b"mp4a" + | b"Opus" + | b"opus" + | b"fLaC" + | b"flac" + | b"ac-3" + | b"ec-3" + | b"ac-4" + | b"alac" + | b"dtsc" + | b"dtse" + | b"dtsh" + | b"dtsl" + | b"mha1" + | b"mhm1" + | b".mp3" + | b"enca" + ) +} + +/// Choose AUDIO vs VIDEO for the CENC protected sample entry (`enca` vs `encv`). The track's `hdlr` +/// handler type is AUTHORITATIVE — 'soun' = audio, 'vide' = video — because it is set per ISO-BMFF +/// independent of the codec. Only when the handler type is absent/unknown (zeroed) do we fall back to +/// the codec-4CC allowlist [`is_audio_fourcc`]. This keeps an uncommon audio codec from being +/// mis-signaled as `encv` (a non-compliant init that also makes [`strip_cenc_signal`] pick the wrong +/// fixed-field size). +fn is_audio_sample_entry(handler_type: &[u8; 4], fourcc: &[u8; 4]) -> bool { + match handler_type { + b"soun" => true, + b"vide" => false, + _ => is_audio_fourcc(fourcc), + } +} + +/// Rebuild `container` (located at `container_off`) with the single child at +/// `child_off` (of `child_old_size` bytes) replaced by `new_child`, fixing the +/// container's size. Assumes a 32-bit box header (true for moov/trak/.../stsd). +fn splice_child( + data: &[u8], + container_off: usize, + container_h: BoxHeader, + child_off: usize, + child_old_size: usize, + new_child: &[u8], +) -> Vec { + let content_start = container_off + container_h.header_size; + let content_end = container_off + container_h.size; + let mut content = Vec::with_capacity(content_end - content_start + new_child.len()); + content.extend_from_slice(&data[content_start..child_off]); + content.extend_from_slice(new_child); + content.extend_from_slice(&data[child_off + child_old_size..content_end]); + make_box(&container_h.box_type, &content) +} + +/// Walk `data[moov_off..]` down `moov > trak > mdia > minf > stbl > stsd` and return +/// `(offset, header)` for each box on the path plus the first sample entry in `stsd`. +/// The producer emits one `trak` per standalone init (PC2 `demux_tracks`), so the +/// first `trak` is the track. +struct StsdPath { + moov: (usize, BoxHeader), + trak: (usize, BoxHeader), + mdia: (usize, BoxHeader), + minf: (usize, BoxHeader), + stbl: (usize, BoxHeader), + stsd: (usize, BoxHeader), + entry: (usize, BoxHeader), + /// The track's `mdia > hdlr` handler type ('soun'/'vide'); zeroed if no `hdlr` is present. The + /// authoritative audio/video signal for the `enca`/`encv` choice (see [`is_audio_sample_entry`]). + handler_type: [u8; 4], +} + +fn locate_stsd_path(init: &[u8]) -> Result { + let (moov_off, moov_h) = top_level_boxes(init)? + .into_iter() + .find(|(_, h)| &h.box_type == b"moov") + .ok_or("init has no moov")?; + let moov_end = moov_off + moov_h.size; + let (trak_off, trak_h) = find_box(init, moov_off + moov_h.header_size, moov_end, b"trak") + .ok_or("moov has no trak")?; + let trak_end = trak_off + trak_h.size; + let (mdia_off, mdia_h) = find_box(init, trak_off + trak_h.header_size, trak_end, b"mdia") + .ok_or("trak has no mdia")?; + let mdia_end = mdia_off + mdia_h.size; + // The AUTHORITATIVE track kind for the enca/encv choice: the mdia>hdlr handler_type. Zeroed when + // no hdlr is present, in which case the caller falls back to the codec-4CC allowlist. + let mut handler_type = [0u8; 4]; + if let Some((hdlr_off, hdlr_h)) = find_box(init, mdia_off + mdia_h.header_size, mdia_end, b"hdlr") + { + let h_at = hdlr_off + hdlr_h.header_size + 8; + if let Some(slice) = init.get(h_at..h_at + 4) { + handler_type.copy_from_slice(slice); + } + } + let (minf_off, minf_h) = find_box(init, mdia_off + mdia_h.header_size, mdia_end, b"minf") + .ok_or("mdia has no minf")?; + let minf_end = minf_off + minf_h.size; + let (stbl_off, stbl_h) = find_box(init, minf_off + minf_h.header_size, minf_end, b"stbl") + .ok_or("minf has no stbl")?; + let stbl_end = stbl_off + stbl_h.size; + let (stsd_off, stsd_h) = find_box(init, stbl_off + stbl_h.header_size, stbl_end, b"stsd") + .ok_or("stbl has no stsd")?; + let stsd_end = stsd_off + stsd_h.size; + // stsd content: FullBox header (4) + entry_count (4) + entries. + let entry_off = stsd_off + stsd_h.header_size + 8; + let entry_h = read_box_header(init, entry_off).ok_or("stsd has no sample entry")?; + if entry_h.size < 8 || entry_off + entry_h.size > stsd_end { + return Err("sample entry overruns stsd".into()); + } + Ok(StsdPath { + moov: (moov_off, moov_h), + trak: (trak_off, trak_h), + mdia: (mdia_off, mdia_h), + minf: (minf_off, minf_h), + stbl: (stbl_off, stbl_h), + stsd: (stsd_off, stsd_h), + entry: (entry_off, entry_h), + handler_type, + }) +} + +/// Build a CENC `sinf` (Protection Scheme Information) for `orig_fourcc`, declaring the +/// `cenc` scheme and a `tenc` (TrackEncryptionBox v0) with `default_kid` + `iv_size`-byte +/// per-sample IVs (full-sample CTR encryption, matching [`encrypt_fragment`]). +fn build_sinf(orig_fourcc: &[u8; 4], default_kid: &[u8; 16], iv_size: u8) -> Vec { + let frma = make_box(b"frma", orig_fourcc); + // schm (FullBox v0, flags 0): scheme_type 'cenc', scheme_version 0x00010000. + let mut schm = vec![0u8, 0, 0, 0]; + schm.extend_from_slice(b"cenc"); + schm.extend_from_slice(&0x0001_0000u32.to_be_bytes()); + let schm = make_box(b"schm", &schm); + // tenc (FullBox v0): reserved, reserved(v0), default_isProtected=1, IV size, default_KID. + let mut tenc = vec![0u8, 0, 0, 0]; + tenc.push(0); // reserved + tenc.push(0); // reserved (v0) + tenc.push(1); // default_isProtected + tenc.push(iv_size); // default_Per_Sample_IV_Size + tenc.extend_from_slice(default_kid); + let schi = make_box(b"schi", &make_box(b"tenc", &tenc)); + let mut sinf = Vec::new(); + sinf.extend_from_slice(&frma); + sinf.extend_from_slice(&schm); + sinf.extend_from_slice(&schi); + make_box(b"sinf", &sinf) +} + +/// CENC-signal a (single-track) fMP4 **init** segment for MPEG-DASH/CENC (ISO/IEC +/// 23001-7) compliance: wrap the sample entry as `encv`/`enca` carrying a `sinf` +/// (`frma` + `schm` 'cenc' + `tenc` with `default_kid`), and inject `pssh_box` as a +/// child of `moov`. `iv_size` MUST match the per-sample IV size the fragments use +/// (8 here, see [`encrypt_fragment`]). The inverse is [`strip_cenc_signal`]. +/// +/// This is the producer half of the "one compliant asset" model: the published init +/// is standards-compliant (a stock CENC player / FFmpeg keys decryption off `tenc`), +/// and the server-side decrypt rail calls [`strip_cenc_signal`] to hand its own +/// player an unencrypted-looking init. +pub fn cenc_signal_init( + init: &[u8], + default_kid: &[u8; 16], + iv_size: u8, + pssh_box: &[u8], +) -> Result, String> { + let p = locate_stsd_path(init)?; + let (entry_off, entry_h) = p.entry; + let orig_fourcc = entry_h.box_type; + if &orig_fourcc == b"encv" || &orig_fourcc == b"enca" { + return Err("init is already CENC-signaled (encv/enca present)".into()); + } + let prot_fourcc: &[u8; 4] = if is_audio_sample_entry(&p.handler_type, &orig_fourcc) { + b"enca" + } else { + b"encv" + }; + + // New sample entry: same fixed fields + child boxes, type swapped, sinf appended. + let sinf = build_sinf(&orig_fourcc, default_kid, iv_size); + let entry_content_start = entry_off + entry_h.header_size; + let entry_content_end = entry_off + entry_h.size; + let mut new_entry_content = init[entry_content_start..entry_content_end].to_vec(); + new_entry_content.extend_from_slice(&sinf); + let new_entry = make_box(prot_fourcc, &new_entry_content); + + // Rebuild the path bottom-up (stsd entry -> stsd -> stbl -> minf -> mdia -> trak). + let (stsd_off, stsd_h) = p.stsd; + let new_stsd = splice_child(init, stsd_off, stsd_h, entry_off, entry_h.size, &new_entry); + let (stbl_off, stbl_h) = p.stbl; + let new_stbl = splice_child(init, stbl_off, stbl_h, stsd_off, stsd_h.size, &new_stsd); + let (minf_off, minf_h) = p.minf; + let new_minf = splice_child(init, minf_off, minf_h, stbl_off, stbl_h.size, &new_stbl); + let (mdia_off, mdia_h) = p.mdia; + let new_mdia = splice_child(init, mdia_off, mdia_h, minf_off, minf_h.size, &new_minf); + let (trak_off, trak_h) = p.trak; + let new_trak = splice_child(init, trak_off, trak_h, mdia_off, mdia_h.size, &new_mdia); + + // Rebuild moov: replace the trak and append the pssh box as a moov child. + let (moov_off, moov_h) = p.moov; + let moov_end = moov_off + moov_h.size; + let moov_content_start = moov_off + moov_h.header_size; + let mut new_moov_content = Vec::new(); + new_moov_content.extend_from_slice(&init[moov_content_start..trak_off]); + new_moov_content.extend_from_slice(&new_trak); + new_moov_content.extend_from_slice(&init[trak_off + trak_h.size..moov_end]); + new_moov_content.extend_from_slice(pssh_box); + let new_moov = make_box(b"moov", &new_moov_content); + + let mut out = Vec::with_capacity(init.len() + sinf.len() + pssh_box.len() + 16); + out.extend_from_slice(&init[..moov_off]); + out.extend_from_slice(&new_moov); + out.extend_from_slice(&init[moov_end..]); + Ok(out) +} + +/// Inverse of [`cenc_signal_init`]: restore the original `avc1`/`mp4a`/... sample entry +/// (from the `sinf`'s `frma`), drop the `sinf`, and remove every `pssh` child from +/// `moov` — yielding the "unencrypted-looking" init the server-side decrypt rail serves +/// its own player. No-op-ish if the init is not CENC-signaled (returns it unchanged). +pub fn strip_cenc_signal(init: &[u8]) -> Result, String> { + let p = locate_stsd_path(init)?; + let (entry_off, entry_h) = p.entry; + if &entry_h.box_type != b"encv" && &entry_h.box_type != b"enca" { + return Ok(init.to_vec()); // not CENC-signaled + } + let entry_content_start = entry_off + entry_h.header_size; + let entry_content_end = entry_off + entry_h.size; + // Child boxes start after the fixed sample-entry fields (both include the 8-byte + // SampleEntry preamble); the protected entry keeps the original entry's field layout. + let fixed = if &entry_h.box_type == b"encv" { + VISUAL_SAMPLE_ENTRY_FIXED_BYTES + } else { + AUDIO_SAMPLE_ENTRY_FIXED_BYTES + }; + let children_start = entry_content_start + fixed; + if children_start > entry_content_end { + return Err("protected sample entry shorter than its fixed fields".into()); + } + // Walk the entry's child boxes; pull the original 4CC out of sinf>frma and drop sinf. + let mut orig_fourcc: Option<[u8; 4]> = None; + let mut kept_children = Vec::new(); + let mut off = children_start; + while off + 8 <= entry_content_end { + let h = read_box_header(init, off).ok_or("malformed child box in sample entry")?; + if h.size < 8 || off + h.size > entry_content_end { + return Err("child box overruns sample entry".into()); + } + if &h.box_type == b"sinf" { + let sinf_end = off + h.size; + let (frma_off, frma_h) = find_box(init, off + h.header_size, sinf_end, b"frma") + .ok_or("sinf has no frma")?; + let fc = init + .get(frma_off + frma_h.header_size..frma_off + frma_h.header_size + 4) + .ok_or("frma too short")?; + orig_fourcc = Some([fc[0], fc[1], fc[2], fc[3]]); + } else { + kept_children.extend_from_slice(&init[off..off + h.size]); + } + off += h.size; + } + let orig_fourcc = orig_fourcc.ok_or("CENC-signaled entry has no sinf/frma")?; + + let mut new_entry_content = init[entry_content_start..children_start].to_vec(); + new_entry_content.extend_from_slice(&kept_children); + let new_entry = make_box(&orig_fourcc, &new_entry_content); + + let (stsd_off, stsd_h) = p.stsd; + let new_stsd = splice_child(init, stsd_off, stsd_h, entry_off, entry_h.size, &new_entry); + let (stbl_off, stbl_h) = p.stbl; + let new_stbl = splice_child(init, stbl_off, stbl_h, stsd_off, stsd_h.size, &new_stsd); + let (minf_off, minf_h) = p.minf; + let new_minf = splice_child(init, minf_off, minf_h, stbl_off, stbl_h.size, &new_stbl); + let (mdia_off, mdia_h) = p.mdia; + let new_mdia = splice_child(init, mdia_off, mdia_h, minf_off, minf_h.size, &new_minf); + let (trak_off, trak_h) = p.trak; + let new_trak = splice_child(init, trak_off, trak_h, mdia_off, mdia_h.size, &new_mdia); + + // Rebuild moov: replace the trak, dropping any pssh children. + let (moov_off, moov_h) = p.moov; + let moov_end = moov_off + moov_h.size; + let moov_content_start = moov_off + moov_h.header_size; + let mut new_moov_content = Vec::new(); + let mut moff = moov_content_start; + while moff + 8 <= moov_end { + let h = read_box_header(init, moff).ok_or("malformed moov child")?; + if h.size < 8 || moff + h.size > moov_end { + return Err("moov child overruns moov".into()); + } + if moff == trak_off { + new_moov_content.extend_from_slice(&new_trak); + } else if &h.box_type != b"pssh" { + new_moov_content.extend_from_slice(&init[moff..moff + h.size]); + } + moff += h.size; + } + let new_moov = make_box(b"moov", &new_moov_content); + + let mut out = Vec::with_capacity(init.len()); + out.extend_from_slice(&init[..moov_off]); + out.extend_from_slice(&new_moov); + out.extend_from_slice(&init[moov_end..]); + Ok(out) +} + /// object can ride the exact same CENC rail as media. The blob becomes one sample /// inside `mdat`; the synthesized `moof/traf/trun` carry the one flag set /// [`encrypt_fragment`] requires: `data_offset` (0x1) + per-sample `size` (0x200), @@ -726,7 +1034,9 @@ fn parse_codec_string(data: &[u8], stsd_off: usize, stsd_size: usize) -> String return "unknown".to_string(); }; let fourcc = entry.box_type; - let is_audio_entry = matches!(&fourcc, b"mp4a" | b"Opus" | b"fLaC" | b"enca"); + // One source of truth for the audio/video split (was a divergent inline list that misclassified + // ac-3/ec-3/etc. as video and thus computed the wrong child-box offset). + let is_audio_entry = is_audio_fourcc(&fourcc); let child_start = content_start + entry.header_size + if is_audio_entry { @@ -1177,4 +1487,186 @@ mod meta_tests { "the served variant symbol must survive back to the clean fragment" ); } + + // ── CENC init signaling (cenc_signal_init / strip_cenc_signal) ────────────────── + + /// Build a minimal single-track init: `ftyp` + `moov { mvhd, trak { mdia { minf { + /// stbl { stsd { } } } } } }`. `mvhd` is a sibling before `trak` so the + /// roundtrip also proves non-trak moov children survive. + fn minimal_init(entry_fourcc: &[u8; 4], fixed_bytes: usize, codec_child: &[u8]) -> Vec { + let mut entry_content = vec![0u8; fixed_bytes]; + entry_content.extend_from_slice(codec_child); + let entry = make_box(entry_fourcc, &entry_content); + let mut stsd_content = vec![0, 0, 0, 0, 0, 0, 0, 1]; // version/flags + entry_count=1 + stsd_content.extend_from_slice(&entry); + let stsd = make_box(b"stsd", &stsd_content); + let stbl = make_box(b"stbl", &stsd); + let minf = make_box(b"minf", &stbl); + let mdia = make_box(b"mdia", &minf); + let trak = make_box(b"trak", &mdia); + let mvhd = make_box(b"mvhd", &[0u8; 8]); + let mut moov_content = Vec::new(); + moov_content.extend_from_slice(&mvhd); + moov_content.extend_from_slice(&trak); + let moov = make_box(b"moov", &moov_content); + let ftyp = make_box(b"ftyp", b"isom\0\0\0\0isomiso2"); + let mut init = Vec::new(); + init.extend_from_slice(&ftyp); + init.extend_from_slice(&moov); + init + } + + /// Like [`minimal_init`] but the `mdia` also carries an `hdlr` box with `handler_type`, so the + /// authoritative audio/video signal (not just the codec 4CC) is exercised. + fn minimal_init_hdlr( + entry_fourcc: &[u8; 4], + fixed_bytes: usize, + codec_child: &[u8], + handler_type: &[u8; 4], + ) -> Vec { + let mut entry_content = vec![0u8; fixed_bytes]; + entry_content.extend_from_slice(codec_child); + let entry = make_box(entry_fourcc, &entry_content); + let mut stsd_content = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd_content.extend_from_slice(&entry); + let stsd = make_box(b"stsd", &stsd_content); + let stbl = make_box(b"stbl", &stsd); + let minf = make_box(b"minf", &stbl); + // hdlr: FullBox(4) + pre_defined(4) + handler_type(4) + reserved(12) + name("\0"). + let mut hdlr_content = vec![0u8; 8]; + hdlr_content.extend_from_slice(handler_type); + hdlr_content.extend_from_slice(&[0u8; 13]); + let hdlr = make_box(b"hdlr", &hdlr_content); + let mut mdia_content = Vec::new(); + mdia_content.extend_from_slice(&hdlr); + mdia_content.extend_from_slice(&minf); + let mdia = make_box(b"mdia", &mdia_content); + let trak = make_box(b"trak", &mdia); + let mvhd = make_box(b"mvhd", &[0u8; 8]); + let mut moov_content = Vec::new(); + moov_content.extend_from_slice(&mvhd); + moov_content.extend_from_slice(&trak); + let moov = make_box(b"moov", &moov_content); + let ftyp = make_box(b"ftyp", b"isom\0\0\0\0isomiso2"); + let mut init = Vec::new(); + init.extend_from_slice(&ftyp); + init.extend_from_slice(&moov); + init + } + + /// Regression (ELACITY-2283): the `enca`/`encv` choice must follow the track's `hdlr` handler + /// type, NOT a codec-4CC guess. An audio track (`hdlr='soun'`) whose sample-entry 4CC is not in + /// the audio allowlist (here a deliberately unlisted `ipcm`) must still be signaled `enca` and + /// round-trip — before the fix it became `encv`, yielding a non-compliant init that `strip` then + /// missized (78-byte visual fixed fields over a 28-byte audio entry). + #[test] + fn cenc_signal_chooses_enca_from_the_hdlr_handler_type() { + let init = minimal_init_hdlr( + b"ipcm", + AUDIO_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"pcmC", &[0u8; 6]), + b"soun", + ); + let signaled = + cenc_signal_init(&init, &[0x33u8; 16], 8, &make_box(b"pssh", b"x")).expect("signal"); + let p = locate_stsd_path(&signaled).expect("locate"); + assert_eq!( + &p.entry.1.box_type, b"enca", + "hdlr='soun' must force enca regardless of the sample-entry 4CC" + ); + assert_eq!( + strip_cenc_signal(&signaled).expect("strip"), + init, + "an authoritatively-audio entry must round-trip via the audio fixed-field size" + ); + } + + /// Regression (ELACITY-2283): with no `hdlr` to consult, the codec-4CC fallback must recognize + /// the common audio codecs the old list omitted (here `ac-3`) as `enca`, not video. + #[test] + fn cenc_signal_classifies_ac3_audio_as_enca_via_fallback() { + let init = minimal_init( + b"ac-3", + AUDIO_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"dac3", &[0u8; 3]), + ); + let signaled = + cenc_signal_init(&init, &[0x44u8; 16], 8, &make_box(b"pssh", b"x")).expect("signal"); + assert_eq!( + &locate_stsd_path(&signaled).expect("locate").entry.1.box_type, + b"enca", + "ac-3 must be recognized as audio by the expanded fallback allowlist" + ); + assert_eq!(strip_cenc_signal(&signaled).expect("strip"), init); + } + + #[test] + fn cenc_signal_init_roundtrips_video() { + let init = minimal_init( + b"avc1", + VISUAL_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"avcC", &[0x01, 0x64, 0x00, 0x28]), + ); + let kid = [0x11u8; 16]; + let pssh = make_box(b"pssh", b"PSSH-PAYLOAD-BYTES"); + + let signaled = cenc_signal_init(&init, &kid, 8, &pssh).expect("signal"); + // Structure: sample entry is now `encv`, a `tenc` carrying the KID is present, + // and the `pssh` payload was injected. + let p = locate_stsd_path(&signaled).expect("locate"); + assert_eq!(&p.entry.1.box_type, b"encv"); + assert!( + signaled.windows(pssh.len()).any(|w| w == pssh.as_slice()), + "pssh box must be injected into moov" + ); + assert!( + signaled.windows(16).any(|w| w == kid), + "tenc must carry the default_KID" + ); + // Inverse restores the original init byte-for-byte. + let stripped = strip_cenc_signal(&signaled).expect("strip"); + assert_eq!(stripped, init, "strip_cenc_signal must invert cenc_signal_init"); + } + + #[test] + fn cenc_signal_init_roundtrips_audio() { + let init = minimal_init( + b"mp4a", + AUDIO_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"esds", &[0u8; 4]), + ); + let kid = [0x22u8; 16]; + let pssh = make_box(b"pssh", b"AUDIO-PSSH"); + + let signaled = cenc_signal_init(&init, &kid, 8, &pssh).expect("signal"); + let p = locate_stsd_path(&signaled).expect("locate"); + assert_eq!(&p.entry.1.box_type, b"enca", "audio entry must become enca"); + + let stripped = strip_cenc_signal(&signaled).expect("strip"); + assert_eq!(stripped, init); + } + + #[test] + fn strip_cenc_signal_is_noop_on_unsignaled_init() { + let init = minimal_init( + b"avc1", + VISUAL_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"avcC", &[0x01, 0x42, 0x00, 0x1e]), + ); + assert_eq!(strip_cenc_signal(&init).expect("strip"), init); + } + + #[test] + fn cenc_signal_init_rejects_already_signaled() { + let init = minimal_init( + b"avc1", + VISUAL_SAMPLE_ENTRY_FIXED_BYTES, + &make_box(b"avcC", &[0x01, 0x64, 0x00, 0x28]), + ); + let signaled = cenc_signal_init(&init, &[0u8; 16], 8, &make_box(b"pssh", b"x")).unwrap(); + assert!( + cenc_signal_init(&signaled, &[0u8; 16], 8, &make_box(b"pssh", b"y")).is_err(), + "double-signaling must fail closed" + ); + } } diff --git a/capsules/dkms-authority/src/main.rs b/capsules/dkms-authority/src/main.rs index 14900209..a8659b7d 100644 --- a/capsules/dkms-authority/src/main.rs +++ b/capsules/dkms-authority/src/main.rs @@ -97,6 +97,32 @@ const OPERATOR_VK_ENV: &str = "DKMS_AUTHORITY_OPERATOR_VK"; /// credential, the analogue of PC2's session TTL (`mediaSessionManager` lifetime). const SESSION_TTL_SECONDS: u64 = 300; +/// Per-connection read timeout (ELACITY-2282 Defect A insurance). Bounds an idle/stalled read so a +/// leaked or abandoned client connection can never camp its serving thread forever; the read then +/// errors and the connection thread ends. Applied on BOTH the Unix and TCP serve loops. A live +/// pooled client re-establishes on the next release if it idled past this window. +const CONNECTION_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Cap on concurrently-served connection threads (ELACITY-2282 hardening). Each accepted connection +/// is served on its OWN thread; without a bound, a hostile peer that opens many connections and +/// trickles a byte slower than [`CONNECTION_READ_TIMEOUT`] to keep each alive would spawn an +/// UNBOUNDED number of threads and exhaust the node's threads/memory — and taking 2-of-3 quorum +/// nodes down drops every release below threshold. Once this many connections are in flight, further +/// accepts are dropped (the socket is closed) until a slot frees, so a slow-loris peer can occupy at +/// most this many slots rather than crash the daemon. Sized generously so legitimate pooled clients +/// are never turned away in practice. +const MAX_ACTIVE_CONNECTIONS: usize = 512; + +/// The daemon-lifetime REVOKED-caller set (Day 109–112), shared LIVE across every connection thread +/// via one `Arc`. A revocation performed on ANY connection is visible IMMEDIATELY to every other +/// connection's gates, so "revocation outranks a live session" holds across concurrency — not only +/// after the revoking connection closes (the ELACITY-2282 thread-per-connection follow-up: the old +/// per-connection snapshot merged additions back only on close, leaving a revoked caller with a warm +/// pooled connection served until the revoker disconnected). A node restart clears it, at which point +/// the operator's allow-list is the standing gate. A poisoned lock is recovered (`into_inner`): a +/// peer panic never corrupts the set, and the gate must still read it (fail-closed, never fail-open). +type RevokedSet = std::sync::Arc>>>; + /// A node-issued, node-signed SESSION TOKEN: it binds the client's handshake `challenge` AND the /// caller's ephemeral PUBLIC key (`caller_pub_b64`) to an `expires_at`, and the node SIGNS /// `(challenge, caller_pub, expires_at)` with its master-derived key. The node REQUIRES one on every @@ -591,12 +617,15 @@ struct DkmsAuthorityNode { /// signatures authorize `rotate_share` + `revoke_caller`. Set by the OPERATOR at daemon start /// (env), never by the connecting client. `None` = lifecycle ops fail closed. operator_vk: Option>, - /// Callers REVOKED at runtime (Day 109–112): their `hello` is refused and a `recover` under a - /// still-live session token is refused (revocation outranks a live session). Daemon-lifetime - /// state shared across connections (a revoked caller stays revoked on the next connection). - /// In-memory like PC2's `revokedDelegations` map (`utils/secureViewSession.ts:374`) — a node - /// restart clears it, at which point the operator's allow-list is the standing gate. - revoked_callers: Vec>, + /// Callers REVOKED at runtime (Day 109–112) — see [`RevokedSet`]. Their `hello` is refused and a + /// `recover` under a still-live session token is refused (revocation outranks a live session). + /// Shared LIVE across all connection threads (the same `Arc` every thread holds), so a revocation + /// is enforced the instant the operator's `revoke_caller` lands — on every already-open connection, + /// not just future ones. In-memory like PC2's `revokedDelegations` map + /// (`utils/secureViewSession.ts:374`) — a node restart clears it, at which point the operator's + /// allow-list is the standing gate. `#[derive(Default)]` gives a freshly-constructed node its OWN + /// empty set; the serve loop wires every connection to ONE shared set instead. + revoked_callers: RevokedSet, } impl DkmsAuthorityNode { @@ -813,7 +842,7 @@ impl DkmsAuthorityNode { } // REVOCATION GATE (Day 109–112): a caller the operator revoked at runtime is refused at the // handshake even though it is still on the allow-list — no new session is ever minted for it. - if self.revoked_callers.iter().any(|vk| vk.as_slice() == caller_pub.as_slice()) { + if self.is_caller_revoked(caller_pub.as_slice()) { return Response::error( "caller_revoked", "caller identity has been revoked by the operator — this node no longer serves it", @@ -963,7 +992,7 @@ impl DkmsAuthorityNode { // delegation nonce is read back per request BEFORE the session view is resurrected, // `secureViewSession.ts:104`–`:112`.) if let Ok(token_caller) = b64().decode(&args.session_token.caller_pub_b64) { - if self.revoked_callers.iter().any(|vk| vk.as_slice() == token_caller.as_slice()) { + if self.is_caller_revoked(token_caller.as_slice()) { return Response::error( "caller_revoked", "caller identity has been revoked by the operator — a live session does not outrank a revocation", @@ -1265,12 +1294,25 @@ impl DkmsAuthorityNode { "revocation refused: the signature does not verify under the pinned operator identity", ); } - if !self.revoked_callers.iter().any(|vk| vk.as_slice() == caller_pub.as_slice()) { - self.revoked_callers.push(caller_pub); - } + // Insert into the SHARED live set: the revocation binds every other open connection's gates + // at once (HashSet insertion is idempotent, so a repeat revoke is a no-op). + self.revoked_callers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(caller_pub); Response::ok(json!({ "revoked": true })) } + /// True iff `vk` is in the shared daemon-lifetime revocation set, read LIVE (a revocation on any + /// connection is visible here immediately). Recovers the guard if a peer thread poisoned the lock + /// — the set's data survives a panic and the gate must never fail open. + fn is_caller_revoked(&self, vk: &[u8]) -> bool { + self.revoked_callers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(vk) + } + /// QUORUM RECONFIGURATION — CONTRIBUTE (Day 121–125). This OLD quorum member re-shares its share /// into the new k-of-m set. Authorization is the OPERATOR SEAL, checked FIRST: the auth envelope /// must open under this node's recipient secret, verify under the pinned operator identity, and @@ -2078,30 +2120,130 @@ fn serve_socket(path: &str) { if operator_vk.is_some() { eprintln!("dkms-authority: operator identity pinned (lifecycle ops enabled)"); } - let mut revoked_callers: Vec> = Vec::new(); eprintln!("dkms-authority: listening on {path}"); - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let reader = match stream.try_clone() { - Ok(s) => io::BufReader::new(s), - Err(err) => { - eprintln!("dkms-authority: connection clone failed: {err}"); - continue; - } - }; - // The Unix transport is host-local (filesystem-permissioned), so the encrypted - // channel is OPTIONAL here — a client that offers a channel key still gets one. - serve_connection_io(reader, stream, &allowed_callers, &operator_vk, &mut revoked_callers, false); - } + serve_unix_listener(listener, allowed_callers, operator_vk); +} + +/// A transport an accepted connection can be served over (Unix or TCP). Abstracts the two +/// otherwise-identical accept loops behind one generic [`serve_accept_loop`], so the per-connection +/// lifecycle — read-timeout arming, reader split, concurrency cap, thread spawn — lives in EXACTLY +/// one place and can never diverge between the host-local and hostile-network transports. +#[cfg(unix)] +trait AcceptedConn: io::Write + Send + Sized + 'static { + /// An independent, buffered read handle over the same connection. + type Reader: io::Read + Send + 'static; + /// Bound every read on this connection (idle/stall insurance, ELACITY-2282 Defect A). + fn arm_read_timeout(&self); + /// A buffered reader over a clone of this connection (the write half stays on `self`). + fn split_reader(&self) -> io::Result; +} + +#[cfg(unix)] +impl AcceptedConn for std::os::unix::net::UnixStream { + type Reader = io::BufReader; + fn arm_read_timeout(&self) { + let _ = self.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); + } + fn split_reader(&self) -> io::Result { + Ok(io::BufReader::new(self.try_clone()?)) + } +} + +#[cfg(unix)] +impl AcceptedConn for std::net::TcpStream { + type Reader = io::BufReader; + fn arm_read_timeout(&self) { + let _ = self.set_read_timeout(Some(CONNECTION_READ_TIMEOUT)); + } + fn split_reader(&self) -> io::Result { + Ok(io::BufReader::new(self.try_clone()?)) + } +} + +/// RAII guard for one slot in the [`MAX_ACTIVE_CONNECTIONS`] budget: decrements the active-connection +/// counter when a served connection ends — normal return OR panic — so the cap can never leak slots. +#[cfg(unix)] +struct ActiveSlot(std::sync::Arc); +#[cfg(unix)] +impl Drop for ActiveSlot { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// The shared accept loop for BOTH transports (ELACITY-2282). Each accepted connection is served on +/// its OWN thread so a single idle/slow/leaked client can never head-of-line-block the others — the +/// daemon always returns to `accept`. The daemon-lifetime revoked-caller set is shared LIVE across +/// every connection thread (one `Arc`, see [`RevokedSet`]), so a revocation binds every open +/// connection immediately. Concurrency is bounded by [`MAX_ACTIVE_CONNECTIONS`]: past the cap, a new +/// connection is dropped rather than spawning an unbounded thread, so a slow-loris peer cannot +/// exhaust the node. `require_channel` distinguishes the hostile-network TCP transport (a plaintext +/// recover is refused) from the host-local Unix transport. +#[cfg(unix)] +fn serve_accept_loop( + incoming: I, + allowed_callers: Option>>, + operator_vk: Option>, + require_channel: bool, +) where + S: AcceptedConn, + I: IntoIterator>, +{ + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + let allowed_callers = Arc::new(allowed_callers); + let operator_vk = Arc::new(operator_vk); + let revoked_callers: RevokedSet = RevokedSet::default(); + let active = Arc::new(AtomicUsize::new(0)); + for stream in incoming { + let stream = match stream { + Ok(stream) => stream, Err(err) => { eprintln!("dkms-authority: accept error: {err}"); continue; } + }; + stream.arm_read_timeout(); + let reader = match stream.split_reader() { + Ok(reader) => reader, + Err(err) => { + eprintln!("dkms-authority: connection clone failed: {err}"); + continue; + } + }; + // CONCURRENCY CAP (ELACITY-2282 hardening): claim a slot atomically. At the cap, roll the + // claim back and DROP this connection (the reader + stream close on scope exit) rather than + // spawn an unbounded thread — a slow-loris peer can hold at most the cap, not exhaust us. + if active.fetch_add(1, Ordering::AcqRel) >= MAX_ACTIVE_CONNECTIONS { + active.fetch_sub(1, Ordering::AcqRel); + eprintln!( + "dkms-authority: connection cap reached ({MAX_ACTIVE_CONNECTIONS}) — dropping connection" + ); + continue; } + let slot = ActiveSlot(Arc::clone(&active)); + let allowed = Arc::clone(&allowed_callers); + let operator = Arc::clone(&operator_vk); + let revoked = revoked_callers.clone(); + std::thread::spawn(move || { + let _slot = slot; // released (counter decremented) when this connection thread ends + serve_connection_io(reader, stream, &allowed, &operator, &revoked, require_channel); + }); } } +/// The Unix accept loop, factored out of [`serve_socket`] so it can be driven over a test-owned +/// listener. The Unix transport is host-local (filesystem-permissioned), so the encrypted channel is +/// OPTIONAL (`require_channel = false`): a client that offers a channel key still gets one. +#[cfg(unix)] +fn serve_unix_listener( + listener: std::os::unix::net::UnixListener, + allowed_callers: Option>>, + operator_vk: Option>, +) { + serve_accept_loop(listener.incoming(), allowed_callers, operator_vk, false); +} + /// TCP serve mode (Day 105–108): the node taken OFF localhost — a REAL network listener with the /// same framed protocol. Because the network is HOSTILE (no filesystem permission boundary), every /// `recover` on this transport REQUIRES the app-layer encrypted, mutually-authenticated channel: @@ -2127,29 +2269,20 @@ fn serve_tcp(addr: &str) { if operator_vk.is_some() { eprintln!("dkms-authority: operator identity pinned (lifecycle ops enabled)"); } - let mut revoked_callers: Vec> = Vec::new(); eprintln!("dkms-authority: listening on tcp:{addr}"); - for stream in listener.incoming() { - match stream { - Ok(stream) => { - // NETWORK-FAULT SEMANTICS: a stalled remote peer must not wedge the sequential - // daemon — bound every read so the listener always gets back to `accept`. - let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30))); - let reader = match stream.try_clone() { - Ok(s) => io::BufReader::new(s), - Err(err) => { - eprintln!("dkms-authority: connection clone failed: {err}"); - continue; - } - }; - serve_connection_io(reader, stream, &allowed_callers, &operator_vk, &mut revoked_callers, true); - } - Err(err) => { - eprintln!("dkms-authority: accept error: {err}"); - continue; - } - } - } + serve_tcp_listener(listener, allowed_callers, operator_vk); +} + +/// The TCP accept loop, factored out of [`serve_tcp`]. Like the Unix loop it serves each connection +/// on its OWN thread (ELACITY-2282); every recover on this hostile transport still REQUIRES the +/// encrypted, mutually-authenticated channel (`require_channel = true`). +#[cfg(unix)] +fn serve_tcp_listener( + listener: std::net::TcpListener, + allowed_callers: Option>>, + operator_vk: Option>, +) { + serve_accept_loop(listener.incoming(), allowed_callers, operator_vk, true); } /// Parse the OPERATOR's KNOWN-caller allow-list from `ALLOWED_CALLERS_ENV`: a comma-separated list @@ -2212,15 +2345,16 @@ fn serve_connection_io( mut writer: W, allowed_callers: &Option>>, operator_vk: &Option>, - revoked_callers: &mut Vec>, + revoked_callers: &RevokedSet, require_channel: bool, ) { use ddrm_envelope::frame::{read_frame, write_frame}; let mut node = DkmsAuthorityNode { allowed_callers: allowed_callers.clone(), operator_vk: operator_vk.clone(), - // Seed this connection with the daemon-lifetime revoked set (a caller revoked on an - // earlier connection stays revoked here); write any additions back when we're done. + // Share the ONE daemon-lifetime revoked set (an `Arc` clone, not a snapshot): revocations + // this connection performs are visible to every other open connection immediately, and + // revocations they perform are visible here immediately. revoked_callers: revoked_callers.clone(), ..DkmsAuthorityNode::default() }; @@ -2348,9 +2482,8 @@ fn serve_connection_io( break; } } - // Persist any revocations this connection performed into the daemon-lifetime set, so they bind - // every FUTURE connection too (a revoked caller cannot just reconnect). - *revoked_callers = node.revoked_callers; + // No write-back needed: `node.revoked_callers` IS the shared daemon-lifetime set (an `Arc` + // clone), so every revocation was already published to all connections the instant it landed. } /// Write one response frame: SEALED to the client's channel key (+ signed by the node, AAD-bound to @@ -2415,6 +2548,134 @@ mod tests { .into_owned() } + /// A SHORT temp Unix-socket path — macOS `sun_path` is only 104 bytes and `temp_dir()` is long, + /// so bind under `/tmp` to stay well under the limit. + #[cfg(unix)] + fn unique_socket_path(tag: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("/tmp/dkms-{tag}-{}-{nanos}.sock", std::process::id()) + } + + /// ELACITY-2282 (regression): a single idle client must NOT head-of-line-block the accept loop. + /// Drive the real Unix accept loop (`serve_unix_listener`) with connection A held idle — it never + /// sends a frame, so its serving read blocks — and assert a SECOND client B is still served + /// promptly. Before the thread-per-connection fix, A wedged the sequential loop and B starved in + /// the kernel backlog until the client's hard cap → fail-closed quorum. + #[test] + #[cfg(unix)] + fn idle_connection_does_not_block_other_clients() { + use ddrm_envelope::frame::{read_frame, write_frame}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::time::Duration; + + let path = unique_socket_path("hol"); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + // Anonymous (no allow-list), no operator — `status` is a public message that answers here. + std::thread::spawn(move || serve_unix_listener(listener, None, None)); + + // Client A connects and stays IDLE (sends nothing): its serving thread blocks in read_frame. + let idle = UnixStream::connect(&path).unwrap(); + std::thread::sleep(Duration::from_millis(150)); // let A be accepted + block + + // Client B must still be served promptly while A is idle. + let mut b = UnixStream::connect(&path).unwrap(); + b.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + write_frame(&mut b, &serde_json::to_vec(&json!({ "op": "status" })).unwrap()).unwrap(); + let resp = read_frame(&mut b) + .expect("client B must be served while A is idle (no head-of-line block)") + .expect("a framed response"); + let resp: Value = serde_json::from_slice(&resp).unwrap(); + assert_eq!(resp["status"].as_str().unwrap(), "ok"); + assert_eq!(resp["data"]["provider"].as_str().unwrap(), "dkms-authority"); + + drop(idle); + let _ = std::fs::remove_file(&path); + } + + /// ELACITY-2282 (regression, shared-live revocation): a revoke performed on ONE connection must + /// bind every OTHER already-open connection IMMEDIATELY — not only after the revoking connection + /// closes. Two `DkmsAuthorityNode`s sharing the daemon-lifetime revoked set model two concurrent + /// connections exactly as `serve_accept_loop` wires them (one shared `Arc`). Before the fix each + /// connection held a private snapshot merged back only on close, so a revoked caller holding a + /// warm pooled connection kept being served until the operator's connection disconnected. + #[test] + #[cfg(unix)] + fn revocation_binds_other_open_connections_immediately() { + let shared: RevokedSet = RevokedSet::default(); + let (operator, operator_vk) = ddrm_envelope::seal::mldsa_seal_keypair([0x6Fu8; 32]); + let (_caller_signer, caller_vk) = caller_keypair(); + let caller_vk_b64 = b64().encode(&caller_vk); + + // Connection B: the caller's own connection — initialized and serving its `hello`. + let store = unique_store("revoke-shared-b"); + let mut conn_b = DkmsAuthorityNode { revoked_callers: shared.clone(), ..Default::default() }; + conn_b.init(json!({ "authority_key_store": store.clone() })); + let challenge = b64().encode([0xA1u8; 32]); + assert!( + matches!(conn_b.hello(&challenge, &caller_vk_b64, Some(NOW), None), Response::Ok { .. }), + "before revocation the caller is served", + ); + + // Connection A: the operator's admin connection — revokes the caller while B stays OPEN. + let mut conn_a = DkmsAuthorityNode { + operator_vk: Some(operator_vk), + revoked_callers: shared.clone(), + ..Default::default() + }; + let sig = b64().encode(ddrm_envelope::sign_revocation(&operator, &caller_vk)); + assert!( + matches!(conn_a.revoke_caller(&caller_vk_b64, &sig), Response::Ok { .. }), + "the pinned operator's genuine revocation is accepted", + ); + + // Connection B — never closed — must refuse the caller's next `hello` at once. + assert_eq!( + error_code(&conn_b.hello(&challenge, &caller_vk_b64, Some(NOW), None)), + "caller_revoked", + "a revoke on connection A must bind the still-open connection B immediately", + ); + + let _ = std::fs::remove_file(&store); + } + + /// ELACITY-2282 (regression, connection-cap leak-safety): the [`ActiveSlot`] guard that bounds + /// concurrent connection threads via [`MAX_ACTIVE_CONNECTIONS`] must decrement the counter when a + /// connection ends — on a normal return AND when the serving thread panics — or the cap would + /// leak slots and eventually refuse every client (a self-inflicted outage). Without the RAII + /// guard a panicking connection would permanently consume a slot. + #[test] + #[cfg(unix)] + fn active_slot_releases_on_drop_and_on_panic() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + let active = Arc::new(AtomicUsize::new(0)); + + active.fetch_add(1, Ordering::AcqRel); + { + let _slot = ActiveSlot(Arc::clone(&active)); + assert_eq!(active.load(Ordering::Acquire), 1); + } + assert_eq!(active.load(Ordering::Acquire), 0, "slot released on normal drop"); + + active.fetch_add(1, Ordering::AcqRel); + let claimed = Arc::clone(&active); + let joined = std::thread::spawn(move || { + let _slot = ActiveSlot(claimed); + panic!("connection thread panicked mid-serve"); + }) + .join(); + assert!(joined.is_err(), "the worker thread panicked as set up"); + assert_eq!( + active.load(Ordering::Acquire), + 0, + "slot must release even when the connection thread panics", + ); + } + const CONTENT: &str = "bafybeigdyrcontent"; const PRINCIPAL: &str = "did:key:zViewer"; const SESSION: &str = "session:abc"; @@ -3057,7 +3318,7 @@ mod tests { let (mut client, server) = UnixStream::pair().unwrap(); let handle = std::thread::spawn(move || { let reader = io::BufReader::new(server.try_clone().unwrap()); - serve_connection_io(reader, server, &allowed, &None, &mut Vec::new(), false) + serve_connection_io(reader, server, &allowed, &None, &RevokedSet::default(), false) }); let call = |client: &mut UnixStream, req: Value| -> Value { @@ -3125,7 +3386,7 @@ mod tests { let (mut bad, server2) = UnixStream::pair().unwrap(); let handle2 = std::thread::spawn(move || { let reader = io::BufReader::new(server2.try_clone().unwrap()); - serve_connection_io(reader, server2, &None, &None, &mut Vec::new(), false) + serve_connection_io(reader, server2, &None, &None, &RevokedSet::default(), false) }); // A header promising 64 bytes followed by only 3, then half-close. use std::io::Write as _; @@ -3213,7 +3474,7 @@ mod tests { let (mut client, server) = UnixStream::pair().unwrap(); let handle = std::thread::spawn(move || { let reader = io::BufReader::new(server.try_clone().unwrap()); - serve_connection_io(reader, server, &None, &None, &mut Vec::new(), true) + serve_connection_io(reader, server, &None, &None, &RevokedSet::default(), true) }); let call_plain = |client: &mut UnixStream, req: Value| -> Value { write_frame(client, &serde_json::to_vec(&req).unwrap()).unwrap(); diff --git a/capsules/encrypt-provider/src/main.rs b/capsules/encrypt-provider/src/main.rs index f5891e46..d2b05282 100644 --- a/capsules/encrypt-provider/src/main.rs +++ b/capsules/encrypt-provider/src/main.rs @@ -157,9 +157,62 @@ enum Request { #[serde(default)] av_variants: bool, }, + /// MEDIA producer op (feature `escrow`): MPEG-DASH/CENC (ISO/IEC 23001-7) PSSH injection + + /// CENC init signaling. PURE PUBLIC box surgery — no CEK or secret is involved. The runtime + /// builds the PSSH JSON envelope (the asset's `.asset.protections` slot, single-sourced by + /// `dkms_protection`) and passes it here; this op wraps it in a `pssh` box (Elacity PQ + /// systemId) and rewrites EACH per-track DASH init to be CENC-compliant (encv/enca + sinf + + /// tenc(default_KID) + the pssh box, placed as the last `moov` child), so the published asset + /// matches the Bento4/PC2 CENC wire format. The matching segment `senc` already comes from + /// `seal_segments_threshold`; the decrypt rail strips this signaling back for its own player. + #[cfg(feature = "escrow")] + CencSignalInits { + /// Per-track DASH init segments (base64), each tagged with its publish path. + inits: Vec, + /// The asset's `default_KID` (32-hex) == the on-chain `bytes16` contentId. + kid_hex: String, + /// Per-sample IV size the fragments use (8, matching `encrypt_fragment`). + #[serde(default = "default_iv_size")] + iv_size: u8, + /// The PSSH `Data` payload: base64 of the UTF-8 JSON envelope the runtime built. + pssh_data_b64: String, + }, Shutdown, } +/// One per-track DASH init segment to CENC-signal (feature `escrow`). +#[cfg(feature = "escrow")] +#[derive(Debug, Deserialize)] +struct InitSegment { + path: String, + b64: String, +} + +/// Default per-sample IV size (bytes) — matches `ddrm_media::mp4::encrypt_fragment`. +#[cfg(feature = "escrow")] +fn default_iv_size() -> u8 { + 8 +} + +/// Decode a 32-hex `default_KID` (optionally `0x`-prefixed) to 16 bytes. +#[cfg(feature = "escrow")] +fn decode_kid16(kid_hex: &str) -> Result<[u8; 16], String> { + let s = kid_hex.strip_prefix("0x").unwrap_or(kid_hex); + // Validate BOTH length and charset in BYTES before any byte-offset slicing below: `str::len()` + // counts bytes, so a 32-byte string containing multibyte UTF-8 would pass a bare length gate yet + // split a char boundary in `&s[i * 2..i * 2 + 2]` and PANIC (crashing the capsule on a malformed + // request). Requiring ASCII hex digits keeps every char exactly one byte, so the slices are safe. + if s.len() != 32 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("kid must be 32 ASCII hex chars".to_string()); + } + let mut out = [0u8; 16]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16) + .map_err(|_| "kid is not valid hex".to_string())?; + } + Ok(out) +} + /// One node of the threshold quorum a producer escrows to (feature `escrow`): the public /// identity the runtime reads from the dKMS authority descriptor — recipient key (where the /// share is sealed) + verifying key (pins the node-set the open must match). No secrets. @@ -249,6 +302,13 @@ impl EncryptProvider { &nodes, av_variants, ), + #[cfg(feature = "escrow")] + Request::CencSignalInits { + inits, + kid_hex, + iv_size, + pssh_data_b64, + } => self.cenc_signal_inits(&inits, &kid_hex, iv_size, &pssh_data_b64), Request::Shutdown => Response::empty_ok(), } } @@ -775,6 +835,64 @@ impl EncryptProvider { Response::ok(body) } + /// CENC-signal each per-track DASH init + inject the Elacity PQ `pssh` (feature `escrow`). + /// PURE PUBLIC box surgery: it touches no CEK/secret. `pssh_data_b64` is the runtime-built + /// PSSH JSON envelope (the `.asset.protections` slot); it is wrapped in a `pssh` box under + /// `ELASTOS_PQ_SYSTEM_ID` and each init is rewritten to encv/enca + sinf + tenc(default_KID). + #[cfg(feature = "escrow")] + fn cenc_signal_inits( + &self, + inits: &[InitSegment], + kid_hex: &str, + iv_size: u8, + pssh_data_b64: &str, + ) -> Response { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD; + let kid = match decode_kid16(kid_hex) { + Ok(k) => k, + Err(e) => return Response::error("invalid_request", e), + }; + let pssh_data = match b64.decode(pssh_data_b64) { + Ok(d) => d, + Err(_) => return Response::error("invalid_request", "pssh_data_b64 is not valid base64"), + }; + let pssh_box = ddrm_envelope::pssh::build_pssh( + &ddrm_envelope::pssh::ELASTOS_PQ_SYSTEM_ID, + &[kid], + &pssh_data, + ); + let mut out = Vec::with_capacity(inits.len()); + for it in inits { + let init = match b64.decode(&it.b64) { + Ok(b) => b, + Err(_) => { + return Response::error( + "invalid_request", + format!("init '{}' is not valid base64", it.path), + ) + } + }; + let signaled = match ddrm_media::mp4::cenc_signal_init(&init, &kid, iv_size, &pssh_box) { + Ok(s) => s, + Err(e) => { + return Response::error( + "invalid_request", + format!("CENC-signal init '{}': {e}", it.path), + ) + } + }; + out.push(json!({ "path": it.path, "b64": b64.encode(&signaled) })); + } + Response::ok(json!({ + "inits": out, + "scheme": SUPPORTED_SCHEMES[0], + // The full pssh box (base64) — the runtime embeds this verbatim in the MPD + // ContentProtection element (same bytes injected into each init). + "pssh_b64": b64.encode(&pssh_box), + })) + } + /// The in-boundary MEDIA threshold pipeline (feature `escrow`): mint ONE CEK+KID, CENC each /// real fMP4 fragment under a single continuous IV counter (via the canonical /// `ddrm_media::mp4::encrypt_fragment`), then SHAMIR-split the CEK and seal each indexed @@ -1492,6 +1610,33 @@ fn main() { mod tests { use super::*; use base64::Engine; + + /// Regression: a `kid_hex` that is 32 BYTES but contains multibyte UTF-8 must be REJECTED, not + /// panic. Before the fix the byte-length gate `s.len() == 32` passed while the byte-offset slice + /// `&s[i*2..i*2+2]` split a char boundary, crashing the capsule process on a malformed request + /// instead of returning an error like every other validation path. + #[test] + #[cfg(feature = "escrow")] + fn decode_kid16_rejects_multibyte_input_without_panicking() { + // "a" + 15×'é' (2 bytes each) + "b" = 32 bytes but only 17 chars. + let mut kid = String::from("a"); + for _ in 0..15 { + kid.push('é'); + } + kid.push('b'); + assert_eq!(kid.len(), 32, "the input must hit the 32-byte length gate"); + assert!( + decode_kid16(&kid).is_err(), + "a 32-byte multibyte kid must be rejected, not panic" + ); + + // Genuine 32-hex kids still decode (with or without the 0x prefix). + assert!(decode_kid16("00112233445566778899aabbccddeeff").is_ok()); + assert!(decode_kid16("0x00112233445566778899aabbccddeeff").is_ok()); + // Wrong length and non-hex ASCII are rejected cleanly (no panic). + assert!(decode_kid16("00112233").is_err()); + assert!(decode_kid16("zz112233445566778899aabbccddeeff").is_err()); + } use zeroize::Zeroize; fn seal_request_json() -> Value { diff --git a/capsules/home/browser/shell.js b/capsules/home/browser/shell.js index 4bf543c2..f10d5d28 100644 --- a/capsules/home/browser/shell.js +++ b/capsules/home/browser/shell.js @@ -102,6 +102,10 @@ const SHELL_MESSAGE_OPEN_TARGET_SOURCES = Object.freeze({ ]), marketplace: "runtime-target", services: new Set(["browser", "chat-room"]), + // The dDRM marketplace reveals a downloaded asset in the File Explorer (library) and opens it in its + // protected viewer — exactly the two viewers the library itself may open. Narrowly scoped (no ambient + // launch authority, P7/P16): it can only ask Home to open these three targets, nothing else. + "marketplace-content": new Set(["library", "ddrm-viewer", "elacity-player"]), system: "visible-target", "wallet": new Set(["wallet-metamask", "wallet-unisat"]), }); diff --git a/capsules/key-provider/src/main.rs b/capsules/key-provider/src/main.rs index 72b39766..3c29c8f6 100644 --- a/capsules/key-provider/src/main.rs +++ b/capsules/key-provider/src/main.rs @@ -692,6 +692,35 @@ fn collect_quorum_shares( (results, served) } +/// The outcome of a delegated recover on one node connection. Distinguishes a TRANSPORT fault (the +/// socket/framing failed — typically a warm pooled connection the node's idle timeout closed while +/// our session token was still live by TTL, ELACITY-2282) from a node REJECTION (the recover reached +/// the node and it refused: revoked caller, bad proof, expired token). Only a transport fault is +/// safe to retry on a fresh session; a rejection MUST fail closed and must never be retried, so a +/// real denial is never masked and the nodes are never hammered. +#[cfg(all(feature = "key-authority-ref", unix))] +#[derive(Debug)] +enum NodeRecoverError { + Transport(String), + Rejected(String), +} + +#[cfg(all(feature = "key-authority-ref", unix))] +impl NodeRecoverError { + /// The human-readable, fail-closed error string this outcome surfaces to the release path. + fn message(self) -> String { + match self { + NodeRecoverError::Transport(m) | NodeRecoverError::Rejected(m) => m, + } + } + + /// True only for a transport fault: retry ONCE on a freshly-established session is safe. A node + /// rejection returns false — it is a genuine denial that must fail closed without a retry. + fn is_retryable_on_fresh_session(&self) -> bool { + matches!(self, NodeRecoverError::Transport(_)) + } +} + /// OPEN the long-lived node connection + establish the handshake session ONCE: spawn the granted /// `endpoint`, `init` it (the node resolves its OWN master store), then run the identity handshake — /// send a fresh challenge, require a signature over it under the descriptor-PINNED verifying key @@ -801,6 +830,16 @@ fn establish_dkms_session( client.endpoint ) })?; + // FAULT SEMANTICS (ELACITY-2282): bound a stalled node read on the LOCAL Unix transport too + // (the tcp/carrier branches already do) — a wedged or slow node fails the recover read within + // a bounded window, fail-closed with no partial material, never a hung release. This only + // fires during an active recover (the client reads only after sending a request), so it does + // not disturb idle pooled connections. + stream + .set_read_timeout(Some(std::time::Duration::from_millis( + DKMS_TCP_READ_TIMEOUT_MS, + ))) + .map_err(|e| format!("dkms unix read timeout could not be set: {e}"))?; let reader = std::io::BufReader::new( stream .try_clone() @@ -1832,7 +1871,8 @@ impl KeyProvider { /// validate the node's reply. Shared by both the cold (fresh-establish) and warm (pooled) quorum /// paths so the per-node security gates — session token echo + monotonic `recover_seq` + caller /// possession proof — are byte-identical regardless of whether the session was just opened or - /// reused. Returns the node's re-sealed material `data`, or a fail-closed error string. + /// reused. Returns the node's re-sealed material `data`, or a [`NodeRecoverError`] that tells the + /// caller whether the failure is safe to retry on a fresh session. #[cfg(all(feature = "key-authority-ref", unix))] fn run_recover_on_conn( conn: &mut DkmsNodeConn, @@ -1842,7 +1882,7 @@ impl KeyProvider { decrypt_session_pub_b64: &str, content_id: &str, now: Option, - ) -> Result { + ) -> Result { let t_rec = std::time::Instant::now(); let recover_seq = conn.next_recover_seq(); let recover = @@ -1863,19 +1903,28 @@ impl KeyProvider { client_endpoint, t_rec.elapsed().as_millis() ); - let recover = recover.map_err(|err| format!("dkms node transport failed: {err}"))?; + // A TRANSPORT fault (socket/framing) on a warm pooled connection is the node's idle timeout + // closing a socket our token still considers live — safe to retry ONCE on a fresh session. + let recover = recover.map_err(|err| { + NodeRecoverError::Transport(format!("dkms node transport failed: {err}")) + })?; + // The recover REACHED the node and it refused (revoked caller / bad proof / expired token): + // a genuine denial that must fail closed and must NEVER be retried (retrying would hammer the + // node and could mask a real revocation). if recover.get("status").and_then(|v| v.as_str()) != Some("ok") { - return Err(format!( + return Err(NodeRecoverError::Rejected(format!( "dkms node recover failed: {}", recover .get("message") .and_then(|v| v.as_str()) .unwrap_or("recover rejected") - )); + ))); } match recover.get("data") { Some(data) => Ok(data.clone()), - None => Err("dkms node recover returned no material".to_string()), + None => Err(NodeRecoverError::Rejected( + "dkms node recover returned no material".to_string(), + )), } } @@ -1899,7 +1948,9 @@ impl KeyProvider { ) -> Result { // CHECK OUT: take this node's warm connection if present + still live; otherwise establish a // fresh one. We hold the pool lock only across the cheap map op, never across the slow recover. - let mut conn = { + // `reused` tracks whether we took a WARM connection — the only case a transport fault is worth + // retrying (a freshly-established connection that transport-fails is a genuine node fault). + let (mut conn, reused) = { let mut guard = pool .lock() .map_err(|_| "dkms pool lock poisoned".to_string())?; @@ -1909,7 +1960,7 @@ impl KeyProvider { "key-provider timing: reuse warm session ({})", client.endpoint ); - c + (c, true) } _ => { drop(guard); @@ -1920,27 +1971,55 @@ impl KeyProvider { client.endpoint, t_sess.elapsed().as_millis() ); - c + (c, false) } } }; - let result = Self::run_recover_on_conn( - &mut conn, - &client.endpoint, - recover_req, - kid_hex, - decrypt_session_pub_b64, - content_id, - now, - ); - // CHECK IN on success so the next open reuses the advanced session; DROP on error so a broken - // or rejected connection is never reused (the next open re-establishes from cold, fail-closed). - if result.is_ok() { - if let Ok(mut guard) = pool.lock() { - guard.insert(client.endpoint.clone(), conn); + let recover_on = |conn: &mut DkmsNodeConn| { + Self::run_recover_on_conn( + conn, + &client.endpoint, + recover_req, + kid_hex, + decrypt_session_pub_b64, + content_id, + now, + ) + }; + match recover_on(&mut conn) { + // CHECK IN on success so the next open reuses the advanced session. + Ok(value) => { + if let Ok(mut guard) = pool.lock() { + guard.insert(client.endpoint.clone(), conn); + } + Ok(value) + } + // WARM-CONNECTION TRANSPORT FAULT (ELACITY-2282): the node's 30s idle read-timeout closed + // the pooled socket while our token was still live by its 300s TTL, so the reused write + // hit a dead socket. The old `conn` is already dropped (fail-closed). Re-establish a FRESH + // session and retry the recover ONCE — the first open after any >30s idle gap now succeeds + // instead of failing closed below quorum. A genuine node outage fails the fresh attempt + // too and then fails closed. + Err(outcome) if reused && outcome.is_retryable_on_fresh_session() => { + eprintln!( + "key-provider: warm dkms session transport-failed ({}); re-establishing once", + client.endpoint + ); + let mut fresh = establish_dkms_session(client, now)?; + match recover_on(&mut fresh) { + Ok(value) => { + if let Ok(mut guard) = pool.lock() { + guard.insert(client.endpoint.clone(), fresh); + } + Ok(value) + } + Err(retry_outcome) => Err(retry_outcome.message()), + } } + // A node REJECTION, or a transport fault on a just-established (cold) connection: fail + // closed with no retry (the connection is already dropped). + Err(outcome) => Err(outcome.message()), } - result } /// 2-of-3 QUORUM release (Day 113–116): dial + handshake + recover ALL THREE secret-holding @@ -4167,6 +4246,31 @@ mod tests { assert!(!dkms_session_live(expires_at, None)); } + /// ELACITY-2282 (regression): the warm-connection retry decision. A TRANSPORT fault on a + /// reused pooled connection (the node's 30s idle read-timeout closed a socket our token still + /// considered live by its 300s TTL) is retryable on a fresh session — the fix that keeps the + /// first open after a >30s idle gap from failing closed below quorum. A node REJECTION + /// (revoked caller / bad proof / expired token) reached the node and is a genuine denial: it + /// must NOT be retried, or a real revocation could be masked and the nodes hammered. + #[test] + fn transport_fault_is_retryable_but_a_node_rejection_fails_closed() { + assert!( + NodeRecoverError::Transport("dkms node transport failed: broken pipe".to_string()) + .is_retryable_on_fresh_session(), + "a warm-socket transport fault must retry once on a fresh session", + ); + assert!( + !NodeRecoverError::Rejected("dkms node recover failed: caller_revoked".to_string()) + .is_retryable_on_fresh_session(), + "a node rejection must fail closed with no retry", + ); + // The surfaced message is preserved regardless of variant (fail-closed diagnostics). + assert_eq!( + NodeRecoverError::Rejected("caller_revoked".to_string()).message(), + "caller_revoked" + ); + } + fn reference_provider() -> KeyProvider { let mut provider = KeyProvider::default(); // init must succeed and stand up the reference authority. diff --git a/capsules/marketplace-content/Cargo.lock b/capsules/marketplace-content/Cargo.lock new file mode 100644 index 00000000..914f5425 --- /dev/null +++ b/capsules/marketplace-content/Cargo.lock @@ -0,0 +1,121 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "elastos-guest" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "marketplace-content" +version = "0.1.0" +dependencies = [ + "elastos-guest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/capsules/marketplace-content/Cargo.toml b/capsules/marketplace-content/Cargo.toml new file mode 100644 index 00000000..6d38be09 --- /dev/null +++ b/capsules/marketplace-content/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "marketplace-content" +version = "0.1.0" +edition = "2021" +description = "Marketplace content storefront capsule (discover / buy / trade dDRM assets)" +license = "MIT" + +[[bin]] +name = "marketplace-content" +path = "wasm/main.rs" + +[dependencies] +elastos-guest = { path = "../../elastos/crates/elastos-guest" } + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/marketplace-content/README.md b/capsules/marketplace-content/README.md new file mode 100644 index 00000000..bbc25433 --- /dev/null +++ b/capsules/marketplace-content/README.md @@ -0,0 +1,21 @@ +# marketplace-content (DRAFT) + +The marketplace shell — **discover, buy, trade, list, withdraw** dDRM assets (content now; apps & games later, same storefront). A **pure UI capsule**: it reads `elastos://market/*`, renders, requests **unsigned** orders, and routes signing to the wallet. On buy it **triggers a pin of the encrypted asset into your local Library**; opening is handed off to the existing **player**, minting to the **creator app**. It **mints nothing, plays/renders nothing**, and holds **no signer, token, CEK, or chain RPC** (Principle 16). Canonical scope: `docs/marketplace/SCOPE.md`. + +## Run it standalone (review) +Open `browser/index.html` in a browser. It runs against an embedded mock (`mock.js`) that also **documents the `elastos://market/*` contract** (see `api.js`). The wallet pill shows **demo** when no gateway answers, **live** when one does. + +- **Discover** — asset-type (Content; Apps/Games soon) + medium sections (Watch/Listen/Read/View/Explore), facets, search, access-right chips, poster/cover art. +- **Asset detail** — poster/cover ("Identity verified · contentId == KID") + commerce/trust sidebar (buy box, royalty splits, About, Provenance). Owned → "Open in your library" (hands off to the player); the shell renders nothing. +- **Buy** — assembles an **unsigned** order **routed to the wallet** (the shell never signs); on success the encrypted asset **pins to your library**; surfaces the Phase-1 invariant (terms re-verified from chain, abort on drift). +- **Vault** — Owned / Listed (manage resale listings) / History. Minting lives in the creator app, not here. + +## The contract this shell expects (for the index + gateway) +`api.js` is the source of truth: `GET market/sections` · `GET market/search` · `GET market/get?content_id` (returns the listing **plus a live re-verified `on_chain` block** — never trusted from cache) · `POST market/order/assemble {content_id,quantity,seller}` (returns an **unsigned** tx) · `POST market/order/cancel` (unsigned `withdrawListing`) · `POST market/acquire {content_id}` (triggers the buy→pin to the local Library) · `GET market/vault`. + +## Status / follow-ups +- Frontend is complete, runnable, and **wired to the live gateway** (mock fallback for standalone review). +- The thin `marketplace-content.wasm` launcher is **built** (`wasm/main.rs` + `Cargo.toml`, mirrors the `marketplace` capsule; compiles `--release`). +- Remaining: KID/metadata enrichment (name/cover/`content_cid` from `token_uri`), the live-chain test (Cursor), and launcher registration. +- Backend: `content-index` (polling cache over `content-market`) + the `/api/market/*` gateway routes (Phase 2); the buy path's re-verified-listing binding (Phase 1) is the gate before the buy button does anything real. +- See `docs/marketplace/SCOPE.md` (canonical scope), `PHASE1_BUY_INVARIANT.md`, `PHASE3_ACQUIRE_AND_TRADE.md`, `CONTRACTS.md`. diff --git a/capsules/marketplace-content/browser/api.js b/capsules/marketplace-content/browser/api.js new file mode 100644 index 00000000..7176f014 --- /dev/null +++ b/capsules/marketplace-content/browser/api.js @@ -0,0 +1,324 @@ +/* api.js — the /api/market/* client. Canonical contract: docs/marketplace/API_CONTRACT.md (the SSOT both + * this shell and the gateway converge on). Falls back to MOCK so the shell runs standalone for review. + * + * BUILT (served by gateway.rs today; a call may set live=true): + * GET /api/market/search?op&q -> { listings:[Listing], indexed, coverage } + * GET /api/market/sections -> { sections:[{id,title,...}] } + * POST /api/market/order/sell {ledger,token_id,quantity,price,pay_token?} -> { unsigned_tx } (Home-gated) + * POST /api/market/order/withdraw {operative,token_id,quantity} -> { unsigned_tx } (Home-gated) + * POST /api/market/order/approve {operative} -> { unsigned_tx } (Home-gated) + * POST /api/market/buy {uri,…} -> buy outcome (Home-gated) + * GET /api/market/get?operative&token_id -> { on_chain{token_id,seller,price,pay_token,supply_left,has_access} } (live re-verify) + * GET /api/market/vault -> { owned:[{uri,name,content_cid,mime}] } (Home-gated; the Library Acquired assets) + * POST /api/market/acquire {content_id,content_cid,metadata?,background?} -> sync: { object,uri,… }; background:true -> { status:"started",content_cid } (Home-gated; gates hasAccessByContentId then pins) + * GET /api/market/acquire-status?cid&token_uri -> { state:"downloaded"|"downloading"|"failed"|"idle", downloaded, uri?, message? } (Home-gated; truth from Acquired file-presence + in-flight run) + * + * PENDING (this shell SPECs them; gateway does NOT serve them yet -> MOCK ONLY, never set live=true): + * GET /api/market/get?content_id (by-CID variant; needs KID/metadata enrichment — use ?operative&token_id today) + * GET /api/market/listed|history (Phase 2) + * + * The marketplace MINTS nothing (creator app) and PLAYS nothing (runtime players). After buy it TRIGGERS a + * pin of the encrypted asset into the local Library (acquire), then hands off opening to the runtime's + * `POST /api/viewers/open { uri }` — the marketplace never builds a viewer session or touches a CEK (P15/P16). + * + * OnChain (the Phase-1 re-verified terms read live at detail/buy time, never trusted from cache): + * { token_id, price, pay_token, supply_left, seller, has_access } (price in USDC minor units on chain) + */ +window.API = (function () { + const base = "/api/market"; + // The runtime launches this capsule with ?home_token=… in the URL; gated routes need it as a header. + const homeToken = () => { try { return new URL(location.href).searchParams.get("home_token") || ""; } catch { return ""; } }; + const H = () => { const t = homeToken(); const h = { accept: "application/json" }; if (t) h["x-elastos-home-token"] = t; return h; }; + // Live = embedded in the runtime (the launcher passes ?home_token). In live mode we NEVER fall back to the + // demo fixtures (mock.js) — a missing/failed endpoint fails CLOSED to empty, so the UI shows truthful state + // (e.g. "no listings yet") instead of fabricated assets. Mock fixtures are only for standalone review. + const liveMode = () => !!homeToken(); + async function tryGet(path) { + try { + const r = await fetch(`${base}${path}`, { headers: H() }); + if (!r.ok) throw new Error(String(r.status)); + return await r.json(); + } catch { return null; } // standalone / gateway absent -> mock fallback below + } + // The LIVE index Listing is lean (operative_address, token_id, op_type, token_uri; content_id null until + // KID/metadata enrichment). Normalize it into the shell render shape with graceful fallbacks, and cache by + // id so the detail view can resolve operative/token_id from the route. A mock/enriched listing passes through. + const _cache = {}; + function normalize(raw) { + if (!raw || (raw.name && Array.isArray(raw.listings))) return raw; + const id = raw.content_id || `${raw.operative_address || ""}:${raw.token_id || ""}`; + const n = { + content_id: id, operative_address: raw.operative_address, token_id: raw.token_id, + channel_address: raw.channel_address, token_uri: raw.token_uri, content_cid: raw.content_cid || null, + op_type: raw.op_type || "buy_once", name: raw.name || `Asset ${String(raw.token_id || id).slice(0, 12)}`, + medium: raw.medium || "view", creator_address: raw.creator_address || raw.channel_address || "—", + category: raw.category || "", image_url: raw.image_url || null, + // No fabricated tier/holders/copies: only pass through values a real source actually provided + // (mock listings carry their own; live listings leave these undefined and the UI omits them). + tier: raw.tier, copies: raw.copies, holders: raw.holders, + description: raw.description || "", pay_token: raw.pay_token || "", + // Phase-A card economics: the cheapest active listing's price/supply/resale, attached by the gateway's + // discovery enrichment (sellersOf+listings). Undefined on rows beyond the per-sweep read budget — the + // card then shows a neutral placeholder, never a fabricated number. + price: raw.price, price_formatted: raw.price_formatted, pay_token_symbol: raw.pay_token_symbol, + for_sale: raw.for_sale, terms_read: raw.terms_read, + supply_available: raw.supply_available, resale_pct: raw.resale_pct, duration: raw.duration, + created_at: raw.created_at, preview_url: raw.preview_url, content_type: raw.content_type, + categories: Array.isArray(raw.categories) ? raw.categories : [], tags: Array.isArray(raw.tags) ? raw.tags : [], + listings: Array.isArray(raw.listings) ? raw.listings : [], _lean: !raw.name, + }; + _cache[id] = n; return n; + } + const mimeToMedium = (mime) => { + const t = String(mime || "").split("/")[0]; + if (t === "video") return "watch"; + if (t === "audio") return "listen"; + if (t === "image") return "view"; + if (mime === "application/pdf" || t === "text") return "read"; + if (t === "model") return "explore"; + return null; + }; + const byId = (id) => _cache[id] || (window.MOCK && window.MOCK.listings.find((x) => x.content_id === id)); + // Ask the Home shell to open another runtime window on our behalf (the runtime's only cross-app launch + // seam — there is no server-side "pop a window"). Home authorizes this per-source in shell.js + // (SHELL_MESSAGE_OPEN_TARGET_SOURCES["marketplace-content"]) and re-checks our home_token against our + // frame route, so this carries no ambient authority (P7/P16): it can only open the targets Home allows. + // Returns false when we're standalone (no parent / no token) so the caller can fall back truthfully. + function launch(target, query) { + try { + const t = homeToken(); + if (!t || window.parent === window) return false; + window.parent.postMessage({ type: "home:open-target", target, query: query || {}, homeToken: t }, location.origin); + return true; + } catch { return false; } + } + + return { + live: false, // flips true if a real gateway answers + // Your OWN market identity (linked wallet + the handle you set in Home), home-token-gated. Used only to + // label your own cards with your name instead of a bare address (creator profiles, Phase 0). The address + // is normalized lower-case by the caller; null in demo/standalone -> the address is shown (fail-closed). + async me() { const r = await tryGet("/me"); if (r) { this.live = true; } return r || null; }, + async sections({ lean } = {}) { + const real = await tryGet(`/sections${lean ? "?lean=1" : ""}`); + if (real) { this.live = true; return (real.sections || []).map((s) => ({ ...s, listings: (s.listings || []).map(normalize) })); } + if (liveMode()) return []; + return window.MOCK.sections.map((s) => ({ + ...s, + listings: s.ids ? s.ids.map(byId) : window.MOCK.listings.filter(s.filter || ((x)=>x.medium===s.id)), + })); + }, + async search({ medium, q, op, channel, lean } = {}) { + const real = await tryGet(`/search?medium=${medium||""}&q=${encodeURIComponent(q||"")}&op=${op||""}&channel=${encodeURIComponent(channel||"")}${lean ? "&lean=1" : ""}`); + if (real) { this.live = true; let r = (real.listings || []).map(normalize); if (medium) r = r.filter((x) => x.medium === medium); return r; } + if (liveMode()) return []; + let r = window.MOCK.listings.slice(); + if (medium) r = r.filter((x) => x.medium === medium); + if (op) r = r.filter((x) => x.op_type === op); + if (channel) r = r.filter((x) => (x.channel_address || "").toLowerCase() === channel.toLowerCase()); + if (q) { const s = q.toLowerCase(); r = r.filter((x) => (x.name + x.creator_address).toLowerCase().includes(s)); } + return r; + }, + async get(content_id) { + // LIVE: resolve operative/token_id from the cached listing, then read the on-chain terms via /get. + const cached = _cache[content_id]; + if (cached && cached.operative_address && cached.token_id) { + const tu = cached.token_uri ? `&token_uri=${encodeURIComponent(cached.token_uri)}` : ""; + const oc = await tryGet(`/get?operative=${encodeURIComponent(cached.operative_address)}&token_id=${encodeURIComponent(cached.token_id)}${tu}`); + // Use the LIVE answer whenever the gateway responded with real data — even if there is no active + // listing (on_chain null). We must NOT discard the real metadata/royalty/supply and fall back to + // mock zeros just because nobody currently has it listed for sale. + if (oc && (oc.on_chain || oc.metadata || oc.royalty)) { + this.live = true; + const on = oc.on_chain, m = oc.metadata || {}; + // Merge the enriched metadata.json fields (name/cover/content_cid/mime) onto the cached listing. + const listing = { ...cached, + name: m.name || cached.name, description: m.description || cached.description, + image_url: m.image_url || cached.image_url, content_cid: m.content_cid || cached.content_cid, + medium: mimeToMedium(m.mime_type) || cached.medium, + // Phase-B Properties panel: the real metadata.json attributes/properties + file size + mime. + mime_type: m.mime_type || cached.mime_type, + attributes: Array.isArray(m.attributes) ? m.attributes : cached.attributes, + properties: Array.isArray(m.properties) ? m.properties : cached.properties, + media_size: m.media_size != null ? m.media_size : cached.media_size, + content_type: m.content_type || cached.content_type, preview_url: m.preview_url || cached.preview_url, + created_at: m.created_at || cached.created_at, + categories: Array.isArray(m.categories) ? m.categories : cached.categories, + tags: Array.isArray(m.tags) ? m.tags : cached.tags, + listings: cached.listings.length ? cached.listings : (on && on.price != null ? [{ price: on.price, seller: on.seller }] : []) }; + _cache[content_id] = listing; // remember the enriched shape (so acquire() has content_cid) + // on_chain is null when there is no active listing: present a null-safe block + listed flag so + // the UI shows "not listed for sale" rather than a fabricated 0 USDC / 0 copies. + const on_chain = on || { token_id: cached.token_id || "—", price: null, pay_token: "", supply_left: null, seller: null, has_access: null }; + // royalty = the REAL per-asset splits read on-chain by the gateway (operative royaltyInfo + + // resellerCut, CoreStorage protocolShares); null/unavailable -> the UI hides the splits panel. + return { listing, on_chain, royalty: oc.royalty || null, listed: !!on }; + } + } + const listing = byId(content_id); + if (!listing) return { listing: { content_id, name: "Unknown asset", medium: "view", op_type: "buy_once", listings: [] }, on_chain: { token_id: "—", price: null, pay_token: "", supply_left: null, seller: null, has_access: false }, royalty: null, listed: false }; + // MOCK of the LIVE re-verified on-chain block (Phase-1: never trusted from the cache) + const cheapest = listing.listings.reduce((a, b) => (b.price < a.price ? b : a), listing.listings[0] || { price: 0 }); + return { + listing, + on_chain: { + token_id: "0x" + String(content_id).slice(2, 10) + "…(real tokenId resolved from chain)", + price: cheapest.price, pay_token: listing.pay_token, + supply_left: (listing.copies || 0) - (listing.sold || 0), seller: cheapest.seller, + has_access: (window.MOCK && window.MOCK.owned.some((o) => o.content_id === content_id)) || false, + }, + royalty: null, // standalone/demo has no chain read -> the splits panel is hidden (no fabricated splits) + listed: (listing.listings && listing.listings.length > 0) || false, + }; + }, + // Resolve the asset's CLEAR DASH preview into an MSE play plan (per-track mime + cached segment URLs). + // The gateway parses the manifest + warms its byte cache; the shell's standalone MSE player plays it. + async previewPlan(token_uri) { + if (!token_uri) return null; + const r = await tryGet(`/preview/plan?token_uri=${encodeURIComponent(token_uri)}`); + if (r && Array.isArray(r.tracks) && r.tracks.length) { this.live = true; return r; } + return null; + }, + async assembleOrder({ content_id, quantity, seller, price, pay_token }) { + // BUILT route: POST /api/market/buy (buy_owned_access, Home-gated). Send the asset's on-chain + // identity from the cached listing so the gateway sources live terms (sellersOf/listings at id=1) + // with no env pins; price/pay_token arm abort-on-drift (the live re-read must match what was shown). + const c = _cache[content_id] || {}; + const real = await fetch(`${base}/buy`, { + method: "POST", headers: { ...H(), "content-type": "application/json" }, + body: JSON.stringify({ + content_id, quantity, seller, + operative: c.operative_address, token_id: c.token_id, ledger: c.channel_address, + expected_price: price != null ? String(price) : undefined, + expected_pay_token: pay_token || undefined, + }), + }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + if (real) { this.live = true; return real; } + // MOCK unsigned tx — the shell NEVER signs; this is handed to your wallet (human-in-loop). + return { + unsigned_tx: { + to: "AuthorityGateway 0xf758…0ad9", selector: "buyAccess(...)", + content_id, quantity, seller, value: "(price re-read from chain at assembly)", + note: "UNSIGNED. Signed only by your wallet after human approve. Phase-1 invariant: " + + "terms re-verified from chain + aborts on drift before broadcast.", + }, + }; + }, + async vault() { + const real = await tryGet("/vault"); + if (real) { + this.live = true; + return (real.owned || []).map((o) => { + // Chain-access-held rows arrive listing-shaped (operative/token_id/token_uri/op_type/medium + + // enriched name/cover/kid), so they normalize into real cards that open the detail view. Library- + // only rows (pinned but outside the discovery window) carry just uri/name/content_cid/mime — fall + // back to a minimal medium from the mime. Either way, preserve the held/acquired flags. + const n = normalize(o.content_id || o.operative_address ? o : { + content_id: o.content_cid || o.uri, content_cid: o.content_cid, name: o.name, + op_type: "free", medium: mimeToMedium(o.mime) || "view", + }); + n.acquired = o.acquired !== false; + n.held = o.held !== false; + if (!n.medium || n.medium === "view") { const m = mimeToMedium(o.mime || o.mime_type); if (m) n.medium = m; } + n.uri = o.uri; // the Library URI — the open handoff opens this + return n; + }); + } + return liveMode() ? [] : window.MOCK.owned; + }, + // Hand off to the EXISTING runtime open path (POST /api/viewers/open). The marketplace renders nothing; + // the runtime gates rights, recovers the CEK in decrypt-provider, and opens the player. + async open(uri) { + if (!uri) return null; + const r = await fetch("/api/viewers/open", { + method: "POST", headers: { ...H(), "content-type": "application/json" }, body: JSON.stringify({ uri }), + }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + if (r) this.live = true; + return r; + }, + // Open the downloaded asset in its runtime player via Home's launch seam — the SAME path the Library app + // uses (openTarget(viewer, {objectUri,…})). The viewer capsule itself runs the rights/decrypt open; the + // marketplace renders nothing and holds no CEK (P15/P16). `medium` chooses elacity-player (av) vs + // ddrm-viewer (everything else), mirroring the Library's viewer routing. Returns false if standalone. + openInPlayer({ uri, name, mime, medium, content_cid }) { + if (!uri) return false; + const viewer = medium === "watch" || medium === "listen" ? "elacity-player" : "ddrm-viewer"; + const query = { objectUri: uri, uri, name: name || "", mime: mime || "application/octet-stream" }; + if (content_cid) query.contentCid = content_cid; + return launch(viewer, query); + }, + // Reveal the downloaded file in the File Explorer (the Library app), opening its containing folder — + // the runtime equivalent of PC2's openFolder. We open the PARENT directory so the file shows in context + // (the buyer's `…/Acquired` space). Returns false if standalone (no Home parent). + reveal(uri) { + if (!uri) return false; + const cut = uri.lastIndexOf("/"); + const folder = cut > "localhost://".length ? uri.slice(0, cut) : uri; + return launch("library", { uri: folder }); + }, + async listed() { + const r = await tryGet("/listed"); + if (r) { + this.live = true; + // Live rows carry my_price in minor units + my_price_formatted (human) + the operative for withdraw. + return (r.listed || []).map((it) => ({ + ...it, + medium: it.medium || mimeToMedium(it.mime_type || it.mime) || "view", + my_price: it.my_price_formatted != null ? it.my_price_formatted : it.my_price, + content_id: it.content_id || `${it.operative_address || ""}:${it.token_id || ""}`, + })); + } + return liveMode() ? [] : window.MOCK.listed; + }, + // Marketplace-wide recent on-chain activity (ItemListed/ItemSold/ItemUnlisted), read live from the + // AuthorityGateway logs by the gateway. Falls back to the demo feed only when no gateway answers. + async history() { const r = await tryGet("/history"); if (r) { this.live = true; return r.history || []; } return liveMode() ? [] : window.MOCK.history; }, + // One asset's on-chain trade history (same events, filtered to its operative). [] when none/standalone. + async assetHistory(operative, token_id) { + if (!operative) return []; + const tid = token_id ? `&token_id=${encodeURIComponent(token_id)}` : ""; + const r = await tryGet(`/history?operative=${encodeURIComponent(operative)}${tid}`); + if (r) { this.live = true; return r.history || []; } + return []; + }, + async assembleCancel({ operative, quantity }) { + // BUILT route: POST /api/market/order/withdraw {operative, token_id, quantity}. The listing is keyed + // at the ERC-1155 ACCESS_TOKEN id (== 1), so withdraw passes token_id "1" + the listed quantity. + const real = await fetch(`${base}/order/withdraw`, { + method: "POST", headers: { ...H(), "content-type": "application/json" }, + body: JSON.stringify({ operative, token_id: "1", quantity: String(quantity || 1) }), + }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + if (real) { this.live = true; return real; } + return { unsigned_tx: { to: "AuthorityGateway", selector: "withdrawListing(operative,tokenId,quantity) 0x3e65bbba", operative, token_id: "1", quantity: String(quantity || 1), + note: "UNSIGNED — routed to wallet; the access right is unaffected, only the resale listing is cancelled." } }; + }, + // Buy -> pin: after the access right is granted, TRIGGER (never perform) the encrypted asset's pin into + // the local Library via content/*, then it's openable from the runtime player. BUILT route (Home-gated): + // it gates hasAccessByContentId(content_id) then dispatches the Acquire op. content_id = the bytes16 + // KID (entitlement); content_cid = the encrypted IPFS CID (what is pinned). + async acquire({ content_id, content_cid, token_uri, metadata, background }) { + const real = await fetch(`${base}/acquire`, { + method: "POST", headers: { ...H(), "content-type": "application/json" }, + body: JSON.stringify({ content_id, content_cid, token_uri, metadata, background: !!background }), + }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + if (real) { this.live = true; return real; } + // Fail CLOSED in live mode: a failed gateway acquire must NOT fabricate a Library URI. A fake + // `localhost://Users//…` path would send Reveal/Open to a phantom folder OUTSIDE your + // real principal root (the trusted core then rejects it as "outside the active principal root"). + if (liveMode()) return null; + // Standalone demo only (no gateway): acknowledge the trigger WITHOUT a navigable Library URI — nothing + // is actually materialized, so Reveal/Open correctly report "download it to your node first". + return { content_id, pin_status: "complete", + note: "Standalone demo — no gateway. In the runtime this pins the encrypted CID (content/ensure) and registers a Library object; the marketplace holds no keys (P15)." }; + }, + // Truthful download state for a BACKGROUND acquire — derived server-side from the materialized file in + // your Acquired space (durable truth) and the in-flight run (running/failed). No fabricated %. Returns + // { state: "downloaded"|"downloading"|"failed"|"idle", downloaded, uri?, message? } or null standalone. + async acquireStatus({ cid, token_uri }) { + const tu = token_uri ? `&token_uri=${encodeURIComponent(token_uri)}` : ""; + const r = await tryGet(`/acquire-status?cid=${encodeURIComponent(cid || "")}${tu}`); + if (r) this.live = true; + return r || null; + }, + }; +})(); diff --git a/capsules/marketplace-content/browser/app.js b/capsules/marketplace-content/browser/app.js new file mode 100644 index 00000000..00ce13d7 --- /dev/null +++ b/capsules/marketplace-content/browser/app.js @@ -0,0 +1,732 @@ +/* app.js — marketplace-content shell. Pure UI: renders, requests UNSIGNED orders, routes signing + * to the wallet. Holds no signer/token/CEK (Principle 16). Runs standalone via api.js mock fallback. */ +(function () { + const $ = (s, r = document) => r.querySelector(s); + const view = $("#view"), modalRoot = $("#modal-root"); + const state = { kind: null, op: null, q: "", category: null }; + + // Inline feather/lucide-style stroke icons (currentColor, stroke-width 2, viewBox 24) — same convention as + // the sibling capsules/marketplace/browser/marketplace.js icons={}. No emoji (per-OS, off-palette, reads as + // a prototype), no scratch art, no new pack. icon() returns an aria-hidden SVG that inherits text color. + const ICONS = { + compass: '', + key: '', + bell: '', + clapperboard: '', + package: '', + gamepad: '', + tv: '', + headphones: '', + book: '', + image: '', + box: '', + unlock: '', + cart: '', + recycle: '', + link: '', + sun: '', + moon: '', + play: '', + layers: '', + bookOpen: '', + fileText: '', + download: '', + folder: '', + }; + const icon = (name, cls) => ``; + const MEDIA = { watch: "tv", listen: "headphones", read: "book", view: "image", explore: "box" }; + const mediaIcon = (m, cls) => icon(MEDIA[m] || "box", cls); + // Display label for the medium bucket: the gateway's internal value (watch/listen/view/read/explore, + // derived from MIME) shown to users as elacity's content-type names (Video/Audio/Images/Documents/Other). + const MEDIUM_LABEL = { watch: "Video", listen: "Audio", read: "Documents", view: "Images", explore: "Other" }; + const mediumLabel = (m) => MEDIUM_LABEL[m] || (m ? m.charAt(0).toUpperCase() + m.slice(1) : ""); + // The "Type" axis is the gateway's finer normalised `category` (video/audio/image/document + the kinds MIME + // alone can't express: 3d/comic/ebook/article). A row lacking a category (lean/legacy) falls back to its + // coarse `medium` mapped into the same vocab, so nothing disappears from a Type filter. + const MEDIUM_TO_KIND = { watch: "video", listen: "audio", view: "image", read: "document", explore: "other" }; + const kindOf = (l) => String(l.category || MEDIUM_TO_KIND[String(l.medium || "")] || "other"); + const inKind = (l, k) => !k || kindOf(l) === k; + const KIND_LABEL = { video: "Video", audio: "Audio", image: "Images", document: "Documents", "3d": "3D", comic: "Comics", ebook: "e-books", article: "Articles", other: "Other" }; + const kindLabel = (k) => KIND_LABEL[k] || (k ? k.charAt(0).toUpperCase() + k.slice(1) : ""); + const CCY = "USDC"; // Base pay token (gas = ETH); on-chain in 6-decimal minor units + const money = (v) => (v ? `${v} ${CCY}` : "Free"); + const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + // Asset descriptions are creator-authored HTML (e.g. `

`). Render them as readable text, not + // literal tags: parse to a detached node (innerHTML assignment never executes scripts) and take its + // textContent (decodes entities + strips tags). The result is plain text — esc() it at the insertion + // point so any residual `<` can't re-open a tag. + const plainText = (s) => { const d = document.createElement("div"); d.innerHTML = String(s == null ? "" : s); return (d.textContent || "").replace(/\s+/g, " ").trim(); }; + const short = (id) => String(id == null ? "" : id).slice(0, 8) + "…" + String(id == null ? "" : id).slice(-4); + // Enriched poster: render image_url as a cover. http(s) and the runtime content routes (/content, /ipfs) + // load as-is; ipfs://CID[/path] (and bare Qm/bafy CIDs) resolve through the runtime's content plane + // (/content/ — never raw public ipfs from the browser, P4); anything else keeps the medium glyph. + const coverUrl = (u) => { + const s = String(u || "").trim(); + if (!s) return null; + if (/^https?:\/\//.test(s) || s.startsWith("/content/") || s.startsWith("/ipfs/")) return s; + const cid = s.replace(/^ipfs:\/\//, "").replace(/^\/ipfs\//, ""); + return /^(Qm|bafy)/.test(cid) ? "/content/" + cid : null; + }; + const coverBg = (l) => { const u = coverUrl(l && l.image_url); return u ? ` style="background-image:url('${esc(u)}');background-size:cover;background-position:center"` : ""; }; + // Real royalty splits: rendered ONLY from the gateway's on-chain read (operative royaltyInfo + + // resellerCut, CoreStorage protocolShares). No fabricated role model — parties are wallet addresses with + // their real percentage. fmtPct trims to 1dp; the bar/legend cycle the 4 split colors. + const SPLIT_COLORS = ["s1", "s2", "s3", "s4"]; + const fmtPct = (p) => `${(Math.round((Number(p) || 0) * 10) / 10).toString().replace(/\.0$/, "")}%`; + const splitBars = (dist) => dist.map((x, i) => ``).join(""); + const splitLegend = (dist) => dist.map((x, i) => `${esc(short(String(x.address || "")))} ${fmtPct(x.pct)}`).join(""); + const cap = (s) => { s = String(s == null ? "" : s); return s ? s[0].toUpperCase() + s.slice(1) : ""; }; + // Duration ms -> "M:SS" or "H:MM:SS" (mirrors elacity timeFormat(floor(ms/1000))). + const fmtDuration = (ms) => { + let s = Math.floor((Number(ms) || 0) / 1000); if (s <= 0) return ""; + const h = Math.floor(s / 3600); s -= h * 3600; const m = Math.floor(s / 60); s -= m * 60; + const p = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${m}:${p(s)}`; + }; + // ISO timestamp -> "3 days ago" (elacity dayjs.fromNow). "" when absent/unparseable — never fabricated. + const relTime = (iso) => { + const t = Date.parse(iso); if (!t) return ""; + let s = Math.floor((Date.now() - t) / 1000); if (s < 0) s = 0; + for (const [name, secs] of [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]]) { + const n = Math.floor(s / secs); if (n >= 1) return `${n} ${name}${n > 1 ? "s" : ""} ago`; + } + return "just now"; + }; + const prettyLabel = (s) => cap(String(s == null ? "" : s).replace(/[-_]+/g, " ").trim()); + // USD-pegged pay tokens: for these, fiat == token amount 1:1, so a "≈ $X" line is truthful with NO oracle. + // Non-stable tokens return "" (we don't guess a rate we can't read). + const USD_STABLE = new Set(["USDC", "USDBC", "USDC.E", "DAI", "USDT", "PYUSD", "USDS", "GUSD"]); + const fiatUsd = (amount, sym) => { + const n = Number(amount); + if (!isFinite(n) || n <= 0 || !USD_STABLE.has(String(sym || "").toUpperCase())) return ""; + return "≈ $" + (Math.round(n * 100) / 100).toFixed(2); + }; + // Append one fetched init/segment into an MSE SourceBuffer, serialized on `updateend` (mirrors the + // runtime's own elacity-player: MSE requires waiting between appends, and keeps strict in-order delivery). + function appendURL(sb, url) { + return fetch(url) + .then((r) => { if (!r.ok) throw new Error("segment " + r.status); return r.arrayBuffer(); }) + .then((buf) => new Promise((resolve, reject) => { + const done = () => { sb.removeEventListener("updateend", done); sb.removeEventListener("error", fail); resolve(); }; + const fail = () => { sb.removeEventListener("updateend", done); sb.removeEventListener("error", fail); reject(new Error("SourceBuffer append error")); }; + sb.addEventListener("updateend", done); sb.addEventListener("error", fail); + try { sb.appendBuffer(new Uint8Array(buf)); } catch (e) { sb.removeEventListener("updateend", done); sb.removeEventListener("error", fail); reject(e); } + })); + } + // Play the clear DASH preview into `mount` via MSE — one SourceBuffer per track (video AV1 + audio AAC), + // fed by the gateway's cached preview route. Fail-soft: any unsupported codec / fetch error shows a note, + // never a broken element. This is the public preview only; owned playback stays on the runtime player path. + async function playPreview(l, mount) { + if (!window.MediaSource) { mount.innerHTML = '
This browser can\u2019t play the preview.
'; return; } + mount.innerHTML = '
Loading preview\u2026
'; + const plan = await window.API.previewPlan(l.token_uri); + if (!plan) { mount.innerHTML = '
Preview unavailable.
'; return; } + const tracks = plan.tracks.filter((t) => window.MediaSource.isTypeSupported(t.mime)); + if (!tracks.length) { mount.innerHTML = '
This browser can\u2019t decode the preview format (AV1).
'; return; } + const video = document.createElement("video"); + video.className = "previewvid"; video.controls = true; video.autoplay = true; video.playsInline = true; video.muted = false; + const ms = new MediaSource(); + video.src = URL.createObjectURL(ms); + mount.innerHTML = ""; mount.appendChild(video); + ms.addEventListener("sourceopen", async () => { + try { + const multi = tracks.length > 1; + const bufs = tracks.map((t) => { + const sb = ms.addSourceBuffer(t.mime); + if (!multi && t.kind !== "video") sb.mode = "sequence"; + return { sb, t }; + }); + for (const b of bufs) await appendURL(b.sb, b.t.init_url); + await Promise.all(bufs.map(async (b) => { for (const u of b.t.seg_urls) await appendURL(b.sb, u); })); + if (ms.readyState === "open") ms.endOfStream(); + } catch (e) { + mount.innerHTML = `
Preview failed: ${esc(e.message || String(e))}
`; + } + }); + } + // True only for assets whose preview is a clear DASH (.mpd) clip on a playable medium. + const hasDashPreview = (l) => !!l.preview_url && /\.mpd(\?|$)/i.test(l.preview_url) && (l.medium === "watch" || l.medium === "listen"); + // Category + tag chips from real metadata.properties (mirrors elacity). Empty -> "" (nothing fabricated). + const chipsRow = (l) => { + const cats = (Array.isArray(l.categories) ? l.categories : []).map((c) => `${esc(c)}`); + const tags = (Array.isArray(l.tags) ? l.tags : []).map((t) => `#${esc(t)}`); + const all = cats.concat(tags); + return all.length ? `
${all.join("")}
` : ""; + }; + const fmtBytes = (n) => { let v = Number(n) || 0; if (v <= 0) return ""; const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${i ? v.toFixed(1) : v} ${u[i]}`; }; + const CHAINS = { "8453": "Base", "20": "Elastos ESC", "1": "Ethereum" }; + const shortIfAddr = (v) => (/^0x[0-9a-fA-F]{40}$/.test(String(v)) ? short(String(v)) : v); + // Properties panel — mirrors elacity MediaProperties: a CURATED, ordered list of real metadata/on-chain + // fields (never a raw dump). Pure render of data the gateway already fetched/read; rows are omitted when + // their source is absent, so nothing is fabricated. Extra creator attributes are appended (prettified). + function propertiesPanel(l, oc, royalty) { + const attr = {}; (Array.isArray(l.attributes) ? l.attributes : []).forEach((a) => { if (a && a.label != null) attr[String(a.label).toLowerCase()] = a.value; }); + const prop = {}; (Array.isArray(l.properties) ? l.properties : []).forEach((p) => { if (p && p.label != null) prop[String(p.label)] = p.value; }); + const rows = []; + const ctype = l.content_type || attr["content-type"] || l.mime_type; + if (ctype) rows.push(["Content type", ctype]); + const dur = fmtDuration(l.duration != null ? l.duration : attr["duration"]); + if (dur) rows.push(["Duration", dur]); + rows.push(["Access type", l.preview_url ? "Protected · preview available" : "Protected"]); + const total = attr["supply"]; + const avail = (oc && oc.supply_left != null && oc.supply_left !== "") ? oc.supply_left : null; + if (total != null && total !== "") rows.push(["Supply", `${total}${avail != null ? ` · ${avail} for sale` : ""}`]); + else if (avail != null) rows.push(["Supply available", String(avail)]); + const when = relTime(l.created_at); + if (when) rows.push(["Uploaded", when]); + if (l.media_size) { const b = fmtBytes(l.media_size); if (b) rows.push(["File size", b]); } + const resalePct = (royalty && royalty.reseller_cut_pct != null) ? royalty.reseller_cut_pct : l.resale_pct; + if (l.op_type === "buy_and_resell" && resalePct != null) rows.push(["Resale royalty", fmtPct(resalePct)]); + if (prop.distribution) rows.push(["Usage rights", prop.distribution]); + if (prop.labelType) rows.push(["Label", prop.labelType]); + if (l.operative_address) rows.push(["Access", "ERC1155 · " + short(l.operative_address)]); + if (prop.authority) rows.push(["Authority", shortIfAddr(prop.authority)]); + if (prop.publisher) rows.push(["Publisher", shortIfAddr(prop.publisher)]); + if (prop.chainId) rows.push(["Blockchain", CHAINS[String(prop.chainId)] || prop.chainId]); + rows.push(["Storage", "IPFS"]); + // Any extra creator attributes not already represented above, prettified so none render label-less. + const SHOWN = new Set(["content-type", "duration", "supply", "optype", "rrl-percent", "resell-allowed"]); + (Array.isArray(l.attributes) ? l.attributes : []).forEach((a) => { + if (!a || a.label == null || a.value == null || a.value === "") return; + if (SHOWN.has(String(a.label).toLowerCase())) return; + rows.push([prettyLabel(a.label), a.value]); + }); + if (!rows.length) return ""; + const body = `${rows.map(([k, v]) => `
${esc(k)}${esc(String(v))}
`).join("")} +
Read from the asset's on-chain metadata & operative contract.
`; + return accCard("Properties", body, true); + } + // A collapsible detail section (native
; mirrors elacity's AccordionGroup). `open` expands it. + const accCard = (title, bodyHTML, open) => + `

${esc(title)}

${bodyHTML}
`; + function splitsPanel(royalty, opType) { + if (opType === "free" || !royalty || !royalty.available) return ""; + const dist = Array.isArray(royalty.distributions) ? royalty.distributions : []; + const split = dist.length ? `
${splitBars(dist)}
${splitLegend(dist)}
` : ""; + const rows = []; + if (opType === "buy_and_resell" && royalty.reseller_cut_pct != null) rows.push(`
Resale royalty${fmtPct(royalty.reseller_cut_pct)}
`); + if (royalty.protocol_pct != null) rows.push(`
Protocol fee${fmtPct(royalty.protocol_pct)}
`); + if (!split && !rows.length) return ""; + return `

Royalty split · read on-chain

+ ${split}${rows.join("")} +
Read live from the asset's operative contract — paid automatically on every sale.
`; + } + + function toast(msg) { const t = $("#toast"); t.textContent = msg; t.hidden = false; clearTimeout(t._t); t._t = setTimeout(() => (t.hidden = true), 3200); } + // Accessible modal: role=dialog + aria-modal, Escape-to-close, focus moved in + trapped, focus restored on + // close. The sheet inner HTML supplies a `; + return `
${cats.map(chip).join("")}
`; + }; + const inCategory = (l, c) => !c || (Array.isArray(l.categories) && l.categories.some((x) => String(x).toLowerCase() === c.toLowerCase())); + + async function renderDiscover() { + // Lean-first: paint cards from the instant lean response (cached descriptive, no on-chain terms), + // then refresh with the full response (price/cover/duration). A per-call sequence number cancels an + // in-flight paint if the user navigates/filters again, so a slow full fetch can't clobber a newer view. + const myRun = (renderDiscover._seq = (renderDiscover._seq || 0) + 1); + const live = () => myRun === renderDiscover._seq; + const filtering = state.kind || state.op || state.q || state.category; + if (filtering) { + const paintFiltered = (all) => { + if (!live()) return; + const items = all.filter((l) => inKind(l, state.kind) && inCategory(l, state.category)); + const labelBits = [state.kind && kindLabel(state.kind), state.op && state.op.replace(/_/g, " "), state.category].filter(Boolean).map((s) => " · " + s).join(""); + view.innerHTML = `

${items.length} result${items.length === 1 ? "" : "s"}${labelBits}

+
${categoryBar(all, state.category)}${items.length ? grid(items) : '
No assets match. Clear filters to browse everything.
'}`; + $("#clear-filters")?.addEventListener("click", () => { state.kind = state.op = state.category = null; state.q = ""; $("#search").value = ""; syncFacets(); renderDiscover(); }); + }; + const args = { op: state.op, q: state.q }; + paintFiltered(await window.API.search({ ...args, lean: true })); + if (!live()) return; + paintFiltered(await window.API.search(args)); + return; + } + view.innerHTML = `
${icon("key")} Buy the right to open it — the file pins to your library +

One market for every asset.

+

Discover, buy, and trade dDRM assets. On purchase the encrypted file pins to your library and opens in your player. Content today — apps & games coming to the same marketplace. Keys are used, never owned.

+
+
${'
'.repeat(4)}
`; + const paintHome = (sections) => { + if (!live() || !$("#shelves")) return; + const allListings = sections.flatMap((s) => s.listings || []); + $("#catbar-home").innerHTML = categoryBar(allListings, null); + $("#shelves").innerHTML = sections.filter((s) => (s.listings || []).length).map((s) => + `

${esc(s.title)}

See all
${grid(s.listings)}`).join(""); + }; + paintHome(await window.API.sections({ lean: true })); + if (!live()) return; + paintHome(await window.API.sections()); + } + + async function renderAsset(id) { + view.innerHTML = `← Back
`; + const { listing: l, on_chain: oc, royalty, listed } = await window.API.get(id); + const owned = oc.has_access; + const isFree = l.op_type === "free"; + // Honest price/currency: human-formatted price + the real pay-token symbol from the gateway + // (price_formatted/pay_token_symbol). Falls back to the raw price / CCY only in standalone/mock. + const sym = oc.pay_token_symbol || CCY; + const hasPrice = oc.price != null && oc.price !== ""; + const priceStr = oc.price_formatted != null ? oc.price_formatted : oc.price; + // forSale = there is an active listing with a real price you can actually buy right now. When false we + // must NOT show a fabricated "0 USDC / 0 copies" — show an honest "not listed for sale" state instead. + const forSale = !isFree && listed && hasPrice; + const buyable = owned || isFree || forSale; + const desc = plainText(l.description); + const cta = owned ? "Open in your library" : (isFree ? "Add to library · free" : (forSale ? (l.op_type === "buy_and_resell" ? "Buy access · resellable" : "Buy access") : "Not listed for sale")); + view.innerHTML = `← Back to Discover +
+
+
${coverUrl(l.image_url) ? "" : mediaIcon(l.medium, "ico-cover")}
${esc(mediumLabel(l.medium))}${owned ? " · in your library" : ""}
${hasDashPreview(l) ? '" : ""}
+

${esc(l.name)}

+
by ${esc(ownerLabel(l.creator_address))} · ${esc(mediumLabel(l.medium))}
+
✓ Identity verified contentId ${short(l.content_id)} (== KID)
+ ${chipsRow(l)} + ${desc ? `

${esc(desc)}

` : ""} +
+
+
+

${owned ? "You own this" : (forSale ? "Buy access" : "Access")}

+
${isFree ? "Free" : (forSale ? esc(priceStr) + " " + esc(sym) : "Not listed for sale")}${forSale && l.listings.length > 1 ? ' · cheapest of ' + l.listings.length + " listings" : ""}
+ ${forSale && fiatUsd(priceStr, sym) ? `
${esc(fiatUsd(priceStr, sym))} USD
` : ""} + ${forSale ? `
Pay token${esc(sym)} · gas ETH
+
Copies available${esc(String(oc.supply_left))}
+
Seller${oc.seller === "primary" ? "Primary" : esc(short(oc.seller))}
` : ""} + + ${owned ? `
+ ` : ""} +
${owned ? "The encrypted file downloads to your node and lives in your library (Acquired) — open it in your player, the marketplace renders nothing." : (forSale ? "You buy the right to open it. On purchase the encrypted file pins to your library and opens in your player." : "No seller currently has this listed for sale. Royalty terms below are read live from the asset's contract.")}
+
+ ${owned && l.op_type === "buy_and_resell" ? `

Your rights

You own access to this asset.
` : ""} + ${splitsPanel(royalty, l.op_type)} + ${propertiesPanel(l, oc, royalty)} + ${desc ? accCard("About", `
${esc(desc)}
`, true) : ""} + ${accCard("Provenance", `
MintAssetCreated event (minted in the creator app)
+
tokenId${esc(oc.token_id)}
`, false)} + ${accCard("History", `
Loading on-chain history…
`, true)} +
+
+
`; + $("#cta").addEventListener("click", () => { + if (owned) return openInLibrary(l); + if (isFree) return openBuy(l, oc); // free = a zero-price access grant — routes a real (unsigned) order to your wallet, then downloads + if (!forSale) return; // not listed for sale — button is disabled, nothing to buy + openBuy(l, oc); + }); + $("#download")?.addEventListener("click", (e) => downloadToNode(l, e.currentTarget)); + $("#reveal")?.addEventListener("click", () => revealInExplorer(l)); + $("#resell")?.addEventListener("click", () => openResale(l, oc, royalty)); + $("#prev-play")?.addEventListener("click", () => playPreview(l, $("#prev-stage"))); + loadAssetHistory(l); // on-chain trade history, fetched after first paint and injected into the panel + loadMoreFromCreator(l); // sibling assets from the same channel, fetched after first paint + } + + // "More from this creator" — other assets minted to the same channel (elacity's creator rail). Fetched + // after the detail paints; hidden entirely when the creator has no other discoverable assets. + async function loadMoreFromCreator(l) { + const el = document.getElementById("more-creator"); + if (!el || !l.channel_address) return; + const sibs = (await window.API.search({ channel: l.channel_address })).filter((x) => x.content_id !== l.content_id); + if (!sibs.length) return; + el.innerHTML = `

More from this creator

${grid(sibs.slice(0, 8))}`; + } + + // One on-chain trade-history row (ItemListed / ItemSold / ItemUnlisted) decoded by the gateway. Honest + // fields only — price formatted with the pay-token decimals; block height + a BaseScan tx link; no emoji. + const EXPLORER_TX = "https://basescan.org/tx/"; + function histRowReal(h) { + const label = h.type === "sale" ? "Sold" : h.type === "list" ? "Listed" : h.type === "unlist" ? "Unlisted" : cap(h.type); + const ic = h.type === "sale" ? "cart" : h.type === "unlist" ? "recycle" : "key"; + const sym = h.pay_token_symbol || ""; + const price = (h.price_formatted != null && h.price_formatted !== "") ? `${h.price_formatted}${sym ? " " + sym : ""}` : ""; + const who = h.type === "sale" ? (h.buyer ? `buyer ${short(h.buyer)}` : "") : (h.seller ? `seller ${short(h.seller)}` : ""); + const meta = [h.block ? `block ${h.block}` : "", h.tx ? `tx ${esc(short(h.tx))}` : ""].filter(Boolean).join(" · "); + return `
${icon(ic)} +
${label}${who ? ` · ${esc(who)}` : ""}${meta ? `
${meta}
` : ""}
+ ${price ? `${esc(price)}` : ""}
`; + } + + async function renderActivity() { + view.innerHTML = `

${icon("bell")} Activity

+
Loading recent on-chain activity…
`; + const hist = await window.API.history(); + const body = $("#act-body"); + if (body) body.innerHTML = (Array.isArray(hist) && hist.length) + ? hist.map(histRowReal).join("") + : '
No on-chain activity in the recent window. Listings and sales appear here as they happen.
'; + } + + async function loadAssetHistory(l) { + const el = document.getElementById("asset-history-body"); + if (!el) return; + const hist = await window.API.assetHistory(l.operative_address, l.token_id); + el.innerHTML = (Array.isArray(hist) && hist.length) + ? hist.map(histRowReal).join("") + : '
No on-chain trade history in the recent window.
'; + } + + // Hand off to the EXISTING runtime open path — the marketplace renders NOTHING. In the runtime this emits + // a Library "open" launch (or POST /api/viewers/open { uri }); the runtime gates rights, recovers the CEK + // inside decrypt-provider, and opens elacity-player/ddrm-viewer (chosen by mime). Standalone: mock the handoff. + // Resolve an owned asset's Library URI: from the listing, then the Vault (by content CID), then — if it's + // held on-chain but not yet downloaded locally — by ACQUIRING it now. Acquire re-checks + // hasAccessByContentId server-side and holds no keys (P15); it fails closed if you don't actually own it. + // Returns "" when it can't be resolved/downloaded. Caches the uri back onto the listing for later actions. + async function resolveUri(l, { acquire = true } = {}) { + if (l.uri) return l.uri; + if (l.content_cid) { + const owned = await window.API.vault(); + const hit = owned.find((o) => o.content_cid && o.content_cid === l.content_cid); + if (hit && hit.uri) { l.uri = hit.uri; return hit.uri; } + } + if (acquire && l.content_id && l.content_cid) { + const r = await window.API.acquire({ content_id: l.content_id, content_cid: l.content_cid, token_uri: l.token_uri, metadata: { name: l.name } }); + const uri = (r && (r.uri || r.library_uri)) || ""; + if (uri) l.uri = uri; + return uri; + } + return ""; + } + + // Explicit "Download to your node" — kick off a BACKGROUND pin (the request doesn't block on a multi-GB + // fetch) and poll the gateway's truthful state. Honest only: the pin is opaque, so there is NO fabricated + // % — we report downloading → downloaded/failed from real server state (file-presence in Acquired, or the + // in-flight run's error). On success the file is on your node and Reveal/Open light up. + async function downloadToNode(l, btn) { + const status = document.getElementById("dlstatus"); + const setStatus = (msg, cls) => { if (status) { status.hidden = false; status.textContent = msg; status.className = "dlstatus muted small" + (cls ? " " + cls : ""); } }; + const done = (msg, cls) => { setStatus(msg, cls); if (btn) btn.disabled = false; }; + if (btn) btn.disabled = true; + if (l.uri) return done("Already downloaded — it's in your library (Acquired)."); + if (!l.content_id || !l.content_cid) return done("This asset has no resolvable content to download.", "err"); + setStatus("Starting the download to your node…"); + const started = await window.API.acquire({ content_id: l.content_id, content_cid: l.content_cid, token_uri: l.token_uri, metadata: { name: l.name }, background: true }); + if (!started) return done("Couldn’t start the download — the asset must be owned (held on-chain) to pull the encrypted file.", "err"); + // Poll real state; the pin keeps running server-side even past our UI deadline (then it shows in the Vault). + const deadline = Date.now() + 5 * 60 * 1000; + let dots = 0; + let blanks = 0; // consecutive reads that report neither progress NOR completion (idle / unreadable status) + const tick = async () => { + const s = await window.API.acquireStatus({ cid: l.content_cid, token_uri: l.token_uri }); + if (s && s.state === "downloaded") { l.uri = s.uri || l.uri; return done("Downloaded — it's in your library (Acquired). Open it in your player or reveal it in File Explorer."); } + if (s && s.state === "failed") return done("Download failed: " + (s.message || "unknown error") + ".", "err"); + if (Date.now() > deadline) return done("Still downloading in the background — it'll appear under your Vault’s Downloaded filter shortly."); + // Truthful progress ONLY when the server confirms a run is in flight; never animate "downloading" for a + // state that isn't actually running. "idle" (no in-flight run materialized — e.g. lost across a restart) + // or an unreadable status are reported honestly after a short grace instead of a fake forever-spinner. + if (s && s.state === "downloading") { + blanks = 0; + dots = (dots % 3) + 1; + setStatus("Downloading the encrypted file to your node" + ".".repeat(dots)); + } else { + blanks += 1; + if (blanks >= 3) return done("Couldn’t confirm the download — it may still be pinning in the background. Check your Vault’s Downloaded filter, or try again.", "err"); + setStatus("Confirming the download to your node…"); + } + setTimeout(tick, 2000); + }; + setTimeout(tick, 1200); + } + + // Reveal the downloaded file in the File Explorer (Library app), opening its containing folder. Downloads + // first if needed (acquire), then asks Home to open the Library at that folder (PC2's openFolder parity). + async function revealInExplorer(l) { + toast(`Locating “${l.name}” in your library…`); + const uri = await resolveUri(l, { acquire: true }); + if (uri && window.API.reveal(uri)) { toast(`Revealed “${l.name}” in File Explorer.`); return; } + toast(uri ? `Couldn’t open File Explorer for “${l.name}”.` : `“${l.name}” isn’t downloaded yet — download it to your node first.`); + } + + async function openInLibrary(l) { + toast(`Opening “${l.name}”…`); + const uri = await resolveUri(l, { acquire: true }); + // Preferred path: ask Home to launch the player (same seam the Library app uses); the viewer capsule + // runs the rights/decrypt open. The marketplace renders nothing and holds no CEK (P15/P16). + if (uri && window.API.openInPlayer({ uri, name: l.name, mime: l.mime_type, medium: l.medium, content_cid: l.content_cid })) { + toast(`Opening “${l.name}” in your player…`); return; + } + // Standalone fallback (no Home parent): the HTTP open path still sets up the session. + if (uri) { const opened = await window.API.open(uri); if (opened) { toast(`Opening “${l.name}” in your player…`); return; } } + toast(uri ? `Couldn’t launch the player for “${l.name}”.` : `“${l.name}” isn’t in your library yet — download it to your node first.`); + } + + // Pre-flight: only the check the gateway can actually verify right now — supply (real on-chain + // supply_left). Wallet balance + ERC-20 allowance are verified by the wallet at signing time, so we do + // NOT assert them here with fabricated ✓ ticks; the order note explains the wallet/abort-on-drift flow. + function preflight(oc) { + return [ + { label: `Supply available (${oc.supply_left} left)`, ok: Number(oc.supply_left) > 0 }, + ]; + } + async function openBuy(l, oc) { + const checks = preflight(oc); + const blocked = checks.find((c) => !c.ok); + const max = Math.max(1, oc.supply_left || 1); + let qty = 1; + // Human price + real symbol for display/totals; the raw oc.price (minor units) is still what + // assembleOrder sends as expected_price for the gateway's abort-on-drift re-read. + const sym = oc.pay_token_symbol || CCY; + const priceHuman = oc.price_formatted != null ? parseFloat(oc.price_formatted) : (Number(oc.price) || 0); + const totalStr = () => (l.op_type === "free" ? "Free" : (priceHuman * qty).toFixed(2) + " " + sym); + let order = blocked ? null : await window.API.assembleOrder({ content_id: l.content_id, quantity: qty, seller: oc.seller, price: oc.price, pay_token: oc.pay_token }); + // Mount ONCE; the qty stepper PATCHES the qty/total/disabled in place (no sheet rebuild → no modal-pop + // replay, no focus loss, no per-tick network call). The order-preview refresh is debounced. + const inner = `

Buy access

+

${esc(l.name)} · ${priceHuman} ${esc(sym)} each

+
You receiveAn access right to ${short(l.content_id)}
+
Quantity${qty}max ${max}
+
Total${totalStr()}
+

Pre-flight

+ ${checks.map((c) => `
${c.ok ? "✓" : "✕"} ${esc(c.label)}
`).join("")} + ${blocked + ? `

Blocked: ${esc(blocked.label)} — fix before buying.

` + : `

Unsigned order — the shell holds no keys. Signed only by your wallet (human-in-loop); terms re-verified from chain and abort on drift before broadcast (Phase-1 invariant).

+
${esc(JSON.stringify(order.unsigned_tx, null, 2))}
+
`}`; + mountModal(inner, "buy-title"); + let previewT; + function setQty(n) { + qty = Math.max(1, Math.min(max, n)); + $("#qty").textContent = qty; + $("#total").textContent = totalStr(); + $("#dec").disabled = qty <= 1; $("#inc").disabled = qty >= max; + if (blocked) return; + clearTimeout(previewT); + previewT = setTimeout(async () => { + order = await window.API.assembleOrder({ content_id: l.content_id, quantity: qty, seller: oc.seller, price: oc.price, pay_token: oc.pay_token }); + const code = $("#order-code"); if (code && order) code.textContent = JSON.stringify(order.unsigned_tx, null, 2); + }, 200); + } + $("#dec")?.addEventListener("click", () => setQty(qty - 1)); + $("#inc")?.addEventListener("click", () => setQty(qty + 1)); + $("#sign")?.addEventListener("click", async (e) => { + const btn = e.currentTarget; + if (btn.dataset.busy) return; // double-submit guard + btn.dataset.busy = "1"; btn.disabled = true; btn.textContent = "Routing to wallet…"; + toast("Routed to your wallet → sign → broadcast → access granted…"); + // After the right lands, TRIGGER the pin into your Library (live: gated by hasAccessByContentId). + const res = await window.API.acquire({ content_id: l.content_id, content_cid: l.content_cid, token_uri: l.token_uri, metadata: { name: l.name } }); + closeModal(); + toast(res && res.uri ? `Downloaded “${l.name}” to your node — it's in your library (Acquired).` : `Purchase complete — finishing the download. Check your Vault.`); + }); + } + + function openResale(l, oc, royalty) { + const sym = (oc && oc.pay_token_symbol) || CCY; + const floor = (oc && oc.price_formatted != null) ? oc.price_formatted : (l.resale_floor || (l.listings[0] && l.listings[0].price) || ""); + // Real on-chain royalty split (read by the gateway); omit entirely if unavailable — no fabricated cut. + const hasSplit = royalty && royalty.available && Array.isArray(royalty.distributions) && royalty.distributions.length; + const splitBlock = hasSplit + ? `

Royalty split · read on-chain

+
${splitBars(royalty.distributions)}
+
${splitLegend(royalty.distributions)}${royalty.reseller_cut_pct != null ? `your resale royalty ${fmtPct(royalty.reseller_cut_pct)}` : ""}
` + : `

Royalties route automatically on-chain on every sale.

`; + mountModal(`

List for resale

+

${esc(l.name)}${floor !== "" ? ` · current floor ${esc(String(floor))} ${esc(sym)}` : ""}

+
+
+ ${splitBlock} +

Requires proof you hold access (anti-spoof). Lists an UNSIGNED order routed to your wallet; royalties route automatically on every hop.

+
`, "resale-title"); + $("#list").addEventListener("click", (e) => { const b = e.currentTarget; if (b.dataset.busy) return; b.dataset.busy = "1"; b.disabled = true; b.textContent = "Routing to wallet…"; setTimeout(() => { closeModal(); toast("Resale listing routed to your wallet — it appears as a secondary listing once signed."); }, 150); }); + } + + let vaultTab = "owned"; + let ownedFilter = "all"; // all | downloaded | onchain — the buyer's "have I pulled the file to my node yet?" axis + // Downloaded = the encrypted file is materialized in your library (Acquired); on-chain only = you hold the + // access token but haven't pulled the bytes to this node yet. Truthful flags from the gateway (acquired/held). + const isDownloaded = (o) => o.acquired === true && !!o.uri; + const inOwnedFilter = (o) => ownedFilter === "all" || (ownedFilter === "downloaded" ? isDownloaded(o) : !isDownloaded(o)); + function listedRow(it) { + return `
+ ${mediaIcon(it.medium)} +
${esc(it.name)}
${esc(String(it.my_qty))} listed · ${esc(String(it.my_price))} ${esc(it.pay_token_symbol || CCY)} each · resale
+
`; + } + async function renderVault() { + const [owned, listed, history] = await Promise.all([window.API.vault(), window.API.listed(), window.API.history()]); + const tab = (id, label, n) => ``; + const dl = owned.filter(isDownloaded).length; + const fchip = (id, label, n) => ``; + const ownedFilterBar = `
${fchip("all", "All", owned.length)}${fchip("downloaded", "Downloaded", dl)}${fchip("onchain", "On-chain only", owned.length - dl)}
`; + let body; + if (vaultTab === "owned") { + const shown = owned.filter(inOwnedFilter); + body = (owned.length ? ownedFilterBar : "") + (shown.length ? grid(shown) + : (owned.length ? '
Nothing in this view. Switch filters, or download an on-chain-only asset to your node.
' + : '
No assets yet. Discover something to buy — it downloads to your node.
')); + } + else if (vaultTab === "listed") body = listed.length ? listed.map(listedRow).join("") : '
No active resale listings. List a resellable asset you own from its page.
'; + else body = history.length ? history.map(histRowReal).join("") : '
No on-chain activity in the recent window.
'; + view.innerHTML = `

${icon("key")} Vault — assets you own & your listings

+
${tab("owned", "Owned", owned.length)}${tab("listed", "Listed", listed.length)}${tab("history", "History")}
+
${body}
+

Owned assets live in your library — open them in your player from there. Minting happens in the creator app.

`; + view.querySelector(".tabs").addEventListener("click", (e) => { const b = e.target.closest("button[data-tab]"); if (b) { vaultTab = b.dataset.tab; renderVault(); } }); + view.querySelector("#vault-body").addEventListener("click", (e) => { const b = e.target.closest("button[data-ofilter]"); if (b) { ownedFilter = b.dataset.ofilter; renderVault(); } }); + view.querySelectorAll("[data-withdraw]").forEach((btn) => btn.addEventListener("click", () => openWithdraw(listed.find((x) => x.listing_id === btn.dataset.withdraw)))); + } + async function openWithdraw(it) { + const order = await window.API.assembleCancel({ operative: it.operative_address, quantity: it.my_qty }); + mountModal(` +

Withdraw listing

${esc(it.name)} · ${esc(String(it.my_qty))} copies @ ${esc(String(it.my_price))} ${CCY}

+

Cancels your resale listing on-chain. Unsigned — routed to your wallet; your access right is unaffected, only the listing is withdrawn.

+
${esc(JSON.stringify(order.unsigned_tx, null, 2))}
+
`, "wd-title"); + $("#wd").addEventListener("click", (e) => { const b = e.currentTarget; if (b.dataset.busy) return; b.dataset.busy = "1"; b.disabled = true; b.textContent = "Routing to wallet…"; setTimeout(() => { closeModal(); toast("Cancel routed to your wallet — the listing is withdrawn once signed."); }, 150); }); + } + + // NOTE: minting lives in the runtime's `creator` capsule, not here. The marketplace is buy/trade only. + // (Removed the in-shell Studio mint wizard — see docs/marketplace/SCOPE.md.) + + // ---- routing ---- + function router() { + const h = location.hash.replace(/^#\/?/, "") || "discover"; + const [route, param] = h.split("/"); + document.querySelectorAll(".rail a").forEach((a) => { + const on = a.dataset.route === route; + a.classList.toggle("active", on); + if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); + }); + if (route === "asset" && param) return renderAsset(decodeURIComponent(param)); + if (route === "vault") return renderVault(); + if (route === "activity") return renderActivity(); + return renderDiscover(); + } + + function syncFacets() { + document.querySelectorAll("#medium-facets button").forEach((b) => b.classList.toggle("on", b.dataset.kind === state.kind)); + document.querySelectorAll("#op-facets button").forEach((b) => b.classList.toggle("on", b.dataset.op === state.op)); + } + + function wire() { + // Cards + rail + back + vault-pill are real now — the hash router handles navigation natively + // (keyboard-operable, focus-ring surfaced), so no JS click delegation is needed for them. + // facets + $("#medium-facets").addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; state.kind = state.kind === b.dataset.kind ? null : b.dataset.kind; syncFacets(); if ((location.hash || "").includes("asset")) location.hash = "#/discover"; else renderDiscover(); }); + $("#op-facets").addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; state.op = state.op === b.dataset.op ? null : b.dataset.op; syncFacets(); if ((location.hash || "").includes("asset")) location.hash = "#/discover"; else renderDiscover(); }); + // category facet (delegated: works for the home bar, the filtered-view bar, and detail-page chips) + view.addEventListener("click", (e) => { + const b = e.target.closest("[data-cat]"); if (!b) return; + e.preventDefault(); + const c = b.dataset.cat || null; + state.category = (state.category && c && state.category.toLowerCase() === c.toLowerCase()) ? null : c; + if ((location.hash || "").includes("asset")) location.hash = "#/discover"; else renderDiscover(); + }); + // search (debounced) + let t; $("#search").addEventListener("input", (e) => { clearTimeout(t); t = setTimeout(() => { state.q = e.target.value.trim(); if ((location.hash || "").includes("asset")) location.hash = "#/discover"; else renderDiscover(); }, 200); }); + // theme + $("#theme-toggle").addEventListener("click", () => { const cur = document.documentElement.getAttribute("data-theme"); document.documentElement.setAttribute("data-theme", cur === "light" ? "dark" : "light"); }); + window.addEventListener("hashchange", router); + } + + async function boot() { + // Inject the inline SVG icons into the static chrome (rail / facets / pills) — one icon source, no emoji. + document.querySelectorAll("[data-icon]").forEach((el) => el.insertAdjacentHTML("afterbegin", icon(el.dataset.icon))); + // Theme toggle: stack sun + moon for the cross-fade (CSS shows one per [data-theme]). + const tt = $("#theme-toggle"); if (tt) tt.innerHTML = icon("sun", "ico-sun") + icon("moon", "ico-moon"); + wire(); router(); + // Resolve "me" (your wallet + handle) non-blocking, then re-render only if a handle actually applies, so + // your own cards relabel from address -> your name. Fail-closed: no handle/no gateway -> no change. + window.API.me().then((m) => { + if (m && m.wallet) { me.wallet = String(m.wallet).toLowerCase(); me.name = m.display_name || ""; if (me.name) router(); } + }).catch(() => {}); + // surface whether a real gateway answered, after first load + setTimeout(() => { $("#wallet-pill").textContent = window.API.live ? "◎ live" : "◎ demo"; }, 400); + } + document.addEventListener("DOMContentLoaded", boot); +})(); diff --git a/capsules/marketplace-content/browser/index.html b/capsules/marketplace-content/browser/index.html new file mode 100644 index 00000000..3557c9c5 --- /dev/null +++ b/capsules/marketplace-content/browser/index.html @@ -0,0 +1,65 @@ + + + + + + Marketplace — ElastOS + + + + + + +
+ + + + +
+
+ + + + + + + + + + diff --git a/capsules/marketplace-content/browser/mock.js b/capsules/marketplace-content/browser/mock.js new file mode 100644 index 00000000..a2e464bb --- /dev/null +++ b/capsules/marketplace-content/browser/mock.js @@ -0,0 +1,52 @@ +/* mock.js — embedded sample data so the shell runs STANDALONE (open index.html). + * It also DOCUMENTS the elastos://market/* listing shape the index/gateway must serve. + * Every field except {tier,medium,listings,resale_floor,holders} comes from content-market's + * decode; those few are index-derived. content_id == bytes16 KID (the trust anchor). + * Pay token on Base = USDC (6 decimals) 0x833589fC… — confirmed against PC2 v3 + the gateway. */ +window.MOCK = (function () { + const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC — the confirmed pay token (gas = ETH) + function L(o) { + const base = { + schema: "elastos.market.listing/v1", + channel_address: "0x6756…b8b9", chain_id: 8453, token_uri: "ipfs://bafy…/metadata.json", + metadata_cid: "bafy…", pay_token: USDC, mime_type: "", asset_type: "", kind: o.kind || "content", // kind: content|app|game (apps/games later) + creator_address: o.creator || "0x4f…a2", metadata_status: "resolved", + op_type_code: { free: 0, buy_once: 1, buy_and_resell: 2 }[o.op_type], + listings: [], resale_floor: null, holders: o.holders || 0, sold: o.sold || 0, + }; + const merged = Object.assign(base, o); + // primary listing + optional resale listings (for cheapest-default + vendor selector) + merged.listings = [{ seller: "primary", price: o.price ?? 0, copies_left: (o.copies||0)-(o.sold||0) }]; + if (o.op_type === "buy_and_resell" && o.price) { + merged.listings.push({ seller: "0x91…7c", price: +(o.price * 0.92).toFixed(2), copies_left: 3 }); + merged.resale_floor = +(o.price * 0.92).toFixed(2); + } + return merged; + } + const listings = [ + L({ content_id: "0x9c2a000000000000000000000000e1a1", name: "Aerials — Episode 1", medium: "watch", tier: 1, op_type: "buy_and_resell", price: 4.0, copies: 500, sold: 388, holders: 388, description: "A short aerial film. ISCC-fingerprinted. Pins to your library on purchase; opens in your player." }), + L({ content_id: "0x7b11000000000000000000000000c0fe", name: "Nocturne (LP)", medium: "listen", tier: 1, op_type: "buy_once", price: 2.5, copies: 1000, sold: 210, creator: "lumen" }), + L({ content_id: "0x33aa00000000000000000000000010ff", name: "The Long Field", medium: "read", tier: 2, op_type: "free", price: 0, copies: 0, creator: "hatch", description: "A novella (PDF), pixel-locked reader. Free to open." }), + L({ content_id: "0xd1ce000000000000000000000000beef", name: "Relic — scan #12", medium: "explore", tier: 5, op_type: "buy_and_resell", price: 9.0, copies: 50, sold: 12, creator: "atlas", description: "A 3D scan (glTF). Orbit-only secure preview." }), + L({ content_id: "0x5501000000000000000000000000a17e", name: "Solar / 03", medium: "view", tier: 2, op_type: "buy_once", price: 1.2, copies: 200, sold: 41, creator: "mira" }), + L({ content_id: "0x6f20000000000000000000000000c01d", name: "Field Notes", medium: "read", tier: 2, op_type: "buy_once", price: 3.3, copies: 300, sold: 96, creator: "koto", description: "Comic (CBZ), pixel-locked pager." }), + L({ content_id: "0x8e44000000000000000000000000dr1f", name: "Drift — short", medium: "watch", tier: 1, op_type: "free", price: 0, copies: 0, creator: "0x91…7c" }), + L({ content_id: "0x2c77000000000000000000000000e3ad", name: "Loops vol.2", medium: "listen", tier: 1, op_type: "buy_and_resell", price: 0.8, copies: 800, sold: 305, creator: "ember" }), + ]; + const owned = [listings[0], listings[2], listings[4]]; // mock "My Vault" + const listed = [{ ...listings[0], listing_id: "0xL1", my_price: 3.7, my_qty: 2 }]; // you listed 2 copies of Aerials for resale + const history = [ + { type: "buy", name: "Aerials — Episode 1", value: "−4.0 USDC", when: "2d ago" }, + { type: "buy", name: "The Long Field", value: "Free", when: "5d ago" }, + { type: "resold", name: "Loops vol.2", value: "+0.74 USDC net", when: "1w ago" }, + { type: "royalty", name: "Solar / 03", value: "+0.18 USDC", when: "1w ago" }, + ]; + const sections = [ + { id: "trending", title: "Trending", ids: listings.slice(0, 4).map(x => x.content_id) }, + { id: "new", title: "New mints", ids: listings.slice(4).map(x => x.content_id) }, + { id: "free", title: "Free to open", filter: x => x.op_type === "free" }, + { id: "resell", title: "Resellable rights", filter: x => x.op_type === "buy_and_resell" }, + { id: "watch", title: "Watch" }, { id: "read", title: "Read" }, + ]; + return { listings, owned, listed, history, sections }; +})(); diff --git a/capsules/marketplace-content/browser/styles.css b/capsules/marketplace-content/browser/styles.css new file mode 100644 index 00000000..5980f517 --- /dev/null +++ b/capsules/marketplace-content/browser/styles.css @@ -0,0 +1,289 @@ +/* marketplace-content — design system. Token-driven craft, motion, and accessibility. + * Owner: Design Director + Design-Systems/Motion + Accessibility leads. + * Class contract is stable (index.html + app.js depend on it); this pass elevates feel & look. + * Follow-up (design-systems): replace emoji glyphs with a real inline-SVG icon set. */ + +/* ---------- TOKENS ---------- */ +:root{ + /* color — semantic, dark default */ + --bg:#0a0c0f; --bg-elev:#0e1116; --panel:#14171d; --panel-2:#1a1e26; --panel-3:#20252e; + --line:#252b34; --line-2:#2f3640; --ink:#eef2f7; --ink-2:#c5ccd6; --mut:#8b94a1; --mut-2:#828b98; + /* brand: elacity turquoise + gold (design-tokens.json; from elacity-web feature/ui-polish-2026). turquoise + is a LIGHT fill, so on-accent text is DARK (matches elacity-web primary.contrastText #0a0a0a). */ + --acc:#5edad9; --acc-press:#3fc7c5; --acc-soft:#0f2a28; --acc2:#daa520; --on-acc:#04211f; + --gold:#daa520; --vivid:#fdecb6; --brand-ink:#131a22; --ok:#34d399; --warn:#fbbf24; --bad:#f87171; + --chip:#16213e; --chip-ink:#bcd0ff; --chip-line:#2b3a5e; + /* type ramp (1.20 minor third) */ + --f:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif; + --fxs:11.5px; --fsm:12.5px; --fbase:14.5px; --flg:16px; --fxl:19px; --f2xl:23px; --f3xl:31px; + --lh:1.55; --lh-tight:1.25; + /* space scale (4pt) */ + --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:24px; --s7:32px; --s8:44px; + /* radius + elevation */ + --r-sm:9px; --r:13px; --r-lg:18px; --r-pill:999px; + --e1:0 1px 0 rgba(255,255,255,.03),0 1px 2px rgba(0,0,0,.3); + --e2:0 1px 0 rgba(255,255,255,.04),0 6px 18px rgba(0,0,0,.32); + --e3:0 1px 0 rgba(255,255,255,.05),0 18px 44px rgba(0,0,0,.46); + /* shadow-as-border depth (elacity-web ui-polish-2026 shadowBorder) — dark collapses to one translucent ring */ + --depth:0 0 0 1px rgba(255,255,255,.08); --depth-hover:0 0 0 1px rgba(255,255,255,.13); + --ring:0 0 0 2px var(--bg),0 0 0 4px var(--acc); + --t-fast:.12s cubic-bezier(.2,.6,.2,1); --t:.2s cubic-bezier(.2,.6,.2,1); --t-slow:.32s cubic-bezier(.2,.6,.2,1); +} +[data-theme=light]{ + --bg:#f7f8fa; --bg-elev:#fff; --panel:#fff; --panel-2:#f1f3f6; --panel-3:#e9edf2; + --line:#e4e8ee; --line-2:#d7dde5; --ink:#0e131a; --ink-2:#2c3340; --mut:#5b6573; --mut-2:#838c98; + /* light: turquoise is too light to read as text/fill on a light bg, so the accent deepens to a teal that + stays AA both as accent-text and as a white-text fill (dark theme keeps the vivid turquoise). */ + --acc:#0a7370; --acc-press:#086460; --on-acc:#fff; --acc-soft:#e0f5f4; --chip:#e0f5f4; --chip-ink:#0a6360; --chip-line:#bfe9e7; + --e1:0 1px 2px rgba(20,30,50,.05); --e2:0 1px 2px rgba(20,30,50,.06),0 8px 22px rgba(20,30,50,.08); + --e3:0 2px 6px rgba(20,30,50,.08),0 18px 44px rgba(20,30,50,.12); --ring:0 0 0 2px var(--bg),0 0 0 4px var(--acc); + --depth:0 0 0 1px rgba(0,0,0,.06),0 1px 2px -1px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04); --depth-hover:0 0 0 1px rgba(0,0,0,.08),0 1px 2px -1px rgba(0,0,0,.08),0 2px 4px rgba(0,0,0,.06); +} + +/* ---------- BASE ---------- */ +*{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--ink);font:var(--fbase)/var(--lh) var(--f); + -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-optical-sizing:auto;text-rendering:optimizeLegibility} +.wrap{max-width:1240px;margin:0 auto;padding:0 var(--s6)} .row{display:flex;align-items:center;gap:var(--s4)} +.muted{color:var(--mut)} .small{font-size:var(--fsm)} .spacer{flex:1} a{color:inherit;text-decoration:none} +:focus-visible{outline:none;box-shadow:var(--ring);border-radius:8px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} + +/* ---------- PRIMITIVES ---------- */ +.lockchip{display:inline-flex;align-items:center;gap:5px;background:var(--chip);color:var(--chip-ink); + border:1px solid var(--chip-line);border-radius:var(--r-pill);padding:3px 10px;font-size:var(--fxs);font-weight:600;letter-spacing:.2px} +.pill{display:inline-flex;align-items:center;gap:6px;background:var(--panel-2);border:1px solid var(--line); + border-radius:var(--r-pill);padding:6px 12px;font-size:var(--fsm);color:var(--ink-2)} +.btn{appearance:none;background:linear-gradient(180deg,var(--acc),var(--acc-press));color:var(--on-acc);border:0; + border-radius:11px;padding:11px 17px;font:600 var(--fbase)/1 var(--f);cursor:pointer; + transition:transform var(--t-fast),box-shadow var(--t-fast),filter var(--t-fast);box-shadow:var(--e1)} +.btn:hover{filter:brightness(1.06);box-shadow:var(--e2)} .btn:active:not(:disabled){transform:scale(.96)} +.btn.block{width:100%} .btn.ghost{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);box-shadow:none} +.btn.ghost:hover{background:var(--panel-3);filter:none} .btn:disabled{opacity:.45;cursor:not-allowed;filter:none;transform:none} +.glyph{width:36px;height:36px;display:inline-grid;place-items:center;border-radius:var(--r-sm);background:var(--panel-2); + border:1px solid var(--line);font-size:15px;cursor:pointer;color:var(--ink);transition:background var(--t-fast),transform var(--t-fast)} +.glyph:hover{background:var(--panel-3)} .glyph:active{transform:scale(.96)} +.badge{font-size:var(--fxs);color:var(--ok);border:1px solid #1f4d3a;background:#0f2a20;border-radius:7px;padding:2px 7px;font-weight:600} +[data-theme=light] .badge{background:#e7f8f0;border-color:#bfe9d6;color:#0a7a52} + +/* ---------- NAV ---------- */ +.nav{position:sticky;top:0;z-index:5;background:color-mix(in srgb,var(--bg) 78%,transparent); + backdrop-filter:saturate(140%) blur(12px);border-bottom:1px solid var(--line)} +.nav .row{height:62px} .logo{font-weight:800;font-size:var(--flg);letter-spacing:-.2px} .logo b{color:var(--acc)} +.search{flex:1;max-width:540px;display:flex;align-items:center;gap:9px;background:var(--panel);border:1px solid var(--line); + border-radius:11px;padding:0 13px;transition:border-color var(--t-fast),box-shadow var(--t-fast)} +.search:focus-within{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft)} +.search .kbd{color:var(--mut-2);font-size:var(--fsm);font-variant-numeric:tabular-nums} +.search input{flex:1;background:none;border:0;outline:none;color:var(--ink);padding:11px 0;font:var(--fbase) var(--f)} +.search input::placeholder{color:var(--mut-2)} + +/* ---------- SHELL ---------- */ +.shell{display:grid;grid-template-columns:218px minmax(0,1fr);gap:var(--s7);padding:var(--s6) 0} +.rail{position:sticky;top:78px;align-self:start} +.rail a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:10px;font-weight:600;cursor:pointer; + color:var(--ink-2);transition:background var(--t-fast),color var(--t-fast)} +.rail a:hover{background:var(--panel);color:var(--ink)} +.rail a.active{background:var(--panel);border:1px solid var(--line);color:var(--ink);box-shadow:var(--e1)} +.rail .lbl{margin:var(--s5) 0 var(--s2);color:var(--mut-2);font-size:var(--fxs);text-transform:uppercase;letter-spacing:.9px} +.facets{display:flex;flex-direction:column;gap:2px} +.facets button{text-align:left;background:none;border:0;color:var(--mut);font:var(--fsm) var(--f);padding:7px 12px; + border-radius:9px;cursor:pointer;transition:background var(--t-fast),color var(--t-fast)} +.facets button:hover{color:var(--ink);background:var(--panel)} +.facets button.on{color:var(--ink);background:var(--acc-soft);border:1px solid var(--chip-line);font-weight:600} + +/* ---------- VIEW MOTION ---------- */ +#view{animation:viewin var(--t-slow)} @keyframes viewin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} + +/* ---------- HERO ---------- */ +.hero{position:relative;border-radius:var(--r-lg);overflow:hidden;border:1px solid var(--line);padding:var(--s7); + background:radial-gradient(130% 130% at 92% 8%,rgba(124,91,255,.38),transparent 55%), + radial-gradient(130% 130% at 8% 92%,rgba(91,140,255,.32),transparent 52%),var(--panel);box-shadow:var(--e2)} +.hero h1{margin:.18em 0;font-size:var(--f3xl);line-height:var(--lh-tight);letter-spacing:-.5px} +.hero p{max-width:580px;color:var(--ink-2)} + +/* ---------- SHELVES + CARDS ---------- */ +.shelf{margin:var(--s7) 0 var(--s2);display:flex;align-items:baseline;justify-content:space-between} +.shelf h3{margin:0;font-size:var(--fxl);letter-spacing:-.2px} +.shelf .more{color:var(--acc);font-size:var(--fsm);cursor:pointer;font-weight:600;background:none;border:0;font-family:inherit;padding:0} +/* Card grid + card = elacity CapsuleCard parity: transparent card (no border/bg/shadow/hover-lift), a + rounded 16:9 thumbnail floating above text. Thumb radius = elacity theme.shape.borderRadius (12px). */ +.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s5)} +.card{min-width:0;background:transparent;border:0;border-radius:8px;overflow:visible;cursor:pointer;display:block;color:inherit;text-decoration:none} +.thumb{aspect-ratio:16/9;position:relative;display:grid;place-items:center;color:#fff;font-size:30px; + border-radius:12px;overflow:hidden;background-size:cover;background-position:center} +.card:hover .t{color:var(--acc)} +.g-watch{background:linear-gradient(135deg,#3b2f63,#5b8cff)} .g-listen{background:linear-gradient(135deg,#143b2e,#34d399)} +.g-read{background:linear-gradient(135deg,#3a2740,#c084fc)} .g-view{background:linear-gradient(135deg,#3a2a18,#fbbf24)} +.g-explore{background:linear-gradient(135deg,#10243f,#38bdf8)} +/* overlay chips on the thumbnail (elacity TokenMeta: paper pill, 11.5px/500, radius 4px, 6px insets) */ +.tm{position:absolute;z-index:2;display:inline-flex;align-items:center;gap:5px;background:var(--panel);color:var(--ink); + font-size:11.5px;font-weight:500;line-height:1.45;padding:2px 8px;border-radius:4px;box-shadow:var(--e1)} +.tm .ico{width:13px;height:13px} +.tm-listings{top:6px;left:6px} .tm-duration{bottom:6px;right:6px} +.tm-resell{position:absolute;top:6px;right:6px;z-index:2;display:flex;flex-direction:column;align-items:flex-end;gap:5px} +.tm-resell .tm{position:static} +.tm-xl{font-size:13px;padding:3px 6px} +.tm-off{background:var(--bad);color:#fff} +/* below the thumbnail: avatar + 2-line title + owner | price (right). Padding 8/8/12 (elacity CardContent) */ +.meta{padding:8px 8px 12px} +.meta .head{display:flex;align-items:flex-start;gap:8px} +.ava{flex:none;width:40px;height:40px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px} +.meta .who{min-width:0;flex:1;overflow:hidden} +.meta .t{font-weight:500;font-size:15.5px;line-height:1.15;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical; + overflow:hidden;overflow-wrap:break-word;transition:color var(--t-fast)} +.meta .owner{color:var(--mut);font-size:12.5px;line-height:1.2;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.meta .pricebtn{flex:none;font-weight:600;font-size:15px;white-space:nowrap;color:var(--ink);padding-left:8px} +.meta .pricebtn.free{color:var(--ok)} .meta .pricebtn.muted{color:var(--mut);font-weight:500} +.more-rail{margin-top:var(--s7)} +.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} +.chip{display:inline-flex;align-items:center;font-size:12.5px;line-height:1;color:var(--mut);background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:6px 10px} +a.chip-cat{cursor:pointer;transition:background .15s,color .15s} +a.chip-cat:hover{color:var(--ink);background:var(--panel-2)} +.catbar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 var(--s5)} +.catchip{font:inherit;font-size:13px;line-height:1;color:var(--mut);background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:8px 14px;cursor:pointer;transition:background .15s,color .15s,border-color .15s} +.catchip:hover{color:var(--ink);background:var(--panel-2)} +.catchip.on{color:var(--bg);background:var(--ink);border-color:var(--ink);font-weight:600} +.chip-cat{color:var(--ink);border-color:transparent;background:var(--panel-3);font-weight:500} +.prevplay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-flex;align-items:center;gap:8px; + background:rgba(0,0,0,.6);color:#fff;border:0;border-radius:999px;padding:11px 20px;font:inherit;font-size:15px;font-weight:600; + cursor:pointer;backdrop-filter:blur(4px);transition:background .15s,transform .15s} +.prevplay:hover{background:rgba(0,0,0,.78);transform:translate(-50%,-50%) scale(1.04)} +.prevplay .ico{width:18px;height:18px} +.cover .previewvid{position:absolute;inset:0;width:100%;height:100%;background:#000;object-fit:contain;border-radius:inherit} +.cover .prevmsg{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;text-align:center;color:var(--mut);font-size:var(--fsm)} +.histrow{display:flex;align-items:center;gap:12px} +.histrow .glyph{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:color-mix(in srgb,var(--mut) 14%,transparent);color:var(--mut);flex:none} +.histrow .cid{text-decoration:none} .histrow .cid:hover{color:var(--ink)} +.price{font-weight:800;font-variant-numeric:tabular-nums} + +/* ---------- ASSET DETAIL ---------- */ +.detail{display:grid;grid-template-columns:1fr 380px;gap:var(--s7);margin-top:var(--s1)} +.back{display:inline-flex;gap:7px;color:var(--mut);cursor:pointer;margin:var(--s1) 0 var(--s4);font-size:var(--fsm); + transition:color var(--t-fast)} .back:hover{color:var(--ink)} +.stage{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--e2)} +.cover{aspect-ratio:16/9;position:relative;display:grid;place-items:center;font-size:54px;color:#fff} +.cover::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 62%,rgba(0,0,0,.30))} +.covertag{position:absolute;left:var(--s4);bottom:var(--s3);font-size:var(--fsm);color:#e7edf5;text-transform:capitalize;z-index:1;background:rgba(0,0,0,.35);padding:3px 10px;border-radius:999px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)} +.stagehead{padding:var(--s5);border-top:1px solid var(--line)} +.stagehead h2{margin:0 0 var(--s1);font-size:var(--f2xl);letter-spacing:-.3px} +.verified{display:inline-flex;align-items:center;gap:6px;color:var(--ok);font-size:var(--fsm);margin-top:var(--s2)} +.cid{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--mut);font-size:12px} +.cardx{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);padding:var(--s5);margin-bottom:var(--s4);box-shadow:var(--e1)} +.cardx h4{margin:0 0 var(--s3);font-size:var(--fxs);color:var(--mut);text-transform:uppercase;letter-spacing:.8px} +.kv{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin:8px 0;font-size:var(--fbase)} +.kv .k{color:var(--mut);flex:none} .kv>span:last-child{min-width:0;text-align:right;overflow-wrap:anywhere} +.bigprice{font-size:var(--f2xl);font-weight:800;margin:2px 0;letter-spacing:-.4px;font-variant-numeric:tabular-nums} +.fiat{margin:-2px 0 6px;font-variant-numeric:tabular-nums} +details.acc>summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px} +details.acc>summary::-webkit-details-marker{display:none} +details.acc>summary h4{margin:0} +details.acc>summary::after{content:"";width:8px;height:8px;flex:none;margin-right:2px;border-right:2px solid var(--mut);border-bottom:2px solid var(--mut);transform:rotate(-45deg);transition:transform .18s} +details.acc[open]>summary::after{transform:rotate(45deg)} +.accbody{margin-top:14px} +.stepper{display:flex;align-items:center;gap:10px;margin:var(--s3) 0} +.stepper button{width:36px;height:36px;border-radius:var(--r-sm);background:var(--panel-2);border:1px solid var(--line); + color:var(--ink);font-size:18px;cursor:pointer;transition:background var(--t-fast),transform var(--t-fast)} +.stepper button:hover{background:var(--panel-3)} .stepper button:active:not(:disabled){transform:scale(.96)} +.note{color:var(--mut);font-size:var(--fsm);margin-top:var(--s3);text-align:center} +.splits{display:flex;height:13px;border-radius:7px;overflow:hidden;margin:var(--s2) 0 var(--s3);border:1px solid var(--line)} +.splits i{display:block;transition:width var(--t-slow)} .s1{background:var(--acc)} .s2{background:var(--acc2)} .s3{background:var(--ok)} .s4{background:#475569} +.legend{display:flex;flex-wrap:wrap;gap:var(--s3);font-size:12px;color:var(--mut)} .legend b{color:var(--ink)} +.dot{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px;vertical-align:middle} + +/* ---------- VAULT / FORMS / GENERIC ---------- */ +.gridv{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--s4);margin-top:var(--s4)} +.tabs{display:flex;gap:var(--s2);margin:var(--s4) 0} +.tabs button{background:var(--panel-2);border:1px solid var(--line);color:var(--mut);border-radius:9px;padding:8px 14px; + cursor:pointer;font:var(--fbase) var(--f);transition:background var(--t-fast),color var(--t-fast)} +.tabs button:hover{color:var(--ink)} .tabs button.on{color:var(--ink);background:var(--panel);font-weight:700;box-shadow:var(--e1)} +.field{display:flex;flex-direction:column;gap:6px;margin:var(--s3) 0} +.field input,.field select,.field textarea{background:var(--panel-2);border:1px solid var(--line);border-radius:10px; + color:var(--ink);padding:11px 13px;font:var(--fbase) var(--f);transition:border-color var(--t-fast),box-shadow var(--t-fast)} +.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft)} +.facets button[disabled]{opacity:.5;cursor:default} +.empty{border:1px dashed var(--line-2);border-radius:var(--r-lg);padding:var(--s8);text-align:center;color:var(--mut)} + +/* ---------- MODAL + TOAST ---------- */ +#modal-root .overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(3px);display:grid; + place-items:center;z-index:20;padding:var(--s5);animation:fade var(--t)} +@keyframes fade{from{opacity:0}to{opacity:1}} +.sheet{background:var(--bg-elev);border:1px solid var(--line-2);border-radius:var(--r-lg);max-width:460px;width:100%; + padding:var(--s6);box-shadow:var(--e3);animation:pop var(--t-slow)} +@keyframes pop{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}} +/* Subtle EXIT (skill: softer than the enter — short ease-in, small translateY). The .closing class is added + by closeModal() ~150ms before the DOM is cleared; reduced-motion disables it (the clear still fires). */ +#modal-root .overlay.closing{animation:fade-out .15s ease-in forwards} +#modal-root .overlay.closing .sheet{animation:pop-out .15s ease-in forwards} +@keyframes fade-out{to{opacity:0}} +@keyframes pop-out{to{opacity:0;transform:translateY(-8px) scale(.99)}} +.sheet h3{margin:0 0 var(--s1);font-size:var(--fxl)} .sheet .x{float:right;cursor:pointer;color:var(--mut);font-size:18px;background:none;border:0;font-family:inherit;line-height:1;padding:4px} +.sheet .x:hover{color:var(--ink)} +.code{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;background:var(--panel-2);border:1px solid var(--line); + border-radius:10px;padding:var(--s3);white-space:pre-wrap;word-break:break-all;color:var(--mut);max-height:170px;overflow:auto} +.toast{position:fixed;left:50%;bottom:var(--s6);transform:translateX(-50%);background:var(--bg-elev);border:1px solid var(--line-2); + border-radius:11px;padding:12px 17px;box-shadow:var(--e3);z-index:30;animation:toastin var(--t)} +@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}} +.skeleton{background:linear-gradient(90deg,var(--panel) 25%,var(--panel-2) 37%,var(--panel) 63%);background-size:400% 100%; + animation:sh 1.4s ease-in-out infinite;border-radius:12px;aspect-ratio:16/9} +@keyframes sh{0%{background-position:100% 0}100%{background-position:-100% 0}} + +/* ---------- RESPONSIVE ---------- */ +@media(max-width:1080px){.detail{grid-template-columns:1fr 340px}} +@media(max-width:980px){.shell{grid-template-columns:minmax(0,1fr)}.rail{display:none}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.detail{grid-template-columns:1fr}} +@media(max-width:560px){.cards{grid-template-columns:minmax(0,1fr)}.hero h1{font-size:var(--f2xl)}.search{display:none}} + +/* ---------- FEEL POLISH (make-interfaces-feel-better — the principles not already in the system) ---------- */ +/* Balanced headings (no orphan words / lopsided wraps); pretty body wrapping (no single-word last lines). */ +.hero h1,.shelf h3,.stagehead h2,.sheet h3,.cardx h4{text-wrap:balance} +.hero p,.note,.meta .c,.legend,.empty,.kv,.small{text-wrap:pretty} +/* Tabular numerals on EVERY value that can change (incl. the live result count + wallet pill), so a + price/qty/supply/count update never shifts layout. */ +.kv,.stepper,.legend,.bigprice,.price,.covertag,.cid,.shelf h3,.pill{font-variant-numeric:tabular-nums} +/* Inset outline on media covers — PURE neutrals only (skill non-negotiable: a tinted ring reads as dirt on + the poster edge): pure white in dark, pure black in light. */ +.thumb,.cover{box-shadow:inset 0 0 0 1px rgba(255,255,255,.1)} +[data-theme=light] .thumb,[data-theme=light] .cover{box-shadow:inset 0 0 0 1px rgba(0,0,0,.1)} + +/* ---------- INLINE ICONS (lucide-style stroke, inherits currentColor — no emoji) ---------- */ +.ico{width:16px;height:16px;flex:none} /* default inline size (rail/facets/pills) */ +.facets button{display:flex;align-items:center;gap:9px} /* icon + label, left-aligned with a gap */ +.badge{display:inline-flex;align-items:center;gap:4px} +.lockchip .ico,.badge .ico{width:13px;height:13px} +.glyph .ico{width:18px;height:18px} +.ico-cover{width:34px;height:34px;opacity:.95} /* the card thumb glyph */ +.cover .ico-cover{width:56px;height:56px} /* the detail-stage cover glyph */ + +/* ---------- SKELETON BAR + THEME CROSS-FADE ---------- */ +/* loading placeholder bars (no aspect-ratio, unlike .skeleton) — reuse the sh shimmer */ +.sk{background:linear-gradient(90deg,var(--panel) 25%,var(--panel-2) 37%,var(--panel) 63%);background-size:400% 100%;animation:sh 1.4s ease-in-out infinite;border-radius:8px} +/* theme toggle: cross-fade sun<->moon (skill icon-animation: scale .25->1, opacity 0->1, blur 4->0). The + reduced-motion guard disables the transition; the [data-theme] state still swaps instantly. */ +.theme{position:relative} +.theme .ico{position:absolute;top:50%;left:50%;width:18px;height:18px;margin:-9px 0 0 -9px; + transition:opacity .3s cubic-bezier(.2,0,0,1),transform .3s cubic-bezier(.2,0,0,1),filter .3s cubic-bezier(.2,0,0,1)} +.theme .ico-sun{opacity:0;transform:scale(.25);filter:blur(4px)} +.theme .ico-moon{opacity:1;transform:scale(1);filter:blur(0)} +[data-theme=light] .theme .ico-sun{opacity:1;transform:scale(1);filter:blur(0)} +[data-theme=light] .theme .ico-moon{opacity:0;transform:scale(.25);filter:blur(4px)} + +/* ---------- BRAND (elacity turquoise->gold; transplanted from elacity-web, values-only — no MUI) ---------- */ +/* Signature gradient text. @supports-guarded: only goes transparent where background-clip:text works, else + falls back to the solid accent so the wordmark never disappears in an older webview. */ +.grad-text{color:var(--acc)} +.glassy{background:color-mix(in srgb,var(--bg-elev) 72%,transparent); + backdrop-filter:blur(9px) saturate(1.3);-webkit-backdrop-filter:blur(9px) saturate(1.3);border:1px solid var(--line)} +@supports ((-webkit-background-clip:text) or (background-clip:text)){ + .grad-text,.logo{background:linear-gradient(92deg,var(--acc),var(--gold)); + -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent} + .logo b{-webkit-text-fill-color:transparent} +} + +/* Owned-asset actions on the detail card: Download + Reveal side by side, with an honest status line. */ +.ownedrow{display:flex;gap:8px;margin-top:10px} +.ownedrow .btn{flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding-inline:10px} +.dlstatus{margin-top:10px;line-height:1.45} +.dlstatus.err{color:var(--warn)} +/* Vault "Downloaded / On-chain only" facet row — horizontal pills (overrides the sidebar .facets column). */ +.vault-facets{flex-direction:row;flex-wrap:wrap;gap:8px;margin-bottom:14px} +.vault-facets button{border:1px solid var(--line);border-radius:999px;padding:6px 13px} diff --git a/capsules/marketplace-content/capsule.json b/capsules/marketplace-content/capsule.json new file mode 100644 index 00000000..288550b5 --- /dev/null +++ b/capsules/marketplace-content/capsule.json @@ -0,0 +1,14 @@ +{ + "schema": "elastos.capsule/v1", + "name": "marketplace-content", + "version": "0.1.0", + "description": "The marketplace: discover, buy, trade, list, and withdraw dDRM assets (content today; apps & games later, same storefront). A pure UI shell — reads listings from elastos://market/* (re-derivable index over content-market's chain-truth decode), requests UNSIGNED orders, and routes all signing to the wallet. On buy it TRIGGERS a pin of the encrypted asset into the local Library; opening is handed off to the runtime player (POST /api/viewers/open) and minting to the creator app. Mints nothing, plays/renders nothing, holds NO signer, token, CEK, chain RPC, or mutation handle (Principle 16).", + "role": "app", + "type": "wasm", + "author": "elastos", + "entrypoint": "marketplace-content.wasm", + "resources": { + "memory_mb": 24, + "gpu": false + } +} diff --git a/capsules/marketplace-content/wasm/main.rs b/capsules/marketplace-content/wasm/main.rs new file mode 100644 index 00000000..04026bbc --- /dev/null +++ b/capsules/marketplace-content/wasm/main.rs @@ -0,0 +1,20 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +// Thin launcher for the marketplace-content storefront capsule. The UI lives in `browser/` (HTML/CSS/JS); +// the runtime instantiates this `.wasm` as the capsule entrypoint under the capability sandbox (zero +// ambient authority, P3/P16) and serves the frontend. The shell holds no signer/token/CEK/RPC — all +// `/api/market/*` calls flow through the runtime capability plane; signing is routed to wallet-provider. +fn main() { + let info = elastos_guest::CapsuleInfo::from_env(); + let launched_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + + eprintln!( + "marketplace-content capsule launched: name={} id={} ts={}", + info.name(), + info.id(), + launched_at + ); +} diff --git a/capsules/media-provider/src/main.rs b/capsules/media-provider/src/main.rs index 8efd1ac7..b925a585 100644 --- a/capsules/media-provider/src/main.rs +++ b/capsules/media-provider/src/main.rs @@ -15,8 +15,12 @@ //! No ambient authority (PRINCIPLE #3): the only external tool is ffmpeg/ffprobe — //! the same dependency PC2 has — and its path + a scratch directory are supplied by //! a narrow operator config (`ELASTOS_MEDIA_PROVIDER_CONFIG`). No network. Confined -//! to the scratch dir. Unconfigured ⇒ explicit `not_configured` error, never a -//! silent skip (PRINCIPLE #11 — fail closed). +//! to the scratch dir, plus one optional, caller-granted write capability: when the +//! `package_dash` request carries `progress_path`, the provider writes ONLY transcode +//! progress (`{stage,pct}`) to that single path the host explicitly handed in for the +//! call — capability passing, not path discovery. Absent the field, nothing is written. +//! Unconfigured ⇒ explicit `not_configured` error, never a silent skip (PRINCIPLE #11 — +//! fail closed); a progress-write failure is non-fatal and never blocks the mint. //! //! Parity anchors (from PC2 source): //! transcode: AV1 ladder (`av1_nvenc` GPU → `libsvtav1` CPU → `libx264` fallback, @@ -30,11 +34,11 @@ use ddrm_media::{mp4, mpd}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::io::{self, BufRead, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { Some(version) => version, @@ -284,6 +288,12 @@ enum Request { /// `media.ts:1753`). Capped at 60s. The clip never carries the CEK — it's a teaser. #[serde(default)] preview_duration: Option, + /// Optional, caller-granted progress sink: an absolute path the host owns and polls. + /// When present the provider writes `{"stage":"transcode","pct":N}` here as ffmpeg + /// advances (measured against the ffprobe duration). Capability passing — the provider + /// writes ONLY here, only this. Absent ⇒ no progress is reported (UI stays indeterminate). + #[serde(default)] + progress_path: Option, }, Shutdown, } @@ -334,10 +344,12 @@ impl MediaProvider { content_b64, filename, preview_duration, + progress_path, } => self.package_dash( &content_b64, filename.as_deref(), preview_duration.unwrap_or(0), + progress_path.as_deref(), ), Request::Shutdown => Response::empty_ok(), } @@ -477,7 +489,7 @@ impl MediaProvider { r: &Rendition, probe: &ProbeResult, ) -> Result { - let frag_bytes = self.fragment_rendition(tools, workdir, input, r, probe)?; + let frag_bytes = self.fragment_rendition(tools, workdir, input, r, probe, None)?; let split = mp4::split_fragmented(&frag_bytes)?; let meta = mp4::parse_fragment_metadata(&frag_bytes)?; @@ -504,6 +516,7 @@ impl MediaProvider { input: &Path, r: &Rendition, probe: &ProbeResult, + progress: Option<&Path>, ) -> Result, String> { let transcoded = workdir.join(format!("t-{}.mp4", r.id)); let fragmented = workdir.join(format!("f-{}.mp4", r.id)); @@ -577,8 +590,23 @@ impl MediaProvider { .arg("-b:a") .arg(&r.audio_bitrate); } + // Measured transcode progress (the long pole): when the caller granted a progress + // sink AND we know the source duration, stream ffmpeg's `-progress` and report + // out_time ÷ duration. Otherwise run the plain blocking form (UI stays indeterminate). + let want_progress = progress.is_some() && probe.duration > 0.0; + if want_progress { + tx.arg("-progress").arg("pipe:1").arg("-nostats"); + } tx.arg(&transcoded); - run_ffmpeg(&mut tx, &format!("transcode {}", r.id))?; + match (want_progress, progress) { + (true, Some(sink)) => run_ffmpeg_with_progress( + &mut tx, + &format!("transcode {}", r.id), + probe.duration, + sink, + )?, + _ => run_ffmpeg(&mut tx, &format!("transcode {}", r.id))?, + } // Step 2 — fragment: copy streams into a fragmented MP4. let mut fr = Command::new(&tools.ffmpeg); @@ -606,6 +634,7 @@ impl MediaProvider { content_b64: &str, filename: Option<&str>, preview_duration: u64, + progress_path: Option<&str>, ) -> Response { let tools = match self.config.resolve() { Ok(t) => t, @@ -620,7 +649,15 @@ impl MediaProvider { Ok(d) => d, Err(e) => return Response::error("scratch_error", e), }; - let result = self.package_dash_in(&tools, &workdir, &bytes, filename, preview_duration); + let progress = progress_path.map(Path::new); + let result = self.package_dash_in( + &tools, + &workdir, + &bytes, + filename, + preview_duration, + progress, + ); let _ = std::fs::remove_dir_all(&workdir); match result { Ok(data) => Response::ok(data), @@ -635,6 +672,7 @@ impl MediaProvider { bytes: &[u8], filename: Option<&str>, preview_duration: u64, + progress: Option<&Path>, ) -> Result { let ext = filename .and_then(|f| Path::new(f).extension()) @@ -679,7 +717,7 @@ impl MediaProvider { None }; - let frag_bytes = self.fragment_rendition(tools, workdir, &input, &top, &probe)?; + let frag_bytes = self.fragment_rendition(tools, workdir, &input, &top, &probe, progress)?; let streams = mp4::demux_tracks(&frag_bytes)?; let meta = mp4::parse_fragment_metadata(&frag_bytes)?; @@ -874,6 +912,93 @@ fn run_ffmpeg(cmd: &mut Command, label: &str) -> Result<(), String> { Ok(()) } +/// Run ffmpeg with `-progress pipe:1` and stream measured progress to the caller-granted +/// `progress_path`. `cmd` must already carry `-progress pipe:1`. stderr is drained on a +/// side thread (so a full pipe can't deadlock the encode) and only its tail is surfaced on +/// failure. Progress writes are throttled and best-effort: a write error never fails the run. +fn run_ffmpeg_with_progress( + cmd: &mut Command, + label: &str, + duration_secs: f64, + progress_path: &Path, +) -> Result<(), String> { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| format!("ffmpeg spawn failed ({label}): {e}"))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("ffmpeg {label}: no progress pipe"))?; + // Drain stderr concurrently to avoid a pipe-buffer deadlock on long encodes. + let stderr = child.stderr.take(); + let err_handle = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(se) = stderr { + let _ = BufReader::new(se).read_to_string(&mut buf); + } + buf + }); + + let total_us = (duration_secs * 1_000_000.0).max(1.0); + let mut last_pct: i32 = -1; + let mut last_write = Instant::now() + .checked_sub(Duration::from_secs(1)) + .unwrap_or_else(Instant::now); + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if let Some(us) = parse_out_time_us(&line) { + let pct = pct_from_us(us, total_us); + if pct > last_pct && last_write.elapsed() >= Duration::from_millis(250) { + last_pct = pct; + last_write = Instant::now(); + write_transcode_progress(progress_path, pct); + } + } + } + + let status = child + .wait() + .map_err(|e| format!("ffmpeg wait failed ({label}): {e}"))?; + let stderr_buf = err_handle.join().unwrap_or_default(); + if !status.success() { + let tail: String = stderr_buf + .lines() + .rev() + .take(8) + .collect::>() + .join(" | "); + return Err(format!("ffmpeg {label} exited {status}: {tail}")); + } + Ok(()) +} + +/// Parse the microsecond timestamp from an ffmpeg `-progress` line, tolerating the +/// `out_time_us` (preferred) and legacy `out_time_ms` (actually µs in ffmpeg) keys. +fn parse_out_time_us(line: &str) -> Option { + let v = line + .strip_prefix("out_time_us=") + .or_else(|| line.strip_prefix("out_time_ms="))?; + let parsed = v.trim().parse::().ok()?; + (parsed >= 0.0).then_some(parsed) +} + +/// Clamp progress to 0..=99 while encoding (100 is reserved for server-confirmed completion). +fn pct_from_us(out_time_us: f64, total_us: f64) -> i32 { + ((out_time_us / total_us) * 100.0).clamp(0.0, 99.0) as i32 +} + +/// Atomically publish `{stage,pct}` to the caller's sink (temp + rename so the host never +/// reads a torn write). Best-effort: any failure is swallowed — progress is non-critical. +fn write_transcode_progress(path: &Path, pct: i32) { + let tmp = path.with_extension("tmp"); + let body = format!("{{\"stage\":\"transcode\",\"pct\":{pct}}}"); + if std::fs::write(&tmp, body.as_bytes()).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + // --------------------------------------------------------------------------- // base64 (engine::general_purpose::STANDARD) — small wrappers for readability. // --------------------------------------------------------------------------- @@ -998,6 +1123,39 @@ mod tests { assert_eq!(err_code(resp), "invalid_request"); } + #[test] + fn out_time_parsing_and_pct_are_monotonic_and_clamped() { + // Recognised keys (us preferred, legacy ms key is actually µs in ffmpeg). + assert_eq!(parse_out_time_us("out_time_us=1500000"), Some(1_500_000.0)); + assert_eq!(parse_out_time_us("out_time_ms=2500000"), Some(2_500_000.0)); + // Irrelevant / malformed lines are ignored. + assert_eq!(parse_out_time_us("frame=42"), None); + assert_eq!(parse_out_time_us("out_time_us=N/A"), None); + + // 10s source: % rises with out_time and never reports 100 mid-encode. + let total_us = 10_000_000.0; + assert_eq!(pct_from_us(0.0, total_us), 0); + assert_eq!(pct_from_us(5_000_000.0, total_us), 50); + assert_eq!(pct_from_us(9_900_000.0, total_us), 99); + assert_eq!(pct_from_us(10_000_000.0, total_us), 99); // clamped below 100 + assert_eq!(pct_from_us(50_000_000.0, total_us), 99); // overshoot clamped + } + + #[test] + fn write_transcode_progress_publishes_atomically() { + let dir = std::env::temp_dir().join(format!("mp-prog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("p.json"); + write_transcode_progress(&path, 37); + let body = std::fs::read_to_string(&path).unwrap(); + assert_eq!(body, "{\"stage\":\"transcode\",\"pct\":37}"); + assert!( + !path.with_extension("tmp").exists(), + "temp file left behind" + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn default_ladder_is_descending_quality_tiers() { let ladder = default_ladder(); diff --git a/capsules/object-provider/capsule.json b/capsules/object-provider/capsule.json index 43f7629a..a4cbd065 100644 --- a/capsules/object-provider/capsule.json +++ b/capsules/object-provider/capsule.json @@ -14,7 +14,7 @@ { "resource": "elastos://object/*", "actions": ["read", "write", "delete"], - "operations": ["roots", "list", "stat", "read", "download", "write", "mkdir", "rename", "move", "copy", "trash", "restore", "delete_permanently", "empty_trash", "status", "events", "publish", "unpublish", "repair", "share"] + "operations": ["roots", "list", "stat", "read", "download", "write", "mkdir", "rename", "move", "copy", "trash", "restore", "delete_permanently", "empty_trash", "status", "events", "publish", "unpublish", "repair", "acquire", "share"] } ], "audit_events": ["object.provider.requested", "object.provider.completed", "object.provider.failed"] diff --git a/docs/CONSOLIDATION_LEDGER_2026-07-03.md b/docs/CONSOLIDATION_LEDGER_2026-07-03.md new file mode 100644 index 00000000..5b4ee22d --- /dev/null +++ b/docs/CONSOLIDATION_LEDGER_2026-07-03.md @@ -0,0 +1,67 @@ +# Branch Consolidation Ledger — 2026-07-03 + +**Goal:** collapse every outstanding branch into one line (`flint-0.5`) with +**zero value lost**, verified by CONTENT (symbol/file audits), never by branch +name or commit-SHA reachability (re-authored work has different SHAs but the +same content). + +**Method:** the consolidation was assembled on the session workbench branch +`claude/git-proxy-auth-roadmap-c214hu` (this session can only push there), gated +at every slice (build → test → clippy), and is delivered as **one pull request +into `flint-0.5`** — the human clicks Merge once and `flint-0.5` holds everything. + +Workbench tip vs the old `flint-0.5` tip (`9af4177`): **64 files, +12,856 / −239.** + +--- + +## What the workbench now adds on top of `flint-0.5` + +| Commit | What | +|---|---| +| `238e1e5` `939e44a` `6798942` `04052ef` `40086d3` | the team's **`fix/mpeg-dash-compliance`** delta (MPEG-DASH/CENC compliance, DKMS quorum reliability ELACITY-2282/2283, code-review hardening) absorbed via `--no-ff` merge | +| `b419da2` | **recovered** the Elacity Bible (`docs/narrative/ELACITY_BIBLE.md`) — the only unique commit on the deleted `claude/elacity-narrative-strategy-pmsr9j`; nearly lost | +| `84ae217` | **restored** WASM epoch operator-termination (dropped in the mpeg-dash squash) + made it race-free (red-team fix) | +| `451faab` | **restored** media transcode-progress reporting (dropped in the mpeg-dash squash) | +| `8d14230` `26abf3c` `2e8c3db` `a2dd4af` `b12528e` | **marketplace** transplant (slices 1–5): chain resolver, buy/trade authorities, `/api/market/*` + content-index, library Acquire, storefront capsule + Home wiring | +| `dd7ec4b` | red-team hardening + registered findings (MKT-1..4) + `acquire` conformance | +| `ab10be0` | preserved the `w2-consent-source` unique commit as a patch + decision note | + +--- + +## Per-branch disposition (all 13 live branches + the one already deleted) + +| Branch | Disposition | Evidence | Safe to delete? | +|---|---|---|---| +| **`flint-0.5`** | **TARGET** — the workbench PR merges here | — | **NO — keep. This is the one branch.** | +| `claude/git-proxy-auth-roadmap-c214hu` | workbench (session-scoped) | holds the consolidation; the PR source | after the PR merges | +| `fix/mpeg-dash-compliance` | **ABSORBED** | its delta is merged into the workbench (0 unique commits vs workbench) | after PR merges (PR #11 becomes redundant with the merged history) | +| `feat/marketplace-runtime` | **TRANSPLANTED** (slices 1–5) | market API/capsule/authorities/Acquire all re-landed + gated; S6 dkms superseded by flint-0.5's better retry-once | after PR merges + a spot-check | +| `feat/ddrm-hardening-and-creator-parity` | **CARRIED** | content re-authored into `fix/mpeg-dash-compliance` (now absorbed) + the 2 dropped features restored (`84ae217`,`451faab`) | after PR merges | +| `claude/keep-consent-architecture-0fz0ll` | **SUPERSEDED** | audit: every symbol (DidNotAct, read_bounded_line, EgressFirewall…) present in flint-0.5 with tests; **0 unique code files** | yes (after PR merges) | +| `feat/capsule-inspector` (PR #6) | **SUPERSEDED** | audit: approval core, inspect/intent, ratchet registry, inspector capsule all in flint-0.5; **0 unique code files** | close PR #6 as delivered; then delete | +| `w2-consent-source` | **PARTIALLY SUPERSEDED — 1 preserved** | 2/3 commits superseded; commit `3694975` (gateway 202-consent seam) banked as `docs/patches/w2-gateway-consent-request-3694975.patch` + decision note (collides with flint-0.5's pinned flat-403; needs an architecture decision) | yes (the patch preserves the unique work) | +| `claude/branch-deep-audit-yiez86` | **SUPERSEDED** | 0 unique code files vs workbench (audit/docs branch) | yes | +| `review/0.5.0` | **SUPERSEDED** | 0 unique commits vs workbench | yes | +| `flint` | **SUPERSEDED** | fully contained in flint-0.5 (0 unique commits) | yes | +| `claude/elacity-narrative-strategy-pmsr9j` | **already deleted — value RECOVERED** | its only unique commit (Elacity Bible) is restored as `b419da2` | already gone; nothing lost | +| `upstream/0.6-dev` | **TEAM-OWNED — do not touch** | the team's audit-staging base; PR #9 (`flint-0.5`→`0.6-dev`) is theirs to merge | keep | +| `main` | **LIVE — never touch** | production | keep | + +--- + +## Registered follow-ups (build-visible in `KNOWN_GAPS.md`) + +- **MKT-1 (HIGH, fix before the marketplace ships):** the KID→tokenId resolver + can mis-bind to a hostile co-channel mint (pre-existing in the marketplace + source, transplanted faithfully). On-chain-reachable; the buyer can pay for an + attacker's token. Not client-API-reachable. +- **MKT-2/3/4 (hardening):** unbounded resolve RPC fan-out; media `progress_path` + unconfined; ffmpeg-progress stdout-read deadlock window. All pre-existing. +- **w2 consent seam:** decide runtime-intent-envelope (current) vs gateway + 202-consent (banked patch) — one consent story. + +## The human's two actions + +1. **Merge the PR** (workbench → `flint-0.5`). `flint-0.5` then holds everything. +2. **Delete** the branches marked "yes" above (GitHub UI — the session proxy + blocks ref deletion). Keep `flint-0.5`, `upstream/0.6-dev`, `main`. diff --git a/docs/KNOWN_GAPS.md b/docs/KNOWN_GAPS.md index 2bcbac60..7a303475 100644 --- a/docs/KNOWN_GAPS.md +++ b/docs/KNOWN_GAPS.md @@ -12,12 +12,18 @@ green, and the row moves to "Closed." ## Open gaps +> **MARKETPLACE TRANSPLANT + RED-TEAM 2026-07-03 — branch consolidation onto `flint-0.5`.** The `feat/marketplace-runtime` value (73 commits: chain-provider KID→tokenId resolver, buy/trade authorities, `/api/market/*` + content-index, library Acquire, storefront capsule) was re-landed as gated slices, and two features the `fix/mpeg-dash-compliance` re-authoring had SILENTLY DROPPED were restored (WASM epoch operator-termination `84ae217`; media transcode-progress `451faab`). An adversarial red-team of the fresh + transplanted code CONFIRMED four defects that are **pre-existing in the source branches** (byte-identical transplants — NOT introduced here) and are registered below as **MKT-1..MKT-4** rather than fixed inline (fixing transplanted code mid-migration diverges from the reviewable source; each needs its own scoped fix + ratchet). **MKT-1 (HIGH, on-chain-reachable) is the one to fix before the marketplace ships.** The red-team ALSO found a real race in the *restored* epoch-termination (`start()` reset clobbering a concurrent `stop()` + a single-increment arm race) — that one WAS fixed here (it is our code, not a transplant): the reset is removed and an in-`execute_wasm` epoch **watchdog** makes the operator kill race-free with bounded latency (`elastos-compute/src/providers/wasm.rs`, test `runaway_capsule_is_terminable_via_stop_signal`). Conformance: the universal `all_provider_manifests_preview_actions_match_verb_map_or_tracked` gate caught the new `object-provider:acquire` op as preview≠enforce drift; it is registered in the G3b `known_divergences` ledger as fail-CLOSED (Admin-enforced, previewed-but-denied — a money-path write kept at highest privilege, NOT loosened without a review), exactly mirroring its sibling `share`. + > **SECURITY + PERF + QUALITY AUDIT 2026-07-02 — 10-seat 0.01% swarm (8 primary + 3 sub-agents), findings adversarially verified against source.** Six confirmed reachable defects were fixed fail-closed on `flint-0.5`, each its own gated slice (build→test→clippy→commit) with a regression/ratchet test; full `just verify` green after. **T1** (`299b907`, Sol): the unauthenticated Carrier `provider_invoke` plane accepted anonymous writes + key/decrypt/drm/rights ops (self-referential envelope check, no peer auth) → LOCKED to a read-only allowlist (`content:{fetch,status,admission}`); residual peer-auth tracked as **G-CARRIER-PEER**. **T2** (`2ef8496`, Priya): unbounded `read_line` on the unauthenticated inbound + client response paths (pre-auth OOM) → bounded via the existing BUG-6 reader (1 MB cap). **T3** (`2b6a29e`, Nadia): IPv4-mapped IPv6 SSRF bypass (`::ffff:169.254.169.254` slipped the private-IP guard) in exit + net providers → normalize-and-recurse. **T4** (`ace2df6`, Vera+Dmitri): audit-chain signature strippable via `alg` downgrade (alg/sig not in the hash preimage) → `verify_chain` now requires ed25519 for every record when a key is present (no on-disk format change). **T5** (`7502970`, Nadia): `http_fetch` auto-followed redirects past the IP guard + allowlist → `redirects(0)`. **T6** (`7502970`, Nadia): carrier `operation` path-traversal into the local API → single-segment charset guard. **Cleared (verified sound):** wallet-link verify, viewer cross-principal access, spend meter, capability store, all high-severity concurrency, egress firewall, NFLOG reader. **Deferred roadmap (documented, not regressions):** T7 (pre-release P2P crypto migration — `ed25519-dalek 3.0-pre` via distributed-topic-tracker); the two structural perf ceilings (audit group-commit + auth-state cache — MEASURE-first, in "Performance + bugs" below); quality cleanups (shared-validator extraction, `content.rs`/`library.rs` splits, stringly-error typing on the mint path). > **SECURITY AUDIT 2026-06-25 — grade 7/10** (six-specialist 0.01% panel `wu4y6lvzb`, every finding independently refuted before counting). **ZERO confirmed Critical, ZERO default-reachable High; nothing exploitable by an untrusted capsule** — all confirmed findings sit at the operator/shell trust boundary or are audit-completeness debt. Core enforcement is excellent (runtime-derived action+resource at enforce time, exact-action equality, traversal-safe matcher, fail-closed symmetric consent, real ed25519 chain, sound identity binding). Per-domain: authz 8, gateway/carrier 8, identity 8, fail-closed 7, audit 6, trust-anchors 5. The 5 confirmed findings below (AUD-1..AUD-5) are the roadmap to 10/10 — notably most are WIRING (the crypto/verifiers are already written + correct), not new design. Calibration: the panel killed its own loudest alarms (the "cross-capsule inspect disclosure" High rested on a false auto-grant-is-default premise — the policy engine fail-closed denies inspect/*; the "chain truncation" High is the declared-open G8b; per-name identity reuse is by-design). **PROGRESS (2026-06-26): AUD-1 (High), AUD-2, AUD-3 all CLOSED.** AUD-2 (`e68f850a`) + AUD-3 (`628783e0`) made fail-closed (mirror the deny path); AUD-1 (`e88f4367` + canonical-form prereq `8954590e`) wired the fail-closed-when-configured author gate onto the VM launch path (residual: production trust roots = founder config; carrier-service path deferred). **ALL 5 audit findings now addressed: AUD-1/2/3/5 CLOSED, AUD-4 plane-(b) CLOSED (plane-(a) -> Wave 2).** Effective security grade ~9/10. A second-wave sweep (swarm `w3y7cu6ao`, 13 agents, verify killed nothing) added a SPEED grade (5/10) + a confirmed bug cluster — see "Performance + bugs" below. | # | Gap | Why open | Ratchet test (`#[ignore]`d) | Close criteria | |---|-----|----------|------------------------------|----------------| +| MKT-1 | **(High — on-chain-reachable) KID→ledger-tokenId resolver can confidently mis-bind to a hostile token** | Red-team 2026-07-03 (CONFIRMED, pre-existing in `feat/marketplace-runtime`, transplanted byte-identical in slice 1). `capsules/chain-provider/src/abi.rs:409` "prefer precise over substring" + the resolve loop's newest-first, creator-unconstrained window scan (`src/main.rs`) defeat the headline "fail-closed on ambiguous binding": (i) a legit asset minted via a RELAYER (the common case on Base) decodes into `substring` while a hostile canonical mint whose `opRawData` starts with the victim's public 16-byte KID lands in `precise`, so `precise` wins and the buyer binds the attacker's `(operative, tokenId)`; (ii) uniqueness is only evaluated WITHIN one 10 000-block window, so a hostile mint in a newer window is returned before the victim's older window is scanned. The KID is public (readable from the victim's own mint calldata). The buy binds — and pays for — the attacker's token. NOT client-API-reachable (the `chain` proxy allowlist excludes `resolve_token_id`), but the adversary here is the chain itself (any co-channel minter). | *Pending* — the ratchet would assert: given a victim mint + a hostile canonical mint carrying the victim's KID prefix, in the same AND in a newer window, `resolve_token_id` returns `None` (fail-closed), never the attacker's token. | Resolver binds a KID→tokenId ONLY when the candidate is creator-authenticated (constrain the scan to the asset's own channel/creator) or fails closed across ALL windows on any cross-tier/cross-window ambiguity; ratchet green. Fix before the marketplace goes live. | +| MKT-2 | **(hardening) Resolve path has no aggregate RPC time/size budget** | Red-team 2026-07-03 (pre-existing, transplanted). `capsules/chain-provider/src/main.rs` fetches one `eth_getTransactionByHash` per `AssetCreated` log across every window from `latest` down to `deploy_block` with no cap on total calls / wall-clock; a caller-supplied lower `from_block` deepens it and a mint-spammed channel amplifies per-window fan-out. Each call is individually bounded (15 s) but the aggregate is not. Internal buy path only (not client-reachable), so DoS-class, not an external primitive. | *Pending* (resource bound, not a unit ratchet). | An absolute per-resolve budget (max windows / max tx fetches / wall-clock deadline) that fails closed when exceeded. | +| MKT-3 | **(hardening) media-provider writes the caller-supplied `progress_path` with no confinement** | Red-team 2026-07-03 (pre-existing in the ddrm original + restored in `451faab`). `capsules/media-provider/src/main.rs:994` `write_transcode_progress` writes `{path}` + `{path}.tmp` raw from the request field — an arbitrary file create/truncate primitive for any caller who reaches `package_dash`, contradicting the module's "confined to the scratch dir" doc. Capped today: `media` is NOT in the client-facing proxy allowlist and the host generates the sink path with a process/clock/counter nonce (`creator.rs::pkg_progress_sink_path`, never from client input) — so it is a missing second line of defense, not exploitable via the shipped surface. | *Pending* — assert the provider REJECTS a `progress_path` that is not absolute + under the configured scratch dir. | The provider validates `progress_path` (`is_absolute` + canonicalize + `starts_with(scratch)`) and refuses otherwise; ratchet green. | +| MKT-4 | **(hardening) `run_ffmpeg_with_progress` can wedge if the stdout `-progress` pipe read errors mid-encode** | Red-team 2026-07-03 (pre-existing, restored in `451faab`). `capsules/media-provider/src/main.rs:950` breaks the stdout loop on a read error and then `child.wait()`s with the pipe undrained; if ffmpeg keeps emitting progress it can block on a full pipe and `wait()` never returns (the stderr side-thread keeps draining, so only stdout wedges). Low probability (the normal path is ffmpeg closing stdout on exit); no zombie (wait is always reached on the happy path). Secondary: stderr accrues into an unbounded `String`. | *Pending* — a fault-injection test that errors the stdout read mid-stream and asserts the call returns within a deadline. | On an early stdout-loop exit, kill/await the child with a deadline (never an unbounded `wait()`); bound the stderr capture. | | AUD-1 | **(High)** Author-signature verification absent in the LIVE launch path | The server builds an empty `SignatureVerifier` (runtime.rs:59,76); `effective_trusted_keys` has empty built-in roots + zero configured CAs; the supervisor never calls `verify_capsule_signature` before boot. Only operator sha256 pinning protects launch, NOT the ed25519 author trust anchor. The verifier crypto itself is correct (G2 core) — it is simply not wired into launch. Sharper, live-path superset of G2b. | n/a — enforced by `supervisor::tests::aud1_gate_*` (trusted passes / foreign refused / unsigned refused / empty passes / tampered refused) + `signature::verifier::tests::signed_manifest_with_multiple_providers_verifies_across_serialization`. | **MOSTLY CLOSED `e88f4367`** (+ canonical-form prereq `8954590e`): `gate_author_signature` wired into `launch_capsule` (VM branch, before boot), FAIL-CLOSED-WHEN-CONFIGURED (empty verifier = byte-for-byte today, zero breakage); seeded from config `trusted_keys` at serve (malformed hex aborts startup). Hazard RESOLVED (swarm `wyz4koqqr`: sign-domain == verify-domain byte-for-byte, no false-deny) + the latent multi-provider HashMap-order false-deny fixed (canonical signed form). **RESIDUAL**: (a) production trust roots = founder config (`effective_trusted_keys`/`trust_cmd`; the gate is inert until set); (b) the **carrier-service** launch path (`launch_carrier_service` runs a host binary via `find_carrier_binary`, a distinct artifact model — gating it with the entrypoint-hash needs separate design to avoid false-denies). | | AUD-2 | **(Med)** Gateway audit log silently downgrades to an unsigned, fail-OPEN memory log | `GatewayState::audit_log` (gateway.rs:238-248) catches ANY `AuditLog::with_file` error and falls back to `AuditLog::new()` (signer=None, writer=None); `emit` then skips durable write + returns Ok with alg='none' while content serving proceeds — defeats fail-closed custody. | n/a — enforced by `gateway::aud2_audit_failclosed_tests::audit_log_fails_closed_when_signed_log_cannot_open`. | **CLOSED `e68f850a`** — `audit_log()` returns `Result` and returns `Err` instead of falling back to the unsigned memory log; the custody-critical `content_open(opened)` path refuses the open (SERVICE_UNAVAILABLE) when the signed log is unavailable; the denied record stays best-effort. | | AUD-3 | **(Med)** Revocation is fail-OPEN (asymmetric with the fail-closed deny) | `revoke_request`/`revoke_all_granted` (pending.rs:553-583) flip status to Denied FIRST, then `emit_best_effort(CapabilityDenied)` which swallows the Err — a revoke can complete with no durable signed record (the exact inverse of the fail-closed `deny_request`). | n/a — enforced by `capability::pending::tests::revoke_request_fails_closed_when_audit_write_fails` (+ working-sink companion). | **CLOSED `628783e0`** — `revoke_request`/`revoke_all_granted` now return `Result` and emit-before-mutate (mirror `deny_request`); the two callers propagate the error as 500 rather than reporting success with a lost record. | diff --git a/docs/marketplace/API_CONTRACT.md b/docs/marketplace/API_CONTRACT.md new file mode 100644 index 00000000..ae81ceea --- /dev/null +++ b/docs/marketplace/API_CONTRACT.md @@ -0,0 +1,60 @@ +# `/api/market/*` — canonical contract (SSOT for the shell ↔ gateway seam) + +> The single vocabulary the storefront shell (`capsules/marketplace-content/browser/api.js`) and the +> gateway (`gateway.rs` → `gateway_marketplace.rs` / `viewer_open.rs`) MUST share (P10 one canonical path, +> P12 docs==code). The 15-seat council flagged that the two were built to **different** route sets hidden +> behind the shell's mock fallback ("browser-verified ≠ wired"). This doc reconciles them and marks each +> route **BUILT** (served today) or **PENDING** (shell SPECs it; not yet served). The money path NEVER +> trusts discovery — buy re-verifies terms live (Phase-1). + +## BUILT — served by `gateway.rs` today +| Method · Route | Handler | Request | Response | Auth | +|---|---|---|---|---| +| `GET /api/market/search?op&q` | `market_search` | query | `{listings[], indexed, coverage}` | public* | +| `GET /api/market/sections` | `market_sections` | — | `{sections:[{id,title,...}]}` | public* | +| `POST /api/market/order/sell` | `market_order_sell` | `{gateway?, ledger, token_id, quantity, price, pay_token?}` | `{unsigned_tx{to,data,value,selector,note}}` | **Home token** | +| `POST /api/market/order/withdraw` | `market_order_withdraw` | `{gateway?, operative, token_id, quantity}` | `{unsigned_tx}` | **Home token** | +| `POST /api/market/order/approve` | `market_order_approve` | `{operative, gateway?}` | `{unsigned_tx}` | **Home token** | +| `POST /api/market/buy` | `buy_owned_access` | storefront: `{content_id, operative, token_id, ledger, quantity?, seller?, expected_price?, expected_pay_token?}` · legacy re-buy: `{uri}` | buy outcome / unsigned tx | **Home token** | +| `GET /api/market/get?operative&token_id` | `market_get` | query | `{on_chain{token_id,seller,price,pay_token,supply_left,has_access}, sellers, coverage}` — live `sellersOf`+`listings`, lowest active price | public* | +| `GET /api/market/vault` | `market_vault` | — (principal from token) | `{owned:[{uri,name,content_cid,mime,acquired}], count, source}` — the buyer's Library Acquired assets | **Home token** | +| `POST /api/market/acquire` | `market_acquire` | `{content_id (KID), content_cid (CID), uri?, metadata?}` | `{object, uri, content_cid, availability}` — gates `hasAccessByContentId` then dispatches the Acquire op | **Home token + on-chain entitlement** | + +\* discovery is public but rate-bounded by a 10s in-process TTL cache (`recent_index_cached`) so an +unauthenticated burst collapses to one chain sweep. `get` takes `operative`+`token_id` (the shell has both +from the index listing) — `has_access` (the bytes16 KID lookup) is the enrichment follow-on. + +**`POST /buy` (storefront, not-yet-owned):** the shell sends the asset's on-chain identity +(`operative`+`token_id`+`ledger`, all from the discovery listing). On the live `chain` path the gateway +sources `seller`/`price`/`payToken` LIVE from `sellersOf`/`listings` (keyed at ACCESS_TOKEN id=1) — picking +the lowest active seller — so **no `ELASTOS_DDRM_BUY_*` env pins are required** (env still overrides for +dev/fixtures). `expected_price`/`expected_pay_token` (what the buyer saw, from `/get`) arm **abort-on-drift**: +the live re-read at buy time must match or the buy fails closed before signing. `buyAccess` binds the real +content `tokenId`; the buy stays UNSIGNED (external wallet only on a release build). Omitting `content_id` +falls back to the legacy `{uri}` re-buy for an object already in the Library. + +## PENDING — the shell SPECs these; gateway does NOT serve them yet (mock-only in the shell) +| Method · Route | Purpose | Lands in | +|---|---|---| +| `GET /api/market/get?content_id` | by-`content_id` variant (needs KID/metadata enrichment so the index carries the CID); the by-`{operative,token_id}` variant above is BUILT | **Phase 2** enrichment | +| `GET /api/market/listed` · `/history` | the buyer's listed / activity views | **Phase 2** | + +## Name-fork reconciliation (the shell's old verbs → the canonical built routes) +- shell `order/assemble` (a **buy** assembly) → canonical **`POST /buy`** (the built buy path). "assemble" was a + buy verb; the built surface splits buy (`/buy`) from resale (`/order/*`). Use `/buy`. +- shell `order/cancel` → canonical **`POST /order/withdraw`** (same `withdrawListing` op; renamed). +- shell had **no resale-list call** → canonical **`POST /order/sell`** (+ prerequisite `POST /order/approve`). + The "List for resale" flow must call these two. + +## Listing schema gap (reconcile in Phase 2) +`content_index::Listing.to_json` (schema `elastos.market.listing/v1`) emits +`{channel_address, operative_address, token_id, token_uri, op_type, content_id, metadata_status}` — it has +**no** `name`/`medium`/`tier`/`listings[]`/`copies` that the shell's `cardHTML`/`renderAsset` read. Today +`metadata_status:"needs_kid"` (name/medium are empty on the live path). **Phase 2 KID/metadata enrichment** +fills `name`/`medium`/etc. from the asset's `tokenURI` metadata; until then the shell must tolerate the lean +schema (render placeholders) rather than assume the mock shape. Add a serde round-trip/schema test so the two +shapes cannot silently drift again. + +## Rule +A shell call may set `live=true` ONLY for a **BUILT** route. PENDING routes stay mock and must be visibly +labelled mock — never let a 404 fall back to mock that masquerades as wired. diff --git a/docs/marketplace/CONTRACTS.md b/docs/marketplace/CONTRACTS.md new file mode 100644 index 00000000..a04bd5fd --- /dev/null +++ b/docs/marketplace/CONTRACTS.md @@ -0,0 +1,415 @@ +# Marketplace Contracts — Turnkey Backend Reference (Base mainnet) + +**Status:** decision-resolved, but **read the Verification status below before any mainnet write** — some items are confirmed in this repo and some are single-source (grounded in elacity-web/PC2, not yet checked against deployed bytecode). Cross-referenced across three sources: + +1. `elacity-web` `release/base-network` — ABIs in `src/lib/drm/contracts/*.json`, address map `src/lib/web3/Ecosystem.tsx`. +2. PC2 node (`pc2.net/pc2-node`) — `data/installed-apps/elacity-market/wallet.js` (canonical client ABI strings + selectors), `src/services/ContentIndexerService.ts` (event topics + read selectors), `config/default.json` (`content_indexer.contracts.v3`). +3. This runtime — `capsules/chain-provider/src/{abi.rs,main.rs,config.rs}`, `capsules/content-market/src/main.rs`, `elastos/crates/elastos-server/src/api/buy_authority.rs`. + +All 4-byte selectors in this doc were **independently recomputed with keccak-256** and cross-checked against the pinned constants in the three sources. Where a selector is newly computed (no existing pin), it is flagged `[COMPUTED]`. + +## ⚠️ Verification status (honest confidence — read before any mainnet write) +- **Selector math: certain; ABI match: not.** All 20 selectors were keccak-recomputed — but **keccak-correct ≠ ABI-correct**: a selector is only right if the *deployed* function signature matches the one assumed here. +- **Confirmed in THIS repo (rely on these):** `AuthorityGateway 0x09dBe7…` and `USDC 0x833589fC…` are pinned identically in `buy_authority.rs:58` + `chain-provider/main.rs:102`; and the buy-path selectors `buyAccess` (`0xf7580ad9` native / `0x0ede2294` ERC-20) + `hasAccessByContentId 0x54d42821` match the runtime's own pins. +- **✅ EMPIRICALLY CONFIRMED on the deployed chain (`verify-selectors.mjs`, verified 2026-06-22 on Base mainnet):** the AuthorityGateway `0x09dBe7…` is an **EIP-1967 proxy → implementation `0x305e37267b7a9eafbfed6b380d8cad9117a265d1`** (12,137 bytes), and **all eight core selectors are present in the deployed bytecode**: `buyAccess` (native `0xf7580ad9` + ERC-20 `0x0ede2294`), `hasAccessByContentId 0x54d42821`, **`sellAccess 0x9a3fa9f5`**, **`withdrawListing 0x3e65bbba`**, **`paymentProcessor() 0xf1c6bdf8`** (the ERC-20 approve target), `sellersOf 0x997eab2d`, `listings 0x6bd3a64b`. So the entire **buy / list / cancel / has-access / payment** surface is keccak-correct AND on-chain-confirmed — and **list+cancel live on the SAME gateway** (there is no separate contract for them). Re-run `verify-selectors.mjs` after any proxy upgrade. +- **✅ Secondary market RESOLVED (live):** a distinct **TradeGateway `0xd02451BCE627EF476B8ee52Cf131C426f67dbcB2`** is deployed on Base (EIP-1967 proxy → impl `0xe60433e553a35091571471a93a49d86d3223a59f`, 10.5 KB) with all four secondary selectors present: `sellToken 0xad1ee6be`, `buyToken 0x7d17ff3d`, `createOffer 0xd898aaf2`, `withdrawListing 0x3e65bbba`. So **primary access market = AuthorityGateway `0x09dBe7…`; secondary royalty/offers market = TradeGateway `0xd02451…`** — both real, both live-confirmed. + - ⚠️ **Address reconciliation:** the elacity-web v3 audit reported a base-network `TRADE_GATEWAY = 0xDe239B63949948FaC2A21aaa39bE0cd4775b1763`, but **that address has NO Base bytecode** (`eth_getCode` empty) — a wrong-network read (a non-Base Ecosystem.tsx row). The live, selector-bearing TradeGateway on Base is **`0xd02451…`** (confirmed twice via bytecode). Do not "correct" it to `0xDe239B`. The TradeGateway (royalty-share, tokenId=2) is a SEPARATE system from the access-token market — the runtime's core resale path is `AuthorityGateway.sellAccess`. +- **⚠️ Buy ERC-20 approve spender (grounded in elacity-web v3):** the `approve(spender, MaxInt256)` leg for an ERC-20 (USDC) buy targets the asset's **Operative `paymentProcessor()`** (read live from the operative) — **NOT the AuthorityGateway.** Allowance is pre-checked against `paymentProcessor`. The native (AddressZero) path needs no approve; `value = pricePerToken × quantity`. (Sell side: ERC-1155 `setApprovalForAll(operator = AuthorityGateway, true)` is sent to the **Operative**; `sellAccess` arg0 = **ledger**, `withdrawListing` arg0 = **operative** — an intentional asymmetry; the gateway maps ledger↔operative.) +- **✅ Confirmed Base-8453 address set** (elacity-web `release/base-network` 8453 block × live bytecode): AuthorityGateway `0x09dBe796f40ECEffEAccf243c3d758C4c1d8D87D` (→`0x305e3726…`) · TradeGateway `0xd02451BCE627EF476B8ee52Cf131C426f67dbcB2` (→`0xe60433e5…`) · CoreStorage `0x0C1EeA2A3361B80AC0e42179335dB536A951760b` · ChannelCore `0xE1365ed47353De2F8A6a69E271e36650A9EE368F`. **Runtime↔elacity reconciliation:** both drive the **identical** AuthorityGateway on Base — the `0x47275C…`/`0xE89B4d…` gateways are *other chains* (no Base bytecode), not a conflict. +- **✅ Pay-token CONFIRMED = canonical Base USDC `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`** (deployed; used in PC2's own code `pc2-node/dist/api/index.js:909`; matches the runtime's pin). **There is NO WELA on Base** — `WELA 0x517e…` and `USDC 0x175F…`/`0xA06b…` are in elacity-web's `currencies` map under **ESC chains 20/21**, NOT Base 8453 (a corrected earlier cross-block misread). On Base: **gas = ETH, listings/payments = USDC `0x833589fC…`** (PC2 SSOT also lists USDT `0xfde4C96c…`, DAI `0x50c57259…`, WETH `0x42000000…0006` as accepted pay-tokens). + +### ✅ PC2-SSOT reconciliation (2026-06-22 — the user's consolidated `pc2.net` reference) +- **Addresses all confirmed** against the runtime pins: CentralStorage `0x0C1EeA2A…`, AuthorityGateway `0x09dBe796…`, ChannelFactory `0xE1365ed4…`, **RoyaltyTradeGateway `0xd02451BC…`** (this VINDICATES keeping `0xd02451` over the elacity-web-audit agent's `0xDe239B…`, which has no Base bytecode = a wrong-network read), EventHub `0x5a694A6d…`. Additional (not previously banked): **AssetFactory `0x4c80A6209F16437f0dc4a98E3D43f08aeBF57765`**, **SubscriptionManager `0xb00456b5…`**, and the extended channel/operative factories (PublicChannel `0xfcDffDd1…`, PrivateChannel `0x6d0369f5…`, MultiChannel `0x2E8B108a…`, BuyableOperative `0xFbf39a09…`, BuyableSellableOperative `0xd4FE224a…`). +- **⛔ V2-deprecated — DO NOT USE:** CoreStorage `0xc8F50Bf1…`, AuthorityGateway-V2 `0x8fe6bf98…`, ChannelCore `0x6a3f7780…`, TradeGateway-V2 `0x9eC53758…`. (These still appear in stale docs/compiled `.js`.) +- **⚠️ Mint event correction (empirically verified on Base):** the live mint event is **`AssetCreated`** (emits from EventHub; KID is in the mint `opRawData` calldata, NOT the event). **`DigitalAssetRegistered` does NOT emit on Base** (0 logs anywhere in an active window) — so the KID→ledger-tokenId resolver binds the KID via the mint **calldata** (`mint_input_binds_content_id`), keyed off `AssetCreated`, NOT a `DigitalAssetRegistered` event. (`content-market` still *decodes* DigitalAssetRegistered for the calldata-identity case, but nothing emits it on-chain.) +- **Lit/Particle are PC2 infra the runtime does NOT use:** the runtime replaces Lit (chipotle PKP/TEE) with its own `decrypt-provider` + 2-of-3 dKMS quorum, and Particle smart-accounts with `wallet-provider`. Do **not** port §5/§9 Lit/Particle specifics; the access gate `hasAccessByContentId(holder, bytes16)` and the contract surface are shared, the key/decrypt infra is not. +- **Per-asset authority:** prefer `metadata.properties.authority` over the hardcoded gateway (the dApp itself is inconsistent; the runtime's `0x09dBe7` matches current assets). +- **✅ Canonical v3 indexer config** (PC2 `pc2-node/config/default.json` → `content_indexer.contracts.v3`, chain 8453 — the source of truth for Phase 2): `authority_gateway 0x09dBe796…` · `channel_factory 0xE1365ed4…` · `central_storage 0x0C1EeA2A…` · **`event_hub 0x5a694A6d988354dca491fe0F6db7a6ef46b656c2`** (the index's event source — **live**, resolves the earlier "missing EventHub" gap) · `from_block 43892000` (backfill genesis) · scan **5 min / 10 000 blocks** over the Base RPC subset · IPFS gateways `ipfs.ela.city`, `dweb.link`, `cloudflare`. Phase-2 reads event logs from **EventHub**, not the channel/asset contracts directly. +- **✅ Phase-2 event topics — COMPLETE (V3, source-confirmed in PC2 `sdk/config.ts:174`+`ContentIndexerService.ts`; `AssetCreated` observed live on EventHub):** the index `getLogs` the EventHub for — + - **`AssetCreated(address indexed _to, address indexed _channel, uint256 _tokenId, string _tokenUri, uint16 _opType, address indexed opContract)`** — topic0 `0xc0a995e4052be044599af577ab2f3382d67bd34df95a76226e7c464e9d4dba46`. Carries the **Operative address (`opContract`)** → the per-asset contract for `sellersOf`/`listings`/`paymentProcessor`/supply reads. + - **`DigitalAssetRegistered(address indexed channel, uint256 indexed tokenId, address creator, string tokenURI, uint16 opType, bytes16 contentId)`** — topic0 `0x1b24f7763272894608506beba5887c374d345cd231bf52bd03f40bc2d0508d7b`. Carries the **`contentId` (== bytes16 KID)** — the trust anchor `content-market` validates metadata against. + - **`ChannelCreated(uint8 indexed channelType, uint8 indexed scope, address indexed creator, address channel, address factoryAddr)`** — topic0 `0x4ae6ef95ddade103ca67593cd4cf68dda177aa1054ad4eeb4963d2c3df44702e`. + - plus ERC-1155 `TransferSingle 0xc3d58168…` + `ContractCreated 0x2d49c679…`. IPFS: upload `https://base.ela.city/api/v2/ipfs/upload`, gateway `https://ipfs.ela.city/ipfs`. + - **✅ Empirically confirmed (`index-proto.mjs`, live Base): EventHub emits `AssetCreated` ONLY** — a working scan decoded **50 real listings** (channel `0x6756e140…`, real Operatives, real IPFS `metadata.json` URIs, all `buy_and_resell`). **`DigitalAssetRegistered` + `ChannelCreated` are emitted by the channel/factory contracts, NOT EventHub** — so the index resolves `contentId`/KID from the asset's **`metadata.json`** (via `tokenURI`; `content-market` validates `metadata.kid`) and reads **price/supply from the Operative** (`sellersOf`/`listings`). Phase-2 discovery is now *proven against the real chain*, not just specced — see the runnable `index-proto.mjs`. +- **Proxy-upgrade watch:** both gateways are upgradeable — pin impls `0x305e…` (Authority) + `0xe60433…` (Trade); re-run `verify-selectors.mjs` on any upgrade. +- **Remaining judgment call:** the `ChannelBridge (bytes32,address)` hasAccess mismatch (different selector + registry `0x96826e93…`, absent from the runtime) — still unconfirmed; likely a stale elacity path. +- **Resolved by judgment, not proof:** `ChannelBridge.ts`'s `hasAccessByContentId(bytes32,address)` (= `0x594a4a6b`, a *different* selector on a *different* registry `0x96826e93…` absent from this runtime) was resolved in favor of the runtime's `(address,bytes16)` form on `0x09dBe7…`. Likely a stale elacity path — but confirm it isn't a genuinely different contract before relying. +- **Net:** the buy path's *core* (gateway + `buyAccess` + `hasAccess`) is verified in-tree; the **approve-target + secondary-market selectors and the non-gateway addresses are to-verify**. Treat this as a turnkey *starting point* whose unpinned items get a one-time bytecode check before value moves. + +### ✅ LIVE PASS (2026-06-23 — read-only Base mainnet verification + one money-path fix) +Verified against real deployed state (public RPC, no wallet); decoded against real txs/logs: +- **`listings()` return word order CONFIRMED `(qty, pricePerToken, payToken)`** — decoded 3 real `ItemListed` events + cross-checked the live `listings(op,1,seller)` return at the event block; the decisive case (qty=10000, price=20000) is unambiguous. `buy_authority::decode_listing_return` was already correct; a stale doc-comment (said `(price,qty,…)`) was fixed. +- **🐞 FIXED money-path bug: `listings`/`sellersOf` must key at ACCESS_TOKEN id=1, not the content tokenId.** Confirmed live: `listings(op, 1, seller)` is populated (qty=9999, price=10000, USDC) while `listings(op, contentTokenId, seller)` returns an EMPTY slot (0,0,0x0). The runtime's `read_listing_terms` + `sellers_of_live` (and so the buy abort-on-drift re-read AND `/api/market/get`) were keying at the **content** tokenId → every live buy would abort (empty re-read ⇒ drift/sold-out) and `/get` would show no price. Fixed to read at id=1 while `buyAccess` keeps the content tokenId; regression test added (`listings_read_is_keyed_at_access_token_id_one_not_content_tokenid`). +- **`buyAccess` arg shape CONFIRMED** from a real ERC-20 buy (tx `0x64b70816…dcd56c`): selector `0x0ede2294`, `value=0`, `payToken=USDC`, `ledger`=per-channel ledger (NOT the gateway), `tokenId`=the big content tokenId (NOT 1, NOT `word_from_id`), `qty=1`, `price=10000`. Matches the runtime's assembled order. +- **KID→tokenId resolver CONFIRMED** against 3 real assets: a direct mint (`0x47cbeeb4`) resolves via the precise `decode_mint_content_id` (== `metadata.kid`); two **relayed** mints (`0xcef6d209`) resolve via the substring binder — proving the relayer-safe fallback is genuinely required on Base. Each KID uniquely bound its real ledger tokenId. +- **Event topic0 CONFIRMED** (`ItemListed`/`ItemSold` live; `ItemUnlisted` keccak-correct, sample-pending) — see §1.1 EVENTS. +- **Still requires a funded wallet (handed to the operator):** the unsigned→wallet→broadcast money path (buy grants `hasAccessByContentId`; wrong-token/drift aborts pre-broadcast), `/api/market/get` enrichment on a real asset, and `/api/market/acquire` (buy→pin→Library→open). + +> **Single most important framing.** The "marketplace" is **not** one contract. It is: +> - **AuthorityGateway** — the **primary access-token market** (buy/sell the right to consume an asset; ERC-1155 sub-token id `ACCESS_TOKEN = 1`) **and** the EIP-712 license `verifyingContract` and the on-chain access oracle. +> - **TradeGateway** — the **secondary / royalty-share market** (buy/sell/offer the resale + royalty token; ERC-1155 sub-token id `ROYALTY_SHARE = 2`). +> - **Operative** — the **per-asset ERC-1155** contract that actually holds balances, roles, the payment processor, and `OP_TYPE`. There is one Operative per asset. +> - **CoreStorage** — registry / fee config. +> - The **legacy ESC NFT Marketplace + Auction** (`MARKETPLACE_ADDRESS` / `AUCTION_ADDRESS`) is a **separate, older** ERC-721/1155 fixed-price+auction system wired only for ESC chains 20/21. **It does NOT exist on Base.** Do not bind to it. Section 1.6 documents it only so nobody confuses the two. + +--- + +## 0. TL;DR for Cursor — what to build + +| Verb | Contract + method | Approval needed | Live reads first | +|------|-------------------|-----------------|------------------| +| **buy** (primary access) | `AuthorityGateway.buyAccess(...)` — native `0xf7580ad9` **or** ERC20 `0x0ede2294` | ERC20 only: `approve(operative.paymentProcessor(), price)` | `paymentProcessor()`, `sellersOf()`, `listings()`, real `tokenId` | +| **list** (primary access) | `AuthorityGateway.sellAccess(...)` `0x9a3fa9f5` | `operative.setApprovalForAll(AuthorityGateway, true)` | `isApprovedForAll()` | +| **cancel** (primary) | `AuthorityGateway.withdrawListing(...)` `0x3e65bbba` | none | `listings()` to know your qty | +| **buy** (secondary/royalty) | `TradeGateway.buyToken(...)` `0x7d17ff3d` | ERC20: `approve(TradeGateway, price)` | `TradeGateway.sellersOf/listings` | +| **list** (secondary/royalty) | `TradeGateway.sellToken(...)` `0xad1ee6be` | `operative.setApprovalForAll(TradeGateway, true)` | `isApprovedForAll()` | +| **cancel** (secondary) | `TradeGateway.withdrawListing(...)` `0x3e65bbba` | none | `listings()` | +| **offer** | `TradeGateway.createOffer(...)` `0xd898aaf2` / `0xa86d2604` | ERC20: `approve(TradeGateway, price)` | — | +| **discover** | `eth_getLogs` for `AssetCreated` + `DigitalAssetRegistered` + `ChannelCreated` | — | + `sellersOf/listings` for prices | +| **open / access gate** | `AuthorityGateway.hasAccessByContentId(holder, bytes16 kid)` `0x54d42821` (bool) + EIP-712 license cert | — | on-chain eth_call | + +**Runtime seams to change:** +- `buy_authority.rs` — replace env-pinned `seller/ledger/tokenId/price/payToken` and the `word_from_id = SHA-256(content_id)` tokenId with **live reads** of `sellersOf`/`listings` and the **real ledger tokenId**, and **implement the ERC20 `approve` leg** (currently only flagged `requires_erc20_approve: true`, never assembled). +- `chain-provider` — add typed read methods `sellers_of`, `listings`, `payment_processor`, `allowance`, and `tokenURI`; add a periodic `eth_getLogs` scan for listing lifecycle events (today it only scans channel/asset-creation events). +- `content-market` — already decodes `AssetCreated` / `DigitalAssetRegistered`; extend its `ContentListingV1` to carry the **live price/seller** fields the indexer populates from `listings()`. +- **Resale assembler — BUILT (gateway-side):** the pure, selector-pinned `sellAccess` / `withdrawListing` / `setApprovalForAll` calldata assembler is in `elastos/crates/elastos-server/src/api/trade_authority.rs` (wired to `/api/market/order/{sell,withdraw,approve}`, Home-token-gated, address-validated, unsigned→wallet) — **not** the `capsules/marketplace/src` wasm shell (which is UI only). Still unbuilt: `sellToken` / `createOffer` / `cancelOffer` (the TradeGateway royalty-share path). + +--- + +## 1. The contract interface + +### 1.1 AuthorityGateway — primary access-token market + access oracle + license verifyingContract +ABI: `elacity-web/src/lib/drm/contracts/AuthorityGateway.json`. AccessControl-based, upgradeable proxy (`initialize()`). + +**BUY (two overloads, selected by `payToken == AddressZero`):** + +``` +// NATIVE (ETH). value = pricePerToken * quantity +buyAccess(address seller, address ledger, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken) payable + selector 0xf7580ad9 (sig "buyAccess(address,address,uint256,uint256,uint256)") + +// ERC20 (USDC default on Base). value = 0. REQUIRES prior approve(paymentProcessor, price). +buyAccess(address seller, address ledger, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken, address _payToken) + selector 0x0ede2294 (sig "buyAccess(address,address,uint256,uint256,uint256,address)") +``` +> The web app and PC2 both **select the overload by the explicit full signature string**, not by guessing. A runtime that hardcodes one selector breaks the other payment path. `value` is set **only** on the native overload. + +**LIST (sell access for resale):** +``` +sellAccess(address ledger, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) + selector 0x9a3fa9f5 [COMPUTED from the verified ABI string in wallet.js / AuthorityGateway.json] +sellAccessOnBehalf(address seller, address ledger, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) +``` + +**CANCEL (primary listing):** +``` +withdrawListing(address operative, uint256 tokenId, uint256 quantity) + selector 0x3e65bbba [COMPUTED — same signature used by both gateways] +``` + +**READS:** +``` +hasAccess(address accessor, address ledger, uint256 tokenId) -> bool 0xcf56b4eb [COMPUTED] +hasAccessByContentId(address accessor, bytes16 contentId) -> bool 0x54d42821 (pinned, real Base ABI) +operative(address ledger, uint256 tokenId) -> address +listings(address op, uint256 tokenId, address seller) -> (uint256 qty, uint256 pricePerToken, address payToken) + 0x6bd3a64b (pinned) +sellersOf(address op, uint256 tokenId) -> address[] 0x997eab2d (pinned) +cstore() -> address +protocolVersion() -> (used in EIP-712 license domain) +``` + +**EVENTS:** +``` +ItemListed(address indexed seller, address indexed op, uint256 indexed tkId, uint256 quantity, uint256 pricePerToken, address payToken) +ItemSold(address seller, address indexed buyer, address indexed op, uint256 indexed tkId, address payToken, uint256 unitPrice, uint256 price) +ItemUnlisted(address indexed seller, address indexed op, uint256 indexed tkId, uint256 quantity) +PaymentLog(from, to, amount, paymentToken) +``` +> **✅ topic0 CONFIRMED (live pass 2026-06-23 — keccak of the canonical v3 ABI signatures, validated by reproducing the pinned `AssetCreated` topic0, then matched against deployed AuthorityGateway `0x09dBe7…` logs):** +> - `ItemListed(address,address,uint256,uint256,uint256,address)` → **`0x90aecdd7f5269ac7f11bea516b4768d0391e0a54aabc19aea64c7758104f66d2`** — CONFIRMED on-chain (22 logs in an 800k-block window; sample tx `0x845f1b4a…abe1a5`, 4 topics + 96B data = the 3 indexed + `quantity,pricePerToken,payToken`). +> - `ItemSold(address,address,address,uint256,address,uint256,uint256)` → **`0x60cd9eee664e26e142eb54813d426c273cd85605b8bfb72f707e4f2927b6a955`** — CONFIRMED on-chain (tx `0x64b70816…dcd56c`, 4 topics + 128B data = `seller,payToken,unitPrice,price`). +> - `ItemUnlisted(address,address,uint256,uint256)` → **`0xdb6bedce61ad043a5e9d9ac95f248702233e64e5818e58734aa38e7fd86db415`** — keccak-correct from the same canonical ABI; not emitted in the scanned window (withdrawals are rare), so on-chain-sample-pending but topic0 is sound. +> The events index `tkId == ACCESS_TOKEN id 1` (NOT the content tokenId) — same keying as `listings`/`sellersOf` (§1.3). + +**EIP-712 license domain (the access/decrypt gate):** `name: "AuthorityGateway"`, `version: protocolVersion()`, `chainId: 8453`, `verifyingContract: `. Message = `LicenseRequest { entitlement, entity: { contentId(bytes16), ledger, tokenId } }`, signed via `eth_signTypedData_v4`. See `elacity-web/src/lib/drm/license/request.ts` + `usePlayerCertificate.tsx`. + +### 1.2 TradeGateway — secondary / royalty-share market + offers +ABI: `TradeGateway.json`. Same `listings`/`sellersOf`/`withdrawListing` surface as AuthorityGateway, **plus**: +``` +buyToken(address seller, address _contract, uint256 tokenId, uint256 _quantity) payable 0x7d17ff3d [COMPUTED] +sellToken(address _contract, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken, address _payToken) 0xad1ee6be [COMPUTED] +withdrawListing(address op, uint256 tokenId, uint256 quantity) 0x3e65bbba [COMPUTED] +createOffer(address _contract, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken) payable 0xd898aaf2 [COMPUTED] +createOffer(address _contract, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken, address payToken) 0xa86d2604 [COMPUTED] +acceptOffer(address from, address _contract, uint256 tokenId, uint256 _quantity) 0xf190078e [COMPUTED] +cancelOffer(address _contract, uint256 tokenId) 0x058a56ac [COMPUTED] +``` +**EVENTS:** `ItemListed / ItemSold / ItemUnlisted / OfferAccepted / OfferCanceled / OfferSettled / PaymentLog`. + +### 1.3 Operative — per-asset ERC-1155 (OperativeBuyable / OperativeBuyableSellable) +ABI: `OperativeBuyable.json` / `OperativeBuyableSellable.json` / `IOperative.json`. + +**Access-token / role model (sub-token ids INSIDE the operative — NOT the content-hash tokenId):** +``` +ACCESS_TOKEN() -> uint256 == 1 // the entitlement to consume. sellersOf/listings/balanceOf queried at id=1. +ROYALTY_SHARE() -> uint256 == 2 // the resale + royalty token, traded on TradeGateway. +DISTRIBUTION_RIGHT() -> uint256 == 3 // distribution shares. +``` +> PC2 confirms (`wallet.js` lines 109-111): `TOKEN_ID_ACCESS=1`, `TOKEN_ID_ROYALTY_SHARE=2`, `TOKEN_ID_DISTRIBUTION=3`. The indexer **always** queries `sellersOf`/`listings` at `id=1`, never at the `AssetCreated` content tokenId. + +**OP_TYPE tiers** (`OP_TYPE() -> uint16`): `0 = FREE`, `1 = BUY_ONCE` (stream/download single-buy), `2 = BUY_AND_RESELL`. FREE ⇒ no listing, no royalty market, no buy. (Note: `elacity-web` also documents an `OP_TYPE` semantic of `1=stream / 2=download / 0=free`; PC2's `0/1/2 = free/buy_once/buy_and_resell` is the catalog tiering. Treat `OP_TYPE` as an opaque uint16 you surface; do not branch on it beyond `==0 ⇒ free/no-market`.) + +**Other reads/writes:** +``` +paymentProcessor() -> address 0xf1c6bdf8 [COMPUTED] // ERC20 approval target for BUY +checkAccess(address) -> tuple[] +royaltyInfo(uint256 salePrice) -> (address receiver, uint256 amount)[] +resellerCut() -> uint16 // bps, OperativeBuyableSellable only +hasTradeAccess(address, uint256) -> bool +contentId() -> bytes16 +balanceOf(address, uint256) -> uint256 +isApprovedForAll(address,address) -> bool 0xe985e9c5 +setApprovalForAll(address,bool) 0xa22cb465 +safeTransferFrom(...) ; withdrawRewards(address payToken) +``` + +### 1.4 DigitalAssetLedger / DigitalAsset (ERC-721 channel ledger) — publish/mint +**On Base there is NO single global ledger.** Ledgers are **per-channel**, enumerated from the `.channels` collection. `DIGITAL_ASSET_LEDGER` is intentionally omitted from the Base map. +``` +mint(address authority, string uri, uint16 opType, bytes opRawData, bytes sellRawData) // DigitalAssetLedger +mint(string _uri, uint16 opType, bytes opRawData, bytes sellRawData) payable // DigitalAsset (channel variant) +``` +Mint encoding (from `elacity-web/src/lib/drm/utils.ts`, mirrored by `chain-provider::assemble_mint`): +- `opRawData = abi.encode(['bytes16','string','address[]','uint256[]','uint256[]', ('uint16' if resellable)], opArgs)` — **leads with `bytes16 contentId`**. +- `sellRawData = abi.encode(['uint256','uint256','address'], [copies, parseUnits(pricePerSale), payToken])`. +- `opType`: `0=FREE / 1=BUY_ONCE / 2=BUY_AND_RESELL`; DistributionRight publish uses type `3`. + +### 1.5 CoreStorage — registry / fees +``` +getListing / getOffer / listings / offers / sellersOf / offerersOf +operator(channel, tokenId) -> address +ipReference(bytes16) -> (address, uint256) +taxInformation() -> (uint16 platformFee, address) +protocolShares() +registerDigitalAsset(...) +``` +Events: `IPBound / ChannelBound / ContractAcknowledged`. + +### 1.6 Legacy ESC NFT Marketplace + Auction — DO NOT BIND ON BASE +`MARKETPLACE_ADDRESS`, `AUCTION_ADDRESS`, `FACTORY_ADDRESS` (`salesMixin.ts`, `src/components/marketplace/*`) are a **separate** fixed-price+auction ERC-721/1155 system wired only for ESC chains **20/21**. Base 8453 ships **only** the dDRM AuthorityGateway/TradeGateway stack. The "Auction" role exists **only in the legacy ESC system** — there is no auction surface on Base. + +--- + +## 2. Addresses + chain ids to pin + +### 2.1 Base mainnet — chainId **8453** (`0x2105`), RPC `https://mainnet.base.org` +All three sources agree on these. **CONFIRMED** = identical in `elacity-web` `Ecosystem.tsx` (`release/base-network`), PC2 `wallet.js`+`config/default.json`, and this runtime. + +| Role | Address | Status | +|------|---------|--------| +| **AuthorityGateway** (primary market, access oracle, license verifyingContract) | `0x09dBe796f40ECEffEAccf243c3d758C4c1d8D87D` | **CONFIRMED** (3/3 sources) | +| **TradeGateway** (secondary/royalty market) | `0xd02451BCE627EF476B8ee52Cf131C426f67dbcB2` | **CONFIRMED** (elacity-web + PC2 client) | +| **CoreStorage** / `central_storage` | `0x0C1EeA2A3361B80AC0e42179335dB536A951760b` | **CONFIRMED** (elacity-web + PC2 config) | +| **Channel factory** / `CHANNEL_CORE` | `0xE1365ed47353De2F8A6a69E271e36650A9EE368F` | **CONFIRMED** (3/3 sources) | +| **EventHub** (`event_hub`, v3 AssetCreated source) | `0x5a694A6d988354dca491fe0F6db7a6ef46b656c2` | **CONFIRMED** (PC2 config; not in runtime yet — ADD) | +| **UniversalCheckin** | `0x2361a02e6727Ff1798920186b8ACf0f100f621C0` | CONFIRMED (elacity-web) | +| **USDC** (default payToken, 6 decimals) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | **CONFIRMED** (3/3 sources; canonical Base USDC) | +| **Native ETH** | `AddressZero` (`0x0000...0000`, 18 decimals) | CONFIRMED | +| **DigitalAssetLedger** | *(intentionally absent — per-channel, enumerate from `.channels`)* | CONFIRMED-ABSENT | +| Channel factory **deploy block** (eth_getLogs lower bound) | `43892000` | **CONFIRMED** (PC2 config + runtime `DEFAULT_CHANNEL_FROM_BLOCK`) | + +> The **AuthorityGateway used at runtime for a specific asset's buy/list/license is read PER-ASSET** from the asset metadata (`tokenInfo.metadata.properties.authority`), **not** from the static map. The map address `0x09dBe7...` is the registry **default/fallback** (and `chain-provider`'s `DEFAULT_AUTHORITY_GATEWAY` when a channel's `authority()` read misses). Resolve per-asset first; fall back to `0x09dBe7...`. + +### 2.2 Other chains (for the chain-provider table; mirror `marketplaceSupportedChainIds = [20,21,421614,8453]`) +- **ESC mainnet (20):** AuthorityGateway `0x3B2Ef1C0342d7844369C031f08FE152f90d558e9`, TradeGateway `0xDe239B63949948FaC2A21aaa39bE0cd4775b1763`, CoreStorage `0x8D66Efaf34958A48F8Fa371A9c2DbDFe3D692fBb`, DigitalAssetLedger `0x9057304A41919008d79B3Bb3fCEBd69414e38b1F`, ChannelCore `0x7B89a5E0728C0f15DDe1D85ed1baa2bEa7E38Da0`. (Legacy ESC market also present here only — §1.6.) +- **Arbitrum Sepolia (421614, testnet):** AuthorityGateway `0x5207439A56C16A6fFb02f1AF0321D79Cf037738f`, TradeGateway `0x308AB0599FCb255773959B994250B9A5b87Db689`, CoreStorage `0x961D93965EA749E1e0A9E96dde05E7C464c59a46`. +- **ESC testnet (21) / 1337 (local):** dev/test placeholders. +- `.env.example` (`REACT_APP_*`, chainId 3) = **stale legacy** — ignore. + +### 2.3 RPC pools (Base) +- General pool: `mainnet.base.org`, `base-rpc.publicnode.com`, `base.drpc.org`, `blastapi`, `meowrpc`, `1rpc.io/base`, with a health tracker that sidelines 5xx/429/403. +- **`eth_getLogs` curated subset (load-bearing):** `[mainnet.base.org, base.gateway.tenderly.co]` only. **publicnode is EXCLUDED** — it silently truncates wide ranges. (`config.rs` `PC2_BASE_LOG_RPC_POOL`.) + +--- + +## 3. Per-verb mapping (contract call + live reads + runtime seam change) + +### 3.1 BUY (primary access) — `AuthorityGateway.buyAccess` +**Live reads first (in order):** +1. Resolve **operative** = `AuthorityGateway.operative(ledger, tokenId)` (or from asset metadata `.properties`). +2. Resolve **real `tokenId`** for the asset — the ledger content tokenId (NOT `SHA-256(content_id)`; see §4). +3. `sellersOf(operative, ACCESS_TOKEN=1)` → pick a seller. +4. `listings(operative, 1, seller)` → `(qty, pricePerToken, payToken)`; take the lowest `pricePerToken`. This gives **seller, price, payToken** — the bytes that must NOT be env-guessed. +5. If `payToken != AddressZero` (ERC20): `paymentProcessor = operative.paymentProcessor()`, then `allowance(buyer, paymentProcessor)`; if `< price*qty`, **assemble `approve(paymentProcessor, MaxUint256)` as a prepended leg**. + +**Tx:** native → `buyAccess(seller, ledger, tokenId, qty, pricePerToken)` `0xf7580ad9`, `value = pricePerToken*qty`. ERC20 → `buyAccess(seller, ledger, tokenId, qty, pricePerToken, payToken)` `0x0ede2294`, `value=0`, after the approve leg. + +**Confirm:** read back `AuthorityGateway.hasAccessByContentId(buyer, kid)` (do NOT trust a ledger flag — see §4 `owned_now`). + +**Seam change (`buy_authority.rs`):** +- Replace ENV terms `ELASTOS_DDRM_BUY_SELLER/_LEDGER/_PRICE/_PAYTOKEN` with values from the `sellersOf`/`listings` live reads via `chain-provider` (these reads do not exist in chain-provider yet — add them, §3.4). +- Replace `word_from_id = SHA-256(content_id)` tokenId default with the real ledger tokenId resolver. +- **Implement the ERC20 approve leg** — currently `assemble_buy_tx` only sets `"requires_erc20_approve": true` and never assembles/broadcasts `approve`. PC2's Market portal **batches** approve+buy; the runtime must emit two transactions (approve then buy) or a batch. + +### 3.2 LIST (primary access) — `AuthorityGateway.sellAccess` +**Live reads:** `operative.isApprovedForAll(seller, AuthorityGateway)`. +**Tx:** if not approved → `operative.setApprovalForAll(AuthorityGateway, true)` `0xa22cb465`; then `sellAccess(ledger, tokenId, quantity, parseUnits(price, decimals), payToken)` `0x9a3fa9f5`. +> **Approval target = the AuthorityGateway itself** (NOT paymentProcessor). Approving the wrong spender reverts. +**Seam change:** does not exist anywhere — build in the new marketplace assembler (§6). + +### 3.3 CANCEL (primary) — `AuthorityGateway.withdrawListing` +**Live reads:** `listings(operative, 1, seller)` for current qty. +**Tx:** `withdrawListing(operative, tokenId, quantity)` `0x3e65bbba`. No approval. +**Seam change:** build in the new assembler (§6). + +### 3.4 Secondary / royalty (TradeGateway) +- **buy:** `sellersOf/listings` on TradeGateway → ERC20 `approve(TradeGateway, price)` → `buyToken(seller, contract, tokenId, qty)` `0x7d17ff3d` (+`value` if native). +- **list:** `setApprovalForAll(TradeGateway, true)` → `sellToken(contract, ROYALTY_SHARE=2-bearing tokenId, qty, pricePerToken, payToken)` `0xad1ee6be`. **Approval target = TradeGateway.** +- **offer:** `approve(TradeGateway, price)` → `createOffer(...)` `0xd898aaf2` (native, `value`) / `0xa86d2604` (ERC20). `acceptOffer(from, contract, tokenId, qty)` `0xf190078e`. `cancelOffer(contract, tokenId)` `0x058a56ac`. +- **cancel listing:** `withdrawListing(contract, tokenId, qty)` `0x3e65bbba`. + +**Seam change (`chain-provider`):** add typed read ops — `sellers_of(op, tokenId)`, `listings(op, tokenId, seller)`, `payment_processor(op)`, `allowance(token, owner, spender)`, `token_uri(channel, tokenId)`. These are plain `eth_call` encoders parallel to the existing `has_access_by_content_id`. No raw RPC passthrough to apps (preserve the typed-capability principle). + +### 3.5 DISCOVER — see §5. + +### 3.6 OPEN / access gate — `hasAccessByContentId` + EIP-712 license +**On-chain bool:** `AuthorityGateway.hasAccessByContentId(holder, bytes16 contentId)` `0x54d42821`, `eth_call latest`. **Fail CLOSED:** a contract revert ⇒ `false` (403). A genuine transport/RPC error ⇒ propagate (503) so an outage can't masquerade as a denial. (This is exactly what `chain-provider/main.rs:829` already does — keep it.) +**Decrypt/playback:** the EIP-712 license cert (domain `"AuthorityGateway"`, `protocolVersion`, `contentId bytes16`); the Lit Action runs `hasAccessByContentId(owner, kid)` on-chain. +> `kid` / `contentId` = the **bytes16** content id = `0x` + lowercase(`kid_hex[0:32]`). **No hash, no truncation beyond taking the metadata kid as-is.** `content-market` enforces `metadata.kid == calldata contentId` or errors `identity_mismatch`. + +--- + +## 4. Phase 1 — the buy-invariant, restated with the REAL ABI + +**Invariant (decision-resolved):** a live primary buy on Base is byte-correct **iff** every one of these is sourced from chain (not env, not a hash): + +1. **method** — native `0xf7580ad9` xor ERC20 `0x0ede2294`, chosen by `payToken == AddressZero` (USDC default ⇒ ERC20 path is the default). +2. **arg order** — `(seller, ledger, tokenId, quantity, pricePerToken [, payToken])`. (This ordering is what `buy_authority.rs` hand-assembles today and flags as "the demo's documented default"; it is **correct** per the real ABI — keep the order, fix the *sources*.) +3. **seller** — from `sellersOf(operative, 1)` (NOT `ELASTOS_DDRM_BUY_SELLER`, NOT `= subject`). +4. **pricePerToken + payToken** — from `listings(operative, 1, seller)` (NOT `ELASTOS_DDRM_BUY_PRICE=0` / `_BUY_PAYTOKEN`). +5. **tokenId** — the **real ledger content tokenId**, NOT `word_from_id = SHA-256(content_id)` (which `buy_authority.rs:374` itself documents as "representative encoding only"). Until a resolver exists, `ELASTOS_DDRM_BUY_TOKEN_ID` must be pinned to the true id; **the real fix is to read it** (from `AssetCreated`/`DigitalAssetRegistered` data via content-market, keyed by the asset's `bytes16` kid). +6. **ledger** — the per-channel ledger address (NOT AuthorityGateway-as-ledger; `buy_authority.rs` already flags `_BUY_LEDGER` defaulting to `to` as wrong). +7. **value** — `pricePerToken * quantity` **only** on the native overload; `0` on ERC20. +8. **approve leg** — ERC20 path MUST prepend `approve(operative.paymentProcessor(), price)` (selector `0x095ea7b3`). **Unimplemented today — build it.** +9. **confirm** — `owned_now` for the live `chain` mode is read back from `hasAccessByContentId`, **not** from a local ledger. (dev/chain-mock set `owned_now=true` from the ledger; live chain sets it from chain. Any caller treating `owned_now` as "confirmed" must use the chain mode.) + +**Word layout (no ABI lib; manual 32-byte concat, matches `buy_authority.rs`):** +``` +selector ‖ leftpad32(seller) ‖ leftpad32(ledger) ‖ tokenId(32) ‖ leftpad32(quantity) ‖ leftpad32(pricePerToken) [‖ leftpad32(payToken)] +``` + +--- + +## 5. Phase 2 — the index (real event topics + listing schema) + +The runtime model is **synchronous pull only** (`eth_getLogs`), no `eth_subscribe`/websocket/filter-polling anywhere. Constraints to honor: +- `DEFAULT_MAX_LOG_RANGE = 10_000` blocks (env `ELASTOS_CHANNEL_MAX_LOG_RANGE`), `MIN_LOG_RANGE = 2_000` floor, adaptive halving on range errors. +- log-RPC curated subset only (`mainnet.base.org` + tenderly; §2.3). +- lower bound = block `43892000`. +- persisted resumable cursor (forward head + backfill floor); a selected channel re-confirmed on-chain before any mint. + +### 5.1 Topics to `getLogs` (CONFIRMED — pinned identically in PC2 indexer + this runtime) +``` +ChannelCreated 0x4ae6ef95ddade103ca67593cd4cf68dda177aa1054ad4eeb4963d2c3df44702e on channel factory + sig: ChannelCreated(uint8 indexed channelType, uint8 indexed scope, address indexed creator, address channel, address factoryAddr) + channel = data word[0]; creator = topics[3]. + +AssetCreated (v3) 0xc0a995e4052be044599af577ab2f3382d67bd34df95a76226e7c464e9d4dba46 on event_hub (fallback central_storage) + sig: AssetCreated(address indexed _to, address indexed _channel, uint256 _tokenId, string _tokenUri, uint16 _opType, address indexed opContract) + topics[1]=creator, topics[2]=channel, topics[3]=opContract(=operative); data=abi.encode(uint256 tokenId, string tokenUri, uint16 opType). + Carries NO contentId -> metadata_status:"needs_kid" (identity resolved later by kid-match). + +DigitalAssetRegistered (legacy + v3 identity) 0x1b24f7763272894608506beba5887c374d345cd231bf52bd03f40bc2d0508d7b + sig: DigitalAssetRegistered(address indexed channel, uint256 indexed tokenId, address creator, string tokenURI, uint16 opType, bytes16 contentId) + topics[1]=channel, topics[2]=tokenId(hex); data carries the bytes16 contentId -> identity COMPLETE. +``` +> Store uint256/hash tokenIds as **hex strings** (avoid JS/serde number overflow). `op_type_code 0/1/2 -> free/buy_once/buy_and_resell`. `content_id` rule: `bytes16 == 0x + lowercase(kid_hex[32])`. + +### 5.2 Listing-lifecycle events (✅ topic0 CONFIRMED live 2026-06-23 — see §1.1 EVENTS) +`ItemListed`/`ItemSold` topic0 are now confirmed against deployed AuthorityGateway logs (`ItemListed 0x90aecdd7…66d2`, `ItemSold 0x60cd9eee…a955`); `ItemUnlisted 0xdb6bedce…b415` is keccak-correct (on-chain sample pending). The TradeGateway `Offer*` set is still unconfirmed. PC2 reconstructs prices by **polling `sellersOf`+`listings` every detail-view open (30s cache)**, not from events. Two viable index designs: +- **(A) Poll model (matches PC2, lowest risk):** every scan cycle, for each paid asset (`op_type>0`, non-zero operative), `eth_call sellersOf(operative,1)` then `listings(operative,1,seller)` per seller; take the lowest `pricePerToken`+`payToken`. No new topics needed. **Recommended for Phase 2.** +- **(B) Event model:** add `ItemListed/ItemSold/ItemUnlisted` to the `getLogs` topic set — **requires computing+confirming their topic0 against a deployed log first** (§7-G). + +### 5.3 Listing schema (`ContentListingV1`, extend content-market's decode output) +``` +content_id : bytes16 (0x + lowercase kid[32]) +channel : address +operative : address // AuthorityGateway.operative(ledger, tokenId) +ledger : address // per-channel ledger +token_id : hex string // real ledger content tokenId +op_type : uint16 // 0 free / 1 buy_once / 2 buy_and_resell +token_uri : string // from event data or tokenURI(tokenId) 0xc87b56dd +metadata : { name, description, image|media.previewURL, media.uri->content_cid, contentType->asset_type, kid } +metadata_status : "needs_kid" | "resolved" | "identity_mismatch" +# live market fields (from §5.2-A): +sellers : address[] // sellersOf(operative, 1) +price : uint256 (lowest) // listings(...).pricePerToken +payment_token : address // listings(...).payToken (USDC or AddressZero) +quantity : uint256 +# secondary (TradeGateway), optional: +royalty_listings : [{ seller, price, payToken, qty }] // sellersOf/listings on TradeGateway at ROYALTY_SHARE=2 +``` + +--- + +## 6. Order-assembly for LIST + CANCEL (real selectors) — the missing capsule + +**BUILT (gateway-side):** the `sellAccess` / `withdrawListing` / `setApprovalForAll` surface is implemented as a **pure, selector-pinned calldata assembler** in `elastos/crates/elastos-server/src/api/trade_authority.rs` (read → pure-encode → wallet sign → `chain-provider.broadcast_transaction`; never holds keys/RPC inline) and wired to `/api/market/order/{sell,withdraw,approve}`. Still unbuilt: the `sell*Token` / `*Offer` TradeGateway royalty-share surface. + +**Primary market (AuthorityGateway):** +``` +LIST sellAccess(address ledger, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) + selector 0x9a3fa9f5 + words: ledger ‖ tokenId ‖ quantity ‖ pricePerToken ‖ payToken + PRE: if !isApprovedForAll(seller, AuthorityGateway) -> setApprovalForAll(AuthorityGateway, true) 0xa22cb465 (target = AuthorityGateway) + +CANCEL withdrawListing(address operative, uint256 tokenId, uint256 quantity) + selector 0x3e65bbba + words: operative ‖ tokenId ‖ quantity + PRE: none. (read listings() for current qty) +``` +**Secondary market (TradeGateway):** +``` +LIST sellToken(address _contract, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken, address _payToken) 0xad1ee6be + PRE: setApprovalForAll(TradeGateway, true) (target = TradeGateway) +CANCEL withdrawListing(address op, uint256 tokenId, uint256 quantity) 0x3e65bbba +OFFER createOffer(address,uint256,uint256,uint256) 0xd898aaf2 (native, value) | createOffer(...,address) 0xa86d2604 (ERC20) + acceptOffer(address from,address _contract,uint256 tokenId,uint256 _quantity) 0xf190078e + cancelOffer(address _contract,uint256 tokenId) 0x058a56ac +``` +> All `[COMPUTED]` selectors above were keccak-derived from the verified ABI signatures in `wallet.js`/`*.json`. **The two whose signature differs by a single comma will collide-check against a deployed contract — confirm `sellAccess`/`sellToken`/`withdrawListing`/`createOffer` selectors against the real bytecode (or an etherscan ABI) before first mainnet write** (§7-F). +> Keep the runtime principle: **selectors supplied as operator-config defaults, never keccak'd in-capsule.** Pin them in `config/default.json` like the existing `buy_authority` selectors, overridable via env. + +--- + +## 7. Honest gaps, risks, and things to verify + +**A. `hasAccessByContentId` — two contradictory declarations in PC2.** RESOLVED in favor of the AuthorityGateway form: +- `storage.ts:2771` + this runtime (`abi.rs`, selector `0x54d42821`, "confirmed against `~/.pc2 contracts/abis.ts`"): `hasAccessByContentId(address holder, bytes16 contentId)` on AuthorityGateway `0x09dBe7...`. ✅ **Use this.** +- `ChannelBridge.ts:508-513`: `hasAccessByContentId(bytes32 contentId, address user)` on registry `0x96826e93c4b0bb9D4dFCcb080bFe6E05cC363e36` — **arg order, types (bytes32 vs bytes16), AND target all differ.** This path is suspect; do not bind to it. **TODO:** treat `ChannelBridge`'s variant as a PC2 bug to fix, not a second valid ABI. The runtime already uses the correct `0x54d42821`/`address,bytes16` form. + +**B. tokenId resolver missing.** No code maps a `bytes16` kid → the real ledger content tokenId. Phase 1 needs this (today it falls back to `SHA-256(content_id)`, which is wrong). Source of truth = `AssetCreated`/`DigitalAssetRegistered` event data, joined on the asset's kid. Until built, pin `ELASTOS_DDRM_BUY_TOKEN_ID`. + +**C. ERC20 approve leg unimplemented in `buy_authority.rs`.** Default path is USDC (ERC20). The buy will revert without a prior `approve(paymentProcessor, price)`. Build it (§3.1/§4-8). + +**D. List/cancel/offer surface entirely absent.** No contract binding, no assembler, no address wiring beyond AuthorityGateway. §6 is greenfield. + +**E. Listing-lifecycle event topics — ✅ RESOLVED (live pass 2026-06-23).** `ItemListed`/`ItemSold` topic0 confirmed against deployed logs; `ItemUnlisted` keccak-correct (sample pending); the TradeGateway `Offer*` set is still unverified. The Phase-2 **poll model (§5.2-A)** still needs none of them; the event-driven `/api/market/listed`+`/history` index can now be built off the confirmed AuthorityGateway topics (`Offer*` excepted). + +**F. `[COMPUTED]` write selectors need on-chain confirmation.** `buyAccess` (`0xf7580ad9`/`0x0ede2294`), `sellersOf` (`0x997eab2d`), `listings` (`0x6bd3a64b`), `setApprovalForAll` (`0xa22cb465`), `hasAccessByContentId` (`0x54d42821`) are **triple-source confirmed**. The rest (`sellAccess 0x9a3fa9f5`, `withdrawListing 0x3e65bbba`, `sellToken 0xad1ee6be`, `buyToken 0x7d17ff3d`, `createOffer 0xd898aaf2/0xa86d2604`, `acceptOffer 0xf190078e`, `cancelOffer 0x058a56ac`, `paymentProcessor 0xf1c6bdf8`, `hasAccess 0xcf56b4eb`) are derived from verified ABI strings but **not yet seen pinned in-source** — confirm against the deployed bytecode / a verified explorer ABI before the first mainnet write. + +**G. Per-asset authority resolution.** Don't assume `0x09dBe7...` for every asset's buy/list/license. Read `metadata.properties.authority` first; the static address is the fallback only. + +**H. Contract-version risk.** Two generations coexist: legacy `DigitalAssetRegistered` (CentralStorage, numeric tokenIds) and current v3 (`EventHub` + `AssetCreated`, 256-bit hash tokenIds, Operative). The indexer must remain **version-keyed** (`content_indexer.contracts.v3`) so a future v4 is a config entry. All contracts are upgradeable proxies (`initialize()` + AccessControl) — addresses are stable but **implementation/ABI can change behind the proxy**; re-verify selectors after any announced upgrade. + +**I. `EventHub` not yet in the runtime.** PC2 scans `AssetCreated` on `event_hub 0x5a694A6d...` (fallback CentralStorage). The runtime's `content-market`/`chain-provider` should add `event_hub` as the primary v3 `AssetCreated` source. + +**J. SubscriptionModule (out of scope here, flagged for completeness).** PC2 references a SubscriptionModule (`bulkUpdatePlans` tuple `(uint8 actionType, bytes args)`, `PlanActionType ADD=1/UPDATE=2/REMOVE=3`, `subscribePlan(uint8,bytes)`, `getPlans/plans`). Not part of buy/list/cancel; cross-check `elacity-web/src/lib/drm/channel/{subscription.ts,subscribe.ts}` if subscriptions are added. + +--- + +## 8. Source-of-truth file map (all absolute) +**elacity-web (`release/base-network`):** `src/lib/drm/contracts/{AuthorityGateway,TradeGateway,DigitalAsset,DigitalAssetLedger,CoreStorage,OperativeBuyable,OperativeBuyableSellable,IOperative}.json`; `src/lib/web3/Ecosystem.tsx`; `src/lib/web3/network/constants.ts`; `src/components/Cinema/Media/MediaContext.tsx` (buy/list); `src/components/Cinema/Governance/contexts/GovernanceActionContext.tsx` (royalty/offers/cancel); `src/lib/web3/executable/executors/eip1193/tx.ts`; `src/hooks/usePlayerCertificate.tsx`; `src/lib/drm/license/request.ts`; `src/lib/drm/utils.ts`; `src/constants/contract.ts`. +**PC2 (`pc2.net/pc2-node`):** `data/installed-apps/elacity-market/wallet.js` (canonical client ABI + selectors); `src/services/ContentIndexerService.ts` (topics + read selectors); `data/installed-apps/elacity-market/api.js` (`catalogItemToNft` adapter); `config/default.json` (`content_indexer.contracts.v3`); `src/api/storage.ts` (`hasAccessByContentIdWithFailover`, the correct ABI); `src/services/gateway/ChannelBridge.ts` (the suspect ABI — §7-A); `src/utils/rpc.ts` (RPC health tracker). +**This runtime (`feat/marketplace-runtime` / `feat/ddrm-hardening-and-creator-parity`):** `elastos/crates/elastos-server/src/api/{buy_authority.rs,trade_authority.rs,content_index.rs,gateway_marketplace.rs}` (money-path + resale assembler + discovery + routes — all built gateway-side); `capsules/chain-provider/src/{main.rs,abi.rs,config.rs,channel_index.rs}` (KID→tokenId resolver §6); `capsules/content-market/src/main.rs`; `capsules/marketplace-content/browser/` (UI shell, no authority); `capsules/marketplace/src` (legacy app-store wasm shell — UI only, not the assembler). diff --git a/docs/marketplace/CONTRACTS_LEGACY_ABI_REFERENCE.md b/docs/marketplace/CONTRACTS_LEGACY_ABI_REFERENCE.md new file mode 100644 index 00000000..1bf3390b --- /dev/null +++ b/docs/marketplace/CONTRACTS_LEGACY_ABI_REFERENCE.md @@ -0,0 +1,441 @@ +# LEGACY — dDRM Marketplace Contract & ABI Reference (superseded) + +> Preserved verbatim from `docs/ELACITY_MARKETPLACE_CONTRACTS.md` on the retired +> `feat/ddrm-hardening-and-creator-parity` branch (2026-07-03 consolidation). +> The CURRENT reference is `docs/marketplace/CONTRACTS.md` (live-verified 2026-06-23); +> this legacy copy is kept for its ABI appendix + Lit/smart-account infra notes. + +# Elacity dDRM Marketplace — Contract & Connectivity Reference + +> Single source of truth for building a marketplace app against the Elacity dDRM +> protocol on **Base mainnet**. Consolidated from the live `pc2.net` implementation +> (the `elacity-market` / `elacity-creator` iframe dApps + the `pc2-node` backend). +> +> **Provenance:** addresses/chain config come from `pc2-node/src/sdk/config.ts` +> (the repo's documented SSOT), `pc2-node/config/default.json`, and the two dApps' +> hardcoded constants. ABI fragments are extracted verbatim from +> `elacity-market/wallet.js` and `elacity-creator/app.js`. These were **not** +> independently verified on BaseScan — see "Caveats". +> +> Last consolidated: 2026-06-22. + +--- + +## 1. Base network configuration + +| Field | Value | +|-------|-------| +| Network | Base mainnet | +| chainId | `8453` (hex `0x2105`) | +| Explorer | `https://basescan.org` | +| Lit network | `chipotle` | +| Indexer deploy block | `43892000` | +| Elacity API (GraphQL/REST) | `https://base.ela.city/api` (GraphQL: `https://base.ela.city/api/2.0/graphql`) | +| IPFS gateway | `https://ipfs.ela.city/ipfs` | + +**RPC pool (fallback order):** + +``` +https://base-rpc.publicnode.com +https://base.drpc.org +https://mainnet.base.org +https://base-mainnet.public.blastapi.io +https://base.meowrpc.com +https://1rpc.io/base +``` + +**Base Sepolia (chainId `84532`)** is scaffolded in `config.ts` but all contract +addresses are empty strings — there is **no committed testnet deployment**. Treat +mainnet (8453) as the only live target. + +--- + +## 2. V3 core contract addresses (Base 8453) — canonical + +These are the active production contracts. Source: `pc2-node/src/sdk/config.ts` +(`CONTRACTS.base`), mirrored in `pc2-node/config/default.json` and the dApps. + +| Contract | Address | Role | +|----------|---------|------| +| **CentralStorage** | `0x0C1EeA2A3361B80AC0e42179335dB536A951760b` | Global fees (`mediaCreationFee`, `channelCreationFee`), royalty-offer storage (`offers`, `offerersOf`) | +| **AuthorityGateway** | `0x09dBe796f40ECEffEAccf243c3d758C4c1d8D87D` | Access-token commerce (`buyAccess`/`sellAccess`/`withdrawListing`) **and** the on-chain access gate the Lit Action calls at decrypt (`hasAccessByContentId`) | +| **ChannelFactory** | `0xE1365ed47353De2F8A6a69E271e36650A9EE368F` | `createChannel` | +| **RoyaltyTradeGateway** (a.k.a. `TradeGateway`) | `0xd02451BCE627EF476B8ee52Cf131C426f67dbcB2` | Royalty-share order book (`sellToken`/`buyToken`/`createOffer`/`acceptOffer`/`cancelOffer`/`withdrawListing`) | +| **AssetFactory** | `0x4c80A6209F16437f0dc4a98E3D43f08aeBF57765` | Asset/operative deployment | +| **EventHub** | `0x5a694A6d988354dca491fe0F6db7a6ef46b656c2` | Canonical event source for the indexer | +| **SubscriptionManager** | `0xb00456b57598006ef11d1F1678DcE68713eC897D` | Subscription registry | + +**Extended factories** (from `docs/core/LIT_CHIPOTLE_MIGRATION.md`; not hardcoded in the dApps): + +| Factory | Address | +|---------|---------| +| PublicChannelFactory | `0xfcDffDd1cb844Fb3AC8c5d3477dF227E6E94ff8c` | +| PrivateChannelFactory | `0x6d0369f5AE83528CC8723027e5F219380d2F26A8` | +| MultiChannelFactory | `0x2E8B108a60189af117F428A6827B3Bfb2e830931` | +| BuyableOperativeFactory | `0xFbf39a097aa5577666e30de499e72120C8B3E82a` | +| BuyableSellableOperativeFactory | `0xd4FE224a71bF3C0c8F3075C4e5FB638C30517DfE` | + +--- + +## 3. Per-asset (dynamic) contracts + +These are deployed per channel/asset and discovered from mint events / the indexer +/ asset metadata (`metadata.properties.authority`, `operative.address`): + +- **Channel** — an ERC-721 `DigitalAsset` contract that is *also* the subscription + module. Holds the collection; `mint()` is called on it; `subscribePlan`, + `bulkUpdatePlans`, `configureTokenOwnershipAccess`, `getPlans`, `tokenURI` live here. +- **Operative** — an ERC-1155 contract holding the three token classes: + + | Token ID | Constant | Meaning | + |----------|----------|---------| + | `1` | `TOKEN_ID_ACCESS` | Access token (what a buyer receives) | + | `2` | `TOKEN_ID_ROYALTY_SHARE` | Tradable royalty share | + | `3` | `TOKEN_ID_DISTRIBUTION` | Distribution right | + + Resolve the operative for a given `(channel, tokenId)` via + `AuthorityGateway.operative(channel, tokenId)`. + +**Op types** (passed to `mint`): `FREE = 0`, `BUY_ONCE = 1`, `BUY_AND_RESELL = 2`. +**Channel scope:** `PUBLIC = 1`, `PRIVATE = 2`. **Channel type:** `STANDARD = 1`, `MULTI = 2`. + +--- + +## 4. Payment tokens & platform addresses (Base 8453) + +| Token | Address | Decimals | +|-------|---------|----------| +| USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | 6 | +| USDT | `0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2` | 6 | +| DAI | `0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb` | 18 | +| WETH | `0x4200000000000000000000000000000000000006` | 18 | +| Native ETH | `0x0000000000000000000000000000000000000000` (sentinel) | 18 | + +| Platform address | Value | +|------------------|-------| +| Elacity asset royalty recipient | `0x0917Aa260359670F7855a5454c630993ce40C52D` (default 5%) | +| Elacity channel royalty recipient | `0xCE4639Aa1E47E400683F49d95025475D5F50192d` | +| Default public channel | `0x2fb53d4ab93112a6c0a1e54ffcd7199c6fd37412` | + +--- + +## 5. dDRM / Lit + smart-account infrastructure + +| Item | Value | +|------|-------| +| Lit network | `chipotle` | +| Lit access-check contract | AuthorityGateway `0x09dBe796f40ECEffEAccf243c3d758C4c1d8D87D` | +| Lit PKP ID | `0x68dcf3dc3c38d726e8a7cdca8ab318f49552c05d` | +| Lit RLI (capacity credits) | `0xd3DEC8965Aa9676a6AfB4e4D05DA14E28D8f11e8` | +| Particle Smart Account Factory (Base) | `0xb3f15a44f91a08a93a11c6fbf6a4933c623275fe` | +| Particle Smart Account EntryPoint (Base) | `0xba418fa699622de824b258c61eb150ed7a13967b` | + +The content-encryption key (CEK) is escrowed as a Lit PKP ciphertext at publish time +and is **never** returned to the client. At decrypt it is recovered inside the Lit +TEE and used in a WASM session; it does not cross into JS. + +--- + +## 6. Action -> contract function -> backend touchpoints + +Write path for every on-chain action: dApp builds calldata with ethers, then +`postMessage` IPC -> `WalletService` -> Particle Universal Account (smart-account +batch) or EOA -> Base. + +| Action | On-chain call | Backend touchpoints | +|--------|---------------|---------------------| +| Buy access (native ETH) | `AuthorityGateway.buyAccess(seller, ledger, tokenId, quantity, pricePerToken)` `{value}` | after success: `POST /api/storage/ipfs/pin` | +| Buy access (ERC-20) | `ERC20.approve(paymentProcessor, amount)` + `AuthorityGateway.buyAccess(seller, ledger, tokenId, quantity, pricePerToken, payToken)` | as above | +| List access for resale | `Operative.setApprovalForAll(AuthorityGateway, true)` + `AuthorityGateway.sellAccess(ledger, tokenId, quantity, pricePerToken, payToken)` | — | +| Cancel access listing | `AuthorityGateway.withdrawListing(operative, tokenId, quantity)` | — | +| List royalty shares | `Operative.setApprovalForAll(TradeGateway, true)` + `TradeGateway.sellToken(operative, 2, quantity, pricePerToken, payToken)` | — | +| Buy royalty shares | (`approve` if ERC-20) + `TradeGateway.buyToken(seller, operative, 2, quantity)` `{value?}` | — | +| Cancel royalty listing | `TradeGateway.withdrawListing(operative, 2, quantity)` | — | +| Create royalty offer (bid) | (`approve`) + `TradeGateway.createOffer(operative, 2, quantity, pricePerToken, payToken)` | — | +| Accept royalty offer | `Operative.setApprovalForAll(TradeGateway, true)` + `TradeGateway.acceptOffer(from, operative, 2, quantity)` | — | +| Cancel royalty offer | `TradeGateway.cancelOffer(operative, 2)` | — | +| Transfer channel NFT | `ERC721.safeTransferFrom(from, to, tokenId)` (channel/ledger address) | — | +| Transfer royalty shares | `Operative.safeTransferFrom(from, to, 2, amount, 0x)` | — | +| Withdraw rewards | `Operative.withdrawRewards(payToken)` or `Operative.multicall([...])` | reads `GET /api/catalog/earnings/:address` | +| Subscribe (paid plan) | (`approve` if ERC-20) + `Channel.subscribePlan(planId, 0x)` `{value?}` | — | +| Manage plans (owner) | `Channel.bulkUpdatePlans([(actionType, args)...])` (actionType 1=ADD,2=UPDATE,3=REMOVE) | best-effort GraphQL `updateSubscriptionPlan` for metadata | +| Token-gate channel | `Channel.configureTokenOwnershipAccess([(tokenAddress, threshold)...])` (threshold in base units) | — | +| Create channel | `ChannelFactory.createChannel(channelType, scope, name, tokenURI, configData)` `{value=channelCreationFee}` | `POST /api/storage/ipfs/*` for tokenURI | +| Mint / publish asset | `DigitalAsset.mint(uri, opType, opRawData, sellRawData)` `{value=mediaCreationFee}` (`opRawData` embeds `contentId = kidToContentId(kid)`) | encrypt: `POST /api/media/encode` (media) or `POST /api/storage/lit/encrypt` (non-media); then `POST /api/catalog/reindex` | +| Grant minter role | `Channel.grantRole(MINTER_ROLE, account)` | — | +| Follow (free channel) | none (social only) | GraphQL `subscribeChannel` / `unsubscribeChannel` | + +`paymentProcessor` for ERC-20 approvals is read from the operative/channel +(`Operative.paymentProcessor()`), not assumed to be the gateway. + +--- + +## 7. View functions used to populate the UI + +| Function | Contract | Use | +|----------|----------|-----| +| `mediaCreationFee()` / `channelCreationFee()` | CentralStorage | Creator fee display | +| `operative(channel, tokenId)` | AuthorityGateway | Resolve operative after mint | +| `sellersOf(operative, tokenId)` / `listings(operative, tokenId, seller)` | AuthorityGateway / TradeGateway | Listing discovery + price/qty/payToken | +| `cstore()` | TradeGateway | Resolve CentralStorage for offers | +| `offers(op, tokenId, owner)` / `offerersOf(op, tokenId)` | CentralStorage | Active royalty offers | +| `balanceOf(account, id)` | Operative | Access (1), royalty (2), distribution (3) balances / ownership | +| `OP_TYPE()` / `resellerCut()` | Operative | Buy-once vs resell badge, reseller % | +| `rewardsOf(user, payToken)` | Operative | Pending royalty rewards | +| `hasTradeAccess(account, tokenId)` | Operative | Royalty-trading permission | +| `paymentProcessor()` | Operative / Channel | ERC-20 approval target | +| `getPlans()` / `tokenURI(tokenId)` | Channel | Subscription plans / metadata | +| `authority()` / `totalSupply()` | Channel (DigitalAsset) | Gateway lookup / token id after mint | +| `hasRole(MINTER_ROLE, addr)` | Channel (AccessControl) | Pre-mint grant check | +| `allowance / balanceOf / decimals` | ERC-20 | Approval + balance checks | +| `name / symbol / decimals / supportsInterface` | Token introspect | Token-gate validation | + +Note: `AuthorityGateway.hasAccess(accessor, ledger, tokenId)` exists in the ABI but +the marketplace UI does **not** call it; decrypt-time ownership uses +`hasAccessByContentId(holder, bytes16 contentId)` inside the Lit Action. +`Channel.hasActiveSubscription` is present but stubbed in the dApp — subscription +status comes from GraphQL `checkChannelAccess`. + +--- + +## 8. Read / data tiers (no TheGraph) + +1. **Local on-chain indexer** (preferred for browse) — `ContentIndexerService` + scans Base events into SQLite, exposed via: + - `GET /api/catalog` (feed/listings; supports `?channel=`) + - `GET /api/catalog/asset/:address/:tokenId` + - `GET /api/catalog/channels`, `GET /api/catalog/channel/:address` + - `GET /api/catalog/owned/:address` + - `GET /api/catalog/operatives` + - `GET /api/catalog/earnings/:address` + - `GET /api/catalog/indexer-status` +2. **Elacity GraphQL** (auth + fallback reads + social) — client calls + `POST /api/elacity/graphql` which proxies to `https://base.ela.city/api/2.0/graphql` + (Bearer JWT, optional `X-ETH-Signer`). Used for SIWE login (`getNonce`/`userLogin`), + item/channel fallback reads, likes/playlists/follows, activity feeds, unpublish, + channel metadata. +3. **Direct Base `eth_call`** (live commerce truth) — the view functions in §7. + +--- + +## 9. dDRM publish -> buy -> decrypt flow + +**Publish (creator):** encrypt (CEK escrowed as Lit ciphertext, never returned) -> +upload encrypted asset + metadata to IPFS -> `DigitalAsset.mint(uri, opType, opRawData, +sellRawData)` with `contentId = kidToContentId(kid)` embedded in `opRawData` -> +`Operative.setApprovalForAll(AuthorityGateway, true)` -> `POST /api/catalog/reindex`. + +**Buy (market):** pick listing (`sellersOf` + `listings`, or GraphQL price) -> +`AuthorityGateway.buyAccess(...)` mints ERC-1155 ACCESS (id=1) (+ DISTRIBUTION id=3) +to the buyer -> client builds a local `.ddrm` descriptor and pins via +`POST /api/storage/ipfs/pin`. + +**Decrypt / play (post-purchase):** mandatory secure-view session, then per-content +decrypt in the TEE/WASM (CEK never reaches JS): + +```mermaid +sequenceDiagram + participant User + participant Market as elacity-market (app.js) + participant Wallet as wallet.js + IPC + participant Base as Base contracts + participant PC2 as pc2-node API + participant Player as viewer / pc2-media-runtime + participant Lit as chipotle-client + Lit TEE + + User->>Market: Buy Now + Market->>Wallet: AuthorityGateway.buyAccess(...) + Wallet->>Base: buyAccess (+ ERC20 approve) + Base-->>Market: tx confirmed + Market->>PC2: POST /api/storage/ipfs/pin + + User->>Market: Play / Open + Market->>PC2: POST /api/media/prepare-auth (buyerAddress) + Market->>Player: launchApp(channel, tokenId, kid, authority, buyerAddress) + Player->>PC2: pc2_secureView_sign + PC2->>PC2: POST /api/storage/lit/begin-session + Player->>Player: wallet personal_sign(delegationCanonical) + PC2->>PC2: POST /api/storage/lit/complete-session -> bearer token + Player->>PC2: POST /api/media/init (X-SecureView-Session) + PC2->>Lit: recover CEK (universal-decrypt Lit Action) + Lit->>Base: hasAccessByContentId(holder, contentId) + Base-->>Lit: access = true + loop per segment / page + Player->>PC2: POST /api/media/segment (or /api/storage/lit/secure-view) + PC2->>Lit: decrypt in WASM (ddrm-decrypt / cenc-decrypt) + PC2-->>Player: cleartext bytes + end +``` + +**Two viewer paths:** +- Media (video/audio DASH): `pc2-media-runtime` -> `POST /api/media/init` then repeated + `POST /api/media/segment`. +- Non-media (PDF/image/ebook): `ddrm-viewer` -> `POST /api/storage/lit/secure-view` + (WASM renderer emits pixels only). + +**Secure-view session lifecycle** (parent frame `pc2-secure-view.js`): +`/api/storage/lit/begin-session` -> wallet `personal_sign(delegationCanonical)` -> +`/api/storage/lit/complete-session` -> bearer token in IndexedDB -> sent as +`X-SecureView-Session` on every decrypt call (validated by +`requireSecureViewSession` middleware). On `401 session_token_invalid` the client +re-signs with `{ refresh: true }` (flag must propagate through every hop). + +--- + +## 10. Caveats (read before building) + +1. **V2 is deprecated.** Old Base addresses still appear in docs and stale compiled + `.js` files — do **not** use them: + - CoreStorage `0xc8F50Bf1A6b765460621f861a64a5d333Bc7f575` + - AuthorityGateway (V2) `0x8fe6bf9877B78BF0126819ff2593235E54Ee1E29` + - ChannelCore `0x6a3f7780C54cb66291f8f1bE609047C2f664Dbf6` + - TradeGateway (V2) `0x9eC53758b698f9F68C0654DDd9159173a159a459` +2. **No standalone `Marketplace`/`Auction` contract** on Base — trading is + AuthorityGateway (access) + RoyaltyTradeGateway (royalty shares). +3. **Buy authority address:** prefer the per-asset `metadata.properties.authority` + over a hardcoded gateway constant (the dApp is inconsistent here). +4. **ABIs are inline** ethers human-readable fragments (no JSON ABI files in + `pc2.net`). The canonical TS implementation lives in the external `elacity-web` + repo (`src/lib/drm/...`, `src/lib/web3/executable/tx.ts`). +5. **On-chain verification TODO:** addresses here come from repo config, not a + BaseScan check. Verify before mainnet writes. + +--- + +## 11. Appendix — ABI fragments (ethers human-readable) + +Extracted verbatim from `elacity-market/wallet.js` and `elacity-creator/app.js`. + +### AuthorityGateway + +```solidity +function buyAccess(address seller, address ledger, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken) payable +function buyAccess(address seller, address ledger, uint256 tokenId, uint256 _quantity, uint256 _pricePerToken, address _payToken) +function sellAccess(address ledger, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) +function withdrawListing(address operative, uint256 tokenId, uint256 quantity) +function operative(address channel, uint256 tokenId) view returns (address) +function sellersOf(address operative, uint256 tokenId) view returns (address[]) +function listings(address operative, uint256 tokenId, address seller) view returns (uint256, uint256, address) +function hasAccess(address accessor, address ledger, uint256 tokenId) view returns (bool) +``` + +### RoyaltyTradeGateway (TradeGateway) + +```solidity +function sellToken(address operative, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) +function buyToken(address seller, address operative, uint256 tokenId, uint256 quantity) payable +function withdrawListing(address operative, uint256 tokenId, uint256 quantity) +function createOffer(address operative, uint256 tokenId, uint256 quantity, uint256 pricePerToken, address payToken) +function acceptOffer(address from, address operative, uint256 tokenId, uint256 quantity) +function cancelOffer(address operative, uint256 tokenId) +function sellersOf(address operative, uint256 tokenId) view returns (address[]) +function listings(address operative, uint256 tokenId, address seller) view returns (uint256, uint256, address) +function cstore() view returns (address) +``` + +### CentralStorage + +```solidity +function mediaCreationFee() view returns (uint256 fee, address token) +function channelCreationFee() view returns (uint256 fee, address token) +function offers(address op, uint256 tokenId, address owner) returns (uint256, uint256, address) +function offerersOf(address op, uint256 tokenId) returns (address[]) +``` + +### ChannelFactory + +```solidity +function createChannel(uint8 _channelType, uint8 _scope, string _name, string _tokenURI, bytes data) payable +event ChannelCreated(uint8 indexed channelType, uint8 indexed scope, address indexed creator, address channel, address factoryAddr) +``` + +### DigitalAsset (Channel / ERC-721) + +```solidity +function mint(string _uri, uint16 opType, bytes opRawData, bytes sellRawData) payable +function authority() view returns (address) +function totalSupply() view returns (uint256) +function safeTransferFrom(address from, address to, uint256 tokenId) +event AssetCreated(address indexed _to, address indexed _channel, uint256 _tokenId, string _tokenUri, uint16 _opType, address indexed opContract) +``` + +### Operative (ERC-1155) + +```solidity +function paymentProcessor() view returns (address) +function setApprovalForAll(address operator, bool approved) +function isApprovedForAll(address account, address operator) view returns (bool) +function balanceOf(address account, uint256 id) view returns (uint256) +function OP_TYPE() view returns (uint16) +function resellerCut() view returns (uint16) +function rewardsOf(address user, address payToken) view returns (uint256) +function hasTradeAccess(address account, uint256 tokenId) view returns (bool) +function withdrawRewards(address paymentToken) +function multicall(bytes[] data) +function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes data) +function royaltyInfo(uint256 salePrice) view returns (tuple(address receiver, uint256 amount)[]) +``` + +### SubscriptionModule (on the Channel; V3, base-network-updates) + +```solidity +function bulkUpdatePlans(tuple(uint8 actionType, bytes args)[] actions) // actionType: 1=ADD, 2=UPDATE, 3=REMOVE +function subscribePlan(uint8 planId, bytes args) payable // args = ABI-encoded metadata CID, or 0x +function configureTokenOwnershipAccess(tuple(address tokenAddress, uint256 threshold)[] thresholds) // threshold in base units +function getPlans() view returns (tuple(uint8 planId, address payToken, uint256 price, uint256 duration, bool active)[]) +function plans(uint8 planId) view returns (uint8 planId, address payToken, uint256 price, uint256 duration, bool active) +function hasActiveSubscription(address subscriber) view returns (bool) // present but stubbed in dApp; use GraphQL +function tokenURI(uint256 tokenId) view returns (string) +function paymentProcessor() view returns (address) +``` + +`bulkUpdatePlans` arg encoding (per action): +- ADD: `(address payToken, uint256 priceWei, uint256 durationSecs, string planURI)` +- UPDATE: `(uint8 planId, address payToken, uint256 priceWei, uint256 durationSecs, string planURI)` +- REMOVE: `(uint8 planId)` + +### AccessControl (on the Channel) + +```solidity +function grantRole(bytes32 role, address account) +function hasRole(bytes32 role, address account) view returns (bool) +``` + +### ERC-20 + +```solidity +function approve(address spender, uint256 amount) returns (bool) +function allowance(address owner, address spender) view returns (uint256) +function balanceOf(address account) view returns (uint256) +function decimals() view returns (uint8) +``` + +### Token introspection (token-gating) + +```solidity +function name() view returns (string) +function symbol() view returns (string) +function decimals() view returns (uint8) +function supportsInterface(bytes4 interfaceId) view returns (bool) +``` + +--- + +## 12. Source file index (in `pc2.net`) + +| Path | Role | +|------|------| +| `pc2-node/src/sdk/config.ts` | SSOT: networks, V3 contracts, tokens, platform, Lit, smart account | +| `pc2-node/config/default.json` | Runtime blockchain + indexer config + RPC pool | +| `pc2-node/data/test-apps/elacity-market/wallet.js` | All marketplace ABIs + write/read encoding + addresses | +| `pc2-node/data/test-apps/elacity-market/app.js` | Buy flow, play/Lit auth, subscription, detail view | +| `pc2-node/data/test-apps/elacity-market/app-features.js` | Resell, royalty order book, offers, plans, token gates | +| `pc2-node/data/test-apps/elacity-market/api.js` | Catalog + GraphQL read/auth layer | +| `pc2-node/data/test-apps/elacity-creator/app.js` | createChannel, mint, encrypt orchestration, ABIs | +| `src/gui/src/IPC.js` | Wallet IPC bridge (postMessage -> WalletService) | +| `src/gui/src/services/WalletService.js` | Particle / Universal Account execution | +| `pc2-node/src/api/media.ts` | `prepare-auth`, `init`, `segment` | +| `pc2-node/src/api/chipotle-client.ts` | Lit Chipotle session + CEK recovery | +| `pc2-node/src/api/storage.ts` | `lit/encrypt`, `lit/secure-view`, sessions | +| `pc2-node/src/api/middleware/secureViewSession.ts` | Bearer session validation | +| `pc2-node/data/lit-actions/universal-decrypt-chipotle.js` | TEE access check (`hasAccessByContentId`) + decrypt | diff --git a/docs/marketplace/PHASE1_BUY_INVARIANT.md b/docs/marketplace/PHASE1_BUY_INVARIANT.md new file mode 100644 index 00000000..5a1e4f74 --- /dev/null +++ b/docs/marketplace/PHASE1_BUY_INVARIANT.md @@ -0,0 +1,54 @@ +# Phase 1 — Bind buy terms to the re-verified on-chain listing (the money-path invariant) + +> **The single most important change in the entire marketplace effort, and it gates everything else.** Hand-to-Cursor spec. Implements on `feat/marketplace-runtime`; reviewed; merged into the dDRM line. Until this passes, **no buy button ships — not even in a demo.** Contract: [PRINCIPLES.md](../../PRINCIPLES.md) §11 (fail closed). Plan context: [PLAN.md](PLAN.md) §0, §4. + +## Implementation status (this branch — compile + unit-tested; live = Cursor) +**Core + abort-on-drift IMPLEMENTED** in `buy_authority.rs` + `chain_tx.rs` (cargo build --tests 0 warnings; 7 `buy_authority` tests pass): +- ✅ Real tokenId bound via the chain-provider `resolve_token_id` op (KID → `AssetCreated` + mint calldata) or a pinned override; **fail-closed** if unresolved — never `word_from_id(content_id)`. +- ✅ `value = price × quantity`; ERC-20 approve leg = `approve(Operative.paymentProcessor(), total)` (not the gateway). +- ✅ **Abort-on-drift**: bind terms → re-read `listings(operative,tokenId,seller)` live before signing → `ensure_no_drift` (fail-closed on price/pay-token drift) → abort if sold out (supply 0). +- ⏳ **Cursor's live-chain pass**: the real `eth_getLogs`/`eth_call`/broadcast against Base + the 3 `#[ignore]`d integration tests. + +## The bug (confirmed in code) +`elastos/crates/elastos-server/src/api/buy_authority.rs` assembles the purchase tx from **`ELASTOS_DDRM_BUY_*` env-pinned terms**, and **`tokenId` defaults to `word_from_id(content_id)`** (`:230-246`) — a hash of the content id, **not the real on-chain tokenId**. Consequence (the worst failure a marketplace can have): a byte-correct `buyAccess` tx is signed and broadcast against a fabricated tokenId / stale price, the wallet **debits**, the tx **confirms** (on-chain finality, no refund), and `has_access_by_content_id` for the asset stays **false** → the open **fails closed**. **Paid + no access + no refund.** + +## The fix — one new order-assembly step + one hard invariant +Insert a **re-verify-then-bind** step between "user clicks buy" and "assemble the unsigned tx": + +1. **Re-decode the listing from chain** (not the index cache): call `content-market.reconstruct_listing` / `listing_from_event` on the asset's mint calldata/event, via `chain-provider`. +2. **Re-read live on-chain state** via `chain-provider`: the real `tokenId`, current `price`, `payToken`, remaining `supply/copies`, and the listing `seller` — bound to the asset's **on-chain** seller (not a caller-supplied or env value). +3. **Bind those exact values into the `UnsignedPurchaseV1`** that gets signed — replacing every `ELASTOS_DDRM_BUY_*` env read and the `word_from_id(content_id)` tokenId default. +4. **Abort on drift (the invariant):** immediately before signing/broadcast, re-read the same fields once more; if `(seller, tokenId, price, payToken, supply)` differ from what was bound at assembly, **fail closed — do not broadcast.** (Optional hardening: also rely on the contract reverting if terms changed, but do not depend on it alone.) + +Keep everything else as-is: `wallet-provider` signs (human-in-loop), `chain-provider` broadcasts, await receipt, `has_access` flips true. The CEK path is untouched (P15). + +## Grounded buy-call shape (verified against elacity-web v3 — `BuyMediaView` → `MediaContext.handleAccessTokenPaymentAsync`) +The real v3 buy is `AuthorityGateway.buyAccess(...)` with two overloads chosen by pay-token. The runtime's **selectors, gateway `0x09dBe7`, USDC `0x833589fC`, chain 8453, and arg order are already correct**; the **bound values** are what's wrong today. Each bound field, grounded: +- **`tokenId` = the ledger MEDIA tokenId** (`id.toBigNumber()` from the asset/route) — **NOT `word_from_id(content_id)`** (a content hash, unrelated to the on-chain id). This is the core fix (binding a fabricated tokenId = paid+no-access). Resolve it from the listing/asset or via the KID→ledger-tokenId resolver (scan `DigitalAssetRegistered`, which carries both `tokenId` and the `bytes16 contentId`). +- **`ledger` = the ERC-1155 channel ledger** (`metadata.properties.ledger`, e.g. `0x6756e140…`) — **NOT the gateway.** Runtime currently defaults `ledger = to` (the gateway) when unpinned → wrong recipient. +- **`seller` = the on-chain listing seller** (from `sellersOf`/`listings` or the listings endpoint), not a caller/env value. +- **`pricePerToken` = the listing price** in pay-token minor units (USDC = 6 decimals); **terms source** = `GET /2.0/authority/{authority}/listings/{operative}/1` (the trailing `/1` = the ERC-1155 ACCESS_TOKEN role id) **or** re-read live from chain. The runtime has no listing-resolution seam today. +- **ERC-20 overload** `buyAccess(seller,ledger,tokenId,quantity,pricePerToken,payToken)` `0x0ede2294`, `value = 0`, **requires a prior `approve(spender, MaxInt256)` where `spender = the OPERATIVE's `paymentProcessor()`** (read live from the operative — **NOT the gateway**), gated by `allowance(account, paymentProcessor)`. Runtime today only emits a `requires_erc20_approve` boolean and never models the `paymentProcessor` spender → **live USDC buys would fail.** +- **Native overload** `buyAccess(seller,ledger,tokenId,quantity,pricePerToken)` `0xf7580ad9`, **`value = pricePerToken × quantity`** (runtime parses `ELASTOS_DDRM_BUY_PRICE` only — **missing the ×quantity multiplier**). +- **`authority` (the gateway) is per-asset** = `metadata.properties.authority` (the runtime's pinned `0x09dBe7` matches current assets, but the assembler should source it per-asset, not hard-pin). + +## Files +- Edit: `elastos/crates/elastos-server/src/api/buy_authority.rs` (the env-pinned terms + `word_from_id` tokenId at `:230-246`; the assemble→sign→broadcast flow). +- Call: `capsules/content-market` (`reconstruct_listing` / `listing_from_event`) for the re-decode; `chain-provider` for the live reads (`prepare_transaction`/typed reads, `has_access_by_content_id`, receipt). +- Do **not** add chain RPC to `buy_authority` itself — go through `chain-provider` (sole RPC declarant; P3/P4). + +## Pass/fail checks (each independently verifiable) +1. **Wrong-token cannot broadcast:** a buy whose bound `tokenId` ≠ the asset's real on-chain tokenId is **refused before broadcast** (unit/integration test on `chain-mock`). *This is the core regression test.* +2. **Drift aborts:** if price/supply/seller changes between assembly and the pre-broadcast re-read, the order **fails closed**, no tx is sent. +3. **Happy path end-to-end:** not-owned → buy (re-verify → sign → broadcast → receipt) → `has_access_by_content_id` true → `drm.open → key.release → decrypt.render` succeeds for a Tier-2 asset, on `chain-mock` **and** against a pinned testnet contract. +4. **No env terms on the live path:** grep confirms the live buy path no longer reads `ELASTOS_DDRM_BUY_*` for `seller/tokenId/price/payToken` (env may remain only for dev/chain-mock fixtures, fenced). +5. **CEK untouched:** no new code path touches the CEK; `key-provider` still releases session-wrapped only. +6. **ERC-20 approve targets `paymentProcessor`:** the assembled approve (when `payToken != native`) has spender = the asset's Operative `paymentProcessor()` (read live), **not** the gateway; allowance pre-checked. A buy assembled without it on an ERC-20 asset is refused. +7. **Native value = price × quantity:** the native-overload `value` equals `pricePerToken * quantity` (not a flat env price); a quantity > 1 native buy binds the correct total. +8. **Real tokenId + ledger bound:** the bound `tokenId` is the resolved ledger media tokenId (not `word_from_id`) and `ledger` is the channel ERC-1155 (not the gateway); both sourced from the asset/listing, asserted in the wrong-token test. + +## Gate +`just test-crate elastos-server` green (incl. the new wrong-token + drift tests); `just alignment-check` OK; changed files clippy-clean. New tests start as real assertions (this is a correctness fix, not a ratchet). + +## Sequencing note +Run **in parallel with P1b** (harden the open boundary — PQ-hybrid threshold release + dKMS v0), the largest item and an external-audit dependency. P1 makes a purchase *provably grant what it charges for*; P1b makes the grant *openable on a real chain*. Both are prerequisites to any UI; both have no UI dependency. diff --git a/docs/marketplace/PHASE2_ENRICHMENT.md b/docs/marketplace/PHASE2_ENRICHMENT.md new file mode 100644 index 00000000..c40d0d93 --- /dev/null +++ b/docs/marketplace/PHASE2_ENRICHMENT.md @@ -0,0 +1,52 @@ +# Phase 2 — listing enrichment (name / cover / content_cid) — turnkey spec + +> The lean discovery `Listing` (operative/token_id/op_type/token_uri) → a RICH listing the shell renders + +> can acquire. **The enrichment logic already exists** — `content-market`'s `enrich_listing` op. This wires +> it into `/get`. It is a **LIVE multi-provider orchestration** (two network fetches + a subprocess fuse), +> so it is Cursor-built + verified against real assets — not a blind compile-build (verify, don't trust). + +## What `content-market` gives us (already built + unit-tested) +`Request::EnrichListing { request: EnrichRequestV1 }` (capsules/content-market/src/main.rs:54, 225). It is +**PURE — it fetches nothing**; the caller hands it everything: +```rust +EnrichRequestV1 { calldata, channel_address, chain_id=8453, expected_selector?, metadata } +``` +It re-derives the contentId from `calldata` (authoritative), **requires `metadata.kid == contentId`** (else +`identity_mismatch`), then attaches descriptive fields. Output `ContentListingV1` (verified by its tests): +``` +{ content_id (==KID), name, description, image_url, content_cid, mime_type, asset_type, creator_address, op_type, … } +``` +Metadata field paths it reads (PC2 `ContentIndexerService.ts:1102`): `name`, `description`, +`image` (else `media.previewURL`) → `image_url`; `media.uri` → `extract_cid` → **`content_cid`** (the +encrypted asset CID the buy→pin needs); `media.contentType` → `mime_type`; `kid` (or `properties.kid`); +`properties.publisher` → `creator_address`. + +## The orchestration to build into `/get` (or a new `/api/market/enrich`) +The shell already has the lean fields; `/get` adds the live terms (built) + should add the rich fields: +1. **Inputs from the index `Listing`** (AssetCreated): `channel_address`, `token_uri`, and the **mint tx hash** + (the AssetCreated tx — the resolver already fetches its input via `chain-provider tx_input`). +2. **Fetch the mint calldata** — `chain-provider` `tx_input(mint_tx_hash)` (already used by `resolve_token_id`). +3. **Fetch `metadata.json`** — `extract_cid(token_uri)` → fetch that CID via the `content/*` plane + (`fetch_bytes_via_provider` / ipfs-provider) → `serde_json::from_slice`. (P4 — content plane, not raw ipfs.) +4. **Fuse** — call `content-market` `{op:"enrich_listing", request:{calldata, channel_address, metadata}}` → + the rich `ContentListingV1`. Fail closed on `identity_mismatch` (a metadata that lies about its KID). +5. **Merge** into the `/get` response (`name`, `image_url`, `content_cid`, `mime_type`, `content_id`) alongside + the already-built live `on_chain` terms. The shell's `normalize()` already tolerates these fields. + +## Why it is Cursor's (the live boundary) +- Two **live fetches** (mint tx-input via chain-provider; `metadata.json` via ipfs-provider) — must run against + a live node + **real assets** to confirm the `media.uri`/`kid` paths hold for production metadata. +- `content-market` is a **subprocess capsule** — confirm the gateway can invoke it (a `run_capsule`-style call + or a registered provider) the same way it invokes `chain-provider`; wire that transport if absent. +- Performance: enrich **lazily on `/get`** (one detail view = one enrichment), not for every discovery card. + +## What this unblocks +Real names/covers on cards + detail; and **`content_cid` for the live buy→pin** (today the shell's `acquire()` +falls back to mock because the lean listing has no `content_cid` — this fills it). After this, the live loop +discover → detail → buy → **acquire (real pin)** → vault → open is complete end-to-end. + +**Also closes the deep-audit KID/CID binding finding (LOW):** once the server resolves the canonical +`content_cid` for a `content_id` (KID) from the asset metadata here, `market_acquire` can **ignore the +client-supplied `content_cid`** and pin only the canonical CID — removing the "entitled buyer pins an +arbitrary CID" gap. Until then it is bounded (opaque ciphertext + open re-gates on the embedded KID) and the +fetch is size-capped (`library_acquire`, `ELASTOS_DDRM_ACQUIRE_MAX_BYTES`). diff --git a/docs/marketplace/PHASE2_INDEX_AND_API.md b/docs/marketplace/PHASE2_INDEX_AND_API.md new file mode 100644 index 00000000..2f843ff8 --- /dev/null +++ b/docs/marketplace/PHASE2_INDEX_AND_API.md @@ -0,0 +1,32 @@ +# Phase 2 — content-index (polling cache) + the `/api/market/*` gateway routes + +> Hand-to-Cursor spec. Builds the **discovery layer** the shell (`capsules/marketplace-content`) already consumes via `browser/api.js`. Implements on `feat/marketplace-runtime`. Contract: [PRINCIPLES.md](../../PRINCIPLES.md); plan: [PLAN.md](PLAN.md) §2–§3. **Not a money-path change** — discovery only; the money path re-verifies live ([PHASE1_BUY_INVARIANT.md](PHASE1_BUY_INVARIANT.md)). + +## Shape (red-team-corrected) +The index is **NOT a new provider capsule** — it's a **server-side cache table inside the gateway** (`elastos-server`), fed by a polling job that calls `content-market` (decode) over `chain-provider` (the sole RPC). It holds **no keys, no RPC of its own, no write authority** — it's a query accelerator *below* the canonical calldata path (P10), never a trusted oracle. `content-market` already owns `elastos://market/*`; the gateway serves `/api/market/*` from the cache. No namespace collision, no new capsule surface. + +## Chunk 1 — the listing row + cache table +Define `MarketListingV1` = exactly what `content-market` emits (`content_id`==bytes16 KID, `channel_address`, `chain_id`, `token_uri`, `metadata_cid`, `op_type`+code, `copies`, `price_wei`, `pay_token`, `name`, `description`, `image_url`, `mime_type`, `asset_type`, `creator_address`, `metadata_status`, `source`) **plus index-derived** (`tier` from asset_type/mime, `medium` ∈ {watch,listen,read,view,explore}, `first_seen_block`, `listings[]` primary+resale, `resale_floor`, `holders`). Persist one row per `content_id`; every row carries `source` so it's re-derivable. *Check:* a fixture mint round-trips calldata → `content-market` decode → row → JSON matching `api.js`'s shape; serde + a schema test pass. + +## Chunk 2 — the polling indexer (NOT subscription) +`chain-provider` exposes `eth_getLogs` only (no `eth_subscribe`), capped to **10k-block windows** on a curated RPC subset. Build a poll loop: maintain a cursor; each tick request `getLogs(DigitalAssetRegistered, AssetCreated)` over `[cursor, min(head, cursor+10k)]`; for each event call `content-market.listing_from_event` then enrich via `ipfs-provider` + `content-market.enrich_listing` (which enforces `metadata.kid == calldata contentId` — keep that reject). Upsert rows; advance cursor. Handle: **backfill** (initial sweep from a configured genesis block), **dedup** (idempotent upsert by `content_id`+`tokenId`), **reorg rollback** (on a head reorg below a confirmed depth, re-derive affected rows by `first_seen_block`), and a **freshness SLO** ("indexed within N seconds," configurable — *not* "one block"). RPC-cost/rate-limit aware (the curated subset; backoff). *Check:* a minted asset appears in the cache within the SLO; a simulated reorg re-derives the affected rows; the loop survives an RPC window that returns nothing / rate-limits. + +## Chunk 3 — the `/api/market/*` gateway routes (serve the shell contract) +In `gateway_marketplace.rs` (extend; it already serves the app catalog), add read routes over the cache: +- `GET /api/market/sections` → `{ sections:[{id,title,listings:[MarketListingV1]}] }` +- `GET /api/market/search?medium&q&op&sort&cursor` → `{ listings, cursor }` (faceted; stable cursor pagination from the cache) +- `GET /api/market/get?content_id` → `{ listing, on_chain }` where **`on_chain` is read LIVE from `chain-provider` at request time** (`token_id`, `price`, `pay_token`, `supply_left`, `seller`, `has_access_by_content_id`) — **never from the cache**. This is the trust hinge: detail + buy use live terms, the cache is only for browse. +- `GET /api/market/vault?wallet` → `{ owned:[MarketListingV1] }` (rows where `has_access` true for the wallet) +- `POST /api/market/order/assemble {content_id,quantity,seller}` → `{ unsigned_tx }` — delegates to the Phase-1 re-verified order-assembly (returns UNSIGNED; never signs). Gate behind the same auth as other viewer routes. +*Check:* the shell (`marketplace-content`) flips its pill from **demo** to **live** and renders real listings; `get` returns live on-chain terms that can differ from the cached row (prove the cache is non-authoritative); `order/assemble` returns an unsigned tx and never touches a key. + +## Chunk 4 — wire the shell to live data +Point `marketplace-content/browser/api.js` at the real routes (it already tries them first, falls back to mock). Remove the mock from the shipped build (keep it under a `?mock=1` dev flag). *Check:* with the gateway up, Discover/Search/Asset-detail render from the chain-derived cache; with it down, the dev mock still runs for offline review. + +## Honest limits (carry in code + docs) +- **Freshness is bounded by polling**, not instant — state the SLO; never imply real-time. +- **The cache is centralized-but-verifiable** — same trust shape as a subgraph; the guarantee is re-derivability + live re-verify at point-of-use, not "no chokepoint." +- **IPFS enrichment may lag/fail** — rows persist with `metadata_status: unresolved`; the UI shows "metadata unavailable," identity + sell terms survive from calldata. + +## Sequencing +Chunk 1 + 3 are tractable immediately (schema + routes over `content-market`). Chunk 2 (the poll loop) is the engineering core. Chunk 4 is a one-line flip once the routes are live. Independent of Phase 1 (discovery vs money path) — can build in parallel, but the buy button stays dark until Phase 1 lands. diff --git a/docs/marketplace/PHASE3_ACQUIRE.md b/docs/marketplace/PHASE3_ACQUIRE.md new file mode 100644 index 00000000..2d7a9a20 --- /dev/null +++ b/docs/marketplace/PHASE3_ACQUIRE.md @@ -0,0 +1,83 @@ +# Phase 3A — `object-provider Acquire` (buy → pin-to-Library) — ✅ BUILT (additive) + +> The buy→pin seam: on a confirmed buy, pin the bought **encrypted** IPFS asset into the buyer's local +> Library and register it as a `LibraryObject`, so the existing player opens it. +> +> **STATUS: built (commit `feat: object-provider Acquire op`), compile + unit-tested.** Implemented with +> the **lower-risk ADDITIVE design** (§ below was the original spec): `library_acquire` = fetch keylessly +> via `content/*` → `content/ensure` pin → `write_library_file_bytes` under the buyer root — with **NO** +> change to `record_is_published` / the publish path (the acquired asset is a normal `published=false` +> Library file; availability comes from the ensure receipt, best-effort). Dispatched in both +> registry-bearing sites; rejected on the keyless path (P11); `capsule.json` allow-lists `acquire`. +> Holds no keys, never decrypts (P4/P15/P16). **Entitlement is gated UPSTREAM by the marketplace/buy api +> caller** (the cleaner layering — the object-provider pins what it is told, like `Publish`). Live +> `fetch`/`ensure` + end-to-end open = Cursor. The original turnkey spec (record + gate-change variant) +> is kept below for reference; the additive build supersedes it. + +## 1. The op (enum variant) — `library.rs` after `Publish` (~:295) +`ObjectProviderRequest` is `#[serde(tag="op", rename_all="snake_case", deny_unknown_fields)]` (so `op:"acquire"`; unknown keys fail closed). Add: +```rust +Acquire { + principal_id: String, + content_cid: String, + #[serde(default)] uri: Option, // optional destination override under the buyer root + #[serde(default)] metadata: Option, // {name?, mime?} from the buy step +}, +``` + +## 2. `library_acquire(...)` — clone `library_publish` (`library.rs:1751`) +`async fn library_acquire(data_dir, registry: Arc, principal_id, content_cid, uri, metadata) -> anyhow::Result`: +1. **Derive destination under the buyer root only** (P16): `root = crate::auth::principal_localhost_root(principal_id)` (`auth.rs:1175`); `name` = `metadata.name` else last segment of `uri` else `"{content_cid}.bin"`; `dest = uri.unwrap_or(format!("{root}/Acquired/{name}"))`. Resolve via `library_target(data_dir, principal_id, &dest)` (`library.rs:2999`) — resolves ONLY under this principal's root, so a buyer can never pin into another's space. +2. **CAS guard:** `check_revision(data_dir, principal_id, &target.uri, None)?` (new object). +3. **Pin via `content/*` ensure** (P4 — never raw ipfs; the `pin_call` below): `registry.send_raw("content", {op:"ensure", cid, object_did: target.uri, publisher_did: principal_id})`; fail closed if `status=="error"` or `availability.status != "local_pinned"` (write NOTHING). +4. **Fetch the (encrypted) bytes keylessly** (`content.rs:2734`): `registry.send_raw("content", {op:"fetch", cid})` → b64-decode `data.data`. Bytes stay opaque ciphertext (P15). *Order: for a cold node, `fetch` (pulls+caches) may need to precede `ensure` (pins); re-check `local_pinned`.* +5. **Materialize under the buyer root:** `let object = write_library_file_bytes(data_dir, principal_id, &target.uri, mime.as_deref(), None, &bytes)?` (`library.rs:3634` → `crate::auth::write_principal_root_object`, encrypt-at-rest if the root is protected). +6. **Record + the gate change (see §3).** +7. `append_library_event(data_dir, principal_id, "acquire", &target.uri, json!({content_cid, availability, object}))?` (`library.rs:6888`). +8. Re-derive `library_object(data_dir, principal_id, &target.uri)?` (`library.rs:3311`) and return §4. + +## 3. The derived-view + the surgical gate change +`LibraryObject` is **never** constructed directly — it's derived from on-disk bytes + a `LibraryPublishRecord` sidecar. Write an **acquire-record** (distinct schema) so the object shows `availability="local_pinned"` + `content_cid`, but `published=false`: +```rust +let record = LibraryPublishRecord { + schema: "elastos.library.acquire-record/v1".into(), // marks acquired, not published + object_uri: target.uri.clone(), + cid: content_cid.into(), // surfaces as published_cid + published_at: now_ts(), unpublished_at: None, shared_at: None, + share_policy: None, share_grants: Vec::new(), + content_security: default_publish_content_security(), // library.rs:5689 + receipt, availability, // from ensure (status:"local_pinned") +}; +write_publish_record(data_dir, principal_id, &record)?; // library.rs:6838 +``` +**Gate change** (`record_is_published`, `library.rs:5679`) — required so `local_pinned` coexists with `published=false`, and so acquired assets do NOT expose publish/unpublish/share/repair caps: +```rust +fn record_is_published(record: &LibraryPublishRecord) -> bool { + if record.schema == "elastos.library.acquire-record/v1" { return false; } // <-- add + record.unpublished_at.is_none() + && record.availability.get("status").and_then(Value::as_str).map(|s| s != "local_unpinned").unwrap_or(true) +} +``` +*Regression guard (unit test): a normal publish-record with `status="local_pinned"` STILL returns true; only the acquire-record returns false.* + +## 4. Response (same envelope as `library_publish`, via `provider_ok`) +```rust +Ok(json!({ "object": object, "uri": object.uri, "content_cid": content_cid, + "availability": record.availability, "receipt": record.receipt })) +``` +The load-bearing return is the **Library uri** under the buyer root (e.g. `localhost://Users//Acquired/`) — the openable path the player resolves via `read_owned_object_for_viewer` (`library.rs:3595`), still ciphertext until the DRM/key providers run at open. + +## 5. Dispatch (3 sites) + capsule manifest +- `ObjectProvider::send_raw` arm after `Publish` (`library.rs:360-381`): `let Some(registry)=self.registry.upgrade() else {…provider_error…}` then `library_acquire(...).await`. +- `handle_object_provider_runtime_request` arm after `Publish` (`library.rs:625-641`): registry is `Arc`; call directly. +- `handle_object_provider_raw_request` (`library.rs:449-453`): add `| ObjectProviderRequest::Acquire { .. }` to the rejected set — the registry-less stdio capsule **fails closed** ("requires Runtime content coordinator"). The buy flow must target the in-process `send_raw`/runtime path (which has the registry), not the standalone capsule. +- `capsules/object-provider/capsule.json` (`:17`): add `"acquire"` to the operations allow-list. + +## 6. Pure-unit-testable here (Cursor or a later pass) +serde of `op:"acquire"` (+ `deny_unknown_fields` rejects junk); destination-URI derivation stays under the buyer root; the `record_is_published` gate change (acquire-record→false, publish-record→true); object derivation after a hand-written acquire-record + temp file (`availability=="local_pinned"`, `published==false`, `content_cid` set, no publish caps); keyless fail-closed reject in `handle_object_provider_raw_request`; `write_library_file_bytes` stores ciphertext verbatim (round-trips, no decrypt). + +## 7. Live (Cursor — needs a running registry + IPFS) +The `ensure` pin + `fetch` (pull/cache the encrypted block); end-to-end Acquire → object opens in the existing player via `read_owned_object_for_viewer` with **no** key release; integrity (fetched bytes hash to `content_cid`); directory CIDs (DASH media — `library_publish` is file-only today; extend with `add_directory`/`download_directory`). + +## 8. Hard prerequisite (security) +**No entitlement check exists on any object-provider path.** The marketplace/settlement plane MUST verify `principal_id` actually bought `content_cid` (the on-chain `hasAccessByContentId` / a confirmed `buyAccess`) **before** dispatching `Acquire`, or add that gate inside `library_acquire`. Pinning encrypted bytes grants no decryption (keys gated at open), so the blast radius is wasted storage, not disclosure — but the gate belongs upstream regardless (P11). diff --git a/docs/marketplace/PHASE3_ACQUIRE_AND_TRADE.md b/docs/marketplace/PHASE3_ACQUIRE_AND_TRADE.md new file mode 100644 index 00000000..b7d75ceb --- /dev/null +++ b/docs/marketplace/PHASE3_ACQUIRE_AND_TRADE.md @@ -0,0 +1,53 @@ +# Phase 3 — Acquire (buy→pin) · Resale/Withdraw · Open-handoff (turnkey for Cursor) + +> The post-buy + trade backend, grounded in the audited runtime seams ([SCOPE.md](SCOPE.md)) + verified contracts ([CONTRACTS.md](CONTRACTS.md)). The marketplace **triggers**; the runtime providers do the work; signing is wallet-only (unsigned→external). Build order: **Phase 1 (buy-invariant) gates everything**; then 3A Acquire ∥ 3B Resale/Withdraw ∥ 3C Open-handoff. + +## Confirmed inputs (empirical, this pass; verified against the runtime + PC2) +- **Listing enrichment** comes from `metadata.json` (fetched by the index over the IPFS gateway `ipfs.ela.city`): `name, description, image, category` · `asset{cid, mimeType, size, encrypted, algorithm, dataToEncryptHash}` · `media{uri:"ipfs://"}` · `properties{chainId 8453, ledger, authority 0x09dBe7, publisher, categories, distribution}`. + - ⚠️ **`metadata.pricing{}` is LEGACY (v1.0)** — PC2's own CHANGELOG records that v1.1+ assets no longer embed pricing inline, and resolving op_type/price from `metadata.pricing` was a *bug*. **Authoritative price/supply/op_type come from the on-chain Operative** (`AssetCreated` + `sellersOf`/`listings` + the sell-terms calldata), per CONTRACTS.md — the index must NOT trust inline `pricing{}` for money. +- **The CID pinned on buy = `metadata.asset.cid`** (the encrypted media object; `media.uri` = `ipfs://`). Pinning it grants **NO** decryption — the CEK is recovered separately at open by the 2-of-3 quorum behind the rights gate (PRINCIPLES.md §15). *(P-tags here map to PRINCIPLES.md headings: P4=§4 carrier plane, P10=§10 one canonical path, P15=§15 provider-mediated decryption, P16=§16 UI≠authority.)* +- **Price is on-chain in the sell-terms calldata** `quantity(32B) | pricePerToken(32B) | payToken(32B)`. The **live `listings()` decode** is PC2 `ContentIndexerService.ts:466-473` (lowest across listings); the runtime **`content-market`** decodes the same 96-byte `uint256|uint256|address` shape from the **mint-time `sellRawData`** (`copies|price_wei|payToken`, `content-market/src/main.rs:608-610`) — same layout, different source/semantics. Pay-token = canonical Base **USDC, 6 decimals**. + +--- + +## 3A · Buy → pin-to-Library (the `Acquire` seam — the one genuinely NEW runtime op) +**Today:** `buy_authority::buy_access` records the access token only; nothing is pinned. `open_quorum_media` re-fetches the asset into an ephemeral `PlaintextTempDir` each open. The `library.rs` `ObjectProviderRequest` enum has only producer + local-file ops — **no consumer import-by-remote-CID**. + +**Build:** +1. **`ObjectProviderRequest::Acquire { principal_id, content_cid, metadata }`** (new variant, `library.rs` enum ~`:151-323`). Creates a `LibraryObject` under the buyer's principal root (`localhost://Users//…`) with `content_cid = asset.cid`, `availability = "local_pinned"`, `mime` + `name` from metadata, `published=false`. Idempotent (no-op if already present). Returns the Library `uri`. +2. **Pin** before/within Acquire: call the **`content/*` plane** `ensure` (`content.rs:3591+` → `ipfs-provider pin`, writes `status="local_pinned"`) — NOT raw `elastos://ipfs/*` (P4). Pull the encrypted bytes into `~/.local/share/elastos/ipfs-repo`. +3. **Gateway route `POST /api/market/acquire { content_id }`** — resolves `asset.cid` from the (re-verified) listing/metadata, runs `ensure`, then `object-provider Acquire`. Returns `{ pin_status, bytes_downloaded, library_uri }`. The shell's `api.js` already defines `API.acquire` (mock); wire it to this live route and poll for progress. +4. **Open reads local first:** the open path should serve the pinned CID from the local blockstore rather than re-fetch into a throwaway dir each play (`viewer_open.rs:174-206,1329-1372`). + +**Acceptance:** after a confirmed buy, the encrypted asset is a `LibraryObject` in the buyer's file tree, managed like any other file; `pin_status` reaches `complete`; opening it works offline from the local repo. +**Invariants:** marketplace TRIGGERS only (no pin/write/keys itself); encrypted-only; idempotent; pin-status surfaced (no silent stall). + +## 3B · Resale + withdraw assemblers (secondary market — the legitimate marketplace listing) +**Today:** there is **no Rust list/cancel/offer assembler anywhere in the runtime** (the `marketplace` capsule has only a stub `wasm/main.rs` + browser UI; a grep for `sellAccess`/`withdrawListing`/`sellToken`/the selectors across all `*.rs` returns zero hits). The UX is built (`openResale`, Vault "Listed", `openWithdraw`). + +**Build** (pure, selector-pinned, UNSIGNED → wallet; mirror `buy_authority` discipline — no keys/RPC): +- **PRE: `setApprovalForAll(operator=AuthorityGateway, true)` `0xa22cb465`** — approve the gateway to move the buyer's ERC-1155 access token (once per owner). **NB:** this is an **Operative/ledger (ERC-1155) method, NOT a gateway method** — keccak-correct but deliberately absent from the AuthorityGateway bytecode (its sibling `isApprovedForAll 0xe985e9c5` *is* present on the gateway); **confirm against a real deployed Operative before relying.** +- **`sellAccess(ledger, tokenId, quantity, pricePerToken, payToken)` `0x9a3fa9f5`** — list owned access for resale. `payToken = USDC 0x833589fC` (6dp); `pricePerToken` in minor units. Bind the **reseller as seller**; require a live `hasAccessByContentId(subject, KID)==true` proof (anti-spoof) before assembling. +- **`withdrawListing(ledger/operative, tokenId, quantity)` `0x3e65bbba`** — cancel; access right unaffected. +- **TradeGateway `0xd02451…`** secondary surface (`sellToken/buyToken/createOffer`) if offers are in scope; else AuthorityGateway list/withdraw suffices. +- Resolve the **real ledger `tokenId`** (NOT `word_from_id(content_id)`) from `DigitalAssetRegistered`/`AssetCreated` keyed by `bytes16 KID` — shared with Phase 1. + +**Acceptance:** owner lists → a secondary listing with floor appears in the index; another wallet buys it; royalty splits (incl. `resellerCut`) enforced on-chain; withdraw removes the listing, access intact. + +## 3C · Open-handoff (marketplace renders nothing) +**Use the existing path verbatim:** +- Owned CTA → `POST /api/viewers/open { uri: }` with header `x-elastos-home-token` (optionally `grant_handle` + `delegation_sig_hex` from `POST /api/viewers/prepare-grant` → wallet `personal_sign` for chain-mode dKMS). Returns `{ viewer, session, title, play_url, rights_binding }`; open `play_url` (`/apps/{viewer}/?session=&home_token=`) as an iframe targeting the returned `viewer`. Cleanest: emit a **Library open launch** `{uri}` and let the home shell's `launchOwnedFromLibrary` run open(+buy-retry) (`shell-windows.js:978-1061`). +- The marketplace must NOT build a session, fetch `/media|/object/{session}`, call decrypt-provider, or embed `